Sunday, December 29, 2019

radiosonde_auto_rx addition - OLED display

The problem with auto_rx on a "headless" Raspberry Pi


So, the Project Horus "radiosonde_auto_rx" package is a wonderful bit of software that one can run on a Raspberry Pi to automatically detect and upload radiosonde data to the CUFS Predictor site.  They've also front-ended that site with one specifically for weathersondes at https://tracker.sondehub.org.

Unfortunately, it's the kind of thing I'd prefer to run "headless" and save the space and expense of keeping a keyboard, monitor and mouse attached to my Raspberry Pi.  I would also like to be able to use it in a mobile application, rather than the Project Horus chasemapper software.  While Chasemapper looks like great software, I presently run HAB chases with Windows APRSISCE32 software.  I run a windows laptop in my chase vehicle, and expect to continue doing so. Therefore, when running with auto_rx, I'd like to keep the mobile footprint of my weather sonde gear minimal and use my Windows laptop.  Therefore, I decided to add an OLED display to the, otherwise headless, Raspberry Pi.

The OLED


I picked up some very cheap (like under $2 each) 0.96" monochrome 128x64 OLED displays from Banggood.  I won't post a link as they seem to come and go with inventory.  The particular model that I got actually has the first 16 rows of pixels in yellow, and the rest in blue.  That actually works out well for my plans, as I'd like to use the top rows for static data, and the lower rows for a scrolling text area with incoming telemetry.


The OLED is an I2C device.  It easily plugs into a Raspberry Pi on pins 1, 3, 5, 6. 

Python Library


It was harder than I expected to find a good library for the OLED in the Raspberry Pi.  Several were expecting Python 2.  Others that I expected to work had dependency problems.  Adafruit was suggesting using their Circuitpython libraries, but I couldn't get them to work.  I ended up downloading Adafruit's legacy Adafruit_SSD1306 library and using that.

Todo: I should really download a contemporary library and get this code working with it.

Interfacing with auto_rx


The next decision was about how to interface with auto_rx as seamlessly as possible.  Project Horus made that very easy by providing the data I wanted via UDP broadcast.  By setting "payoad_summary_enabled" to "True" in the "station.cfg" file of auto_rx, they enable broadcasts.  While intended for use with their Chasemapper app, there's no reason we can't piggy-back on that functionality to get the payload data out separately with no mods at all to the stock auto_rx code.

The basic script

My "runoled.py" script is very simple.  It's basically doing this:

    Initialize OLED
    Initialize UDP Listener
    while (1) 
       update_OLED
          Update first two lines with IP addr and SSID
          Print out the next 6 lines of scrolling text.
       sleep(1)


The real magic comes in when a new UDP packet comes in.  When it does, the interrupt routine updates the 6 lines of rolling text for the OLED display, preparing it for the next update in the main program loop..


def handle_payload_summary(packet)
    ...
    oled_line1 = oled_line2
    oled_line2 = oled_line3
    oled_line3 = oled_line4
    oled_line4 = oled_line5
    oled_line5 = oled_line6
    oled_line6 = _time + ' ' + str(_alt)

This just rolls the lines from bottom to top, and puts the latest packet data (time and altitude) on the bottom. As additional packets come in, we'll scroll the oldest up, and then off.

How it looks in action


Here's an example of how the OLED works on my Pi.  I have the Pi installed in a cheap clear case.  I drilled a hole in it to mount the OLED, and poke the riser pins through the cover.  They're wired to the GPIO riser inside the case.  It's just taped together for now.  If I get fancy, I might install some screws.  The data in the display is just fabricated times and altitudes that I sent to the script via a test program that uses the same library calls that auto_rx uses.


Network display and Wifi setup


I configure multiple SSID's on my auto_rx Raspberry Pi.  When it's in my home, it joins to my home network.  When I'm out in my car, I have it join my cell phone's tether SSID.  I have the first two lines of the OLED set up so that I can see the IP address of the Pi, and also the SSID that it has joined.  This gives me visual confirmation that the Pi is up and running on the network.

I rum a proxy ssh solution on my Pi, so that I can SSH into it, even when on my cell phone tether.

Automated startup


I have the script set up to run out of /etc/rc.local so that it starts at boot time.  I can monitor the first two lines of the OLED to confirm the Pi is on the network, and then when packets start arriving, the timestamp and altitude begin scrolling from the bottom.  

From /etc/rc.local:
/bin/su --login pi -c /home/pi/src/oled/runoled.py > /tmp/runoled.log 2>&1 &


The full source of runoled.py


Shipped by weight not volume.  Some settling may have occurred during shipping.  Actual color may vary from the packaging.   If you experience any unpleasant side effects, discontinue use immediately.


#!/usr/bin/env python
#
#   radiosonde_auto_rx - 'Horus UDP' Receiver Example
#
#   Copyright (C) 2019  Mark Jessop <vk5qi@rfhead.net>
#   Released under GNU GPL v3 or later
#
#   This code provides an example of how the Horus UDP packets emitted by auto_rx can be received
#   using Python. Horus UDP packets are simply JSON blobs, which all must at the very least contain a 'type' field, which
#   (as the name suggests) indicates the packet type. auto_rx emits packets of type 'PAYLOAD_SUMMARY', which contain a summary
#   of payload telemetry information (latitude, longitude, altitude, callsign, etc...)
#
#   Output of Horus UDP packets is enabled using the payload_summary_enabled option in the config file.
#   See here for information: https://github.com/projecthorus/radiosonde_auto_rx/wiki/Configuration-Settings#payload-summary-output
#   By default these messages are emitted on port 55672, but this can be changed.
#
#   In this example I use a UDPListener object (ripped from the horus_utils repository) to listen for UDP packets in a thread,
#   and pass packets that have a 'PAYLOAD_SUMMARY' type field to a callback, where they are printed.
#

import datetime
import json
import pprint
import socket
import time
import traceback
from threading import Thread

import Adafruit_GPIO.SPI as SPI
import Adafruit_SSD1306

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont

import subprocess

oled_line1 = ""
oled_line2 = ""
oled_line3 = ""
oled_line4 = ""
oled_line5 = ""
oled_line6 = ""


class UDPListener(object):
    ''' UDP Broadcast Packet Listener
    Listens for Horus UDP broadcast packets, and passes them onto a callback function
    '''

    def __init__(self,
        callback=None,
        summary_callback = None,
        gps_callback = None,
        port=55673):

        self.udp_port = port
        self.callback = callback

        self.listener_thread = None
        self.s = None
        self.udp_listener_running = False


    def handle_udp_packet(self, packet):
        ''' Process a received UDP packet '''
        try:
            # The packet should contain a JSON blob. Attempt to parse it in.
            print "Packet received"
            packet_dict = json.loads(packet)

            # This example only passes on Payload Summary packets, which have the type 'PAYLOAD_SUMMARY'
            # For more information on other packet types that are used, refer to:
            # https://github.com/projecthorus/horus_utils/wiki/5.-UDP-Broadcast-Messages
            if packet_dict['type'] == 'PAYLOAD_SUMMARY':
                if self.callback is not None:
                    self.callback(packet_dict)

        except Exception as e:
            print("Could not parse packet: %s" % str(e))
            traceback.print_exc()


    def udp_rx_thread(self):
        ''' Listen for Broadcast UDP packets '''

        self.s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
        self.s.settimeout(1)
        self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
        except:
            pass
        self.s.bind(('',self.udp_port))
        print("Started UDP Listener Thread on port %d." % self.udp_port)
        self.udp_listener_running = True

        # Loop and continue to receive UDP packets.
        while self.udp_listener_running:
            try:
                # Block until a packet is received, or we timeout.
                m = self.s.recvfrom(1024)
            except socket.timeout:
                # Timeout! Continue around the loop...
                m = None
            except:
                # If we don't timeout then something has broken with the socket.
                traceback.print_exc()

            # If we hae packet data, handle it.
            if m != None:
                self.handle_udp_packet(m[0])

        print("Closing UDP Listener")
        self.s.close()


    def start(self):
        if self.listener_thread is None:
            self.listener_thread = Thread(target=self.udp_rx_thread)
            self.listener_thread.start()


    def close(self):
        self.udp_listener_running = False
        self.listener_thread.join()




def handle_payload_summary(packet):
    global oled_line1
    global oled_line2
    global oled_line3
    global oled_line4
    global oled_line5
    global oled_line6

    ''' Handle a 'Payload Summary' UDP broadcast message, supplied as a dict. '''

    # Pretty-print the contents of the supplied dictionary.
    pprint.pprint(packet)

    # Extract the fields that should always be provided.
    _callsign = packet['callsign']
    _lat = packet['latitude']
    _lon = packet['longitude']
    _alt = packet['altitude']
    _time = packet['time']

    # The comment field isn't always provided.
    if 'comment' in packet:
        _comment = packet['comment']
    else:
        _comment = "No Comment Provided"

    # Do nothing with these values in this example...
    oled_line1 = oled_line2
    oled_line2 = oled_line3
    oled_line3 = oled_line4
    oled_line4 = oled_line5
    oled_line5 = oled_line6
    oled_line6 = _time + ' ' + str(_alt)


if __name__ == '__main__':
    # Raspberry Pi pin configuration:
    RST = None     # on the PiOLED this pin isnt used
    # Note the following are only used with SPI:
    DC = 23
    SPI_PORT = 0
    SPI_DEVICE = 0
    # 128x64 display with hardware I2C:
    disp = Adafruit_SSD1306.SSD1306_128_64(rst=RST)
    # Initialize library.
    disp.begin()

    # Clear display.
    disp.clear()
    disp.display()

    # Create blank image for drawing.
    # Make sure to create image with mode '1' for 1-bit color.
    width = disp.width
    height = disp.height
    image = Image.new('1', (width, height))

    # Get drawing object to draw on image.
    draw = ImageDraw.Draw(image)

    # Draw a black filled box to clear the image.
    draw.rectangle((0,0,width,height), outline=0, fill=0)

    # Draw some shapes.
    # First define some constants to allow easy resizing of shapes.
    padding = -2
    top = padding
    bottom = height-padding
    # Move left to right keeping track of the current x position for drawing shapes.
    x = 0

    # Load default font.
    font = ImageFont.load_default()
    draw.text((x, top),       "Booting",  font=font, fill=255)
    # Display image.
    disp.image(image)
    disp.display()
################################################################################################
    # Instantiate the UDP listener.
    udp_rx = UDPListener(
        port=55673,
        callback = handle_payload_summary
        )
    # and start it
    udp_rx.start()

    # From here, everything happens in the callback function above.
    try:
        while True:

            # Draw a black filled box to clear the image.
            draw.rectangle((0,0,width,height), outline=0, fill=0)

            # Shell scripts for system monitoring from here : https://unix.stackexchange.com/questions/119126/command-to-display-memory-usage-disk-usage-and-cpu-load
            cmd = "hostname -I | cut -d\' \' -f1"
            IP = subprocess.check_output(cmd, shell = True )

            cmd = "/sbin/iwgetid"
            try:
               SSID = subprocess.check_output(cmd, shell = True )
            except:
               SSID = '"None"'
            SSID = SSID[SSID.find('"')+1:SSID.rfind('"')]

            # Write two lines of text.

            draw.text((x, top),       "IP: " + str(IP),  font=font, fill=255)
            draw.text((x, top+8),     "SSID: " + str(SSID[0:10]), font=font, fill=255)
            draw.text((x, top+16),    oled_line1,  font=font, fill=255)
            draw.text((x, top+24),    oled_line2,  font=font, fill=255)
            draw.text((x, top+32),    oled_line3,  font=font, fill=255)
            draw.text((x, top+40),    oled_line4,  font=font, fill=255)
            draw.text((x, top+48),    oled_line5,  font=font, fill=255)
            draw.text((x, top+56),    oled_line6,  font=font, fill=255)

            # Display image.
            disp.image(image)
            disp.display()

            time.sleep(1)
    # Catch CTRL+C nicely.
    except KeyboardInterrupt:
        # Close UDP listener.
        udp_rx.close()
        # Draw a black filled box to clear the image.
        draw.rectangle((0,0,width,height), outline=0, fill=0)
        # Display image.
        disp.image(image)
        disp.display()
        print("Closing.")


Full source of "send.py"


This program sends UDP packets to the OLED daemon, so that I can test it without waiting for a balloon to come by.  It uses the same subroutine calls that auto_rx uses.  Note, this script isn't pretty.  It was just hacked at until it worked well enough to test the OLED.  I'm not really familiar with the time and datetime libraries or I'm sure I could have done something prettier.

Note, you'll need to set your PYTHONPATH to point into the autorx directories to use this.  Ex:
    $ export PYTHONPATH=/home/pi/src/projecthorus/radiosonde_auto_rx/auto_rx:$PYTHONPATH

Here's the code:

import time
import datetime
from autorx.ozimux import OziUploader
from time import gmtime, localtime

dt = datetime.datetime(2019, 12, 29, 20, 03, 04, 79043)
gmt = gmtime()

ozimux = OziUploader(
    ozimux_port = None,
    payload_summary_port = 55673,
    update_rate = 5,
    station='KD2EAT')


packet = {
    'frame' : '1',
    'id' : '1',
    'datetime' : gmt,
    'lat' : 42.4417,
    'lon' : -76.4985,
    'alt' : 22345,
    'temp': 32,
    'type' : 'PAYLOAD_SUMMARY',
    'freq': '1678',
    'freq_float': 1678.0,
    'datetime_dt' : dt,
    }

_short_time = packet['datetime_dt'].strftime("%H:%M:%S")
ozimux.add(  packet )
print "Sent. sleeping 10 to avoid race condition where we shut down before sending the packet."
time.sleep(10)

ozimux.close()
print "Closed"










1 comment:

  1. Good stuff! Thank you for the time in posting this. I wish I was confident about my Linux skills to say "I can do this" but I barely got autorx running in a Docker container following the bouncing ball of the Wiki instructions (that were really good). Maybe someday though! This is a perfect implementation of what I'd like to do also. Thanks!

    ReplyDelete