Doton on bigger screen

We have a driver for ILI9486 320×480 display and we can run Doton on it. But currently, it would use only half of the screen. Our window manager has hard-coded dimension 240×320.

We need to do something with it. I think we can update manager to calculate grid from dimensions and additionally we can add tiles auto positioning.

Why? Because it seems that not setting position on the screen can be better ๐Ÿ™‚ System can add pages when required and take care of horizontal vs vertical position.
But I think that we should be able to set coords if we want to.

Source code @ GitHub

Planning

So first let’s lunch the Doton on the new screen and see what is going on. Should be fine but who knows.

Next, we have some situation with two different configurations. I think we should move hardware settings to config so we wouldn’t need two main files for each RPi with different screens.

Then we can work on window manager and auto positioning. It would be great to have external configuration for widget, workers and handlers but this is too much at the moment ๐Ÿ™‚

The Doton

To start it we need to swap drivers and touch/screen classes:

from gfxlcd.driver.ili9486.spi import SPI
from gfxlcd.driver.ili9486.ili9486 import ILI9486
from gfxlcd.driver.xpt2046.xpt2046 import XPT2046
(..)
drv = SPI()
lcd_tft = ILI9486(320, 480, drv)
lcd_tft.rotation = 270
lcd_tft.init()

(..)

window_manager = WindowManager(lcd_tft)
touch_panel = XPT2046(480, 320, 17, window_manager.click, 7)
touch_panel.rotate = 180  # 270
touch_panel.init()

And it works, good start ๐Ÿ™‚

Configuration

To run Doton on new RPi I had to change the code. This is not good ๐Ÿ™‚ Let’s improve configuration to avoid such changes.

In config, we need to keep information about LCD and touch. But what exactly?
Driver and chip name, size and rotation should be enough?

But how to get their objects? I think this is a task for the config service. It should try and import required modules. It would be even better if we do not hard-code driver’s names, so dynamic import. Sounds good and complicated ๐Ÿ™‚
Entries in config:

[lcd]
;ili9486  ili9325
lcd=ili9486
size=320,480
rotate=270
driver=spi
driver_pins={"CS": 8,"RST": 25,"RS": 24,"LED": ""}

And the function to parse it in config service:

    def init_lcd(self):
        """dynamically load and init lcd"""
        driver_name = self.config.get('lcd', 'driver')
        chip_name = self.config.get('lcd', 'lcd')
        size = self.config.get('lcd', 'size').split(",")
        driver_pins = self._get_dict(self.config.get('lcd', 'driver_pins'))

        path = "gfxlcd.driver.{}.{}".format(chip_name, driver_name)
        class_ = getattr(import_module(path), driver_name.upper())
        driver = class_()
        if driver_pins:
            driver.pins = driver_pins

        path = "gfxlcd.driver.{}.{}".format(chip_name, chip_name)
        class_ = getattr(import_module(path), chip_name.upper())
        self.lcd = class_(int(size[0]), int(size[1]), driver)
        self.lcd.rotation = int(self.config.get('lcd', 'rotate'))
        self.lcd.init()

   def _get_dict(self, value):
        """str to dict, replace '' with None"""
        if not value:
            return None
        values = json.loads(value)
        for key in values:
            if values[key] == "":
                values[key] = None

        return values

With touch it is not so simple, see that we are passing the window manager callback to it. But maybe we may ask the manager to initialize a touch. So instead of passing LCD, we will pass whole configuration object. And in __init__ start the touch panel.
So the configuration for the panel is:

[touch]
;xpt2046 ad7843
driver=xpt2046
size=480, 320
rotate=0
irq=17
cs=7

Code in config:

     def init_touch(self, callback):
        """dynamically load and init touch panel"""
        driver_name = self.config.get('touch', 'driver')
        size = self.config.get('touch', 'size').split(",")
        cs = self.config.get('touch', 'cs')
        if cs == '':
            cs = None
        else:
            cs = int(self.config.get('touch', 'cs'))
        path = "gfxlcd.driver.{}.{}".format(driver_name, driver_name)
        class_ = getattr(import_module(path), driver_name.upper())
        driver = class_(
            int(size[0]), int(size[1]),
            int(self.config.get('touch', 'irq')),
            callback,
            cs
        )
        driver.rotate = int(self.config.get('touch', 'rotate'))
        driver.init()

This works on both of my Pis. Great ๐Ÿ™‚
Here are two configs, first for ILI9325 and AD7843

[lcd]
;ili9486  ili9325
lcd=ili9325
size=240,320
rotate=0
driver=gpio
driver_pins={"RS": 27,"W": 17,"DB8": 22,"DB9": 23,"DB10": 24,"DB11": 5,"DB12": 12,"DB13": 16,"DB14": 20,"DB15": 21,"RST": 25,"LED": 6,"CS": 18}

[touch]
;xpt2046 ad7843
driver=ad7843
size=240,320
rotate=180
irq=26
cs=

[general]
ip=192.168.1.255
port=5053
node_name=control-node
openweather_apikey=xxx

See that we cannot change only one pin, we must redeclare all.

And sample config for second RPi with ILI9486 and XPT2046:

[lcd]
;ili9486  ili9325
lcd=ili9486
size=320,480
rotate=270
driver=spi
driver_pins={"CS": 8,"RST": 25,"RS": 24,"LED": ""}

[touch]
;xpt2046 ad7843
driver=xpt2046
size=480, 320
rotate=0
irq=17
cs=7

[general]
ip=192.168.1.255
port=5053
node_name=control-node-2
openweather_apikey=xxx

Uff. This close config section.

Window manager

The manager needs to calculate it’s grid. Knowing LCD size and tile size it can be calculated. And first bug, getting the size of the screen do not take rotate into account. So it fails ๐Ÿ™‚ Quick fix and back to calculate dimensions.
A class variable to keep sizes:

 size = {
        "widget_height": 106,
        "widget_width": 106,
        "grid_height": 0,
        "grid_width": 0,
        "margin_height": 0,
        "margin_width": 0,
    }

And a function to calculate missing values:

    def _calculate_grid(self):
        """calculate grid size"""
        self.size['grid_width'] = self.lcd.width // self.size['widget_width']
        self.size['grid_height'] = self.lcd.height // self.size['widget_height']

        margin_width = self.lcd.width - (self.size['grid_width'] * self.size['widget_width'])
        margin_height = self.lcd.height - (self.size['grid_height'] * self.size['widget_height'])
        self.size['margin_height'] = math.floor(margin_height / (self.size['grid_height'] -1))
        self.size['margin_width'] = math.floor(margin_width / (self.size['grid_width'] - 1))

Finally, we need to remove hard-coded values and replace them with calculated.

Currently, we pass coordinates and widget to the manager and add it to the dictionary in Page class. We are not checking if coordinates are correct or if we overwrite some other widget.
This requires refactoring.
When we are creating a page we will pass grid dimensions to it. And Page’s job is to validate and keep track of active slots.

Also, we will add and use function add_widget in Page class. And this function will use validate_slot to check if the widget can be placed. If not exception will be thrown.
This takes care of overwriting and assigning defined slot but what about auto slot?
I tried to do it but it requires lots of work, this is a task for full post so maybe next time ๐Ÿ™‚

There is one more thing to fix. See that position of scroll areas is hard-coded. So on bigger LCD, they are not centred.
A quick change in the _execute_internal_event and we are good.

Weather tiles everywhere!

It would be nice to fill the whole screen with data. But we do not have enough tiles. What can we do?
Ha! We have a hidden data that we do not use. See that weather service has data on a forecast from now to 5 days in future but we display only for tomorrow.
Little refactor in worker and we should be able to err widgetize the screen ๐Ÿ˜€
Or even bigger refactoring. I forgot that we hard-coded the city. It should go to config.

So in the config, we have a new section:

[openweather]
apikey=xxx
cities={"3103402": "Bielsko-Biaล‚a"}

worker initialization changed to:

workerHandler.add('openweather', OpenweatherWorker(config.get_section('openweather'), window_manager.get_widget('openweather')), 5)

We are now passing whole config section.

We have settings so we can change worker so it would send more date to the widget.
Now we have:

    def execute(self):
        """executes worker"""
        self.widget.change_values({
            'current': self.weather(),
            'forecast': self.forecast()
        })

and we change it to:

    def execute(self):
        """executes worker"""
        data = {
            'current': self.weather(),
            'forecast': {}
        }
        date_now = datetime.datetime.now()
        for day in range(0, 5):
            date = date_now + datetime.timedelta(days=day)
            data['forecast'][day] = self.forecast(None, date.strftime("%Y-%m-%d"))

        self.widget.change_values(data)

We gather and send data but widget will ignore it so we need to teach it how to use such data. But we have another thing to parametrize. What we want to show. How to define this?
We need to correlate position in the grid with a tile. Let’s try and do something like this, in widget constructor we pass a dictionary with forecasts we want and current weather is always visible.
We will have:

window_manager.add_widget('openweather', [(0, 1), (1, 1), (2, 1), (3, 1)], OpenweatherWidget([0, 1, 2], FONTS))

and this defines that we want a forecast for today (0), tomorrow(1) and a day after tomorrow(2).
But if we set only two slots for tiles so widget should adjust to this and display only current weather(default) and forecast for today(0).

Refactor of the self.forecast_weather variable, it has a day key and in it a forecast.
Function for drawing knows how many tiles should be drawn. Ok, we can try and join everything…

There is one more thing (bah there is always something more… :D) I want to run this on two different screens without any change in code. In the current situation, it would crash on smaller LCD.
We have an if to check if the position is correct and if not we throw the exception. Let’s change it a little bit and add variable drop_out_of_bounds. If this is set to a false exception is raised but setting to true would only remove faulty position.

Clock widget

We have plenty of space left so let’s add another widget. This time a digital clock in a very simple form. As we have a slow refresh rate we won’t show seconds only hours and minutes.
We do not need any handler or worker for it just a plain class ๐Ÿ™‚

from view.widget import Widget
import datetime


class ClockWidget(Widget):
    """Clock widget"""
    def __init__(self, font):
        self.font = font
        self.work = True
        self.current = {
            'hour': datetime.datetime.now().strftime("%H"),
            'minute': datetime.datetime.now().strftime("%M"),
        }
        self.screen = {
            'hour': None,
            'minute': None
        }
        self.colours = {
            'background': (127, 32, 64),
            'digit_background': (0, 0, 0),
            'border': (244, 244, 244)
        }
        self.initialized = False

    def draw_widget(self, lcd, coords):
        """draw a tile"""
        pos_x, pos_y = coords[0]
        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_circle(pos_x+49, pos_y+25, 2)
        lcd.draw_circle(pos_x+49, pos_y+35, 2)

        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"""
        pos_x, pos_y = coords[0]
        self.current = {
            'hour': datetime.datetime.now().strftime("%H"),
            'minute': datetime.datetime.now().strftime("%M"),
        }
        self.draw_number(
            lcd, pos_x+7, pos_y+15, self.font,
            self.current['hour'], self.screen['hour'], 20,
            force
        )
        self.draw_number(
            lcd, pos_x+57, pos_y+15, self.font,
            self.current['minute'], self.screen['minute'], 20,
            force
        )
        self.screen = self.current.copy()

    def change_values(self, values):
        pass

We are even not using change_values. With each call to draw we update current time.
I added it to the panel with:

window_manager.add_widget('clock', [(3, 0)], ClockWidget(FONTS['15x28']))

Summary

This time we moved hardware configuration to the config file. Next, we added grid calculation to the window manager and added basic validation. We can use our big screen ๐Ÿ™‚
I wanted to add auto-adjustment for the tiles but it becomes harder than expected. We will return to this.
And as the last task, we added the clock widget.

Advertisements

One comment

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