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.
129 lines
3.9 KiB
Go
129 lines
3.9 KiB
Go
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)
|
|
}
|