"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.
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)