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) } }