Adafruit PyPortal UK tide viewer

A little while ago Adafruit produced a learning guide for the PyPortal that displayed tidal data https://learn.adafruit.com/pyportal-tides-viewer/overview unfortunately this was only suitable for use in the USA. I decided to see what changes were required to make a version that could use UK tidal data. In the first instance have a look at the guide and download and play with the code provided

Uk tidal data

A quick search on duck duck go revealed that the Admiralty Maritime Data Solutions, part of the UK Hydrographic office, have a freely available API.

You can register for an account here https://admiraltyapi.portal.azure-api.net/ Once signed up and logged in there is plenty of information about their API and a couple of tools to play with. As part of the process you will receive a couple of keys needed to authenticate you when requesting data.

Details of high and low tide, time and height can be obtained for between one and seven days.

PyPortal

I initially looked at just displaying todays high and low tide, the same as Adafruit. Thinking it would just be a simple matter of plugging in a new API address. A number of days later!!! I had a solution. One major issue was the issue of Authentication. All examples provided by Adafruit passed any authentication strings in the URL Datasource querystring. The Admiralty use an Azure server that is set to receive authentication in a header and not the querystring. A quick check revealed that the default PyPortal library did not support this, although it was supported in libraries further down the stack.

I implemented the functionality in a forked version of PyPortal, this has now been added to PyPortal and is available in all new releases.

Code changes

A copy of my code is provided below. The STATION_ID used is for Peterhead, my nearest tidal station. You will also need to add two keys to the secrets.py file

'Ocp-Apim-Subscription-Key' : 'primary key',
'tidal_secondary' : 'secondary key',

Additional changes are in the pyportal call where we add a headers field.

headers={"Ocp-Apim-Subscription-Key":secrets['Ocp-Apim-Subscription-Key']},

The JSON data returned is in the form of an array so needed to be converted to a DICT format

raw_info = json.loads(raw_info)

The times returned also needed to be reformatted

for i, hi_time in enumerate(tide_info["HighWater"]):
      HI_LABELS[i].text = '{:.5}'.format(hi_time)
for i, lo_time in enumerate(tide_info["LowWater"]):
      LO_LABELS[i].text = '{:.5}'.format(lo_time)

Most of the existing code was reused

Completed code

import time
import board
from adafruit_pyportal import PyPortal
from adafruit_bitmap_font import bitmap_font
from adafruit_display_text.label import Label
import json
from secrets import secrets

#--| USER CONFIG |--------------------------
STATION_ID = "0245"   # tide location, find yours from admiralty website/
HI_COLOR   = 0x00FF00    # high tide times color
LO_COLOR   = 0x11FFFF    # low tide times color
DATE_COLOR = 0xFFFFFF    # date and time color
#-------------------------------------------

# pylint: disable=line-too-long
DATA_SOURCE = "https://admiraltyapi.azure-api.net/uktidalapi/api/V1/Stations/" + STATION_ID + "/TidalEvents?duration=1"
DATA_LOCATION = []

# determine the current working directory needed so we know where to find files
cwd = ("/"+__file__).rsplit('/', 1)[0]
pyportal = PyPortal(url=DATA_SOURCE,
                    headers={"Ocp-Apim-Subscription-Key":secrets['Ocp-Apim-Subscription-Key']},
                    json_path=DATA_LOCATION,
                    status_neopixel=board.NEOPIXEL,
                    default_bg=cwd+"/tides_bg.bmp")

# Connect to the internet and get local time
pyportal.get_local_time()

# Setup tide times font
tide_font = bitmap_font.load_font(cwd+"/fonts/cq-mono-30.bdf")
tide_font.load_glyphs(b'1234567890:')

# Setup date and time font
date_font = bitmap_font.load_font(cwd+"/fonts/Arial-12.bdf")
date_font.load_glyphs(b'1234567890-')

# Labels setup
HI_LABELS = [ Label(tide_font, text="00:00", color=HI_COLOR, x= 40, y= 80) ,
              Label(tide_font, text="00:00", color=HI_COLOR, x= 40, y=165) ]
LO_LABELS = [ Label(tide_font, text="00:00", color=LO_COLOR, x=180, y= 80) ,
              Label(tide_font, text="00:00", color=LO_COLOR, x=180, y=165) ]
DATE_LABEL = Label(date_font, text="0000-00-00 00:00:00", color=DATE_COLOR, x=75, y=228)

# Add all the labels to the display
for label in HI_LABELS + LO_LABELS + [DATE_LABEL]:
    pyportal.splash.append(label)

def get_tide_info():
    """Fetch JSON tide time info and return it."""

    # Get raw JSON data
    raw_info = pyportal.fetch()
    raw_info = json.loads(raw_info)
    # Return will be a dictionary of lists containing tide times
    new_tide_info = {"HighWater":[], "LowWater":[]}
    # Parse out the tide time info
    for info in raw_info:
        tide_type = info['EventType']
        tide_time = info['DateTime'].split("T")[1]
        new_tide_info[tide_type].append(tide_time)

    return new_tide_info

def update_display(time_info, update_tides=False):
    """Update the display with current info."""

    # Tide time info
    if update_tides:
        # out with the old
        for tide_label in HI_LABELS + LO_LABELS:
            tide_label.text = ""
        # in with the new
        for i, hi_time in enumerate(tide_info["HighWater"]):
            HI_LABELS[i].text = '{:.5}'.format(hi_time)
        for i, lo_time in enumerate(tide_info["LowWater"]):
            LO_LABELS[i].text = '{:.5}'.format(lo_time)
    # Date and time
    DATE_LABEL.text = "{:04}-{:02}-{:02} {:02}:{:02}:{:02}".format(time_info.tm_year,
                                                                   time_info.tm_mon,
                                                                   time_info.tm_mday,
                                                                   time_info.tm_hour,
                                                                   time_info.tm_min,
                                                                   time_info.tm_sec)

    board.DISPLAY.refresh_soon()

# First run update
tide_info = get_tide_info()
current_time = time.localtime()
update_display(current_time, True)
current_yday = current_time.tm_yday

# Update daily
while True:
    current_time = time.localtime()
    new_tides = False
    if current_time.tm_yday != current_yday:
        # new day, time to update
        tide_info = get_tide_info()
        new_tides = True
        current_yday = current_time.tm_yday
    update_display(current_time, new_tides)
    time.sleep(0.5)
UK tidal data

Making it graphical

The next stage of the learning guide shows the tidal data plotted over a 24 hour period with tidal times, heights provided every six minutes. The Admiralty doesn’t provide this level of detail. They only provide the high and low water times. I decided to plot them anyway. The code is provided below.

Completed code

import time
import board
import displayio
import json
from secrets import secrets
from adafruit_pyportal import PyPortal
from adafruit_bitmap_font import bitmap_font
from adafruit_display_text.label import Label

#--| USER CONFIG |--------------------------
STATION_ID = "0245"   # tide location, find yours from admiralty website
PLOT_SIZE = 2            # tide plot thickness
PLOT_COLOR = 0x00FF55    # tide plot color
MARK_SIZE = 6            # current time marker size
MARK_COLOR = 0xFF0000    # current time marker color
DATE_COLOR = 0xE0CD1A    # date text color
TIME_COLOR = 0xE0CD1A    # time text color
VSCALE = 20              # vertical plot scale
#-------------------------------------------

# pylint: disable=line-too-long
DATA_SOURCE = "https://admiraltyapi.azure-api.net/uktidalapi/api/V1/Stations/" + STATION_ID + "/TidalEvents?duration=1"
DATA_LOCATION = []

WIDTH = board.DISPLAY.width
HEIGHT = board.DISPLAY.height

# determine the current working directory needed so we know where to find files
cwd = ("/"+__file__).rsplit('/', 1)[0]
pyportal = PyPortal(url=DATA_SOURCE,
                    headers={"Ocp-Apim-Subscription-Key":secrets['Ocp-Apim-Subscription-Key']},
                    json_path=DATA_LOCATION,
                    status_neopixel=board.NEOPIXEL,
                    default_bg=cwd+"/tides_bg_graph.bmp")

# Connect to the internet and get local time
pyportal.get_local_time()

# Setup palette used for plot
palette = displayio.Palette(3)
palette[0] = 0x0
palette[1] = PLOT_COLOR
palette[2] = MARK_COLOR
palette.make_transparent(0)

# Setup tide plot bitmap
tide_plot = displayio.Bitmap(WIDTH, HEIGHT, 3)
pyportal.splash.append(displayio.TileGrid(tide_plot, pixel_shader=palette))

# Setup font used for date and time
date_font = bitmap_font.load_font(cwd+"/fonts/mono-bold-8.bdf")
date_font.load_glyphs(b'1234567890-')

# Setup date label
date_label = Label(date_font, text="0000-00-00", color=DATE_COLOR, x=7, y=14)
pyportal.splash.append(date_label)

# Setup time label
time_label = Label(date_font, text="00:00:00", color=TIME_COLOR, x=234, y=14)
pyportal.splash.append(time_label)

# Setup current time marker
time_marker_bitmap = displayio.Bitmap(MARK_SIZE, MARK_SIZE, 3)
for pixel in range(MARK_SIZE * MARK_SIZE):
    time_marker_bitmap[pixel] = 2
time_marker = displayio.TileGrid(time_marker_bitmap, pixel_shader=palette, x=-MARK_SIZE, y=-MARK_SIZE)
pyportal.splash.append(time_marker)

def get_tide_data():
    """Fetch JSON tide data and return parsed results in a list."""

    # Get raw JSON data
    raw_data = pyportal.fetch()
    raw_data = json.loads(raw_data)
    # Results will be stored in a list that is display WIDTH long
    new_tide_data = [None]*WIDTH

    # Convert raw data to display coordinates
    for data in raw_data:
        _, t = data["DateTime"].split("T") # date and time
        t = '{:.5}'.format(t)
        h, m = t.split(":")         # hours and minutes
        v = data["Height"]               # water level
        x = round( (WIDTH - 1) * (60 * float(h) + float(m)) / 1440 )
        y = (HEIGHT // 2) - round(VSCALE * float(v))
        y = 0 if y < 0 else y
        y = HEIGHT-1 if y >= HEIGHT else y
        new_tide_data[x] = y

    return new_tide_data

def draw_data_point(x, y, size=PLOT_SIZE, color=1):
    """Draw data point on to the tide plot bitmap at (x,y)."""
    if y is None:
        return
    offset = size // 2
    for xx in range(x-offset, x+offset+1):
        for yy in range(y-offset, y+offset+1):
            try:
                tide_plot[xx, yy] = color
            except IndexError:
                pass

def draw_time_marker(time_info):
    """Draw a marker on the tide plot for the current time."""
    h = time_info.tm_hour
    m = time_info.tm_min
    x = round( (WIDTH - 1) * (60 * float(h) + float(m)) / 1440 )
    y = tide_data[x]
    if y is not None:
        x -= MARK_SIZE // 2
        y -= MARK_SIZE // 2
        time_marker.x = x
        time_marker.y = y

def update_display(time_info, update_tides=False):
    """Update the display with current info."""

    # Tide data plot
    if update_tides:
        # out with the old
        for i in range(WIDTH * HEIGHT):
            tide_plot[i] = 0
        # in with the new
        for x in range(WIDTH):
            draw_data_point(x, tide_data[x])

    # Current location marker
    draw_time_marker(time_info)

    # Date and time
    date_label.text = "{:04}-{:02}-{:02}".format(time_info.tm_year,
                                                 time_info.tm_mon,
                                                 time_info.tm_mday)
    time_label.text = "{:02}:{:02}:{:02}".format(time_info.tm_hour,
                                                 time_info.tm_min,
                                                 time_info.tm_sec)

    board.DISPLAY.refresh_soon()

# First run update
tide_data = get_tide_data()
current_time = time.localtime()
update_display(current_time, True)
current_yday = current_time.tm_yday

# Run forever
while True:
    current_time = time.localtime()
    new_tides = False
    if current_time.tm_yday != current_yday:
        # new day, time to update
        tide_data = get_tide_data()
        new_tides = True
        current_yday = current_time.tm_yday
    update_display(current_time, new_tides)
    time.sleep(0.5)