Single board computer projects

Pitoucon: Raspi touch panel with Kivy and MQTT

last updated: 2021-04-19

pitoucon_screen

My Mechanical Ventilation Heat Recovery (MVHR) system from Paul was updated with a Teensy and a Raspberry Pi (see Piventi and is now sending MQTT messages:

piventi mqtt message

To see this messages and manipulate the air flow I needed a touch panel. I bought a 3.5" PiTFT from Adafruit and use it with a Raspberry Pi 3. The Pi is connected over WiFi an is also getting data from a weather station.

To draw the graphical screen and use the touch function of the screen I use Kivy on the console (without X).

pitoucon_weather

Setting up the Raspberry Pi

The panel worked great from 2017 on. After an update from stretch to Buster (2020) I first couldn't get kivy working and had to flash a new SD card. After long hours I got kivy working in headless mode as described here. But then I saw that the touchscreen didn't work as expected. After other long hours digging the internet I came to the conclusion that there is no chance to get my resistive touchscreen work in Buster. So I decided to revert to stretch as I saw on the adafruit page: "The last known for-sure tested-and-working version is March 13, 2018". Aaargh! Again it didn't' work, so back to Buster and more digging.

Now here is the procedure:

Flash an SD card with Raspi OS lite (headless) image and add an empty file called ssh to the boot partition. And also a file called wpa_supplicant.conf with the following content and your WiFi settings:

    country=LU
    ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
    update_config=1

    network={
      ssid="***"
      psk="***"
    }

As I like fix IP addresses and don't want to search with nmap, I give the Raspi a fix IP by mounting rootfs and adding the following lines to /etc/dhcpcd.conf

    interface wlan0
    static ip_address=192.168.1.222
    static routers=192.168.1.1
    static domain_name_servers=192.168.1.1

Reboot and ssh into the Raspi (pw: raspberry), update the files, change the password with passwd and install the midnight commander mcand htop for maintenance:

    sudo ssh pi@192.168.1.222
    //sudo apt update
    //sudo apt upgrade
    passwd
    sudo apt install mc htop

Install Kivy

I read that in order to launch Kivy from the console you need to compile SDL2 from source, as the one bundled with Buster is not compiled with the right back-end. Here are the commands to install everything:

    sudo apt-get install libfreetype6-dev libgl1-mesa-dev libgles2-mesa-dev \
    libdrm-dev libgbm-dev libudev-dev libasound2-dev liblzma-dev libjpeg-dev \
    libtiff-dev libwebp-dev git build-essential
    sudo apt-get install gir1.2-ibus-1.0 libdbus-1-dev libegl1-mesa-dev \
    libibus-1.0-5 libibus-1.0-dev libice-dev libsm-dev libsndio-dev \
    libwayland-bin  libwayland-dev libxi-dev libxinerama-dev libxkbcommon-dev \
    libxrandr-dev libxss-dev libxt-dev libxv-dev x11proto-randr-dev  \
    x11proto-scrnsaver-dev x11proto-video-dev x11proto-xinerama-dev
    wget https://libsdl.org/release/SDL2-2.0.10.tar.gz
    wget https://libsdl.org/projects/SDL_image/release/SDL2_image-2.0.5.tar.gz
    wget https://libsdl.org/projects/SDL_mixer/release/SDL2_mixer-2.0.4.tar.gz
    wget https://libsdl.org/projects/SDL_ttf/release/SDL2_ttf-2.0.15.tar.gz
    tar -zxvf SDL2-2.0.10.tar.gz && tar -zxvf SDL2_image-2.0.5.tar.gz
    tar -zxvf SDL2_mixer-2.0.4.tar.gz && tar -zxvf SDL2_ttf-2.0.15.tar.gz
    pushd SDL2-2.0.10
    ./configure --enable-video-kmsdrm --disable-video-opengl --disable-video-x11 --disable-video-rpi
    make -j$(nproc)
    sudo make install
    popd
    pushd SDL2_image-2.0.5
    ./configure
    make -j$(nproc)
    sudo make install
    popd
    pushd SDL2_mixer-2.0.4
    ./configure
    make -j$(nproc)
    sudo make install
    popd
    pushd SDL2_ttf-2.0.15
    ./configure
    make -j$(nproc)
    sudo make install
    popd
    sudo apt install pkg-config libgl1-mesa-dev libgles2-mesa-dev \
    python3-setuptools libgstreamer1.0-dev git-core \
    gstreamer1.0-plugins-{bad,base,good,ugly} \
    gstreamer1.0-{omx,alsa} python3-dev libmtdev-dev \
    xclip xsel libjpeg-dev
    sudo apt install python3-pip
    sudo python3 -m pip install --upgrade --user pip setuptools
    sudo python3 -m pip install --upgrade --user Cython==0.29.19 pillow
    sudo python3 -m pip install --user https://github.com/kivy/kivy/archive/master.zip    
    sudo apt install python3-rpi.gpio
    sudo python3 -m pip install paho-mqtt

My Python must run as root because of the shutdown script. So I need to install kivy as root (with sudo). Change the dimensions on 480/320 in /root/.kivy/config.ini.The screen driver and the touchscreen controller driver have separate settings for screen rotation. So we need to change the rotation of the touchscreen controller driver to match the rotation of the screen driver. Add under [input] in /root/.kivy/config.ini the following line to get the touchscreen work correctly (again hours of research):

    hid_%(name)s = probesysfs,provider=hidinput,param=rotation=270,param=invert_y=1

Setting up the display

The 3.5" display is from adafruit (PiTFT). We download there installer script and use it:

    wget https://raw.githubusercontent.com/adafruit/Raspberry-Pi-Installer-Scripts/master/adafruit-pitft.sh
    chmod +x adafruit-pitft.sh
    sudo ./adafruit-pitft.sh

I selected the configuration number 5 (PiTFT 3.5" resistive touch (320x480)) and the rotation number 3 (270 degrees (landscape)). As we want to use PiTFT as text console, theoretically we have to say "Yes" to the question "Would you like the console to appear on the PiTFT display." But with this I got the console on the screen, but kivy didn't use the screen. So say no for "Would you like the console to appear on the PiTFT display" and yes for "Would you like the HDMI display to mirror to the PiTFT display?". Now the fbcp files were installed. After this I ran the script again and got back to the first option with a functioning display!

The information on he rotation is stored in /boot/config.txt, so it is possible to change it there if needed.

Look for more infos here.

After installation you can test with a minimal Python program:

    from kivy.app import App

    class pitouconApp(App):
        pass

    if __name__ == '__main__':
        pitouconApp().run()

With the following pitoucon.kv program (same folder):

Screen:
    Label:
        text: 'Hallo'

Start at boot and enabling shutdown

Now kivy is working and also the touch display. But when I started the script on boot with rc.local the kivy screen breaks when something is shown on the console. First I tried to redirect the output to /dev/null, but that was not enough. The solution was to get the console screen out of the way.

Change in /etc/cmdline.txt fbcon=map:10 to fbcon=map:2.

To start the script, add the following line to your /etc/rc.local file (sleep waits for the network):

    (sleep 10
    python3 /home/pi/pitoucon/pitoucon.py) &

I added a push-button (Pin 40 (GPIO21) to Ground) to my pitoucon. When the button is pressed for less than 3 seconds, my Pi reboots. If pressed for more than 3 seconds it shuts down.

The cool script is on github.com/gilyes/pi-shutdown. Download the script and add the following line to /etc/rc.local:

    python3 /home/pi/pishutdown.py &

(If you use pin 5 (GPIO3) and it is pressed while shut down, the Pi restarts. This is not possible with pin 40.)

My Pi became randomly inaccessible over WiFi. In /var/log/syslog I found:
{TIMESTAMP} raspberrypi dhcpcd[{PID}]: wlan0: carrier lost
It turned out the problem was that the Pi WiFi controller has power_save on by default.

Command to read the current power saving mode (Stretch):

    sudo iw wlan0 get power_save

Command to power_save off:

    sudo iw wlan0 set power_save off

To make this permanent I added the following line to /etc/rc.local:

    /sbin/iw dev wlan0 set power_save off

My /etc/rc.local after all the changes:

    _IP=$(hostname -I) || true
    if [ "$_IP" ]; then
      printf "My IP address is %s\n" "$_IP"
    fi
    /usr/local/bin/fbcp &
    /sbin/iw dev wlan0 set power_save off
    (sleep 10
    python3 /home/pi/pitoucon/pitoucon.py) &
    python3 /home/pi/pishutdown.py &
    exit 0

Software

To understand the Kivy logic was not so easy, but finally the Python software worked. A switch on GPIO 3 is used to toggle the backlight. The switch is polled every half of a second in a callback function that is initiated by an event (see https://kivy.org/docs/guide/events.html). Another event every 10 minutes sends an alive message.

I wanted to use the Screenmanager with more screens, but finally one screen sufficed. The pitoucon.kv-file is in the download section. Here is the Python code (the MQTT messages changed from the previous version!):

    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-

    from kivy.app import App
    from kivy.clock import Clock
    from kivy.uix.screenmanager import ScreenManager, Screen
    from kivy.uix.boxlayout import BoxLayout
    from kivy.properties import ObjectProperty, StringProperty
    import paho.mqtt.client as mqtt
    import RPi.GPIO as GPIO
    import json
    import time, datetime

    topic      = "myhome/ventilation"

    class ScreenManagement(ScreenManager):
        pass

    class Main(Screen):
        inoutflow = StringProperty()
        freshexh = StringProperty()
        tacho = StringProperty()
        co2 = StringProperty()
        def setVent(self,percent):
            print('button state is: ', percent)
            message = "{\"flow-rate\":" + str(percent) + "}"
            mqttc.publish(topic,message)

    class Setup(Screen):
        pass

    class pitouconApp(App):
        def build(self):
            global SM #ScreenManager
            global s
            SM = self.root
            s = SM.get_screen('main')

        def on_start(self):
            global mqttc
            global switch_pin
            switch_pin = 3
            global switch_old
            switch_old = 1
            GPIO.setmode(GPIO.BCM)
            GPIO.setwarnings(False)
            GPIO.setup(switch_pin, GPIO.IN)
            clientID   = "pitoucon"
            brokerIP   = "192.168.1.111"
            brokerPort = 1883
            # Callback if CONNACK response from the server.
            def onConnect(client, userdata, flags, rc):
                print("Connected with result code " + str(rc))
                mqttc.subscribe(topic, 0)  # Subscribe (topic name, QoS)
            # Callback that is executed when we disconnect from the broker.
            def onDisconnect(client, userdata, message):
                print("Disconnected from the broker.")
            # Callback that is executed when subscribing to a topic
            def onSubscribe(client, userdata, mid, granted_qos):
                print('Subscribed on topic.')
            # Callback that is executed when unsubscribing to a topic
            def onUnsubscribe(client, userdata, mid, granted_qos):
                print('Unsubscribed on topic.')
            # Callback that is executed when a message is received.
            def onMessage(client, userdata, message):
                io=message.payload.decode("utf-8")
                if (io[2:6] != "flow") and (io[2:7] != "alive"):
                    try:
                        ioj=json.loads(io)
                        inflow_tmp = ioj['air_flows']['in']['temp_C']
                        inflow_hum = ioj['air_flows']['in']['hum_%']
                        outflow_tmp = ioj['air_flows']['out']['temp_C']
                        outflow_hum = ioj['air_flows']['out']['hum_%']
                        in_out_txt = "Temp   Hum\n" + str(inflow_tmp) + "°C   " + \
                                     str(inflow_hum) + "%   IN\n\n" + " " + str(outflow_tmp) + \
                                     "°C   " + str(outflow_hum) + "%   OUT"
                        s.inoutflow = in_out_txt
                        fresh_tmp = ioj['air_flows']['fresh']['temp_C']
                        fresh_hum = ioj['air_flows']['fresh']['hum_%']
                        exhaust_tmp = ioj['air_flows']['exhaust']['temp_C']
                        exhaust_hum = ioj['air_flows']['exhaust']['hum_%']
                        print(fresh_tmp)
                        print(fresh_hum)
                        print(exhaust_tmp)
                        print(exhaust_hum)
                        fresh_exh_txt = " Temp   Hum\nFRESH   " + str(fresh_tmp) + \
                                        "°C  " + str(fresh_hum) + "%\n\nEXHAUST " + \
                                        str(exhaust_tmp) + "°C  " + str(exhaust_hum) + "%"
                        s.freshexh = fresh_exh_txt
                        print(fresh_exh_txt)
                        tacho_in = ioj["tacho"]["in_%"]
                        tacho_out = ioj["tacho"]["out_%"]
                        tacho_txt = "Tacho I/O : " + str(tacho_in) + "% " + str(tacho_out) + "%"
                        s.tacho = tacho_txt
                        print(tacho_txt)
                        co2 = ioj["co2_ppm"]
                        co2_txt = "CO2: " + str(co2) + "ppm"
                        s.co2 = co2_txt
                        print(co2_txt)
                    except:
                        print("json error")
                else:
                    pass
            # Callback every 500ms to poll the backlight switch and act accordingly
            def poll_switch(dt):
                global switch_old
                switch = GPIO.input(switch_pin)
                if (switch != switch_old):
                    if switch:
                        with open("/sys/class/backlight/soc:backlight/brightness", "w") as f:
                            f.write('0')
                    else:
                        with open("/sys/class/backlight/soc:backlight/brightness", "w") as f:
                            f.write('1')
                switch_old = switch
            def send_alive(dt):
                samessage = datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')
                samessage = "{\"alive\":\"" + samessage + "\"}"
                mqttc.publish(topic,samessage)

            mqttc = mqtt.Client(client_id=clientID, clean_session=True)
            mqttc.on_connect      = onConnect   # define the callback functions
            mqttc.on_disconnect   = onDisconnect
            mqttc.on_subscribe    = onSubscribe
            mqttc.on_unsubscribe  = onUnsubscribe
            mqttc.on_message      = onMessage
            mqttc.connect(brokerIP, brokerPort, keepalive=60, bind_address="")
            mqttc.loop_start() # start loop to process callbacks! (new thread!)
            event = Clock.schedule_interval(poll_switch, 1 / 2.) # poll switch 500ms
            event = Clock.schedule_interval(send_alive, 600) # send alive 10 min.
    if __name__ == "__main__":
        pitouconApp().run()

Housing

For the housing I searched for PiTFT on thingiverse and found the OctoPrint housing. But there exist 2 versions of the 3.5" PiTFT with different dimensions. I got Version with the PID 2097 and not the newer PID 2441. So a second search gave me the Touch Pi housing from adafruit. I changed the bottom stl, so a raspi3 could be mounted and added blocks to fix it on the wall. Fortunately there is enough space and the possibility to add a switch for the backlight was given.

pitoucon<em>housing</em>bottom pitoucon<em>housing</em>top pitoucon<em>housing</em>ext pitoucon<em>housing</em>freecad

I also added a push-button to reset or shutdown the Raspi (not shown in this picture).

Downloads

Links