feat(psu): switch a Modbus RTU bench supply from OpsLog
The manufacturer's document arrived, so this is no longer guesswork: 9600 8N1, function codes 03 and 06 only, and a register map with the output on/off at 0x0001, the measurements at 0x0010…0x0013 and the set points at 0x0030/0x0031. ONE REGISTER IS WRITTEN — 0x0001, the output. The map also exposes the voltage and current set points and the three protection trip levels as writable, and none of them belong to a logbook: a wrong value there is 30 V where a radio expected 13.8, or a trip level lifted on a supply feeding an amplifier. They are read and displayed instead, next to the measured values, which is also how an operator sees at a glance that the supply is on and the radio is drawing nothing. The wire layer is tested where it can be. CRC-16/MODBUS is pinned against its published check value — the CRC of "123456789" is 0x4B37 — which fixes the polynomial, the initial value, the reflection and the absence of a final xor all at once; the rest of the protocol is checked frame by frame against the manual, including the byte order of the CRC, an exception reply told apart from a broken line, and a reply from another slave on the bus refused. The write echo must match the value sent: it is the only confirmation the output really switched, and accepting the frame without it is how a radio ends up dark behind a green light. Framing follows the manual's own rules: 3.5 character times of silence between frames (4 ms at 9600), and a frame is over when the line falls quiet — Modbus RTU has no terminator, and a serial read that times out returns (0, nil) here, so the reader is built on a deadline and a quiet-time rather than on an error that never comes. Untested against hardware — nobody here has the supply.
This commit is contained in:
@@ -52,6 +52,7 @@ import (
|
|||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/pskr"
|
"hamlog/internal/pskr"
|
||||||
|
"hamlog/internal/psu"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/relaydev"
|
"hamlog/internal/relaydev"
|
||||||
@@ -690,6 +691,7 @@ type App struct {
|
|||||||
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
||||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||||
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
|
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
|
||||||
|
psu *psu.Client // bench power supply over Modbus RTU (serial); nil when disabled
|
||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
||||||
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
||||||
@@ -1415,6 +1417,8 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.startTunerGenius()
|
a.startTunerGenius()
|
||||||
// PowerGenius XL amp fan control: connect in the background if enabled.
|
// PowerGenius XL amp fan control: connect in the background if enabled.
|
||||||
a.startAmps()
|
a.startAmps()
|
||||||
|
// Bench power supply (Modbus RTU): connect in the background if enabled.
|
||||||
|
a.startPSU()
|
||||||
|
|
||||||
// Autostart: launch the active profile's configured external programs that
|
// Autostart: launch the active profile's configured external programs that
|
||||||
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
|
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
|
||||||
@@ -14211,6 +14215,7 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
a.restartAsync("antenna", a.startUltrabeam)
|
a.restartAsync("antenna", a.startUltrabeam)
|
||||||
a.restartAsync("antgenius", a.startAntGenius)
|
a.restartAsync("antgenius", a.startAntGenius)
|
||||||
a.restartAsync("tuner", a.startTunerGenius)
|
a.restartAsync("tuner", a.startTunerGenius)
|
||||||
|
a.restartAsync("psu", a.startPSU)
|
||||||
a.startQSORecorderIfEnabled()
|
a.startQSORecorderIfEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/psu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Bench power supply (Modbus RTU) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A programmable supply feeding the shack, switched on and off from OpsLog so
|
||||||
|
// the station comes up and goes down with the logbook rather than by reaching
|
||||||
|
// behind the desk.
|
||||||
|
//
|
||||||
|
// OpsLog READS the supply's measurements and its set points, and WRITES exactly
|
||||||
|
// one thing: the output on/off. The register map has the voltage and current
|
||||||
|
// set points and the three protection trip levels as writable too, and none of
|
||||||
|
// them belong to a logbook — a wrong value there is 30 V where a radio expected
|
||||||
|
// 13.8. See internal/psu for the map and where it comes from.
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyPSUEnabled = "psu.enabled"
|
||||||
|
keyPSUPort = "psu.com_port"
|
||||||
|
keyPSUBaud = "psu.baud"
|
||||||
|
keyPSUAddress = "psu.address" // Modbus slave address, 1…15 on this family
|
||||||
|
)
|
||||||
|
|
||||||
|
// PSUSettings is the JSON shape for the Hardware → Power supply panel.
|
||||||
|
type PSUSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ComPort string `json:"com_port"`
|
||||||
|
Baud int `json:"baud"` // 9600 from the factory
|
||||||
|
Address int `json:"address"` // 1 from the factory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUSettings returns the persisted supply config.
|
||||||
|
func (a *App) GetPSUSettings() (PSUSettings, error) {
|
||||||
|
out := PSUSettings{Baud: 9600, Address: 1}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx, keyPSUEnabled, keyPSUPort, keyPSUBaud, keyPSUAddress)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Enabled = m[keyPSUEnabled] == "1"
|
||||||
|
out.ComPort = m[keyPSUPort]
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUBaud]); e == nil && v > 0 {
|
||||||
|
out.Baud = v
|
||||||
|
}
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUAddress]); e == nil && v >= 1 && v <= 250 {
|
||||||
|
out.Address = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePSUSettings persists the config and (re)starts or stops the client.
|
||||||
|
func (a *App) SavePSUSettings(s PSUSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if s.Baud <= 0 {
|
||||||
|
s.Baud = 9600
|
||||||
|
}
|
||||||
|
// The manual gives 1…15 for the address field and 1…250 for the address
|
||||||
|
// SETTING register. Clamped to the wider range and defaulted to 1: an
|
||||||
|
// address of 0 is the Modbus broadcast, which never answers, so accepting it
|
||||||
|
// would give a supply that is present and permanently "not responding".
|
||||||
|
if s.Address < 1 || s.Address > 250 {
|
||||||
|
s.Address = 1
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyPSUEnabled: boolStr(s.Enabled),
|
||||||
|
keyPSUPort: strings.TrimSpace(s.ComPort),
|
||||||
|
keyPSUBaud: strconv.Itoa(s.Baud),
|
||||||
|
keyPSUAddress: strconv.Itoa(s.Address),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.restartAsync("psu", a.startPSU)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPSU stops any running client and starts a fresh one if the supply is
|
||||||
|
// enabled and has a port. Safe to call repeatedly (startup, settings save,
|
||||||
|
// profile switch).
|
||||||
|
func (a *App) startPSU() {
|
||||||
|
if a.psu != nil {
|
||||||
|
go a.psu.Stop()
|
||||||
|
a.psu = nil
|
||||||
|
}
|
||||||
|
s, err := a.GetPSUSettings()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("psu: not started — settings unavailable: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.ComPort) == "" {
|
||||||
|
applog.Printf("psu: not started — no serial port configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("psu: starting on %s @ %d baud, Modbus address %d", s.ComPort, s.Baud, s.Address)
|
||||||
|
a.psu = psu.New(psu.Config{ComPort: s.ComPort, Baud: s.Baud, Address: byte(s.Address)})
|
||||||
|
_ = a.psu.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUStatus returns the supply's last polled state for the UI.
|
||||||
|
func (a *App) GetPSUStatus() psu.Status {
|
||||||
|
if a.psu == nil {
|
||||||
|
return psu.Status{}
|
||||||
|
}
|
||||||
|
return a.psu.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPSUOutput switches the supply's output. The only write OpsLog makes to it.
|
||||||
|
func (a *App) SetPSUOutput(on bool) error {
|
||||||
|
if a.psu == nil {
|
||||||
|
return fmt.Errorf("the power supply is not enabled in Settings")
|
||||||
|
}
|
||||||
|
return a.psu.SetOutput(on)
|
||||||
|
}
|
||||||
+4
-2
@@ -7,14 +7,16 @@
|
|||||||
"A QSO logged from WSJT-X, MSHV or a net now appears in Recent QSOs at once, instead of waiting for a delayed auto-upload to send it.",
|
"A QSO logged from WSJT-X, MSHV or a net now appears in Recent QSOs at once, instead of waiting for a delayed auto-upload to send it.",
|
||||||
"New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.",
|
"New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.",
|
||||||
"Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band.",
|
"Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band.",
|
||||||
"Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission."
|
"Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission.",
|
||||||
|
"New device: a bench power supply on Modbus RTU (BSIDE, Wanptek and kin) — its output switched from Station Control, with volts, amps and watts."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
|
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
|
||||||
"Un QSO logué depuis WSJT-X, MSHV ou un net apparaît aussitôt dans les QSO récents, sans attendre l’envoi d’un upload automatique différé.",
|
"Un QSO logué depuis WSJT-X, MSHV ou un net apparaît aussitôt dans les QSO récents, sans attendre l’envoi d’un upload automatique différé.",
|
||||||
"Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.",
|
"Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.",
|
||||||
"Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne.",
|
"Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne.",
|
||||||
"Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It »."
|
"Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It ».",
|
||||||
|
"Nouvel appareil : alimentation de laboratoire en Modbus RTU (BSIDE, Wanptek et similaires) — sortie commutée depuis Contrôle station, avec V, A et W."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
|
GetPSUSettings, SavePSUSettings,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
@@ -203,6 +204,7 @@ type SectionId =
|
|||||||
| 'antenna'
|
| 'antenna'
|
||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'tunergenius'
|
| 'tunergenius'
|
||||||
|
| 'psu'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
| 'relayauto'
|
| 'relayauto'
|
||||||
@@ -251,6 +253,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
||||||
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
|
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
|
||||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||||
|
{ kind: 'item', label: t('sec.psu'), id: 'psu' },
|
||||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||||
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||||
@@ -1278,6 +1281,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
|
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
||||||
|
|
||||||
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
@@ -1681,6 +1685,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
@@ -1723,6 +1728,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
@@ -1916,6 +1922,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius as any);
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
|
await SavePSUSettings(psuCfg as any);
|
||||||
await SaveAmplifiers(amps as any);
|
await SaveAmplifiers(amps as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
@@ -3383,6 +3390,55 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bench power supply over Modbus RTU. OpsLog reads everything and writes one
|
||||||
|
// register — the output on/off. The voltage and current SET points are shown
|
||||||
|
// because they are worth seeing, and are not editable here: they belong to the
|
||||||
|
// supply's front panel, and a logbook that can set them can set them wrong.
|
||||||
|
function PSUPanelSettings() {
|
||||||
|
const [ports, setPorts] = useState<string[]>([]);
|
||||||
|
useEffect(() => { ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => setPorts([])); }, []);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title={t('psu.title')} hint={t('psu.hint')} />
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={psuCfg.enabled} onCheckedChange={(c) => setPsuCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
{t('psu.enable')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('psu.port')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={psuCfg.com_port || '_'} onValueChange={(v) => setPsuCfg((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
↻
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('psu.baud')}</Label>
|
||||||
|
<Input className="font-mono" value={String(psuCfg.baud ?? 9600)}
|
||||||
|
onChange={(e) => setPsuCfg((s) => ({ ...s, baud: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('psu.address')}</Label>
|
||||||
|
<Input className="font-mono" value={String(psuCfg.address ?? 1)}
|
||||||
|
onChange={(e) => setPsuCfg((s) => ({ ...s, address: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('psu.wireHint')}</p>
|
||||||
|
<p className="text-xs text-warning">{t('psu.writeScope')}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||||
// presents it as brand + model.
|
// presents it as brand + model.
|
||||||
@@ -6349,6 +6405,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
antenna: UltrabeamPanel,
|
antenna: UltrabeamPanel,
|
||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
tunergenius: TunerGeniusPanelSettings,
|
tunergenius: TunerGeniusPanelSettings,
|
||||||
|
psu: PSUPanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
|
|||||||
@@ -19,10 +19,64 @@ import {
|
|||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
|
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
|
|
||||||
|
type PSUState = { connected: boolean; on: boolean; volts: number; amps: number; watts: number; set_volts: number; set_amps: number; protected: number; error?: string };
|
||||||
|
|
||||||
|
// The bench supply. One button — the output — and the three numbers that say
|
||||||
|
// what it is actually delivering.
|
||||||
|
//
|
||||||
|
// The SET points are shown beside them, small, because "13.8 V set" next to
|
||||||
|
// "0.02 A out" is how an operator sees at a glance that the supply is on but
|
||||||
|
// the radio is not drawing. They are not editable: OpsLog reads them and never
|
||||||
|
// writes them, which is the whole safety story of this device.
|
||||||
|
function PSUCard({ st, busy, onToggle, t }: {
|
||||||
|
st: PSUState; busy: boolean; onToggle: (on: boolean) => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const tripped = (st.protected ?? 0) !== 0;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Power className="size-4 text-primary" />
|
||||||
|
<div className="text-sm font-semibold truncate">{t('psu.title')}</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st.connected ? t('station.online') : (st.error || t('psu.offline'))} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" disabled={!st.connected || busy}
|
||||||
|
onClick={() => onToggle(!st.on)}
|
||||||
|
className={cn('flex items-center gap-2 rounded-md border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||||
|
st.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
<span className={cn('flex items-center justify-center size-6 rounded shrink-0',
|
||||||
|
st.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold">{t('psu.output')}</span>
|
||||||
|
<span className={cn('text-[10px] font-bold', st.on ? 'text-success' : 'text-muted-foreground/60')}>
|
||||||
|
{st.on ? t('station.on') : t('station.off')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0 font-mono tabular-nums text-right">
|
||||||
|
<span className="text-lg font-bold">{(st.volts ?? 0).toFixed(2)}</span><span className="text-xs text-muted-foreground"> V</span>
|
||||||
|
<span className="text-lg font-bold ml-3">{(st.amps ?? 0).toFixed(3)}</span><span className="text-xs text-muted-foreground"> A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground font-mono">
|
||||||
|
<span>{(st.watts ?? 0).toFixed(1)} W</span>
|
||||||
|
<span>{t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A</span>
|
||||||
|
</div>
|
||||||
|
{tripped && (
|
||||||
|
<div className="text-[11px] font-bold text-danger">{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Device = {
|
type Device = {
|
||||||
id: string; type: string; name: string; host: string;
|
id: string; type: string; name: string; host: string;
|
||||||
user?: string; pass?: string; channels?: number; labels: string[];
|
user?: string; pass?: string; channels?: number; labels: string[];
|
||||||
@@ -457,6 +511,32 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Bench power supply. Polled slower than the tuner: it has no meters that
|
||||||
|
// track a transmission, and every poll is three Modbus exchanges on a 9600
|
||||||
|
// baud line the operator may also be using to switch the output.
|
||||||
|
const [psu, setPsu] = useState<PSUState>({ connected: false, on: false, volts: 0, amps: 0, watts: 0, set_volts: 0, set_amps: 0, protected: 0 });
|
||||||
|
const [psuEnabled, setPsuEnabled] = useState(false);
|
||||||
|
const [psuBusy, setPsuBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
try { const en: any = await GetPSUSettings(); if (alive) setPsuEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const st: any = await GetPSUStatus(); if (alive && st) setPsu(st as PSUState); } catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const togglePSU = useCallback(async (on: boolean) => {
|
||||||
|
setPsuBusy(true);
|
||||||
|
// No optimistic flip here, unlike the relays: the supply echoes the value it
|
||||||
|
// actually set, so showing ON before it confirms would be showing something
|
||||||
|
// OpsLog does not know. A power switch is the wrong place to guess.
|
||||||
|
try { await SetPSUOutput(on); const st: any = await GetPSUStatus(); if (st) setPsu(st as PSUState); }
|
||||||
|
catch { /* the poll will tell the truth */ }
|
||||||
|
finally { setPsuBusy(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -594,6 +674,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||||
// Tuner Genius XL card (identical to the Flex panel's).
|
// Tuner Genius XL card (identical to the Flex panel's).
|
||||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||||
|
if (psuEnabled) widgets.push({ id: 'psu', node: <PSUCard st={psu} busy={psuBusy} onToggle={togglePSU} t={t} /> });
|
||||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ const en: Dict = {
|
|||||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the board’s HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the board’s HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
||||||
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
|
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
|
||||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplifier', 'sec.psu': 'Power supply',
|
||||||
|
'psu.title': 'Bench power supply (Modbus RTU)', 'psu.hint': 'A programmable supply on a serial port — BSIDE, Wanptek and the other Modbus RTU supplies that use function codes 03 and 06. OpsLog shows what it is delivering and switches its output on and off.', 'psu.enable': 'Control this power supply', 'psu.port': 'COM port', 'psu.baud': 'Baud', 'psu.address': 'Modbus address', 'psu.wireHint': '9600 baud, 8 data bits, no parity, 1 stop bit, address 1 — the factory settings. Change them here only if you changed them on the supply.', 'psu.writeScope': 'OpsLog only ever writes the output on/off. The voltage, current and protection settings are read and displayed, never changed — those stay on the supply’s own panel.', 'psu.output': 'Output', 'psu.setTo': 'set to', 'psu.tripped': 'PROTECTION TRIPPED', 'psu.offline': 'Not responding',
|
||||||
|
'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||||
// CW Keyer settings panel
|
// CW Keyer settings panel
|
||||||
'wk.enable': 'Enable CW keyer (shows the keyer panel)', 'wk.engine': 'Keyer engine',
|
'wk.enable': 'Enable CW keyer (shows the keyer panel)', 'wk.engine': 'Keyer engine',
|
||||||
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
|
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
|
||||||
@@ -596,7 +598,9 @@ const fr: Dict = {
|
|||||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
||||||
'awards.followHint': 'Diplômes affichés dans l’onglet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour l’instant — l’onglet Awards les montre tous.',
|
'awards.followHint': 'Diplômes affichés dans l’onglet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour l’instant — l’onglet Awards les montre tous.',
|
||||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.psu': 'Alimentation',
|
||||||
|
'psu.title': 'Alimentation de laboratoire (Modbus RTU)', 'psu.hint': 'Une alimentation programmable sur port série — BSIDE, Wanptek et les autres alimentations Modbus RTU utilisant les codes fonction 03 et 06. OpsLog affiche ce qu’elle débite et commute sa sortie.', 'psu.enable': 'Piloter cette alimentation', 'psu.port': 'Port COM', 'psu.baud': 'Vitesse', 'psu.address': 'Adresse Modbus', 'psu.wireHint': '9600 bauds, 8 bits de données, sans parité, 1 bit de stop, adresse 1 — les réglages d’usine. Ne les changez ici que si vous les avez changés sur l’alimentation.', 'psu.writeScope': 'OpsLog n’écrit jamais que la marche/arrêt de la sortie. La tension, le courant et les protections sont lus et affichés, jamais modifiés — ils restent sur la face avant de l’alimentation.', 'psu.output': 'Sortie', 'psu.setTo': 'réglée sur', 'psu.tripped': 'PROTECTION DÉCLENCHÉE', 'psu.offline': 'Ne répond pas',
|
||||||
|
'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||||
// Panneau Manipulateur CW
|
// Panneau Manipulateur CW
|
||||||
'wk.enable': 'Activer le manipulateur CW (affiche le panneau)', 'wk.engine': 'Moteur du manipulateur',
|
'wk.enable': 'Activer le manipulateur CW (affiche le panneau)', 'wk.engine': 'Moteur du manipulateur',
|
||||||
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
|
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
|
||||||
|
|||||||
Vendored
+9
@@ -15,6 +15,7 @@ import {cluster} from '../models';
|
|||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
|
import {psu} from '../models';
|
||||||
import {spe} from '../models';
|
import {spe} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
import {tunergenius} from '../models';
|
import {tunergenius} from '../models';
|
||||||
@@ -488,6 +489,10 @@ export function GetPOTAToken():Promise<string>;
|
|||||||
|
|
||||||
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
||||||
|
|
||||||
|
export function GetPSUSettings():Promise<main.PSUSettings>;
|
||||||
|
|
||||||
|
export function GetPSUStatus():Promise<psu.Status>;
|
||||||
|
|
||||||
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
@@ -944,6 +949,8 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
@@ -1024,6 +1031,8 @@ export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<voi
|
|||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetPSUOutput(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -918,6 +918,14 @@ export function GetPSKReporterStatus() {
|
|||||||
return window['go']['main']['App']['GetPSKReporterStatus']();
|
return window['go']['main']['App']['GetPSKReporterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPSUSettings() {
|
||||||
|
return window['go']['main']['App']['GetPSUSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetPSUStatus() {
|
||||||
|
return window['go']['main']['App']['GetPSUStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPendingQSOs() {
|
export function GetPendingQSOs() {
|
||||||
return window['go']['main']['App']['GetPendingQSOs']();
|
return window['go']['main']['App']['GetPendingQSOs']();
|
||||||
}
|
}
|
||||||
@@ -1830,6 +1838,10 @@ export function SavePOTAToken(arg1) {
|
|||||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePSUSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePSUSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveProfile(arg1) {
|
export function SaveProfile(arg1) {
|
||||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1990,6 +2002,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetPSUOutput(arg1) {
|
||||||
|
return window['go']['main']['App']['SetPSUOutput'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2733,6 +2733,24 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PSUSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
address: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new PSUSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
this.address = source["address"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class QSLBulkUpdate {
|
export class QSLBulkUpdate {
|
||||||
sent_status: string;
|
sent_status: string;
|
||||||
rcvd_status: string;
|
rcvd_status: string;
|
||||||
@@ -4063,6 +4081,39 @@ export namespace pskr {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace psu {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
on: boolean;
|
||||||
|
volts: number;
|
||||||
|
amps: number;
|
||||||
|
watts: number;
|
||||||
|
set_volts: number;
|
||||||
|
set_amps: number;
|
||||||
|
protected: number;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.on = source["on"];
|
||||||
|
this.volts = source["volts"];
|
||||||
|
this.amps = source["amps"];
|
||||||
|
this.watts = source["watts"];
|
||||||
|
this.set_volts = source["set_volts"];
|
||||||
|
this.set_amps = source["set_amps"];
|
||||||
|
this.protected = source["protected"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace qslcard {
|
export namespace qslcard {
|
||||||
|
|
||||||
export class Bevel {
|
export class Bevel {
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Package psu drives a bench power supply over Modbus RTU — the BSIDE / Wanptek
|
||||||
|
// family of programmable supplies that sit in a shack feeding the radios.
|
||||||
|
//
|
||||||
|
// The register map and the wire settings come from the manufacturer's own
|
||||||
|
// document ("This machine only support function code 03,06", version 20180611):
|
||||||
|
//
|
||||||
|
// 9600 baud, 8 data bits, no parity, 1 stop bit
|
||||||
|
// function 03 read holding registers
|
||||||
|
// function 06 write single register
|
||||||
|
// slave address 1…15, address 0 broadcast
|
||||||
|
//
|
||||||
|
// NOTHING ELSE IS WRITTEN. The map also carries the output voltage and current
|
||||||
|
// SET points, and the over-voltage, over-current and over-power trip levels, all
|
||||||
|
// read/write. This driver reads them and writes exactly one register: 0x0001,
|
||||||
|
// the output on/off. A wrong value in any of the others is not a wrong reading —
|
||||||
|
// it is 30 V where a radio expected 13.8, or a protection trip lifted on a
|
||||||
|
// supply feeding an amplifier. There is no reason for a logbook to set them, so
|
||||||
|
// it cannot.
|
||||||
|
package psu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registers, from the manufacturer's table. Addresses are as printed there.
|
||||||
|
const (
|
||||||
|
regOnOff = 0x0001 // output on/off — 1 or 0. The ONLY register written.
|
||||||
|
regProtect = 0x0002 // protection status word
|
||||||
|
regModel = 0x0003 // specification model
|
||||||
|
regDecimals = 0x0005 // "V_A_W number of digits" — see readDecimals
|
||||||
|
regVolts = 0x0010 // measured output voltage, 2 decimals
|
||||||
|
regAmps = 0x0011 // measured output current, 3 decimals
|
||||||
|
regWatts = 0x0012 // measured output power, 32-bit across 0x0012/0x0013, 3 decimals
|
||||||
|
regSetVolts = 0x0030 // voltage set point, 2 decimals
|
||||||
|
regSetAmps = 0x0031 // current set point, 3 decimals
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fnRead = 0x03
|
||||||
|
fnWrite = 0x06
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fixed scaling from the manufacturer's "Decimal place" column. The supply also
|
||||||
|
// reports its own digit counts in 0x0005, but the document's "Note 2" that
|
||||||
|
// explains how to decode that word is not in the manual we have — so the
|
||||||
|
// documented per-register values are used, and the raw word is logged once at
|
||||||
|
// connect. If an operator ever reports readings out by a factor of ten, that
|
||||||
|
// line is what says how to decode it properly.
|
||||||
|
const (
|
||||||
|
voltScale = 100.0 // 2 decimals
|
||||||
|
ampScale = 1000.0 // 3 decimals
|
||||||
|
wattScale = 1000.0 // 3 decimals
|
||||||
|
)
|
||||||
|
|
||||||
|
// crc16 is the Modbus RTU frame check: CRC-16/MODBUS — reflected, polynomial
|
||||||
|
// 0xA001, initial value 0xFFFF, no final xor. Transmitted low byte first.
|
||||||
|
func crc16(b []byte) uint16 {
|
||||||
|
crc := uint16(0xFFFF)
|
||||||
|
for _, c := range b {
|
||||||
|
crc ^= uint16(c)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
if crc&1 != 0 {
|
||||||
|
crc = (crc >> 1) ^ 0xA001
|
||||||
|
} else {
|
||||||
|
crc >>= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendCRC closes a frame: low byte first, as the manual states.
|
||||||
|
func appendCRC(f []byte) []byte {
|
||||||
|
c := crc16(f)
|
||||||
|
return append(f, byte(c&0xFF), byte(c>>8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRead frames a function 03 "read holding registers".
|
||||||
|
func buildRead(addr byte, reg uint16, count uint16) []byte {
|
||||||
|
f := []byte{addr, fnRead, byte(reg >> 8), byte(reg), byte(count >> 8), byte(count)}
|
||||||
|
return appendCRC(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWrite frames a function 06 "write single register".
|
||||||
|
func buildWrite(addr byte, reg, val uint16) []byte {
|
||||||
|
f := []byte{addr, fnWrite, byte(reg >> 8), byte(reg), byte(val >> 8), byte(val)}
|
||||||
|
return appendCRC(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modbusError is an exception response — the supply understood the frame and
|
||||||
|
// refused it. Kept distinct from a transport failure: one means "ask
|
||||||
|
// differently", the other means "the cable".
|
||||||
|
type modbusError struct {
|
||||||
|
fn byte
|
||||||
|
code byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e modbusError) Error() string {
|
||||||
|
what := map[byte]string{
|
||||||
|
1: "illegal function",
|
||||||
|
2: "illegal data address",
|
||||||
|
3: "illegal data value",
|
||||||
|
4: "slave device failure",
|
||||||
|
6: "device busy",
|
||||||
|
}[e.code]
|
||||||
|
if what == "" {
|
||||||
|
what = fmt.Sprintf("exception %d", e.code)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("supply refused function 0x%02X: %s", e.fn, what)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRead validates a function 03 reply and returns the register values.
|
||||||
|
func parseRead(addr byte, want uint16, frame []byte) ([]uint16, error) {
|
||||||
|
if err := checkFrame(addr, fnRead, frame, 5); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := int(frame[2])
|
||||||
|
if n != int(want)*2 {
|
||||||
|
return nil, fmt.Errorf("reply carries %d data byte(s), expected %d", n, want*2)
|
||||||
|
}
|
||||||
|
if len(frame) != 3+n+2 {
|
||||||
|
return nil, fmt.Errorf("reply is %d bytes, expected %d", len(frame), 3+n+2)
|
||||||
|
}
|
||||||
|
out := make([]uint16, want)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = binary.BigEndian.Uint16(frame[3+i*2:])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWriteEcho validates a function 06 reply, which echoes the request.
|
||||||
|
func parseWriteEcho(addr byte, reg, val uint16, frame []byte) error {
|
||||||
|
if err := checkFrame(addr, fnWrite, frame, 8); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(frame) != 8 {
|
||||||
|
return fmt.Errorf("write reply is %d bytes, expected 8", len(frame))
|
||||||
|
}
|
||||||
|
if got := binary.BigEndian.Uint16(frame[2:]); got != reg {
|
||||||
|
return fmt.Errorf("write reply is for register 0x%04X, not 0x%04X", got, reg)
|
||||||
|
}
|
||||||
|
// The echoed VALUE is the confirmation that the output actually changed.
|
||||||
|
// Accepting the frame without checking it would report an on/off that the
|
||||||
|
// supply never made.
|
||||||
|
if got := binary.BigEndian.Uint16(frame[4:]); got != val {
|
||||||
|
return fmt.Errorf("supply echoed value %d, not the %d it was sent", got, val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFrame covers what every reply must satisfy: our address, our function
|
||||||
|
// (or its exception), and a good CRC.
|
||||||
|
func checkFrame(addr, fn byte, frame []byte, min int) error {
|
||||||
|
if len(frame) < 4 {
|
||||||
|
return fmt.Errorf("short reply (%d bytes)", len(frame))
|
||||||
|
}
|
||||||
|
if frame[0] != addr {
|
||||||
|
return fmt.Errorf("reply from address %d, expected %d", frame[0], addr)
|
||||||
|
}
|
||||||
|
if frame[1] == fn|0x80 {
|
||||||
|
if len(frame) < 5 {
|
||||||
|
return fmt.Errorf("short exception reply (%d bytes)", len(frame))
|
||||||
|
}
|
||||||
|
if !crcOK(frame[:5]) {
|
||||||
|
return fmt.Errorf("exception reply failed its CRC")
|
||||||
|
}
|
||||||
|
return modbusError{fn: fn, code: frame[2]}
|
||||||
|
}
|
||||||
|
if frame[1] != fn {
|
||||||
|
return fmt.Errorf("reply to function 0x%02X, expected 0x%02X", frame[1], fn)
|
||||||
|
}
|
||||||
|
if len(frame) < min {
|
||||||
|
return fmt.Errorf("short reply (%d bytes, expected at least %d)", len(frame), min)
|
||||||
|
}
|
||||||
|
if !crcOK(frame) {
|
||||||
|
return fmt.Errorf("reply failed its CRC")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// crcOK checks a whole frame, trailing CRC included: the CRC of the entire
|
||||||
|
// frame is zero when it is intact.
|
||||||
|
func crcOK(frame []byte) bool {
|
||||||
|
if len(frame) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body := frame[:len(frame)-2]
|
||||||
|
want := uint16(frame[len(frame)-2]) | uint16(frame[len(frame)-1])<<8
|
||||||
|
return crc16(body) == want
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameGap is the silence that separates two Modbus RTU frames: 3.5 character
|
||||||
|
// times, which at 9600 baud 8N1 (10 bits per character) is 3.65 ms. Rounded up,
|
||||||
|
// because the cost of waiting is nothing and the cost of being early is a
|
||||||
|
// supply that treats our request as the tail of the previous one.
|
||||||
|
const frameGap = 4 * time.Millisecond
|
||||||
|
|
||||||
|
// replyWait is how long a reply may take. The manual promises under 5 ms at
|
||||||
|
// 9600 baud or better; this is generous by two orders of magnitude so a USB
|
||||||
|
// serial bridge that buffers cannot be mistaken for a supply that is not there.
|
||||||
|
const replyWait = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// readFrame collects a reply until it stops arriving.
|
||||||
|
//
|
||||||
|
// A serial read that times out returns (0, nil) on Windows — a timeout is not
|
||||||
|
// an error on this transport — so a loop that trusts an error to end it never
|
||||||
|
// ends. Modbus RTU has no terminator either: a frame is over when the line has
|
||||||
|
// been quiet for 3.5 character times. Both facts point at the same shape, a
|
||||||
|
// deadline and a quiet-time.
|
||||||
|
func readFrame(conn io.Reader, d time.Duration) ([]byte, error) {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
buf := make([]byte, 0, 64)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
lastByte := time.Time{}
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return buf, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
lastByte = time.Now()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Nothing this time: either the frame has ended, or it never started.
|
||||||
|
if len(buf) > 0 && time.Since(lastByte) >= frameGap {
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
if len(buf) > 0 {
|
||||||
|
return buf, nil // partial — let the parser say what is wrong with it
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no reply after %s", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package psu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The CRC is the one thing here that cannot be checked by inspection, and every
|
||||||
|
// frame depends on it. CRC-16/MODBUS has a published check value: the CRC of
|
||||||
|
// the ASCII digits "123456789" is 0x4B37. If this passes, the polynomial, the
|
||||||
|
// initial value, the reflection and the absence of a final xor are all right.
|
||||||
|
func TestCRCMatchesTheStandardCheckValue(t *testing.T) {
|
||||||
|
if got := crc16([]byte("123456789")); got != 0x4B37 {
|
||||||
|
t.Fatalf("crc16(\"123456789\") = 0x%04X, want 0x4B37 — this is not CRC-16/MODBUS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A frame including its own CRC checks to zero. That property is what crcOK
|
||||||
|
// relies on, so it is worth pinning separately from the check value.
|
||||||
|
func TestAFrameVerifiesItself(t *testing.T) {
|
||||||
|
for _, f := range [][]byte{
|
||||||
|
buildRead(1, regVolts, 2),
|
||||||
|
buildWrite(1, regOnOff, 1),
|
||||||
|
buildWrite(15, regOnOff, 0),
|
||||||
|
} {
|
||||||
|
if !crcOK(f) {
|
||||||
|
t.Errorf("% X does not verify against its own CRC", f)
|
||||||
|
}
|
||||||
|
// And a single flipped bit must be caught.
|
||||||
|
bad := append([]byte(nil), f...)
|
||||||
|
bad[2] ^= 0x01
|
||||||
|
if crcOK(bad) {
|
||||||
|
t.Errorf("% X passed the CRC with a corrupted byte", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frame layout, byte for byte against the manual: address, function,
|
||||||
|
// register high/low, count or value high/low, then CRC low byte first.
|
||||||
|
func TestFrameLayout(t *testing.T) {
|
||||||
|
r := buildRead(1, 0x0010, 2)
|
||||||
|
if len(r) != 8 {
|
||||||
|
t.Fatalf("read frame is %d bytes, want 8", len(r))
|
||||||
|
}
|
||||||
|
want := []byte{0x01, 0x03, 0x00, 0x10, 0x00, 0x02}
|
||||||
|
for i := range want {
|
||||||
|
if r[i] != want[i] {
|
||||||
|
t.Fatalf("read frame % X, want % X…", r, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := buildWrite(1, regOnOff, 1)
|
||||||
|
want = []byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01}
|
||||||
|
for i := range want {
|
||||||
|
if w[i] != want[i] {
|
||||||
|
t.Fatalf("write frame % X, want % X…", w, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The CRC goes out low byte first — the manual is explicit, and getting it
|
||||||
|
// backwards makes every frame be ignored in silence.
|
||||||
|
c := crc16(w[:6])
|
||||||
|
if w[6] != byte(c&0xFF) || w[7] != byte(c>>8) {
|
||||||
|
t.Errorf("CRC bytes % X, want %02X %02X (low first)", w[6:], byte(c&0xFF), byte(c>>8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRead(t *testing.T) {
|
||||||
|
// Two registers: 13.80 V (1380 at 2 decimals) and 2.500 A (2500 at 3).
|
||||||
|
frame := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
||||||
|
got, err := parseRead(1, 2, frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseRead: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != 1380 || got[1] != 2500 {
|
||||||
|
t.Fatalf("got %v, want [1380 2500]", got)
|
||||||
|
}
|
||||||
|
if v := float64(got[0]) / voltScale; v != 13.80 {
|
||||||
|
t.Errorf("voltage scaled to %v, want 13.8", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply from another slave on the same bus must not be read as ours.
|
||||||
|
func TestParseRejectsAnotherSlave(t *testing.T) {
|
||||||
|
frame := appendCRC([]byte{0x02, 0x03, 0x02, 0x05, 0x64})
|
||||||
|
if _, err := parseRead(1, 1, frame); err == nil {
|
||||||
|
t.Error("a reply from address 2 was accepted as address 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsABadCRC(t *testing.T) {
|
||||||
|
frame := appendCRC([]byte{0x01, 0x03, 0x02, 0x05, 0x64})
|
||||||
|
frame[3] ^= 0xFF
|
||||||
|
if _, err := parseRead(1, 1, frame); err == nil || !strings.Contains(err.Error(), "CRC") {
|
||||||
|
t.Errorf("err = %v, want a CRC complaint", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exception reply is the supply refusing, not the line failing, and the two
|
||||||
|
// need different answers from the operator.
|
||||||
|
func TestParseReportsAnException(t *testing.T) {
|
||||||
|
frame := appendCRC([]byte{0x01, 0x83, 0x02})
|
||||||
|
_, err := parseRead(1, 1, frame)
|
||||||
|
var me modbusError
|
||||||
|
if !errors.As(err, &me) {
|
||||||
|
t.Fatalf("err = %v, want a modbusError", err)
|
||||||
|
}
|
||||||
|
if me.code != 2 || !strings.Contains(err.Error(), "illegal data address") {
|
||||||
|
t.Errorf("exception decoded as %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The echo is the ONLY confirmation that the output actually switched. A reply
|
||||||
|
// echoing a different value means the supply did something else, and reporting
|
||||||
|
// that as success is how a radio ends up with no power and a green light.
|
||||||
|
func TestWriteEchoMustMatch(t *testing.T) {
|
||||||
|
ok := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01})
|
||||||
|
if err := parseWriteEcho(1, regOnOff, 1, ok); err != nil {
|
||||||
|
t.Fatalf("a correct echo was rejected: %v", err)
|
||||||
|
}
|
||||||
|
wrongVal := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x00})
|
||||||
|
if err := parseWriteEcho(1, regOnOff, 1, wrongVal); err == nil {
|
||||||
|
t.Error("an echo of 0 was accepted for a command of 1 — the output never switched")
|
||||||
|
}
|
||||||
|
wrongReg := appendCRC([]byte{0x01, 0x06, 0x00, 0x30, 0x00, 0x01})
|
||||||
|
if err := parseWriteEcho(1, regOnOff, 1, wrongReg); err == nil {
|
||||||
|
t.Error("an echo for register 0x0030 was accepted for a write to 0x0001")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietPort delivers a frame in pieces, then goes quiet — a serial port that
|
||||||
|
// reports a timeout as (0, nil), which is what Windows does.
|
||||||
|
type quietPort struct {
|
||||||
|
chunks [][]byte
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *quietPort) Read(b []byte) (int, error) {
|
||||||
|
if p.i >= len(p.chunks) {
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
return 0, nil // timeout, not an error
|
||||||
|
}
|
||||||
|
n := copy(b, p.chunks[p.i])
|
||||||
|
p.i++
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Modbus RTU frame has no terminator: it ends when the line falls quiet. The
|
||||||
|
// reader must assemble a dribbled frame and then stop on its own.
|
||||||
|
func TestReadFrameAssemblesUntilQuiet(t *testing.T) {
|
||||||
|
want := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
||||||
|
p := &quietPort{chunks: [][]byte{want[:2], want[2:5], want[5:]}}
|
||||||
|
got, err := readFrame(p, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readFrame: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("read % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("read % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A supply that is switched off, or not on this port, must produce an error
|
||||||
|
// rather than a wait that never ends.
|
||||||
|
func TestReadFrameGivesUpOnSilence(t *testing.T) {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { _, err := readFrame(&quietPort{}, 80*time.Millisecond); done <- err }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Error("silence was reported as a frame")
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("readFrame never returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package psu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is one supply's serial link.
|
||||||
|
type Config struct {
|
||||||
|
ComPort string
|
||||||
|
Baud int // 9600 unless the supply has been reconfigured
|
||||||
|
Address byte // Modbus slave address, 1…15 on this family
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what the UI shows. Everything here is READ from the supply — the
|
||||||
|
// set points included, which the operator sets on the front panel and OpsLog
|
||||||
|
// only reports.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
On bool `json:"on"` // output enabled
|
||||||
|
Volts float64 `json:"volts"` // measured output
|
||||||
|
Amps float64 `json:"amps"` // measured output
|
||||||
|
Watts float64 `json:"watts"` // measured output
|
||||||
|
SetVolts float64 `json:"set_volts"` // the voltage the supply is set to
|
||||||
|
SetAmps float64 `json:"set_amps"` // the current limit it is set to
|
||||||
|
Protected uint16 `json:"protected"` // protection status word, non-zero = tripped
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollEvery = 1500 * time.Millisecond
|
||||||
|
|
||||||
|
// Client owns the serial link to one supply.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
connMu sync.Mutex
|
||||||
|
conn serial.Port
|
||||||
|
|
||||||
|
ioMu sync.Mutex // serialises a request/reply exchange on the shared port
|
||||||
|
|
||||||
|
statusMu sync.Mutex
|
||||||
|
last Status
|
||||||
|
|
||||||
|
stopChan chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
lastErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client. Nothing is opened until Start.
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
if cfg.Address == 0 {
|
||||||
|
cfg.Address = 1
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, stopChan: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the poll loop. It returns immediately: the supply may be off, and
|
||||||
|
// a shack comes up in whatever order it comes up in.
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
go c.loop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the link.
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
c.stopOnce.Do(func() { close(c.stopChan) })
|
||||||
|
c.closeConn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the last poll's answer.
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
return c.last
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOutput switches the supply's output on or off — the one thing this driver
|
||||||
|
// writes. The supply's echo is checked, so a false return of "done" is not
|
||||||
|
// possible: either it confirmed the value or this is an error.
|
||||||
|
func (c *Client) SetOutput(on bool) error {
|
||||||
|
val := uint16(0)
|
||||||
|
if on {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
if err := c.writeRegister(regOnOff, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Report it at once rather than waiting for the next poll: the operator
|
||||||
|
// pressed a button and is looking at it.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.last.On = on
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
log.Printf("psu: output %s", map[bool]string{true: "ON", false: "OFF"}[on])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) loop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.poll(); err != nil {
|
||||||
|
c.noteFailure(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll reads everything the UI shows, in as few exchanges as the register map
|
||||||
|
// allows: the measurements are contiguous (0x0010…0x0013), the set points are
|
||||||
|
// contiguous (0x0030, 0x0031), and the on/off and protection words sit together
|
||||||
|
// at 0x0001/0x0002.
|
||||||
|
func (c *Client) poll() error {
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st := Status{Connected: true}
|
||||||
|
|
||||||
|
state, err := c.readRegisters(regOnOff, 2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.On = state[0] != 0
|
||||||
|
st.Protected = state[1]
|
||||||
|
|
||||||
|
meas, err := c.readRegisters(regVolts, 4) // U, I, P high, P low
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.Volts = float64(meas[0]) / voltScale
|
||||||
|
st.Amps = float64(meas[1]) / ampScale
|
||||||
|
st.Watts = float64(uint32(meas[2])<<16|uint32(meas[3])) / wattScale
|
||||||
|
|
||||||
|
set, err := c.readRegisters(regSetVolts, 2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.SetVolts = float64(set[0]) / voltScale
|
||||||
|
st.SetAmps = float64(set[1]) / ampScale
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.last = st
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
if c.lastErr != "" {
|
||||||
|
log.Printf("psu: %s answering again", c.cfg.ComPort)
|
||||||
|
c.lastErr = ""
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readRegisters(reg, count uint16) ([]uint16, error) {
|
||||||
|
frame, err := c.exchange(buildRead(c.cfg.Address, reg, count))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseRead(c.cfg.Address, count, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeRegister(reg, val uint16) error {
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frame, err := c.exchange(buildWrite(c.cfg.Address, reg, val))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return parseWriteEcho(c.cfg.Address, reg, val, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange sends one frame and reads one reply, holding the port for the whole
|
||||||
|
// round trip. Modbus RTU has no way to match a reply to a request, so two
|
||||||
|
// exchanges in flight at once would read each other's answers.
|
||||||
|
func (c *Client) exchange(req []byte) ([]byte, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return nil, fmt.Errorf("psu: not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
|
|
||||||
|
// The silence before a frame is part of the protocol, not politeness: it is
|
||||||
|
// how the supply knows this is a new message and not the tail of the last.
|
||||||
|
time.Sleep(frameGap)
|
||||||
|
if _, err := conn.Write(req); err != nil {
|
||||||
|
c.closeConn()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
frame, err := readFrame(conn, replyWait)
|
||||||
|
if err != nil {
|
||||||
|
c.closeConn()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
port := strings.TrimSpace(c.cfg.ComPort)
|
||||||
|
if port == "" {
|
||||||
|
return fmt.Errorf("psu: no serial port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(port, &serial.Mode{
|
||||||
|
BaudRate: c.cfg.Baud,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: serial.NoParity,
|
||||||
|
StopBits: serial.OneStopBit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("psu: cannot open %s: %w", port, err)
|
||||||
|
}
|
||||||
|
// Short per-read timeout: readFrame decides when a frame has ended by the
|
||||||
|
// quiet between bytes, so each Read must come back promptly with whatever
|
||||||
|
// has arrived.
|
||||||
|
_ = p.SetReadTimeout(2 * time.Millisecond)
|
||||||
|
c.conn = p
|
||||||
|
log.Printf("psu: %s open at %d baud, Modbus address %d", port, c.cfg.Baud, c.cfg.Address)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) closeConn() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteFailure records a poll failure and says so ONCE per distinct message.
|
||||||
|
//
|
||||||
|
// A supply that is switched off at the mains fails every poll, and a line per
|
||||||
|
// second and a half would be the whole log file — but saying nothing at all is
|
||||||
|
// how "it stopped working" arrives with no evidence.
|
||||||
|
func (c *Client) noteFailure(err error) {
|
||||||
|
msg := err.Error()
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.last = Status{Connected: false, Error: msg}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
if msg != c.lastErr {
|
||||||
|
c.lastErr = msg
|
||||||
|
log.Printf("psu: %v — retrying every %s, and this will not be logged again until it changes", err, pollEvery)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user