diff --git a/succbone/succd/README.md b/succbone/succd/README.md index daae535..4d1b077 100644 --- a/succbone/succd/README.md +++ b/succbone/succd/README.md @@ -3,7 +3,11 @@ 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. +Features: + +1. Monitors the state of the Pirani gauge via the BBE's builtin ADC. +2. Allows enabling/disabling the diffusion/roughing pumps (builtin controller always keeps them enabled). +3. Allows for simulating vent/pumpdown button presses. Accessing at the lab --- diff --git a/succbone/succd/gpio.go b/succbone/succd/gpio.go new file mode 100644 index 0000000..9bdfbf5 --- /dev/null +++ b/succbone/succd/gpio.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "os" + "sync" + + "k8s.io/klog" +) + +// gpio is an abstract GPIO output. +type gpio interface { + // set returns the GPIO value. The meaning of the logic level is + // implementation-dependent. + set(state bool) error +} + +// bbGPIO implements gpio using BeagleBone's built-in GPIO pins. +// +// The value of a GPIO is logically non-inverted: false is 0V, true is 3.3V. +// +// The GPIO can be repeatedly set to the same value without performance penalty +// - only level changes are actually written to hardware registers. +type bbGPIO struct { + path string + mu sync.Mutex + state bool +} + +// newBBGPIO returns a BeagleBone GPIO for a given GPIO number. +// +// See the following for GPIO pin numbers ('GPIO NO.' column): +// +// https://vadl.github.io/images/bbb/P8Header.png +// https://vadl.github.io/images/bbb/P9Header.png +func newBBGPIO(num int, value bool) (*bbGPIO, error) { + path := fmt.Sprintf("/sys/class/gpio/gpio%d", num) + + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("could not access: %w", err) + } + + pathDir := path + "/direction" + if err := os.WriteFile(pathDir, []byte("out"), 0); err != nil { + return nil, fmt.Errorf("could not set direction: %w", err) + } + + pathValue := path + "/value" + res := &bbGPIO{ + path: pathValue, + } + if err := res.set(value); err != nil { + return nil, fmt.Errorf("when setting initial value: %w", err) + } + + return res, nil +} + +func (b *bbGPIO) set(state bool) error { + b.mu.Lock() + defer b.mu.Unlock() + if state && !b.state { + if err := os.WriteFile(b.path, []byte("1"), 0); err != nil { + return fmt.Errorf("could not turn on: %w", err) + } + } else if !state && b.state { + if err := os.WriteFile(b.path, []byte("0"), 0); err != nil { + return fmt.Errorf("could not turn off: %w", err) + } + } + b.state = state + return nil +} + +// fakeGPIO implements a GPIO that logs state changes. +type fakeGPIO struct { + desc string + mu sync.Mutex + state bool +} + +func (b *fakeGPIO) set(state bool) error { + b.mu.Lock() + defer b.mu.Unlock() + if state && !b.state { + klog.Infof("%s on", b.desc) + } else if !state && b.state { + klog.Infof("%s off", b.desc) + } + b.state = state + return nil +} diff --git a/succbone/succd/http.go b/succbone/succd/http.go index 7f4a7b6..5822425 100644 --- a/succbone/succd/http.go +++ b/succbone/succd/http.go @@ -51,6 +51,8 @@ func (d *daemon) httpIndex(w http.ResponseWriter, r *http.Request) { } volts, mbar := d.pirani() + rp := d.rpGet() + dp := d.dpGet() loadB, err := os.ReadFile("/proc/loadavg") load := "unknown" @@ -67,6 +69,8 @@ func (d *daemon) httpIndex(w http.ResponseWriter, r *http.Request) { templateIndex.Execute(w, map[string]any{ "volts": formatVolts(volts), "mbar": formatMbar(mbar), + "rp": rp, + "dp": dp, "hostname": hostname, "load": load, }) @@ -93,14 +97,20 @@ func (d *daemon) httpStream(w http.ResponseWriter, r *http.Request) { case <-t.C: // TODO(q3k): don't poll, get notified when new ADC readout is available. volts, mbar := d.pirani() + rp := d.rpGet() + dp := d.dpGet() v := struct { Volts string Mbar string MbarFloat float32 + RPOn bool + DPOn bool }{ Volts: formatVolts(volts), Mbar: string(formatMbar(mbar)), MbarFloat: mbar, + RPOn: rp, + DPOn: dp, } if err := wsjson.Write(ctx, c, v); err != nil { klog.Errorf("Websocket write failed: %v", err) @@ -119,3 +129,31 @@ func (d *daemon) httpMetrics(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "# TYPE sem_pressure_mbar gauge\n") fmt.Fprintf(w, "sem_pressure_mbar %f\n", mbar) } + +func (d *daemon) httpRoughingPumpEnable(w http.ResponseWriter, r *http.Request) { + d.rpSet(true) + fmt.Fprintf(w, "succ on\n") +} + +func (d *daemon) httpRoughingPumpDisable(w http.ResponseWriter, r *http.Request) { + d.rpSet(false) + fmt.Fprintf(w, "succ off\n") +} + +func (d *daemon) httpDiffusionPumpEnable(w http.ResponseWriter, r *http.Request) { + d.dpSet(true) + fmt.Fprintf(w, "deep succ on\n") +} + +func (d *daemon) httpDiffusionPumpDisable(w http.ResponseWriter, r *http.Request) { + d.dpSet(false) + fmt.Fprintf(w, "deep succ off\n") +} + +func (d *daemon) httpButtonPumpDown(w http.ResponseWriter, r *http.Request) { + d.pumpDownPress() +} + +func (d *daemon) httpButtonVent(w http.ResponseWriter, r *http.Request) { + d.ventPress() +} diff --git a/succbone/succd/index.html b/succbone/succd/index.html index 69be5c5..14fb9dd 100644 --- a/succbone/succd/index.html +++ b/succbone/succd/index.html @@ -29,12 +29,17 @@ h2 { font-style: italic; font-weight: 100; } +button { + height: 4.5em; + padding-left: 1.5em; + padding-right: 1.5em; +}
+
Voltage | @@ -44,6 +49,25 @@ h2 {Pressure | {{.mbar}} |
---|---|---|
Roughing Pump | +{{ if .rp }}ON{{ else }}OFF{{ end }} | +|
Diffusion Pump | +{{ if .dp }}ON{{ else }}OFF{{ end }} | +|
Evac Control | ++ + + + + + + | +|
Status | OK | @@ -197,6 +221,12 @@ window.addEventListener("load", (_) => { let volts = document.querySelector("#volts"); let mbar = document.querySelector("#mbar"); let ping = document.querySelector("#ping"); + + // Buttons + let pd = document.querySelector("#pd"); + let vent = document.querySelector("#vent"); + let rpon = document.querySelector("#rpon"); + let rpoff = document.querySelector("#rpoff"); canvas = document.querySelector("#graph").getContext("2d"); // TODO(q3k): unhardcode this. @@ -226,6 +256,18 @@ window.addEventListener("load", (_) => { const data = JSON.parse(event.data); volts.innerHTML = data.Volts; mbar.innerHTML = data.Mbar; + rp.innerHTML = data.RPOn ? "ON" : "OFF"; + if (data.RPOn) { + rp.style = "background-color: #60f060"; + } else { + rp.style = "background-color: #f06060"; + } + dp.innerHTML = data.DPOn ? "ON" : "OFF"; + if (data.DPOn) { + dp.style = "background-color: #60f060"; + } else { + dp.style = "background-color: #f06060"; + } historicalPush(data.MbarFloat); ping.innerHTML = Date.now(); }); @@ -243,5 +285,24 @@ window.addEventListener("load", (_) => { }); }; connect(); + + pd.addEventListener("click", async (event) => { + await fetch("/button/pumpdown"); + }); + vent.addEventListener("click", async (event) => { + await fetch("/button/vent"); + }); + rpon.addEventListener("click", async (event) => { + await fetch("/rp/on"); + }); + rpoff.addEventListener("click", async (event) => { + await fetch("/rp/off"); + }); + dpon.addEventListener("click", async (event) => { + await fetch("/dp/on"); + }); + dpoff.addEventListener("click", async (event) => { + await fetch("/dp/off"); + }); }); \ No newline at end of file diff --git a/succbone/succd/main.go b/succbone/succd/main.go index e5d524a..913f29e 100644 --- a/succbone/succd/main.go +++ b/succbone/succd/main.go @@ -21,11 +21,23 @@ type daemon struct { // Pirani gauge. adcPirani adc + gpioDiffusionPump gpio + gpioRoughingPump gpio + gpioBtnPumpDown gpio + gpioBtnVent gpio + // mu guards state variables below. mu sync.RWMutex // adcPiraniVolts is a moving window of read ADC values, used to calculate a // moving average. adcPiraniVolts []float32 + rpOn bool + dpOn bool + // ventScheduled and pumpdownScheduled are timers which expire when the + // vent/pumpdown relays should be deactivated. This allows these outputs to + // be controlled momentarily. + ventScheduled time.Time + pumpdownScheduled time.Time } // process runs the pain acquisition and control loop of succd. @@ -56,12 +68,24 @@ func (d *daemon) processOnce(_ context.Context) error { return fmt.Errorf("when reading ADC: %w", err) } d.mu.Lock() + defer d.mu.Unlock() d.adcPiraniVolts = append(d.adcPiraniVolts, v) trim := len(d.adcPiraniVolts) - 100 if trim > 0 { d.adcPiraniVolts = d.adcPiraniVolts[trim:] } - d.mu.Unlock() + if err := d.gpioRoughingPump.set(d.rpOn); err != nil { + return fmt.Errorf("when configuring RP: %w", err) + } + if err := d.gpioDiffusionPump.set(!d.dpOn); err != nil { + return fmt.Errorf("when configuring RP: %w", err) + } + if err := d.gpioBtnPumpDown.set(!d.pumpdownScheduled.After(time.Now())); err != nil { + return fmt.Errorf("when configuring pumpdown: %w", err) + } + if err := d.gpioBtnVent.set(!d.ventScheduled.After(time.Now())); err != nil { + return fmt.Errorf("when configuring vent: %w", err) + } return nil } @@ -84,6 +108,52 @@ func (d *daemon) pirani() (volts float32, mbar float32) { return } +// rpSet enables/disables the roughing pump. +func (d *daemon) rpSet(state bool) { + d.mu.Lock() + defer d.mu.Unlock() + d.rpOn = state +} + +// rpGet returns whether the roughing pump is enabled/disabled. +func (d *daemon) rpGet() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.rpOn +} + +// dpSet enables/disables the diffusion pump. +func (d *daemon) dpSet(state bool) { + d.mu.Lock() + defer d.mu.Unlock() + d.dpOn = state +} + +// dpGet returns whether the diffusion pump is enabled/disabled. +func (d *daemon) dpGet() bool { + d.mu.RLock() + defer d.mu.RUnlock() + return d.dpOn +} + +// pumpDownPressed toggles the pump down relay for 500ms. +func (d *daemon) pumpDownPress() { + d.mu.Lock() + defer d.mu.Unlock() + if d.pumpdownScheduled.Before(time.Now()) { + d.pumpdownScheduled = time.Now().Add(500 * time.Millisecond) + } +} + +// ventPress toggles the vent relay for 500ms. +func (d *daemon) ventPress() { + d.mu.Lock() + defer d.mu.Unlock() + if d.ventScheduled.Before(time.Now()) { + d.ventScheduled = time.Now().Add(500 * time.Millisecond) + } +} + var ( flagFake bool flagListenHTTP string @@ -96,21 +166,49 @@ func main() { ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) - d := daemon{} + d := daemon{ + rpOn: true, + } if flagFake { - klog.Infof("Starting with fake Pirani probe") + klog.Infof("Starting with fake peripherals") d.adcPirani = &fakeADC{} + d.gpioRoughingPump = &fakeGPIO{desc: "rp"} + d.gpioDiffusionPump = &fakeGPIO{desc: "~dp"} + d.gpioBtnPumpDown = &fakeGPIO{desc: "~pd"} + d.gpioBtnVent = &fakeGPIO{desc: "~vent"} } else { adc, err := newBBADC(0) if err != nil { klog.Exitf("Failed to setup Pirani ADC: %v", err) } d.adcPirani = adc + + for _, c := range []struct { + out *gpio + num int + }{ + {&d.gpioRoughingPump, 115}, + {&d.gpioDiffusionPump, 49}, + {&d.gpioBtnPumpDown, 48}, + {&d.gpioBtnVent, 60}, + } { + // Relay, active low. + *c.out, err = newBBGPIO(c.num, true) + if err != nil { + klog.Exitf("Failed to setup GPIO: %v", err) + } + } } http.HandleFunc("/", d.httpIndex) http.HandleFunc("/stream", d.httpStream) http.HandleFunc("/metrics", d.httpMetrics) + http.HandleFunc("/button/vent", d.httpButtonVent) + http.HandleFunc("/button/pumpdown", d.httpButtonPumpDown) + http.HandleFunc("/rp/on", d.httpRoughingPumpEnable) + http.HandleFunc("/rp/off", d.httpRoughingPumpDisable) + http.HandleFunc("/dp/on", d.httpDiffusionPumpEnable) + http.HandleFunc("/dp/off", d.httpDiffusionPumpDisable) klog.Infof("Listening for HTTP at %s", flagListenHTTP) go func() {