Doton the Control Node – part 1

We may now return to the main task, the Control Node with all data from all sources. We will present data in form of a tile.
I’m thinking about creating widgets for both Node One sensors.
Each node is the one tile. We could present light and movement with small icons and humidity and temperature with numbers. It sounds nice and simple but there is a catch. If we refresh the whole screen it would take around 5s to change its content. So we must find a way of repainting only required parts of the screen.

Part 2 – weather widget
Part 3 – window manager

Doton project @ GitHub

Planning

Maybe we could write a widget class in such way that it would keep track of its area and changes. But first, let us consider tile size. Display dimensions are 240×320, we may divide it by 3 and 4.
It gives us the size of 80×80 pixels. But what about spacing? Let’s assume 5px as margin between tiles, 240-10=230, 230/3=76px width. 320-15 => 305/4=76px. Hmm, so tile with size 76×76? Let’s look at it:


It looks nice but a way to small! Try to display something readable on tile or hit it with finger:/ Let’s try again with a 2×3 grid, (240-10)/2=115 and (320-10)/3=103. How it looks:

Much better! We will stick to it.
We have a rectangle 115×103 and we need to fit two small icons and four digits. Any GUI master here? 😀
Do not panic! Icons on the left, 2 digits next to it. Bottom line 3 digits:

Almost good. We forgot about labels but I have no idea where to put them:)

Numbers

Our GfxLCD module can display images and we will use it to display numbers. So let’s prepare a good font and add numbers to GUI mockup.
My font looks like this:

All numbers are in one file. My idea is to always return the same reference to a number so we won’t overload a memory.
Font class:

from PIL import Image


class DigitalNumbers(object):
    """Digital font class"""
    def __init__(self):
        self.resource = Image.open("font/digital_numbers.jpg")
        self.offsets = {
            0: 0,
            1: 24,
            2: 48,
            3: 72,
            4: 96,
            5: 120,
            6: 144,
            7: 168,
            8: 192,
            9: 216
        }
        self.loaded_resources = {}

    def get(self, item):
        """get image reference"""
        if item not in self.loaded_resources:
            self.load_resource(item)

        return self.loaded_resources[item]

    def load_resource(self, number):
        """create reference to single number"""
        area = (self.offsets[number], 0, self.offsets[number] + 24, 42)
        self.loaded_resources[number] = self.resource.crop(area)

    def get_transparency(self):
        """get font transparency"""
        return ((1, 1, 1), (9, 9, 9))

We can draw numbers in tiles. But still, we have no widget 🙂

Node One widget

Remember our Node One sensor? Our goal is to display data from it. We need a class that will take care of it.
This class will paint a tile and repaint values when they change – and this is our widget.



class NodeOneWidget(object):

    def __init__(self, x, y, lcd, font):
        self.x = x
        self.y = y
        self.lcd = lcd
        self.font = font
        self.colours = {
            'background': (255, 250, 0),
            'digit_background': (0, 0, 0)
        }
        self.temperature = {
            'current': 0,
            'previous': 0
        }
        self.humidity = {
            'current': 0,
            'previous': 0
        }

    def draw_widget(self):
        """draw widget"""
        self.lcd.background_color = self.colours['background']
        self.lcd.fill_rect(self.x, self.y, self.x + 115, self.y + 103)

        self.lcd.background_color = (64, 64, 64)
        self.lcd.fill_rect(self.x+5, self.y+5, self.x+25, self.y+25)
        self.lcd.fill_rect(self.x+5, self.y+30, self.x+25, self.y+50)

        self.lcd.background_color = self.colours['digit_background']
        self.lcd.fill_rect(self.x+40, self.y+5, self.x+62, self.y+46)
        self.lcd.fill_rect(self.x+67, self.y+5, self.x+89, self.y+46)

        self.lcd.fill_rect(self.x+40, self.y+55, self.x+60, self.y+95)
        self.lcd.fill_rect(self.x+67, self.y+55, self.x+89, self.y+95)
        self.draw_values(True)

    def draw_values(self, force=False):
        """draw values"""
        old_transparency = self.lcd.transparency_color
        self.lcd.transparency_color = self.font.get_transparency()
        current = str(self.temperature['current']).rjust(2, '0')
        previous = str(self.temperature['previous']).rjust(2, '0')
        if force or current != previous:
            if force or current[0] != previous[0]:
                self.lcd.draw_image(self.x + 40, self.y + 5, self.font.get(int(current[0])))
            if force or current[1] != previous[1]:
                self.lcd.draw_image(self.x + 67, self.y + 5, self.font.get(int(current[1])))

        current = str(self.humidity['current']).rjust(2, '0')
        previous = str(self.humidity['previous']).rjust(2, '0')
        if force or current != previous:
            if force or current[0] != previous[0]:
                self.lcd.draw_image(self.x + 40, self.y + 55, self.font.get(int(current[0])))
            if force or current[1] != previous[1]:
                self.lcd.draw_image(self.x + 67, self.y + 55, self.font.get(int(current[1])))

        self.lcd.transparency_color = old_transparency

There are small changes to the GUI. We are using two digits for temperature and humidity and all is more compressed.
See that we separated drawing a tile from drawing values. This should come handy in a moment.
We need two more icons to finish a GUI prototype: movement and light. But we also need variables to keep current and previous state.
So in widget’s init we need to add

        self.movement = {
            'current': True,
            'previous': None
        }
        self.light = {
            'current': True,
            'previous': None
        }
        self.icon = {
            'movement': Image.open('assets/image/movement.png'),
            'light': Image.open('assets/image/lightbulb.png')
        }

and add drawing to draw_values:

       if force or self.light['current'] != self.light['previous']:
            if self.light['current']:
                self.lcd.transparency_color = (0, 0, 0)
                self.lcd.draw_image(self.x + 5, self.y + 5, self.icon['light'])
            else:
                self.lcd.background_color = self.colours['background']
                self.lcd.fill_rect(self.x+5, self.y+5, self.x+25, self.y+25)

        if force or self.movement['current'] != self.movement['previous']:
            if self.movement['current']:
                self.lcd.transparency_color = (0, 0, 0)
                self.lcd.draw_image(self.x + 5, self.y + 30, self.icon['movement'])
            else:
                self.lcd.background_color = self.colours['background']
                self.lcd.fill_rect(self.x+5, self.y+30, self.x+25, self.y+50)

This way when there is a detection, the icon is drawn and without detection, there is no icon, an area is repainted with a background colour.

We need the code to display everything, a place where everything is initialized and attached. This is of course main.py. And to test all our ideas we need to execute the code:

import RPi.GPIO as GPIO
from gfxlcd.driver.ili9325.gpio import GPIO as ILIGPIO
from gfxlcd.driver.ili9325.ili9325 import ILI9325
from view.nodeone_widget import NodeOneWidget
from assets.font import digital_numbers
GPIO.setmode(GPIO.BCM)

LED = 6
GPIO.setup(LED, GPIO.OUT)
GPIO.output(LED, 1)

lcd_tft = ILI9325(240, 320, ILIGPIO())
lcd_tft.init()

font = digital_numbers.DigitalNumbers()

SENSORS = {
    'kitchen': NodeOneWidget(0, 0, lcd_tft, font),
    'my_room': NodeOneWidget(125, 0, lcd_tft, font),
    #'weather': Widget(0, 108, lcd_tft),
}

SENSORS['kitchen'].colours['background'] = (0, 0, 255)
SENSORS['my_room'].colours['background'] = (0, 255, 255)
for sensor in SENSORS:
    SENSORS[sensor].draw_widget()

This gives us the following effect:

Quite good. Our mock looks nice. Let’s add sensors and real data.

Real data

We have already written sensor readers when we used char LCD to display it. What we need to do is attach them to the proper widget.
And there is the first problem. Our handlers were attached to one view and here we have a few, each widget is a separate view.
So we need some kind of proxy or dispatcher between handler and widget. This dispatcher will take an array of widgets with names as keys and it will be passed to handlers. The handler will call a proper method on dispatcher and by using node name from received message the dispatcher will call a proper widget. Sounds complicated? Let the code speak:
Mocked dispatcher:

class HandlerDispacher(object):
    def __init__(self, widgets):
        self.widgets = widgets

    def set_dht_data(self, node, temp, humi):
        print(node, humi, temp)

    def set_pir_data(self, node, movement):
        print(node, movement)

    def set_light_data(self, node, light):
        print(node, light)

And new main.py, there is so many changes that it is easier to post a whole file:

import RPi.GPIO as GPIO
from gfxlcd.driver.ili9325.gpio import GPIO as ILIGPIO
from gfxlcd.driver.ili9325.ili9325 import ILI9325
from view.nodeone_widget import NodeOneWidget
from assets.font import digital_numbers
from message_listener.server import Server
from iot_message.message import Message
from sensors.DHTHandler import DHTHandler
from sensors.PIRHandler import PIRHandler
from sensors.LightHandler import LightHandler
from service.handler_dispatcher import HandlerDispacher

GPIO.setmode(GPIO.BCM)

msg = Message('control-node')

LED = 6
GPIO.setup(LED, GPIO.OUT)
GPIO.output(LED, 1)

lcd_tft = ILI9325(240, 320, ILIGPIO())
lcd_tft.init()

font = digital_numbers.DigitalNumbers()

WIDGETS = {
    'node-kitchen': NodeOneWidget(0, 0, lcd_tft, font),
    'node-my-room': NodeOneWidget(125, 0, lcd_tft, font),
    #'weather': Widget(0, 108, lcd_tft),
}

This is our dictionary with widgets. Key name must correspond with sensor node name.

WIDGETS['node-kitchen'].colours['background'] = (0, 0, 255)
WIDGETS['node-my-room'].colours['background'] = (0, 255, 255)
for sensor in WIDGETS:
    WIDGETS[sensor].draw_widget()

dispatcher = HandlerDispacher(WIDGETS)
svr = Server(msg)
svr.add_handler('dht11', DHTHandler(dispatcher))
svr.add_handler('pir', PIRHandler(dispatcher))
svr.add_handler('light', LightHandler(dispatcher))
svr.start()

while True:
    pass

svr.join()

Here we initialize the dispatcher and pass it to handlers. When we start the program, it prints received data, so it is working:)
And when we have the full code is easier to explain why we need a dispatcher. See that all handlers(ie DHTHandler) receive messages from all sensors they support, without distinction.
That’s why we need to pass received data to a proper widget. And a widget can display readings from different sensors.

Time to pass data to widget and reaction for it. I’m afraid of one thing or maybe I’m just too paranoid? We have no lock on display, so what will happen when a thread trigger repaint during another repaint?
So maybe it is better to refresh widgets in a loop, just in case 🙂
Ok, so first update to Widget:

    def change_values(self, values):
        """display values"""
        if 'temp' in values:
            self.temperature['previous'] = self.temperature['current']
            self.temperature['current'] = values['temp']

        if 'humi' in values:
            self.humidity['previous'] = self.humidity['current']
            self.humidity['current'] = values['humi']

        if 'pir' in values:
            self.movement['previous'] = self.movement['current']
            self.movement['current'] = values['pir']

        if 'light' in values:
            self.light['previous'] = self.light['current']
            self.light['current'] = values['light']

Next, update Dispatcher:

class HandlerDispacher(object):
    def __init__(self, widgets):
        self.widgets = widgets

    def set_dht_data(self, node, temp, humi):
        if node in self.widgets:
            self.widgets[node].change_values({
                'temp': temp,
                'humi': humi
            })

    def set_pir_data(self, node, movement):
        if node in self.widgets:
            self.widgets[node].change_values({
                'pir': movement
            })

    def set_light_data(self, node, light):
        if node in self.widgets:
            self.widgets[node].change_values({
                'light': light
            })

And finally, update loop in main.py:

while True:
    for sensor in WIDGETS:
        WIDGETS[sensor].draw_values()

svr.join()

With all this code our LCD is alive, it displays real readings. We made it! And believe my word, it looks much better than on picture:

There is one important refactor awaiting. We need to create an abstract for Widget class. It should have information about position and LCD. It should also force us to implement drawing and data receiving functions.

Refactoring done and what now? See that we have a commented line in WIDGETS array:) It is a bad habit to leave commented code in software. We could delete it or…

Summary

..uncomment and use it 🙂 But not this time. It must wait for part 2 🙂
We have done a nice amount of work to get here, the Node One sensor, GfxLCD module, NodeMCU boilerplate, IOT:1, workers and much more.
But the result is rewarding, an RPi powered control node for IoT. It is still an early concept but with many possibilities.

For now, we have one type of tile, only for Node One. But this will change as we have a weather tile approaching.

Advertisements

3 comments

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s