"Kindle Weather"

For a good decade I had a little dream of having a display in my kitchen that used very little power but displayed my upcoming appointments and perhaps the local weather. It seemed very expensive to invest in new parts for it, though... Then I learned that one could hack a Kindle to display a custom screensaver image.

Hacking a Kindle?

I have enough headaches without Amazon chasing me down, so I won't tell you how to do this. It's out there, and in my opinion it's easy to do. I will, however, say that it was very funny to watch the screen flash over and over while figuring it out and to tell all my friends I was having a Kindle rave.

I'm on a schedule, here.

I was tired of Google being in on that schedule, so I started my own calendar service in my house using Radicale. For the small cost of only being able to synchronize my calendar at home, I wrestled another piece of my private life away from the algorithms. I'm sure you could also configure it to be accessible over the internet, but some things I prefer to keep in-house. Using tools like DAVx5 and Thunderbird, I can keep all my appointments synchronized, including on my Kindle.

SOAP and REST

Once I had that set up, I looked to the National Weather Service for my local forecasts. Helpfully, they provide both a SOAP service and a REST service for forecast data. I've found the REST service easier to use. From there, I looked up the predicted highs and lows as well as the weather icons for the current 12 hours and the following 12 hours. I parsed those icons into my own selections from Font Awesome, since the line drawings show up better on an old Kindle. I also grabbed the current temperature from their current observations service.

A soft landing

Python's Pillow library made it easy to lay out and generate images with all of the information I gathered. I chose to show the next 24 hours of simple weather information and the 5 closest appointments over the next 30 days. I also included some graceful degradation measures, in the event that one of the servers failed to reply. Below are two sample images of the weather setup: one with sample data, and one with some of the fallback text.

Weather and Calendar Display

Weather and Calendar Display

I've put an anonymized version of the image generator code below.

from PIL import Image, ImageDraw, ImageFile, ImageFont
from xml.dom import minidom
from zoneinfo import ZoneInfo
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from dateutil.parser import *
import requests
import codecs
import caldav

caldav_url = "http://localhost:5232/radicale/url/"
username = "radicale_u"
password = "radicale_pass"
events = []
latitude = 27.961
longitude = -82.54
weather = {}
time_zone = ZoneInfo("US/Eastern")
current_observations_url = "https://www.weather.gov/xml/current_obs/KTPA.xml"
image_save_location = r"/var/www/html/image.png"
calendar_failed = False

def distill_event(event):
    event_data = event.data
    event_index = event_data.find('BEGIN:VEVENT')
    start_index = event_data.find('DTSTART', event_index) + 7
    end_index = event_data.find('DTEND', event_index) + 5
    summary_index = event_data.find('SUMMARY', event_index) + 8
    location_index = event_data.find('LOCATION', event_index) + 9

    return {
        "start": parse(event_data[start_index:].split(':')[1].splitlines()[0]),
        "end": parse(event_data[end_index:].split(':')[1].splitlines()[0]),
        "allday": "VALUE=DATE:" in event_data[start_index:].splitlines()[0],
        "summary": event_data[summary_index:].splitlines()[0],
        "location": event_data[location_index:].splitlines()[0].replace('\\', '') if location_index > 9 else ""
    }

def fetch_calendar(caldav_url, username, password, time_zone):
    today = datetime.now(time_zone)
    today = datetime(today.year, today.month, today.day, tzinfo=time_zone)
    with caldav.DAVClient(
        url = caldav_url,
        username = username,
        password = password
    ) as client:
        my_principal = client.principal()
        calendars = my_principal.calendars()

        #get all calendar events
        for calendar in calendars:
            events_fetched = calendar.search(
                start = today,
                end = today + timedelta(days=30),
                expand = True,
                event = True
            )

            #save only the info we'll be displaying, in a more usable format
            for event in events_fetched:
                events.append(distill_event(event))

                #remove events that ended before midnight
                if events[len(events) - 1]['end'].astimezone(time_zone) <= datetime.combine(today.date(), time(0, 1, 1), tzinfo=time_zone):
                    events.pop()
                #remove events that are farther than 30 days out
                elif events[len(events) - 1]['start'].astimezone(time_zone) > today + timedelta(days=30):
                    events.pop()

        #sort events by the date they start
        events.sort(key=lambda event: event['start'].astimezone(time_zone))

        #separate into today's events and upcoming events
        tomorrow = datetime(today.year, today.month, today.day, tzinfo=time_zone) + timedelta(days=1)
        events_today = []
        events_upcoming = []

        for i in range(5):
            if i >= len(events):
                break
            elif events[i]['start'].astimezone(time_zone) < tomorrow:
                events_today[len(events_today):] = [events[i]]
            else:
                events_upcoming[len(events_upcoming):] = [events[i]]

        return events_today, events_upcoming

def fetch_weather(latitude, longitude, current_observations_url):
    weather = {}
    weather_xml = ""

    #get current temperature
    try:
        weather_xml = minidom.parseString(requests.get(current_observations_url, timeout=5).text)
        weather["temp_current"] = int(float(weather_xml.getElementsByTagName('temp_f')[0].firstChild.nodeValue))
    except:
        current_failed = True

    #get predicted high and low, weather icon
    weather_xml = minidom.parseString(requests.get("https://digital.weather.gov/xml/sample_products/browser_interface/ndfdBrowserClientByDay.php?lat={}&lon={}&format=12+hourly&numDays=1".format(latitude, longitude)).text)

    xml_temperatures = weather_xml.getElementsByTagName('temperature')
    for item in xml_temperatures:
        if item.getAttribute('type') == 'maximum':
            weather["temp_high"] = int(float(item.getElementsByTagName('value')[0].firstChild.nodeValue))
        if item.getAttribute('type') == 'minimum':
            weather["temp_low"] = int(float(item.getElementsByTagName('value')[0].firstChild.nodeValue))

    xml_icons = weather_xml.getElementsByTagName('icon-link')
    if xml_icons[0].hasAttribute("xsi:nil"): 
        return weather
    weather["icon_now"] = xml_icons[0].firstChild.nodeValue.split('/')[-1].split('.')[0]
    weather["icon_next"] = xml_icons[1].firstChild.nodeValue.split('/')[-1].split('.')[0]

    return weather

def truncate_text(im_draw, text, font, limit):
    characters = len(text)
    while im_draw.textlength(text[0:characters] + "...", font=font) > limit and characters > 10:
        characters = int(characters / 2)
    while im_draw.textlength(text[0:characters] + "...", font=font) < limit and characters < len(text):
        characters += 2
    return text[0:characters - 2] + "..."

def draw_event(im_draw, event, offsets, fonts, colors, time_zone):
    today = datetime.now(time_zone)
    today = datetime(today.year, today.month, today.day)
    #time data
    if datetime(event['start'].year, event['start'].month, event['start'].day) - today < timedelta(hours=24):
        if not event['allday']:
            im_draw.text((offsets['time anchor'], offsets['entry offset'] + (offsets['entry height'] / 2)), event['start'].astimezone(time_zone).strftime("%H:%M"), font=fonts['event time today'], anchor="rm", fill=colors['dim'])
    else:
        im_draw.text((offsets['time anchor'], offsets['entry offset'] + (offsets['entry height'] / 2)), "{} {}".format(event['start'].strftime("%a %b"), event['start'].day), font=fonts['event day upcoming'], anchor="rd", fill=colors['dim'])
        if not event['allday']:
            im_draw.text((offsets['time anchor'], offsets['entry offset'] + (offsets['entry height'] / 2)), event['start'].astimezone(time_zone).strftime("%H:%M"), font=fonts['event time upcoming'], anchor="rt", fill=colors['dim'])

    #place data
    if im_draw.textlength(event['summary'], font=fonts['event title']) > offsets['place limit']:
        event['summary'] = truncate_text(im_draw, event['summary'], fonts['event title'], offsets['place limit'])
    im_draw.text((offsets['place anchor'], offsets['entry offset'] + (offsets['entry height'] / 2) - 34), event['summary'], font=fonts['event title'], fill=colors['main'])

    if len(event['location']) > 1:
        if im_draw.textlength(event['location'], font=fonts['event location']) > offsets['place limit']:
            event['location'] = truncate_text(im_draw, event['location'], fonts['event location'], offsets['place limit'])
        im_draw.text((offsets['place anchor'], offsets['entry offset'] + (offsets['entry height'] / 2) + 22), event['location'], font=fonts['event location'], anchor='ld', fill=colors['dim'])

events_today, events_upcoming = fetch_calendar(caldav_url, username, password, time_zone)
weather = fetch_weather(latitude, longitude, current_observations_url)


#create a 600w, 800h image in 8-bit grayscale color space
im = Image.new(mode = 'L', size = (600, 800), color = 255)
im_draw = ImageDraw.Draw(im)

today = datetime.now(time_zone)
colors = {
    "main": 0,
    "dim": 80}
offsets = {
    "pad": 32,
    "time anchor": 152,
    "place anchor": 172,
    "place limit": 396,
    "entry offset": 250,
    "entry height": 75}
font_paths = {
    "book": r"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    "condensed": r"/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed.ttf",
    "extralight": r"/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf",
    "mono": r"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
    "iconbig": r"/var/www/FA 6 Pro Kit WeatherIcons-Thin-100.otf",
    "iconsmall": r"/var/www/FA 6 Pro Kit WeatherIcons-Light-300.otf"}

fonts = {
    "header": ImageFont.truetype(font_paths["book"], 48),
    "temp current": ImageFont.truetype(font_paths["extralight"], 108),
    "temp forecast": ImageFont.truetype(font_paths["extralight"], 48),
    "tinylabel": ImageFont.truetype(font_paths["book"], 18), #temp labels and dividers
    "event title": ImageFont.truetype(font_paths["condensed"], 32),
    "event location": ImageFont.truetype(font_paths["condensed"], 20),
    "event time today": ImageFont.truetype(font_paths["mono"], 40),
    "event time upcoming": ImageFont.truetype(font_paths["mono"], 24),
    "event day upcoming": ImageFont.truetype(font_paths["condensed"], 28),
    "icon big": ImageFont.truetype(font_paths["iconbig"], 110),
    "icon small": ImageFont.truetype(font_paths["iconsmall"], 48)}

icons = {
    "bkn": "",
    "bknfg": "",
    "blizzard": "",
    "cold": "",
    "du": "",
    "few": "",
    "fg": "",
    "fu": "",
    "fzra": "",
    "hi_shwrs": "",
    "hi_nshwrs": "",
    "hot": "",
    "ip": "",
    "mix": "",
    "nbkn": "",
    "nbknfg": "",
    "nfew": "",
    "nfg": "",
    "novc": "",
    "nra": "",
    "nraip": "",
    "nrasn": "",
    "nsct": "",
    "nsctfg": "",
    "nscttsra": "",
    "nskc": "",
    "nsn": "",
    "nwind": "",
    "ntsra": "",
    "ovc": "",
    "ra": "",
    "raip": "",
    "rasn": "",
    "sct": "",
    "sctfg": "",
    "scttsra": "",
    "shra": "",
    "skc": "",
    "sn": "",
    "tsra": "",
    "wind": ""
}

#draw the top portion with weather stuff
if "temp_current" in weather:
    im_draw.text((offsets['pad'], 24), "{} {}, {}".format(today.strftime("%B"), today.day, today.year), font=fonts['header'], fill=colors['main'])

    im_draw.text((offsets['pad'], 116), "{}°".format(weather['temp_current']), font=fonts['temp current'], fill=colors['main'])
else:
    im_draw.text((offsets['pad'], 24), "No temp.", font=fonts["tinylabel"])

#weather icons
if "icon_now" in weather:
    im_draw.text((232, 120), icons[weather['icon_now'].rstrip('0123456789')], font=fonts['icon big'], fill=colors['main'])
    im_draw.text((380, 144), icons[weather['icon_next'].rstrip('0123456789')], font=fonts['icon small'], fill=colors['dim'])

    if weather['icon_now'][-1].isdigit():
        im_draw.text((292, 240), weather['icon_now'].lstrip('_abcdefghiklmnoprstuvwxz') + "%", font=fonts['tinylabel'], anchor="mt", fill=colors['main'])
    if weather['icon_next'][-1].isdigit():
        im_draw.text((410, 196), weather['icon_next'].lstrip('_abcdefghiklmnoprstuvwxz') + "%", font=fonts['tinylabel'], anchor="mt", fill=colors['dim'])
else:
    im_draw.text((250, 150), "No icons.", font=fonts["tinylabel"])

#highs and lows
if "temp_high" in weather:
    im_draw.text((492, 112), "High", font=fonts['tinylabel'], anchor="mt", fill=colors['dim'])
    im_draw.text((492, 184), "Low", font=fonts['tinylabel'], anchor="mt", fill=colors['dim'])
    im_draw.text((504, 132), "{}°".format(weather['temp_high']), font=fonts['temp forecast'], anchor="mt", fill=colors['main'])
    im_draw.text((504, 204), "{}°".format(weather['temp_low']), font=fonts['temp forecast'], anchor="mt", fill=colors['main'])
else:
    im_draw.text((492, 112), "No high.", font=fonts["tinylabel"])

#draw appointment stuff

#divider
if not calendar_failed:
    im_draw.text((offsets['pad'], offsets['entry offset'] + offsets['entry height'] / 2), "TODAY", font=fonts['tinylabel'], anchor="lm", fill=colors['dim'])
    divider_offset = im_draw.textlength("TODAY", font=fonts['tinylabel'])
    im_draw.line((offsets['pad'] * 2 + divider_offset, offsets['entry offset'] + offsets['entry height'] / 2 - 2, 600 - offsets['pad'], offsets['entry offset'] + offsets['entry height'] / 2 - 2), fill=colors['dim'], width=2)
    offsets['entry offset'] += offsets['entry height']

    #today's events
    if len(events_today) < 1:
        im_draw.text((offsets['place anchor'], offsets['entry offset']), "No events today.", font=fonts['event title'], fill=colors['dim'])
        offsets['entry offset'] += offsets['entry height']
    else:
        for event in events_today:
            draw_event(im_draw, event, offsets, fonts, colors, time_zone)
            offsets['entry offset'] += offsets['entry height']

    #divider
    im_draw.text((offsets['pad'], offsets['entry offset'] + offsets['entry height'] / 2), "UPCOMING", font=fonts['tinylabel'], anchor="lm", fill=colors['dim'])
    divider_offset = im_draw.textlength("UPCOMING", font=fonts['tinylabel'])
    im_draw.line((offsets['pad'] * 2 + divider_offset, offsets['entry offset'] + offsets['entry height'] / 2 - 2, 600 - offsets['pad'], offsets['entry offset'] + offsets['entry height'] / 2 - 2), fill=colors['dim'], width=2)
    offsets['entry offset'] += offsets['entry height']

    #upcoming events
    if len(events_upcoming) < 1:
        im_draw.text((offsets['place anchor'], offsets['entry offset']), "Clear for 30 days.", font=fonts['event title'], fill=colors['dim'])
        offsets['entry offset'] += offsets['entry height']
    else:
        if len(events_upcoming) > 4:
            events_upcoming.pop()
        for event in events_upcoming:
            draw_event(im_draw, event, offsets, fonts, colors, time_zone)
            offsets['entry offset'] += offsets['entry height']
else:
    im_draw.text((offsets['pad'], offsets['entry offset'] + offsets['entry height'] / 2), "OH NO", font=fonts['tinylabel'], anchor="lm", fill=colors['dim'])
    divider_offset = im_draw.textlength("OH NO", font=fonts['tinylabel'])
    im_draw.line((offsets['pad'] * 2 + divider_offset, offsets['entry offset'] + offsets['entry height'] / 2 - 2, 600 - offsets['pad'], offsets['entry offset'] + offsets['entry height'] / 2 - 2), fill=colors['dim'], width=2)
    offsets['entry offset'] += offsets['entry height']
    im_draw.text((offsets['place anchor'], offsets['entry offset']), "Calendar unavailable.", font=fonts['event title'], fill=colors['dim'])

im.save(image_save_location, optimize = True)