Doton gets a touch screen and can control a light

We have a touch driver in our GfxLCD package so we can upgrade Doton a little. We will add clickable widgets.
Our window managers will handle touch propagation. For the first widget with action, we will create a light switcher.

Source @ GitHub

Planning

We will start with a function to find a widget within touch point. Touch generates x and y coordinates so our manager will loop thru all available widgets on the active page in search for clicked one.
Next, we will use other our project, NodeMCU with a relay to create a new widget. This widget would execute an action on click to toggle a light.

Find this widget!

We need to initialize touch panel and attach a function from the window manager.

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

window_manager = WindowManager(lcd_tft)

touch_panel = AD7843(240, 320, 26, window_manager.click)
touch_panel.init()
(..)

How to find clicked tile? We know current page so we need to iterate over all tiles on the page and find one with boundaries. And additionally, we need to know the index of the tile in the widget. See that openweather has two widgets.

    def click(self, point):
        """execute widget action"""
        pos_x, pos_y = point
        holders = self.pages[self.active_page].widgets
        found = (None, None)
        for name in holders:
            idx = 0
            for coords in holders[name].coords:
                if coords[0] < pos_x < coords[0] + 134 and  coords[1] < pos_y < coords[1] + 106:
                    found = (name, idx)
                    break
                idx += 1

        print(found)

It works:

(85, 180)
('openweather', 0)
(210, 132)
('openweather', 1)
(204, 87)
('node-my-room', 0)
(65, 60)
('node-kitchen', 0)

But also shows how cheap is this display. Touch is far from beeing good. The closest to the top-right corner the worst readings are. Maybe next time I will buy something better 😀

Now we just need to launch widget’s action. But this requires some refactoring. Not all widgets will be something more that a pretty tile. We need a good way to distinct action one from passive one.
I think we can do this via an interface. If the widget has an interface Clickable we will call action function. If not we will ignore it.
Interface:

class Clickable(metaclass=abc.ABCMeta):
    """Interface for clickable widget"""
    @abc.abstractmethod
    def action(self, name, index, pos_x, pos_y):
        """action for touch"""
        return

And refactored function click:

   def click(self, point):
        """execute widget action"""
        pos_x, pos_y = point
        holders = self.pages[self.active_page].widgets
        found = (None, None)
        for name in holders:
            idx = 0
            if isinstance(holders[name].widget, Clickable):
                for coords in holders[name].coords:
                    if coords[0] < pos_x < coords[0] + 134 and coords[1] < pos_y < coords[1] + 106:
                        found = (name, idx, pos_x - coords[0], pos_y - coords[1])
                        break
                    idx += 1

        if all(val is not None for val in found):
            self.pages[self.active_page].widgets[found[0]].widget.action(*found)

To check if this is working I quickly added Clickable to the Openweather widget and it was all ok.

Light widget

Light Node was described here.
Tl Dr: Remote node based on NodeMCU and 2 channel solid state relay. It reacts on command: channel.on and channel.off. The state can be checked via channel.states.

Our new widget must know the name of the target node, we will pass it in parameters. It also require Message class to handle messages 🙂
I’m thinking that we can set a number of channels in two ways. First, it needs to be passed to widget constructor and next correct number of points must be passed in add_widget function.

As for the look, we will have one big button, red if the light is off and green if it is on. The problem is with reading base state as we start with off state even that the light may be on. Something to fix later.

"""Widget for Relay Node"""
from view.widget import Widget
from PIL import Image


class RelayWidget(Widget):
    """Class Relay Widget"""
    def __init__(self, message, target_node, channels):
        self.colours = {
            'background': (149, 56, 170),
            'border': (244, 244, 244)
        }
        self.message = message
        self.target_node = target_node
        self.channels = channels
        self.current = [0 for _ in range(0, channels)]
        self.previous = [0 for _ in range(0, channels)]
        self.icon = {
            1: Image.open('assets/image/switch_on.png'),
            0: Image.open('assets/image/switch_off.png'),
        }
        self.initialized = False

    def draw_widget(self, lcd, coords):
        """draw a tile"""
        if len(coords) != self.channels:
            raise Exception('wrong number of channels')

        for pos_x, pos_y in coords:
            lcd.background_color = self.colours['background']
            lcd.fill_rect(pos_x, pos_y, pos_x + 105, pos_y + 105)

            lcd.color = self.colours['border']
            lcd.draw_rect(pos_x, pos_y, pos_x + 105, pos_y + 105)

        self.draw_values(lcd, coords, True)
        self.initialized = True

    def draw_values(self, lcd, coords, force=False):
        """draw values"""
        lcd.transparency_color = (255, 255, 255)
        idx = 0
        for pos_x, pos_y in coords:
            if force or self.current[idx] != self.previous[idx]:
                lcd.draw_image(pos_x + 3, pos_y + 3, self.icon[self.current[idx]])
            idx += 1

    def change_values(self, values):
        pass

We have a nice trick in draw functions. We iterate over given coordinates and draw as many tiles.
This:

window_manager.add_widget('my-room-light', [(0, 2)], RelayWidget(msg, 'my-room-light', 1))

will add a widget with one tile but this:

window_manager.add_widget('my-room-light', [(0, 2), (1, 2)], RelayWidget(msg, 'my-room-light', 2))

with two tiles:

But for this to work we need to set the channel to 2 in the constructor. Seems it become a maximum number of channels. Nice 🙂

And there is light!

Time to add action function. Passed index is a channel number and name is a target node name. We should have everything to broadcast an event.
Ahh! We forgot about socket to send the message. To have them we need broadcast IP and port number. We try to be flexible so we will add two new options to config.ini.
We also need to pass an address to a widget. Quick addition to the main file:

(..)
broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
address = (config.get('ip'), int(config.get('port')))

window_manager = WindowManager(lcd_tft)
(..)
window_manager.add_widget(
    'my-room-light', [(0, 2), (1, 2)],
    RelayWidget(msg, 'my-room-light', broadcast_socket, address, 2)
)
(..)

And action in the widget:

   def change_values(self, values):
        """change values"""
        if 'toggle' in values:
            self.previous[values['toggle'][0]] = self.current[values['toggle'][0]]
            self.current[values['toggle'][0]] = values['toggle'][1]

    def action(self, name, index, pos_x, pos_y):
        """toggle a relay"""
        enabled = self.current[index]
        message = self.message.prepare_message({
            'event': 'channel.off' if enabled else 'channel.on',
            'parameters': {
                'channel': index
            },
            'targets': [self.target_node]
        })
        message = json.dumps(message)
        self.socket.sendto(message.encode(), self.address)
        self.change_values({'toggle': [index, enabled ^ 1]})

It is working!

It wasn’t so hard 🙂

We can go back and add an auto detection of IP if not set. In my case, I must set it because auto-detection takes wrong network interface.
But, hmm, let’s make it more universal. We will add a default value to config.get. We can get IP in such way:

address = (config.get('ip', '<broadcast>'), int(config.get('port')))

Bug

While I played a little bit with node I found that sometimes during boot, one or more widgets do not get a correct tile. It looks like a problem with refreshing data, something with current / previous value. It is already late so we will fix it tomorrow.

I woke up in the middle of the night and I know it!
We have it all wrong. We shouldn’t keep current/previous but current and what is on screen. Next, before tile rendering, we should make a copy of the current and work on it. After the tile is rendered, we will swap screen to current. This will eliminate the problem with value changing during repainting.
I show you what I mean on NodeOne example.

Replace four variables in init with

        self.current = {
            'temperature': 0,
            'humidity': 0,
            'movement': False,
            'light': False
        }
        self.screen = {
            'temperature': None,
            'humidity': None,
            'movement': None,
            'light': None
        }

And in draw_values we will have on top:

        current = {
            'temperature': str(self.current['temperature']).rjust(2, '0'),
            'humidity': str(self.current['humidity']).rjust(2, '0'),
            'movement': self.current['light'],
            'light': self.current['movement']
        }
        screen = {
            'temperature': None if self.screen['temperature'] is None
            else str(self.screen['temperature']).rjust(2, '0'),
            'humidity': None if self.screen['humidity'] is None
            else str(self.screen['humidity']).rjust(2, '0'),
            'movement': self.screen['movement'],
            'light': self.screen['light']

        }

Then we operate only on those. At the end of the function we have:

self.screen = self.current.copy()

And this is it. No more refresh bug.

Switch upgrades

Switch tile won’t refresh if we change the relay state from other sources. We need to do something with it. I’m thinking about using a callback in NodeMCU boilerplate that we made some time ago.

And surprise! The relay handler has no callback, we somehow missed it during refactoring. Boilerplate updated.

New main.lua looks like this:

print ("core ready")

network_message = require "network_message"
relay_handler = require "relay_handler"
server_listener = require "server_listener"
send_socket = net.createConnection(net.UDP, 0)

handler = relay_handler(CHANNELS, function(event, data)    
    local message = network_message.prepareMessage()      
    message.event = 'channel.state'
    message.parameters = {}
    if event == 'channel.off' or event == 'channel.on' then
        local states = {}
        for k,v in pairs(CHANNELS) do
            states[k] = gpio.read(v) == 0 and 1 or 0
        end    
        message.parameters = states
        network_message.sendMessage(send_socket, message) 
    end
    
end)

-- add handlers to listener
server_listener.add("relay", handler)

-- run server
server_listener.start(5053)

When channel changes, node broadcasts a message to all with all states. We need to receive it at the Control Node and refresh all tile’s states.
This requires a handler for the relay. Ok, let’s write it:

"""Handler for relay states"""
from message_listener.abstract.handler_interface import \
    Handler as HandlerInterface


class RelayHandler(HandlerInterface):
    def handle(self, message):
        """handle a message"""
        if message is not None and 'event' in message:
            if message['event'] == 'channel.state':
                self.worker.set_relay_states(
                    message['node'],
                    message['parameters']
                )

And updated function value_change in the widget:

    def change_values(self, values):
        """change values"""
        if 'states' in values:
            for idx in values['states']:
                self.current[idx] = values['states'][idx]

Now, when we change relay state from other sources, mobile for example, our node knows about it and refresh the tile.

Summary

This is it. A new widget for relay control. With it we have three kinds of widgets:
– single tile, only display (NodeOne)
– multiple tiles, only display, with the worker (Openweather)
– multiple tiles, display and clickable (Relay)

This one is special because it is using the new addition to Window Manager, a touch support. With it, we can make lots of interactive widgets.

Our node looks very good now. I think in next step we should add multi-page support for the manager.

Advertisements

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