diff --git a/succd/.gitignore b/succd/.gitignore new file mode 100644 index 0000000..5195297 --- /dev/null +++ b/succd/.gitignore @@ -0,0 +1 @@ +succd diff --git a/succd/README.md b/succd/README.md new file mode 100644 index 0000000..daae535 --- /dev/null +++ b/succd/README.md @@ -0,0 +1,35 @@ +succd +==== + +A little daemon for monitoring the SEM. This is a temporary solution that runs on a BeagleBone Enhanced (`succbone.lab`) and *SHOULD* be replaced with a proper PLC/SCADA system and general process control in the future. + +Currently it monitors the state of the Pirani gauge via the BBE's builtin ADC. + +Accessing at the lab +--- + +Go to [succbone.lab.fa-fo.de](http://succbone.lab.fa-fo.de). + +Known issues +--- + +Sometimes the websocket doesn't connect. Refreshing the page a few times should fix it. + +Running locally +--- + +``` +$ go run . -fake +``` + +Then point your browser to localhost:8080 + +Deploying on the succbone +--- + +``` +$ ssh root@succbone systemctl stop succd +$ GOARCH=arm go build . +$ scp succd root@succbone:/usr/bin/succd +$ ssh root@succbone systemctl start succd +``` diff --git a/succd/adc.go b/succd/adc.go new file mode 100644 index 0000000..2e4130d --- /dev/null +++ b/succd/adc.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "math" + "os" + "strconv" + "strings" + "time" +) + +// adc is an abstract ADC-based analog input. +type adc interface { + // Read returns the ADC value in volts. + Read() (float32, error) +} + +// bbADC implements adc using a BeagleBone's built-in ADC. +type bbADC struct { + path string +} + +// newBBADC returns a BeagleBone ADC for a given channel number. +func newBBADC(num int) (*bbADC, error) { + path := fmt.Sprintf("/sys/bus/iio/devices/iio:device0/in_voltage%d_raw", num) + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("could not access: %w", err) + } + return &bbADC{ + path: path, + }, nil +} + +func (b *bbADC) Read() (float32, error) { + by, err := os.ReadFile(b.path) + if err != nil { + return 0, err + } + d := strings.TrimSpace(string(by)) + v, err := strconv.ParseUint(d, 10, 64) + if err != nil { + return 0, err + } + // The ADC Vref/Vdd is at 1.8V and is 10-bit (0-4095). + vadc := float32(v) * 1.8 / 4096.0 + // The ADC is connected through a resistor divider. + r1 := float32(1000.0) + r2 := float32(4698.0) + vin := vadc / (r1 / (r1 + r2)) + return vin, nil +} + +// fakeADC implements an adc that outputs a sine wave. This is used for testing. +type fakeADC struct { +} + +func (b *fakeADC) Read() (float32, error) { + t := float64(time.Now().UnixMilli()) / 1000 + v := (math.Sin(t/10)+1)*(6.5/2) + 2 + return float32(v), nil +} diff --git a/succd/go.mod b/succd/go.mod new file mode 100644 index 0000000..574985d --- /dev/null +++ b/succd/go.mod @@ -0,0 +1,8 @@ +module git.fa-fo.de/fafo/jeol-t330a/succd + +go 1.22.3 + +require ( + github.com/coder/websocket v1.8.12 + k8s.io/klog v1.0.0 +) diff --git a/succd/go.sum b/succd/go.sum new file mode 100644 index 0000000..c8b5195 --- /dev/null +++ b/succd/go.sum @@ -0,0 +1,5 @@ +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= diff --git a/succd/http.go b/succd/http.go new file mode 100644 index 0000000..07bdc0d --- /dev/null +++ b/succd/http.go @@ -0,0 +1,121 @@ +package main + +import ( + _ "embed" + "fmt" + "html/template" + "net/http" + _ "net/http/pprof" + "os" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "k8s.io/klog" +) + +var ( + //go:embed index.html + templateIndexText string + templateIndex = template.Must(template.New("index").Parse(templateIndexText)) +) + +func formatVolts(v float32) string { + return fmt.Sprintf("%.3f V", v) +} + +// formatMbar formats a millibar value using scientific notation and returns a +// HTML fragment (for superscript support). +func formatMbar(v float32) template.HTML { + exp := 0 + for v < 1 { + v *= 10 + exp -= 1 + } + for v >= 10 { + v /= 10 + exp += 1 + } + res := fmt.Sprintf("%.3f", v) + res += fmt.Sprintf(" x 10%d", exp) + res += " mbar" + return template.HTML(res) +} + +// httpIndex is the / view. +func (d *daemon) httpIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + volts, mbar := d.pirani() + + loadB, err := os.ReadFile("/proc/loadavg") + load := "unknown" + if err == nil { + parts := strings.Fields(string(loadB)) + load = strings.Join(parts[:3], " ") + } + + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + templateIndex.Execute(w, map[string]any{ + "volts": formatVolts(volts), + "mbar": formatMbar(mbar), + "hostname": hostname, + "load": load, + }) +} + +// httpStream is the websocket clientwards data hose, returning a 10Hz update +// stream of pressure/voltage. +func (d *daemon) httpStream(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer c.CloseNow() + + t := time.NewTicker(time.Second / 10) + defer t.Stop() + + ctx := c.CloseRead(r.Context()) + for { + select { + case <-ctx.Done(): + c.Close(websocket.StatusNormalClosure, "") + return + case <-t.C: + // TODO(q3k): don't poll, get notified when new ADC readout is available. + volts, mbar := d.pirani() + v := struct { + Volts string + Mbar string + MbarFloat float32 + }{ + Volts: formatVolts(volts), + Mbar: string(formatMbar(mbar)), + MbarFloat: mbar, + } + if err := wsjson.Write(ctx, c, v); err != nil { + klog.Errorf("Websocket write failed: %v", err) + return + } + } + } +} + +// httpMetrics serves minimalistic Prometheus-compatible metrics. +func (d *daemon) httpMetrics(w http.ResponseWriter, r *http.Request) { + // TODO(q3k): also serve Go stuff using the actual Prometheus metrics client + // library. + _, mbar := d.pirani() + fmt.Fprintf(w, "# HELP sem_pressure_mbar Pressure in the SEM chamber, in millibar\n") + fmt.Fprintf(w, "# TYPE sem_pressure_mbar gauge\n") + fmt.Fprintf(w, "sem_pressure_mbar %f\n", mbar) +} diff --git a/succd/index.html b/succd/index.html new file mode 100644 index 0000000..033a5f8 --- /dev/null +++ b/succd/index.html @@ -0,0 +1,247 @@ + + +succd + + + +

succd

+

nothing more permanent than a temporary solution

+ +

+ + + + + + + + + + + + + +
Voltage{{.volts}}
Pressure{{.mbar}}
StatusOK
+

+

+ +

+ +

+ {{.hostname}} | load: {{.load}} | pprof | metrics | ws ping: +

+ + \ No newline at end of file diff --git a/succd/main.go b/succd/main.go new file mode 100644 index 0000000..c4c6fb9 --- /dev/null +++ b/succd/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "math" + "net/http" + "os" + "os/signal" + "sync" + "time" + + "k8s.io/klog" +) + +// daemon is the main state of the succdaemon. +type daemon struct { + // adcPirani is the adc implementation returning the voltage of the Pfeiffer + // Pirani gauge. + adcPirani adc + + // mu guards state variables below. + mu sync.RWMutex + // adcPiraniVolts is the last readout of adcPirani. + adcPiraniVolts float32 +} + +// process runs the pain acquisition and control loop of succd. +func (d *daemon) process(ctx context.Context) { + ticker := time.NewTicker(time.Millisecond * 100) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := d.processOnce(ctx); err != nil { + if errors.Is(err, ctx.Err()) { + return + } else { + klog.Errorf("Processing error: %v", err) + time.Sleep(time.Second * 10) + } + } + case <-ctx.Done(): + return + } + } +} + +// processOnce runs the main loop step of succd. +func (d *daemon) processOnce(_ context.Context) error { + v, err := d.adcPirani.Read() + if err != nil { + return fmt.Errorf("when reading ADC: %w", err) + } + d.mu.Lock() + d.adcPiraniVolts = v + d.mu.Unlock() + + return nil +} + +// pirani returns the Pirani gauge voltage and pressure. +func (d *daemon) pirani() (volts float32, mbar float32) { + d.mu.RLock() + volts = d.adcPiraniVolts + d.mu.RUnlock() + + // Per Pirani probe docs. + bar := math.Pow(10.0, float64(volts)-8.5) + mbar = float32(bar * 1000.0) + return +} + +var ( + flagFake bool + flagListenHTTP string +) + +func main() { + flag.BoolVar(&flagFake, "fake", false, "Enable fake mode which allows to run succd for tests outside the succbone") + flag.StringVar(&flagListenHTTP, "listen_http", ":8080", "Address at which to listen for HTTP requests") + flag.Parse() + + ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) + + d := daemon{} + if flagFake { + klog.Infof("Starting with fake Pirani probe") + d.adcPirani = &fakeADC{} + } else { + adc, err := newBBADC(0) + if err != nil { + klog.Exitf("Failed to setup Pirani ADC: %v", err) + } + d.adcPirani = adc + } + + http.HandleFunc("/", d.httpIndex) + http.HandleFunc("/stream", d.httpStream) + http.HandleFunc("/metrics", d.httpMetrics) + + klog.Infof("Listening for HTTP at %s", flagListenHTTP) + go func() { + if err := http.ListenAndServe(flagListenHTTP, nil); err != nil { + klog.Errorf("HTTP listen failed: %v", err) + } + }() + + go d.process(ctx) + <-ctx.Done() +}