From 05d102ab9bba705ac7933a8c5fee1e78a1a98737 Mon Sep 17 00:00:00 2001
From: Serge Bazanski
Date: Thu, 12 Sep 2024 03:02:57 +0200
Subject: [PATCH] succbone: init
---
succd/.gitignore | 1 +
succd/README.md | 35 +++++++
succd/adc.go | 61 ++++++++++++
succd/go.mod | 8 ++
succd/go.sum | 5 +
succd/http.go | 121 +++++++++++++++++++++++
succd/index.html | 247 +++++++++++++++++++++++++++++++++++++++++++++++
succd/main.go | 113 ++++++++++++++++++++++
8 files changed, 591 insertions(+)
create mode 100644 succd/.gitignore
create mode 100644 succd/README.md
create mode 100644 succd/adc.go
create mode 100644 succd/go.mod
create mode 100644 succd/go.sum
create mode 100644 succd/http.go
create mode 100644 succd/index.html
create mode 100644 succd/main.go
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}} |
+
+
+ Status |
+ OK |
+
+
+
+
+
+
+
+
+ {{.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()
+}