Graphical front panel display

Thanks!!

I made a few more tweaks. Screenshots from emulation and a new photo are below. I also tried to make the script more resilient to communication failures (e.g., Kodi exiting and restarting).

Driving a larger display would be tough for SPI, which is where DPI comes in I suppose. Also, should freqData ever become available from Kodi, it may be better to stick with a smaller, monochrome OLED display for its speed. Nevertheless, I’m pretty happy with this approach.

I’ll need to move on to the case next.

Source code is below, in case it’s useful to anyone else. The Python script is independent of Kodi. It requires luma.core and luma.lcd, which implies a fairly recent version of Python 3. Also note that I’m not including the default image files or font files below.

Cheeers,
Matt

kodi_panel_emulator1

kodi_panel_emulator2

kodi_panel_photo


kodi_panel.py

#
# MIT License
# 
# Copyright (c) 2020  Matthew Lovell
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from luma.core.interface.serial import spi
from luma.core.render import canvas
from luma.lcd.device import ili9341

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

import time
import logging
import requests
import json
import io

base_url = "http://localhost:8080"
rpc_url  = base_url + "/jsonrpc"
headers  = {'content-type': 'application/json'}    

frameSize    = (320, 240)
thumb_height = 140;

last_image_path = ""
last_thumb      = ""

# Audio/Video codec names
codec_name = {"ac3"      : "DD",
              "eac3"     : "DD",
              "dtshd_ma" : "DTS-MA",
              "dca"      : "DTS",
              "truehd"   : "DD-HD",
              "aac"      : "AAC",
              "wmapro"   : "WMA",
              "mp3float" : "MP3",
              "flac"     : "FLAC",
              "BXA"      : "BXA",
              "alac"     : "ALAC",
              "vorbis"   : "OggV",
              "dsd_lsbf_planar": "DSD",
              "aac"      : "AAC",
              "pcm_s16be": "PCM",
              "mp2"      : "MP2",
              "pcm_u8"   : "PCM"}

# Handle to ILI9341-driven SPI panel via luma
serial = spi(port=0, device=0, gpio_DC=24, gpio_RST=25)
device = ili9341(serial, active_low=False, width=320, height=240,
                 framebuffer="full_frame",
                 bus_speed_hz=32000000)

# Track info fonts
font      = ImageFont.truetype("FreeSans.ttf", 22, encoding='unic')
fontB     = ImageFont.truetype("FreeSansBold.ttf", 22, encoding='unic')
font_sm   = ImageFont.truetype("FreeSans.ttf", 18, encoding='unic')
font_tiny = ImageFont.truetype("FreeSans.ttf", 11)

# Font for time and track
font7S    = ImageFont.truetype("DSEG14Classic-Regular.ttf", 32)
font7S_sm = ImageFont.truetype("DSEG14Classic-Regular.ttf", 11)

image  = Image.new('RGB', (frameSize), 'black')    
draw   = ImageDraw.Draw(image)


def truncate_text(pil_draw, xy, text, fill, font):
    truncating = 0
    new_text = text
    t_width, t_height = pil_draw.textsize(new_text, font)
    while t_width > (frameSize[0] - 20):
        truncating = 1
        new_text = new_text[:-1]
        t_width, t_height = pil_draw.textsize(new_text, font)
    if truncating:
        new_text += "\u2026"
    pil_draw.text(xy, new_text, fill, font)


def progress_bar(pil_draw, bgcolor, color, x, y, w, h, progress):
    pil_draw.ellipse((x+w,y,x+h+w,y+h),fill=bgcolor)    
    pil_draw.ellipse((x,y,x+h,y+h),fill=bgcolor)    
    pil_draw.rectangle((x+(h/2),y, x+w+(h/2), y+h),fill=bgcolor)
   
    if(progress<=0):        
        progress = 0.01    
    if(progress>1):        
        progress=1    
    w = w*progress    
    pil_draw.ellipse((x+w,y,x+h+w,y+h),fill=color)    
    pil_draw.ellipse((x,y,x+h,y+h),fill=color)    
    pil_draw.rectangle((x+(h/2),y, x+w+(h/2), y+h),fill=color)
    

def update_display():
    global last_image_path
    global last_thumb
    draw.rectangle([(1,1), (frameSize[0]-2,frameSize[1]-2)], 'black', 'black')
   
    payload = {
        "jsonrpc": "2.0",        
        "method"  : "Player.GetActivePlayers",
        "id"      : 3,
    }        
    response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
   
    if len(response['result']) == 0:
        # Nothing playing
        device.backlight(False)
        # draw.text(( 5, 5), "Nothing playing",  fill='white', font=font)
        last_image_path = ""
        last_thumb = ""
    elif response['result'][0]['type'] != 'audio':
        # Not audio
        device.backlight(False)        
        #draw.text(( 5, 5), "Not audio playing",  fill='white', font=font)
        last_image_path = ""
        last_thumb = ""        
    else:
        # Something's playing!
        device.backlight(True)
        
        payload = {
            "jsonrpc": "2.0",        
            "method"  : "XBMC.GetInfoLabels",
            "params"  : {"labels": ["MusicPlayer.Title",
                                    "MusicPlayer.Album",
                                    "MusicPlayer.Artist",
                                    "MusicPlayer.Time",
                                    "MusicPlayer.Duration",                                    
                                    "MusicPlayer.TrackNumber",
                                    "MusicPlayer.Property(Role.Composer)",
                                    "MusicPlayer.Codec",
                                    "MusicPlayer.Year",
                                    "MusicPlayer.Genre",                                                                        
                                    "MusicPlayer.Cover",
            ]},
            "id"      : 4,
        }
        response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
        #print("Response: ", json.dumps(response))

        info = response['result']

        # progress bar
        payload = {
            "jsonrpc": "2.0",        
            "method"  : "Player.GetProperties",
            "params"  : {
                "playerid": 0,
                "properties" : ["percentage"],
            },
            "id"      : "prog",
        }        
        response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()        
        if 'percentage' in response['result'].keys():
            prog = float(response['result']['percentage']) / 100.0
        else:
            prog = -1;
           
        # retrieve cover image from Kodi, if it exists and needs a refresh
        image_set = False
        if (info['MusicPlayer.Cover'] != '' and
            info['MusicPlayer.Cover'] != 'DefaultAlbumCover.png' and
            info['MusicPlayer.Cover'] != 'special://temp/airtunes_album_thumb.jpg'):
            image_path = info['MusicPlayer.Cover']
            #print("image_path : ", image_path) # debug info

            if image_path == last_image_path:
                image_set = True
            else:
                last_image_path = image_path
                payload = {
                    "jsonrpc": "2.0",
                    "method"  : "Files.PrepareDownload",
                    "params"  : {"path": image_path},
                    "id"      : 5,
                }
                response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
                if ('details' in response['result'].keys() and
                    'path' in response['result']['details'].keys()) :
                    image_url = base_url + "/" + response['result']['details']['path']
                    #print("image_url : ", image_url) # debug info

                    r = requests.get(image_url, stream = True)
                    # check that the retrieval was successful
                    if r.status_code == 200:
                        try:
                            r.raw.decode_content = True
                            cover = Image.open(io.BytesIO(r.content))
                            # resize while maintaining aspect ratio
                            orig_w, orig_h = cover.size[0], cover.size[1]
                            shrink = (float(thumb_height)/orig_h)
                            new_width = int(float(orig_h)*float(shrink))
                            # just crop if the image turns out to be really wide
                            if new_width > 140:
                                thumb = cover.resize((new_width, thumb_height), Image.ANTIALIAS).crop((0,0,140,thumb_height))
                            else:
                                thumb = cover.resize((new_width, thumb_height), Image.ANTIALIAS)
                            last_thumb = thumb
                            image_set = True
                        except:
                            cover = Image.open("kodi_thumb.jpg")
                            last_thumb = cover
                            image_set = True


        if not image_set:
            # default image when no artwork is available
            last_image_path = "./kodi_thumb.jpg"
            # is Airplay active?
            if info['MusicPlayer.Cover'] == 'special://temp/airtunes_album_thumb.jpg':
                last_image_path = "./airplay_thumb.png"
            cover = Image.open(last_image_path)
            last_thumb = cover
            image_set = True

        if image_set:
            image.paste(last_thumb, (5, 5))
            
        # progress bar and elapsed time
        if prog != -1:
            if info['MusicPlayer.Time'].count(":") == 2:
                # longer bar for longer displayed time
                progress_bar(draw, 'grey', 'lightgreen', 148, 5, 162, 4, prog)                
            else:
                progress_bar(draw, 'grey', 'lightgreen', 148, 5, 102, 4, prog)
                
        draw.text(( 148, 14), info['MusicPlayer.Time'],  fill='lightgreen', font=font7S)  

        # track number
        if info['MusicPlayer.TrackNumber'] != "":
            draw.text(( 148, 60), "Track", fill='white', font=font_tiny)
            draw.text(( 148, 73), info['MusicPlayer.TrackNumber'],  fill='white', font=font7S)

        # track title
        truncate_text(draw, (5, 150), info['MusicPlayer.Title'],  fill='white',  font=font)

        # other track information
        truncate_text(draw, (5, 180), info['MusicPlayer.Album'],  fill='white',  font=font_sm)
        if info['MusicPlayer.Artist'] != "":
            truncate_text(draw, (5, 205), info['MusicPlayer.Artist'], fill='yellow', font=font_sm)
        elif info['MusicPlayer.Property(Role.Composer)'] != "":
            truncate_text(draw, (5, 205), "(" + info['MusicPlayer.Property(Role.Composer)'] + ")", fill='yellow', font=font_sm)

        # audio info
        codec = info['MusicPlayer.Codec']
        if info['MusicPlayer.Duration'] != "":
            draw.text(( 230, 60), info['MusicPlayer.Duration'], font=font_tiny)        
        if codec in codec_name.keys():
            draw.text(( 230, 74), codec_name[codec], font=font_tiny)
        if info['MusicPlayer.Genre'] != "":
            draw.text(( 230, 88), info['MusicPlayer.Genre'][:15], font=font_tiny)            
        if info['MusicPlayer.Year'] != "":
            draw.text(( 230, 102), info['MusicPlayer.Year'], font=font_tiny)
            
    # Output to OLED/LCD display
    device.display(image)

   
def main():
    print("Hello, world")
    device.backlight(False)
    
    # Turn down verbosity from http connections
    logging.basicConfig()
    logging.getLogger("urllib3").setLevel(logging.WARNING)
   
    while True:
    
        while True:
            # first ensure Kodi is up and accessible
            payload = {
                "jsonrpc": "2.0",
                "method"  : "JSONRPC.Ping",
                "id"      : 2,
            }
            
            try:
                response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
                if response['result'] != 'pong':
                    print("Kodi not available via HTTP-transported JSON-RPC.  Waiting...")
                    time.sleep(5)
                else:
                    break
            except:
                time.sleep(5)
                pass
                
        print("Entering display loop.")

        while True:
            try:
                update_display()
            except (ConnectionRefusedError,
                    requests.exceptions.ConnectionError):
                print("Communication disrupted.")
                break
            time.sleep(0.9)
   

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
1 Like

As it’s a standalone python app I’ll try to spin it up on a Pi Zero W to which I can connect an SPI 160x160 OLED or 320x240 TFT, and point to Kodi running on an S905X3 box I have running Kodi under CoreElec.

For DPI/DSI/small HDMI displays a version that outputs to a regular screen might also be useful - rather than to an SPI display?

I think that would be possible. One needs to do something with the constructed image other than

device.display(image)

The image object is straight PIL / Pillow.

Looking (briefly) at the WeatherPi_TFT python program, it seems to make use of pygame and displaying via a framebuffer device (either /dev/fb0 or /dev/fb1). I saw something in the documentation about dtoverlay as well. I didn’t examine things much further, though.

UPDATE: Further observations about the state of pygame and libSDL in 2020 in Post #83 below.

Getting back to Odroid and CoreELEC, does anyone know how to install the RPi.GPIO equivalent for Odroid?

The HardKernel wiki for C2:

https://wiki.odroid.com/odroid-c2/application_note/gpio/rpi.gpio

points to this github repository:

RPi.GPIO-Odroid
https://github.com/awesometic/RPi.GPIO-Odroid

The build instructions listed don’t seem that bad:

  1. Install build-essential, python, python-dev, git
  2. git clone https://github.com/jfath/RPi.GPIO-Odroid.git
  3. cd RPi.GPIO-Odroid
  4. sudo python setup.py clean --all
  5. sudo python setup.py build install

However, CoreELEC lacks git. I don’t know about python-dev, either.

This seems like the fastest route for getting luma.core, luma.lcd up and running on an Odroid board.

Yep - pygame is how a couple of other people have done this.

For anyone following along, see this forum thread:

Entware and the packages it makes available are a great resource to have available for CoreELEC.

kodi_panel running under CoreELEC on an Odroid C4:

kodi_panel_on_C4

No armbian or Ubuntu needed, fortunately!

3 Likes

I no doubt should start a project on github for this. I’ll look into that after getting a bit more organized.

In the meantime, here’s another version with the following changes / “improvements”:

  • bit more clear about several global variables, providing default assignments at the start
  • progress bar is now just two rectangles, rather than being drawn with elipses
  • handle cover art retrieval when playing via UPnP / DLNA
  • if running on the same box as Kodi itself, retrieve cover art when using Airplay

I have a question outstanding on the Kodi forums (under Music Support) regarding that last point. When playing via Airplay, it seems that Kodi makes use of a temporary file named either airtunes_album_thumb.jpg or airtunes_album_thumb.png under /storage/.kodi/temp. The starting image path is then special://temp/airtunes_album_thumb.jpg (or the other file type). Invoking PrepareDownload on that path does return what looks like contents to use for an URL, but trying to fetch it just yields a 401 error.

Anyway, here’s the update.

Cheers,
Matt


kody_panel.py

#
# MIT License
#
# Copyright (c) 2020  Matthew Lovell
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from luma.core.interface.serial import spi
from luma.core.render import canvas
from luma.lcd.device import ili9341

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

from datetime import datetime
import time
import logging
import requests
import json
import io
import re
import os

#base_url = "http://10.0.0.188:8080"  # Odroid C4
base_url = "http://localhost:8080"
rpc_url  = base_url + "/jsonrpc"
headers  = {'content-type': 'application/json'}

frameSize    = (320, 240)
thumb_height = 140;

last_image_path = ""
last_thumb      = ""

# Thumbnail defaults
default_thumb   = "./music_icon.png"
default_airplay =  "./airplay_thumb.png"
special_re      = re.compile('^special:\/\/temp\/(airtunes_album_thumb\.(png|jpg))')


# Audio/Video codec names
codec_name = {"ac3"      : "DD",
              "eac3"     : "DD",
              "dtshd_ma" : "DTS-MA",
              "dca"      : "DTS",
              "truehd"   : "DD-HD",
              "aac"      : "AAC",
              "wmapro"   : "WMA",
              "mp3float" : "MP3",
              "flac"     : "FLAC",
              "BXA"      : "BXA",
              "alac"     : "ALAC",
              "vorbis"   : "OggV",
              "dsd_lsbf_planar": "DSD",
              "aac"      : "AAC",
              "pcm_s16be": "PCM",
              "mp2"      : "MP2",
              "pcm_u8"   : "PCM"}

# Handle to ILI9341-driven SPI panel via luma
serial = spi(port=0, device=0, gpio_DC=24, gpio_RST=25,
             reset_hold_time=0.2, reset_release_time=0.2)
device = ili9341(serial, active_low=False, width=320, height=240,
#                 framebuffer="full_frame",
                 bus_speed_hz=32000000
                 )

# Track info fonts
font      = ImageFont.truetype("FreeSans.ttf", 22, encoding='unic')
fontB     = ImageFont.truetype("FreeSansBold.ttf", 22, encoding='unic')
font_sm   = ImageFont.truetype("FreeSans.ttf", 18, encoding='unic')
font_tiny = ImageFont.truetype("FreeSans.ttf", 11)

# Font for time and track
font7S    = ImageFont.truetype("DSEG14Classic-Regular.ttf", 32)
font7S_sm = ImageFont.truetype("DSEG14Classic-Regular.ttf", 11)

image  = Image.new('RGB', (frameSize), 'black')
draw   = ImageDraw.Draw(image)


def truncate_text(pil_draw, xy, text, fill, font):
    truncating = 0
    new_text = text
    t_width, t_height = pil_draw.textsize(new_text, font)
    while t_width > (frameSize[0] - 20):
        truncating = 1
        new_text = new_text[:-1]
        t_width, t_height = pil_draw.textsize(new_text, font)
    if truncating:
        new_text += "\u2026"
    pil_draw.text(xy, new_text, fill, font)


def progress_bar(pil_draw, bgcolor, color, x, y, w, h, progress):
    pil_draw.rectangle((x,y, x+w, y+h),fill=bgcolor)

    if(progress<=0):
        progress = 0.01
    if(progress>1):
        progress=1
    w = w*progress
    pil_draw.rectangle((x,y, x+w, y+h),fill=color)


def update_display():
    global last_image_path
    global last_thumb
    draw.rectangle([(1,1), (frameSize[0]-2,frameSize[1]-2)], 'black', 'black')

    payload = {
        "jsonrpc": "2.0",
        "method"  : "Player.GetActivePlayers",
        "id"      : 3,
    }
    response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()

    if len(response['result']) == 0:
        # Nothing playing
        device.backlight(False)
        # draw.text(( 5, 5), "Nothing playing",  fill='white', font=font)
        last_image_path = ""
        last_thumb = ""
    elif response['result'][0]['type'] != 'audio':
        # Not audio
        device.backlight(False)
        #draw.text(( 5, 5), "Not audio playing",  fill='white', font=font)
        last_image_path = ""
        last_thumb = ""
    else:
        # Something's playing!
        device.backlight(True)

        payload = {
            "jsonrpc": "2.0",
            "method"  : "XBMC.GetInfoLabels",
            "params"  : {"labels": ["MusicPlayer.Title",
                                    "MusicPlayer.Album",
                                    "MusicPlayer.Artist",
                                    "MusicPlayer.Time",
                                    "MusicPlayer.Duration",
                                    "MusicPlayer.TrackNumber",
                                    "MusicPlayer.Property(Role.Composer)",
                                    "MusicPlayer.Codec",
                                    "MusicPlayer.Year",
                                    "MusicPlayer.Genre",
                                    "MusicPlayer.Cover",
            ]},
            "id"      : 4,
        }
        response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
        #print("Response: ", json.dumps(response))

        info = response['result']

        # progress bar
        payload = {
            "jsonrpc": "2.0",
            "method"  : "Player.GetProperties",
            "params"  : {
                "playerid": 0,
                "properties" : ["percentage"],
            },
            "id"      : "prog",
        }
        response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
        if 'percentage' in response['result'].keys():
            prog = float(response['result']['percentage']) / 100.0
        else:
            prog = -1;

        # retrieve cover image from Kodi, if it exists and needs a refresh
        image_set = False
        if (info['MusicPlayer.Cover'] != '' and
            info['MusicPlayer.Cover'] != 'DefaultAlbumCover.png' and
            not special_re.match(info['MusicPlayer.Cover'])):

            image_path = info['MusicPlayer.Cover']
            #print("image_path : ", image_path) # debug info

            if image_path == last_image_path:
                image_set = True
            else:
                last_image_path = image_path
                if image_path.startswith("http://"):
                    image_url = image_path
                else:
                    payload = {
                        "jsonrpc": "2.0",
                        "method"  : "Files.PrepareDownload",
                        "params"  : {"path": image_path},
                        "id"      : 5,
                    }
                    response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
                    print("Response: ", json.dumps(response))

                    if ('details' in response['result'].keys() and
                        'path' in response['result']['details'].keys()) :
                        image_url = base_url + "/" + response['result']['details']['path']
                        #print("image_url : ", image_url) # debug info

                r = requests.get(image_url, stream = True)
                # check that the retrieval was successful
                if r.status_code == 200:
                    try:
                        r.raw.decode_content = True
                        cover = Image.open(io.BytesIO(r.content))
                        # resize while maintaining aspect ratio
                        orig_w, orig_h = cover.size[0], cover.size[1]
                        shrink = (float(thumb_height)/orig_h)
                        new_width = int(float(orig_h)*float(shrink))
                        # just crop if the image turns out to be really wide
                        if new_width > 140:
                            thumb = cover.resize((new_width, thumb_height), Image.ANTIALIAS).crop((0,0,140,thumb_height))
                        else:
                            thumb = cover.resize((new_width, thumb_height), Image.ANTIALIAS)
                            last_thumb = thumb
                            image_set = True
                    except:
                        cover = Image.open(default_thumb)
                        last_thumb = cover
                        image_set = True


        if not image_set:
            # is Airplay active?
            if special_re.match(info['MusicPlayer.Cover']):
                airplay_thumb = "/storage/.kodi/temp/" + special_re.match(info['MusicPlayer.Cover']).group(1)
                if os.path.isfile(airplay_thumb):
                    last_image_path = airplay_thumb
                else:
                    last_image_path = default_airplay
            else:
                # default image when no artwork is available
                last_image_path = default_thumb

            cover = Image.open(last_image_path)
            cover.thumbnail((thumb_height, thumb_height))
            last_thumb = cover
            image_set = True

        if image_set:
            image.paste(last_thumb, (5, 5))

        # progress bar and elapsed time
        if prog != -1:
            if info['MusicPlayer.Time'].count(":") == 2:
                # longer bar for longer displayed time
                progress_bar(draw, 'dimgrey', 'lightgreen', 150, 5, 164, 4, prog)
            else:
                progress_bar(draw, 'dimgrey', 'lightgreen', 150, 5, 104, 4, prog)

        draw.text(( 148, 14), info['MusicPlayer.Time'],  fill='lightgreen', font=font7S)

        # track number
        if info['MusicPlayer.TrackNumber'] != "":
            draw.text(( 148, 60), "Track", fill='white', font=font_tiny)
            draw.text(( 148, 73), info['MusicPlayer.TrackNumber'],  fill='white', font=font7S)

        # track title
        truncate_text(draw, (5, 150), info['MusicPlayer.Title'],  fill='white',  font=font)

        # other track information
        truncate_text(draw, (5, 180), info['MusicPlayer.Album'],  fill='white',  font=font_sm)
        if info['MusicPlayer.Artist'] != "":
            truncate_text(draw, (5, 205), info['MusicPlayer.Artist'], fill='yellow', font=font_sm)
        elif info['MusicPlayer.Property(Role.Composer)'] != "":
            truncate_text(draw, (5, 205), "(" + info['MusicPlayer.Property(Role.Composer)'] + ")", fill='yellow', font=font_sm)

        # audio info
        codec = info['MusicPlayer.Codec']
        if info['MusicPlayer.Duration'] != "":
            draw.text(( 230, 60), info['MusicPlayer.Duration'], font=font_tiny)
        if codec in codec_name.keys():
            draw.text(( 230, 74), codec_name[codec], font=font_tiny)
        if info['MusicPlayer.Genre'] != "":
            draw.text(( 230, 88), info['MusicPlayer.Genre'][:15], font=font_tiny)
        if info['MusicPlayer.Year'] != "":
            draw.text(( 230, 102), info['MusicPlayer.Year'], font=font_tiny)

    # Output to OLED/LCD display
    device.display(image)


def main():
    print(datetime.now(), "Starting")

    # Turn down verbosity from http connections
    logging.basicConfig()
    logging.getLogger("urllib3").setLevel(logging.WARNING)

    while True:
        device.backlight(True)
        draw.rectangle([(1,1), (frameSize[0]-2,frameSize[1]-2)], 'black', 'black')
        draw.text(( 5, 5), "Waiting to connect with Kodi...",  fill='white', font=font)
        device.display(image)

        while True:
            # first ensure Kodi is up and accessible
            payload = {
                "jsonrpc": "2.0",
                "method"  : "JSONRPC.Ping",
                "id"      : 2,
            }

            try:
                response = requests.post(rpc_url, data=json.dumps(payload), headers=headers).json()
                if response['result'] != 'pong':
                    print(datetime.now(), "Kodi not available via HTTP-transported JSON-RPC.  Waiting...")
                    time.sleep(5)
                else:
                    break
            except:
                time.sleep(5)
                pass

        print(datetime.now(), "Connected with Kodi.  Entering display loop.")
        device.backlight(False)

        while True:
            try:
                update_display()
            except (ConnectionRefusedError,
                    requests.exceptions.ConnectionError):
                print(datetime.now(), "Communication disrupted.")
                break
            time.sleep(0.9)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print(datetime.now(), "Stopping")
        pass

For the case, I’m starting with this design on Thingiverse from Webclaw:

ODROID C2 Kodi Box w/OLED Display Mount

That opening is for a 16x2 character display, so it has to be enlarged. I also decided that I’d try to avoid the through-holes for mounting the display.

As I don’t yet know whether the modified front panel will 3d-print cleanly, I’ll just post images here. If everything prints as intended, I’ll upload the SketchUp and STL files to a Thingiverse project.

1 Like

I started wondering about the resistive touchscreen that’s built into the display… I have no real need to build a true touchscreen interface, but knowing about a touch (like a button press) could be useful.

Turns out that the T_IRQ pin (a.k.a. PENIRQ) appears to be “alive” without having to do anything! The signal is active-low. I verified with a volt meter that it’s pulled up to 3.3 V and drops down to 0 upon pressing the stylus to the screen.

So, I have a mechanism for switching between the display as shown in previous photos and, say, one in which the cover art is full screen (no text). When nothing is playing, I could also have a screen press serve to momentarily turn on the backlight and show Odroid stats (e.g., uptime, load, temperature).

One more pin to connect and some reading about RPi.GPIO interrupts to do!

:+1: good progress, thanks for keeping us updated.
Maybe I missed, but could you give us the name/spec of the Color Display you are currently using?

I don’t think I gave much info previously, since I was largely picking at random!

I’ve had success with both 2.8" and 3.2" SPI-connected ILI9341-based displays. LCDwiki has the following entries for them, matching exactly what I’m using:

The 3.2" module was ordered from Amazon for about $21. The 2.8" module was also ordered from Amazon.

The earlier display modules that also worked (with an RPi) were a white 2.42" OLED SPI SSD1309, also ordered from Amazon, and an I2C 20x4 LCD Module from ameriDroid.

I stuck with ILI19341-based displays largely since that’s among the controllers that luma.lcd (github) supports.

The only display I’ve ordered that I’ve not been able to use is a blue SSD1309 OLED one ordered from BuyDisplay.com. Despite ordering that one supposedly configured for I2C use, it came with no header (not even one in the package) and with resistors populated for 8-bit parallel use! If I feel brave enough, I can try to move the surface-mount resistors for either SPI or I2C operation, as well as solder a header.

Touching the screen during music playback now switches back and forth between two display modes. The mode is persistent, until the next power-on anyway.

Default:
default_mode

Fullscreen:
fullscreen_mode

A screen press when idle (or if video is playing) gets one the status info below (which turns off after 10 seconds).

I think I have a suitable directory structure about ready to make a github project. I just need to track down the license file for the fonts (and then figure out if I can push my local repository to github or if some other hoops are necessary).

Status screen:
status_screen

1 Like

kodi_panel is now up on github:

https://github.com/mattblovell/kodi_panel

3 Likes

Been following your progress. Great job. Keep up the good work.

Agreed. This has been an absolute joy to watch progress.

Many thanks, @Betatester and @kshi! This has been something I’ve long wished existed, and it’s been fun to put together. I got my main desktop machine back together this evening (the motherboard had died), so I’ve been able to exercise artwork retrieval under UPnP/DLNA now. All fixed-up on github.

The spectrum display would still be neat to have. For the speeds needed, though, I think one would need a small-ish OLED display – or a DPI-connected HyperPixel! The dual mini-HDMI connections available on the RPi 4 could be useful for that! :grin: I wonder if one of them would still be available for use even after connecting the HyperPixel.

The 3D-printed case arrives on Saturday. I’ll get to see how well it will retain screws!

Great job mblovell !
I look forward to the details for installation on ODROID N2. I have already installed the OLED SPI driver created by roidy with a SSD1309 2.42 "128x64 monochrome screen, and I am very satisfied, but I would be happy to give it a try with a 320x240 color screen and your kodi_panel. Have you tried it with movies and associated posters?

I’ve updated the README file posted on github, consolidating the various steps that were discussed in the “RPi-GPIO-Odroid & Python Pillow” thread.

I only have an Odroid C4 to try things out on. (Well, that and an Odroid C2, but the C2 doesn’t have any hardware SPI interface.) So, unfortunately, I don’t know much about the N2. If you’ve gotten the OLED screen working, though, you have the bulk of what you need to know. The only real obstacle is getting the dependencies for luma.lcd installed.

Currently it’s solely audio-focused. The update_display() function includes this check

if (len(response['result']) == 0 or
    response['result'][0]['type'] != 'audio'):
    # Nothing is playing or video is playing, but check for screen
    # press before proceeding

and, if true, displays nothing or (upon a screen press) the status screen. It would be straightforward though to “breakout” video playback and have info screens similar to those for audio.

I imagine the aspect ratio for movie posters is a bit different than the square ratio used by album covers. So, several layout details would also have to change.

I’m certainly open to such updates! This weekend, though, will largely be devoted to playing with the 3D-printed case (such as it is). If you want to give it a go now, please feel free (and submit Pull Requests in github if you’re so inclined). I may give it a shot later in time. That would make kodi_panel more complete than KodiDisplayInfo (discussed above).

Cheers,
Matt

1 Like