Industrial controls using plant waterer as a prototype

Intro

During the pandemic many, perhaps too many people got into gardening, including myself. I love the idea of planting the seeds and watching them sprout, grow and bloom. I also enjoy watering the plants; it’s a relaxing and soothing process… sometimes. I also tend to focus on my own things and forget about everything around me… sometimes. The former and the latter tend to be mutually exclusive. I came across the balenaPlant project that should address my absent-mindedness problem. Unfortunately the HAT is discontinued. Then I saw this post, which also requires a HAT. So I decided why not get rid of the hat? After all “best part is no part”. Also, while at it, can I make it modular? scalable? robust enough for an industrial setting?

In order for it to be modular, it should use blocks, naturally. In order for it to be scalable, the blocks can’t be specific to my goal. Finally, in order for it to be appropriate for an industrial setting, it should probably have good tests.

Hardware

So here goes nothing. I got a balena fin (but any Pi will do), cheapest water pump I could find, a capacitive moisture sensor, a BJT I had lying around, and set out to tinkering.

Software

First problem: how do I use these “blocks”? Turns out it’s easy, take a multicontainer app, add a couple of containers of your own, and those are the “blocks”.

Inter-block communication

Next, how do they talk to each other? There are a few ways: sockets, HTTP/REST, mqtt, dbus, quantum tunnelling, carrier pigeons, etc. It should be easy to implement (so much for quantum tunnelling), preferrably standardized, and robust (there goes my carrier pigeon). I’ve decided to use MQTT, since it’s dead simple to deploy:

  mqtt:
    image: eclipse-mosquitto:1.6.15
    ports:
      - "1883:1883"

The only caveat with MQTT is now you have to add MQTT client package to every block, but that turns out to be fairly easy too:

import paho.mqtt.client as mqtt
client = mqtt.Client()

# sending a message
client.connect('mqtt', 1883)
client.publish(topic='topic', payload='message')
client.disconnect()

# subscribing to a topic
def on_connect(*a, **kw):
    client.subscribe('topic')
def on_message(client, userdata, msg)
    print(msg.topic, '-->', msg.payload)

client.on_connect = on_connect
client.on_message = on_message
client.connect('mqtt', 1883)
client.loop_start()

Reading value from a pin

Ok. Now that I have the blocks, they can communicate, what do I actually put in the blocks?

The simplest block would be something that reads a pin (just a 1/0 value). I prefer python, so I grabbed sensor block as a starting point. It’s probably easiest to use gpiozero, so with this docker image, the environment should be set up:

FROM balenalib/%%BALENA_ARCH%%-debian-python

WORKDIR /usr/src/app

RUN /usr/local/bin/python3.9 -m pip install --upgrade pip

RUN apt-get update -y && apt-get install build-essential

RUN /usr/local/bin/python3.9 -m pip install requests RPi.Gpio gpiozero paho-mqtt

COPY . .

CMD ["python3", "app.py"]

I immediately ran into a problem installing gpiozero, it was complaining about missing a RPi.Gpio, but to install that, I had to add build-essential as well. If there are easier/faster ways of getting a container with gpiozero in it, drop a comment please!

Using design patterns is probably a good practice for slightly more complex applications, but more specifically, it’s best to decouple the hardware-specific code from the business logic. So I wrote an adaptor for the Pi hardware to read a pin.

Publishing pin value

While at it, I also threw together a small wrapper around mqtt to publish messages when the pin value changes.

The main application code became very simple:

from devicemqttpub import DeviceMQTTPubWrapper
from piinput import SensorAdaptor
from time import sleep
if __name__ == '__main__':
    sensor = SensorAdaptor(pin_number=17)
    mqtt = DeviceMQTTPubWrapper(device=sensor, name='mqtt-topic')
    while True:
        sleep(1)

Now, whenever the pin is turned on or off, I get an MQTT message with a ‘1’ or ‘0’.

a sidenote

The real power of adaptor design pattern comes in when you want to use use another SBC: you create an adaptor for that hardware only, and the new code will just need to change one import statement:

from xu4input import SensorAdaptor

instead of

from piinput import SensorAdaptor

the rest of the application will work with zero changes.

Now that I have a way to read the moisture sensor, I need to turn on the pump when it’s “dry” and turn it off when it’s “wet”.

That should be easy enough, grab gpiozero again, and write another wrapper for sending signals to a pin.

a sidenote

One thing to note about this particular moisture sensor is that people say it requires an Analog-to-Digital Converter (ADC). However, I don’t need absolute accuracy or precision. A basic “it’s wet” and “it’s dry” output is more than enough for me. To adjust how wet is “wet”, you can place the sensor in the dry soil and either a pull-up or pull-down resistor to calibrate its output to the desired level.

Put it together in a docker-compose, and we’re off to the races:

  motor:
    privileged: true
    build: ./basic-motor
    restart: always
    environment: 
      - INPUT=sensor
      - PIN=27
  sensor:
    privileged: true
    build: ./basic-sensor
    restart: always
    environment:
      - OUTPUT=sensor
      - PIN=17

Wiring up the device

To actually get it to work, I wired up the circuit the simplest possible way:

Sensor VCC -> fin 3v3
Sensor GND -> fin GND
Sensor OUT -> fin GPIO 17

fin GND -> bjt EMITTER
fin GPIO 27 -> bjt BASE
pump GND -> bjt COLLECTOR
fin 5V -> pump POSITIVE

Strictly speaking, there should be a current-limiting resistor for the BJT base, but after measuring the highest current, I got around 30mA, which resulted in 200mA of current draw for the pump, so it’s not an issue here. I know the circuit diagram shows the pull-down potentiometer, but mine turned out to be unnecessary. You might need to add it, or a pull-up one, depending on your voltage levels from the sensor.



a sidenote
Of course you should not use 800mA transistors to power water pumps in general. But since this pump only requires 200mA, the 2N2222 I’m using here should work. For more robust applications, use a power MOSFET e.g. P75NF75 or a relay, probably a low-voltage one if you’re going to control it from a Pi.

Making it more generic

If the goal was to have a rudimentary controller that’ll turn on a water pump when the moisture sensor tells it to, then it’s done. But I could’ve also implemented it using a few AA batteries, a couple of resistors and a MOSFET instead of hacking on a Raspberry Pi. Plus the purpose of this entire endeavour is to demonstrate an industrial-like control system.

So I’ll add a way to turn the pump on or off using a button on the computer, or a smartphone, or a smart fridge, or a toaster, if I feel adventurous.

Safety is number one priority

The pump should run while a user is holding down the button and stops running when the user releases it. Immediately a cautionary thought comes to mind, what if the connection drops before user has a chance to release the button? Thinking about it some more, what if the moisture sensor malfunctions and constantly reports a “dry” state? Having the pump on permanently would be bad news for everyone involved.

So let’s put some safety checks in:

Pump:

  1. Only run pump for a specified period
  2. Add a mandatory cool down time

Button:

  1. Check for user connection loss and send a “pump off” signal.

The parts 1. and 2. should be done in the pump block: when the pump turns on, start a timer, if the timer reaches a preset threshold, stop the pump and let it cool down. Perhaps something like this:

    def start(self):
        if self.__is_cooling_down:
            return
        self.start_time = time()
        self.__motor.on()
        self.__is_motor_on = True
        self.__thread = Thread(target = self.__monitor_motor)
        self.__thread.start()
    
    def __monitor_motor(self):
        while self.__is_motor_on:
            sleep(0.1)
            self.__max_runtime_reached = self.__runtime > self.__max_runtime
            if self.__max_runtime_reached:
                self.stop()

    @property
    def __is_cooling_down(self):
        if not self.__max_runtime_reached:
            return False
        still_cooling_down = (time() - self.__stop_time) < self.__cooldown_time
        return still_cooling_down

Real time communication

Now back to the button. Detecting a press/release and an accidental disconnect is implemented easiest using websockets.

This requires a client code, running on the phone or a toaster and a server, running on the Pi. The client will connect to a specific port on the Pi and send commands. To avoid opening any ports in the network, I either need to create an app or serve the client code over the same connection. To avoid dealing with Google Play store and Apple App stores, I’ll just host a basic HTML page with the client code directly on the device. This sounds like a use case for a reverse proxy.

Websockets

Before taking a deep dive into the reverse proxy, I’m going to try to establish a WS connection to the system. This sample code provides a good enough starting point to run a client directly from my local PC. The server code is dead simple too:

import paho.mqtt.client as mqtt
import asyncio
import websockets

client = mqtt.Client()

def send_mqtt_message(msg):
    client.connect('mqtt', 1883)
    client.publish(topic='output', payload=msg)
    client.disconnect()

async def listener(websocket, path):
    try:
        async for message in websocket:
            if message == 'on':
                send_mqtt_message('1')
            else:
                send_mqtt_message('0')
            await websocket.send(message)
    finally:
        send_mqtt_message('0')

asyncio.get_event_loop().run_until_complete(
    websockets.serve(listener, '0.0.0.0', 8765, ping_interval=None)
    )
asyncio.get_event_loop().run_forever()

Reverse proxy

Having established a proof of concept communication, I need to make sure the client code can sit on the same device, and not on my local PC.

Reverse proxy is well suited for this task. After digging through a bunch of traefik documentation, I couldn’t get it to work without exposing the docker socket, which could be a potential attack vector.

I settled on an NginX implementation, thanks to @tmigone’s example.

The tricky part is to ensure that the websocket server port is configurable from the docker-compose file, so NginX knows where to send the traffic. The port number has to end up in the nginx.conf file somehow. I’ve tried different approaches, but apparently dockerfile doesn’t know about environment variables. So I hacked together a starter script that has access to the environment. The implementation is fairly straightforward: put a pre-defined string in the nginx.conf, e.g. “__WSPORT__”, then use inline editor sed to replace that string with the port number that’s specified in the docker-compose:

sed s/__WSPORT__/${WSPORT:-8765}/ -i /etc/nginx/nginx.conf
nginx -g "daemon off;"

Now the block looks clean:

  reverse-proxy:
    restart: always
    build: ./reverse-proxy
    ports:
      - "80:80"
    environment: 
      - WSPORT=8765

I decided to put the websocket client in the same block as the reverse proxy/NginX. This decision may seem to go against the modular/scalable requirement, but there were two reasons for it. First, the NginX reverse proxy needs very specific code to forward websocket traffic, so making it generic would become very messy and inefficient. Second - the client code needs to sit on some kind of server, and having two NginX instances would be a waste.

With all the blocks deployed, and MQTT messages going to right places, I can finally turn the pump on/off using a button on a web page. Time to celebrate?

Controlling the logic

Huston, we’ve got a problem.

I can turn the pump on using a button on the page, but as soon as the sensor reports “wet” state, the pump turns off. I can release the button and press it again, but the sensor sends another “off” signal.

New approach: I need to have some kind of latching mechanism, or better yet, some sort of logic that decides “if the button is pressed, ignore the sensor”.

I can think of no better solution than a logic gate block that acts as a digital gate for MQTT. It’s a breeze to implement too - I already have the code that can subscribe to MQTT topics (from the motor block) and publish messages (from the sensor block), all I’d have to do is put them through some logic operators.

The pseudocode would be pump_on = button OR sensor. That’s a simple OR gate implemented in one line in python:

def __and(self):
    return any(self.__input_values)

But if I’m implementing a logic block, I should make it generic, after all, implementing an AND and a NOT (or a NOR) gate is just 2 more methods:

def __or(self):
    return int(any(self.__input_values))

def __nor(self):
    return int(not any(self.__input_values))

a sidenote

NOR acts as a NOT gate when only a single input is specified

This very simple, yet powerful logic block can be reused to build fairly advanced systems.

Just for demonstration purposes, I’ll add a weather block using a free weather api. It may not be the most accurate, but I’m only doing it to showcase the power of logic blocks.

The goal is to have the following: if the forecast calls for 15mm or more of rain then don’t turn on the pump; if the sensor is reporting wet soil, don’t turn on the pump; but if the user presses a “on” button, override the previous two readings.

So my docker-compose now has three more blocks:

  gate-button-or-logic:
    build: ./control-logic
    environment: 
      - INPUTS=logic_1,button_1 # button_1 is the output of the websocket block
      - OUTPUT=logic_2
      - GATE=or

  gate-sensor-and-weather:
    build: ./control-logic
    environment: 
      - INPUTS=sensor_1,weather_1 #sensor_1 is the output of the moisture sensor block
      - OUTPUT=logic_1
      - GATE=and

  weather:
    build: ./weather
    environment:
      - OUTPUT=weather_1
      - BELOW_PRECIPITATION=15 # means the block will send a '1' when the forecast precipitation is < 15mm

more to come soon…

4 Likes

What a great log @builder555! Love your work!