Rewrite to reduce memory usage and add more features
In addition to the clock scene, both the animation scene and the weather scene should now work under MicroPython on devices with 520kBytes of RAM (e.g. LoPy 1, WiPy 2) after: - combating heap fragmentation during initialization by temporarily allocating a large chunk of RAM in the beginning of main.py and freeing it after all modules have been imported and initialized - stream parsing the JSON response from the weather API - converting animations to binary and streaming them from the flash file system (additionally, older ESP8266 modules with 4MB flash have been found working under some circumstances with MicroPython 1.9.4 and an 8x8 LED matrix) - 3D parts: add diffuser grid and frame for square LED matrix displays - Arduino projects needs to be in a folder with the same name as the .ino file - config: allow multiple WiFi networks to be configured - config: add support for debug flags - config: add intensity configuration - HAL: unify serial input processing for Arduino and Pycom devices - HAL: handle UART write failures on Pycom devices - HAL: drop garbage collection from .update_display() because it takes several hundred milliseconds on 4MB devices - MCU: clear display when enabling/disabling MCU independence from host - PixelFont: move data to class attributes to reduce memory usage - PixelFont: add more characters - PixelFont: move data generation to scripts/generate-pixelfont.py - LedMatrix: support LED matrixes with strides other than 8 (e.g. as 16x16 matrices) - LedMatrix: add method to render text - LedMatrix: let consumers handle brightness themselves - AnimationScene: MicroPython does not implement bytearray.find - AnimationScene: ensure minimum on-screen time - BootScene: wifi connection and RTC sync progress for Pycom devices - ClockScene: delete unused code, switch to generic text rendering method - FireScene: classical fire effect - WeatherScene: bug fixes, switch to generic text rendering method - WeatherScene: ensure minimum on-screen time - WeatherScene: use custom JSON parsing to reduce memory usage
This commit is contained in:
304
weatherscene.py
304
weatherscene.py
@@ -1,18 +1,15 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Render the current weather forecast from SMHI.se
|
||||
#
|
||||
import os
|
||||
import time
|
||||
import gc
|
||||
try:
|
||||
import ujson as json
|
||||
import urequests as requests
|
||||
except ImportError:
|
||||
import json
|
||||
import requests
|
||||
|
||||
# Local imports
|
||||
from pixelfont import PixelFont
|
||||
from icon import Icon
|
||||
|
||||
# Based on demoscene.py
|
||||
class WeatherScene:
|
||||
@@ -20,6 +17,8 @@ class WeatherScene:
|
||||
This module displays a weather forecast from SMHI (Sweden)
|
||||
"""
|
||||
|
||||
dir_prefix = 'weather/'
|
||||
|
||||
def __init__(self, display, config):
|
||||
"""
|
||||
Initialize the module.
|
||||
@@ -27,117 +26,137 @@ class WeatherScene:
|
||||
update the display via self.display.put_pixel() and .render()
|
||||
"""
|
||||
self.display = display
|
||||
self.font = PixelFont()
|
||||
self.last_refreshed_at = 0
|
||||
self.api_url = 'https://opendata-download-metfcst.smhi.se'
|
||||
self.icon = None
|
||||
self.debug = False
|
||||
self.intensity = 16
|
||||
self.lat = 59.3293
|
||||
self.lon = 18.0686
|
||||
if config:
|
||||
if 'lat' in config:
|
||||
self.lat = config['lat']
|
||||
if 'lon' in config:
|
||||
self.lon = config['lon']
|
||||
|
||||
self.api_url = 'https://opendata-download-metfcst.smhi.se'
|
||||
self.headers = {
|
||||
'User-Agent':'weatherscene.py/1.0 (+https://github.com/noahwilliamsson/lamatrix)',
|
||||
'Accept-Encoding': 'identity',
|
||||
}
|
||||
self.temperature = 0
|
||||
self.wind_speed = 0
|
||||
self.last_refreshed_at = 0
|
||||
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
|
||||
self.symbol = None
|
||||
self.symbol_version = 1
|
||||
self.symbol_to_animation = [
|
||||
self.symbol_to_icon = [
|
||||
None,
|
||||
'weather/weather-moon-stars.json', # clear sky
|
||||
'weather/weather-moon-stars.json', # nearly clear sky
|
||||
'weather/weather-cloud-partly.json', # variable cloudiness
|
||||
'weather/weather-cloud-partly.json', # halfclear sky
|
||||
'weather/weather-cloudy.json', # cloudy sky
|
||||
'weather/weather-cloudy.json', # overcast
|
||||
'weather/weather-cloudy.json', # fog
|
||||
'weather/weather-rain.json', # rain showers
|
||||
'weather/weather-rain.json', # thunderstorm
|
||||
'weather/weather-rain-snowy.json', # light sleet
|
||||
'weather/weather-rain-snowy.json', # snow showers
|
||||
'weather/weather-rain.json', # rain
|
||||
'weather/weather-thunderstorm.json', # thunder
|
||||
'weather/weather-rain-snowy.json', # sleet
|
||||
'weather/weather-snow-house.json', # snowfall
|
||||
['moon-stars.bin', 'sunny.bin'], # clear sky
|
||||
['moon-stars.bin', 'sunny-with-clouds.bin'], # nearly clear sky
|
||||
'cloud-partly.bin', # variable cloudiness
|
||||
'sunny-with-clouds.bin', # halfclear sky
|
||||
'cloudy.bin', # cloudy sky
|
||||
'cloudy.bin', # overcast
|
||||
'fog.bin', # fog
|
||||
'rain.bin', # light rain showers
|
||||
'rain.bin', # medium rain showers
|
||||
'rain.bin', # heavy rain showers
|
||||
'rain.bin', # thunderstorm
|
||||
'rain-snow.bin', # light sleet showers
|
||||
'rain-snow.bin', # medium sleet showers
|
||||
'rain-snow.bin', # heavy sleet showers
|
||||
'rain-snow.bin', # light snow showers
|
||||
'rain-snow.bin', # medium snow showers
|
||||
'rain-snow.bin', # heavy snow showers
|
||||
'rain.bin', # light rain
|
||||
'rain.bin', # medium rain
|
||||
'rain.bin', # heavy rain
|
||||
'thunderstorm.bin', # thunder
|
||||
'rain-snowy.bin', # light sleet
|
||||
'rain-snowy.bin', # medium sleet
|
||||
'rain-snowy.bin', # heavy sleet
|
||||
'snow-house.bin', # light snowfall
|
||||
'snow-house.bin', # medium snowfall
|
||||
'snow-house.bin', # heavy snowfall
|
||||
]
|
||||
self.frames = [[[]]]
|
||||
self.delays = [0]
|
||||
self.num_frames = 1
|
||||
self.remaining_frames = 1
|
||||
self.next_frame_at = 0
|
||||
self.loops = 3
|
||||
if not config:
|
||||
return
|
||||
if 'debug' in config:
|
||||
self.debug = config['debug']
|
||||
if 'intensity' in config:
|
||||
self.intensity = int(round(config['intensity']*255))
|
||||
if 'lat' in config:
|
||||
self.lat = config['lat']
|
||||
if 'lon' in config:
|
||||
self.lon = config['lon']
|
||||
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
This method is called before transitioning to this scene.
|
||||
Use it to (re-)initialize any state necessary for your scene.
|
||||
"""
|
||||
self.remaining_frames = len(self.frames)*self.loops
|
||||
self.next_frame_at = 0
|
||||
|
||||
self.reset_icon()
|
||||
t = time.time()
|
||||
if t < self.last_refreshed_at + 1800:
|
||||
return
|
||||
|
||||
# fetch a new forecast from SMHI
|
||||
print('WeatherScene: reset called, requesting weather forecast')
|
||||
url = '{}/api/category/pmp2g/version/2/geotype/point/lon/{}/lat/{}/data.json'.format(self.api_url, self.lon, self.lat)
|
||||
r = requests.get(url, headers=self.headers)
|
||||
url = '{}/api/category/pmp3g/version/2/geotype/point/lon/{}/lat/{}/data.json'.format(self.api_url, self.lon, self.lat)
|
||||
print('WeatherScene: reset called, requesting weather forecast from: {}'.format(url))
|
||||
|
||||
r = requests.get(url, headers=self.headers, stream=True)
|
||||
if r.status_code != 200:
|
||||
print('WeatherScene: failed to request {}: status {}'.format(url, r.status_code))
|
||||
return
|
||||
|
||||
print('WeatherScene: parsing weather forecast')
|
||||
next_hour = int(time.time())
|
||||
next_hour = next_hour - next_hour%3600 + 3600
|
||||
expected_timestamp = '{:04d}-{:02d}-{:02d}T{:02d}'.format(*time.gmtime(next_hour))
|
||||
temp, ws, symb = self.get_forecast(r.raw, expected_timestamp)
|
||||
# Close socket and free up RAM
|
||||
r.close()
|
||||
r = None
|
||||
gc.collect()
|
||||
|
||||
forecast = None
|
||||
expected_timestamp = '{:04d}-{:02d}-{:02d}T{:02d}'.format(*time.gmtime())
|
||||
data = json.loads(r.text)
|
||||
for ts in data['timeSeries']:
|
||||
if ts['validTime'].startswith(expected_timestamp):
|
||||
forecast = ts
|
||||
break
|
||||
|
||||
if not forecast:
|
||||
print('WeatherScene: failed to find forecast for UNIX timestamp {}'.format(this_hour))
|
||||
if temp == None:
|
||||
print('WeatherScene: failed to find forecast for timestamp prefix: {}'.format(expected_timestamp))
|
||||
return
|
||||
self.temperature = float(temp.decode())
|
||||
self.wind_speed = float(ws.decode())
|
||||
self.symbol = int(symb.decode())
|
||||
self.last_refreshed_at = t
|
||||
|
||||
n = 0
|
||||
for obj in forecast['parameters']:
|
||||
if obj['name'] == 't':
|
||||
self.temperature = obj['values'][0]
|
||||
elif obj['name'] == 'ws':
|
||||
self.wind_speed = obj['values'][0]
|
||||
elif obj['name'] == 'Wsymb':
|
||||
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
|
||||
self.symbol = obj['values'][0]
|
||||
self.symbol_version = 1
|
||||
elif obj['name'] == 'Wsymb2':
|
||||
# http://opendata.smhi.se/apidocs/metfcst/parameters.html#parameter-wsymb
|
||||
self.symbol = obj['values'][0]
|
||||
self.symbol_version = 2
|
||||
else:
|
||||
continue
|
||||
n += 1
|
||||
print('WeatherScene: updated {} parameters from forecast for {}'.format(n, forecast['validTime']))
|
||||
|
||||
filename = self.symbol_to_animation[self.symbol]
|
||||
filename = self.symbol_to_icon[self.symbol]
|
||||
if not filename:
|
||||
return
|
||||
f = open(filename)
|
||||
obj = json.loads(f.read())
|
||||
f.close()
|
||||
obj = json.loads(obj['body'])
|
||||
self.delays = obj['delays']
|
||||
self.frames = obj['icons']
|
||||
self.num_frames = len(self.frames)
|
||||
self.remaining_frames = self.num_frames*4
|
||||
|
||||
def input(self, button_id, button_state):
|
||||
print('WeatherScene: button {} pressed: {}'.format(button_id, button_state))
|
||||
return False # signal that we did not handle the input
|
||||
if type(filename) == list:
|
||||
lt = time.localtime(next_hour)
|
||||
if lt[3] < 7 or lt[3] > 21:
|
||||
# Assume night icon
|
||||
filename = filename[0]
|
||||
else:
|
||||
filename = filename[1]
|
||||
if self.icon:
|
||||
# MicroPython does not support destructors so we need to manually
|
||||
# close the file we have opened
|
||||
self.icon.close() # Close icon file
|
||||
self.icon = Icon(self.dir_prefix + filename)
|
||||
self.icon.set_intensity(self.intensity)
|
||||
self.reset_icon()
|
||||
|
||||
def input(self, button_state):
|
||||
"""
|
||||
Handle button inputs
|
||||
"""
|
||||
return 0 # signal that we did not handle the input
|
||||
|
||||
def set_intensity(self, value=None):
|
||||
if value is not None:
|
||||
self.intensity -= 1
|
||||
if not self.intensity:
|
||||
self.intensity = 16
|
||||
if self.icon:
|
||||
self.icon.set_intensity(self.intensity)
|
||||
return self.intensity
|
||||
|
||||
def render(self, frame, dropped_frames, fps):
|
||||
"""
|
||||
@@ -153,65 +172,98 @@ class WeatherScene:
|
||||
self.remaining_frames -= 1
|
||||
n = self.num_frames
|
||||
index = n - (self.remaining_frames % n) - 1
|
||||
# Calculate next frame
|
||||
self.next_frame_at = frame + int(fps * self.delays[index]/1000)
|
||||
# Calculate next frame number
|
||||
self.next_frame_at = frame + int(fps * self.icon.frame_length()/1000)
|
||||
# Render frame
|
||||
display = self.display
|
||||
data = self.frames[index]
|
||||
for y in range(len(data)):
|
||||
row = data[y]
|
||||
for x in range(len(data)):
|
||||
r = round(row[x][0] * 255)
|
||||
g = round(row[x][0] * 255)
|
||||
b = round(row[x][0] * 255)
|
||||
display.put_pixel(x, y, r, g, b)
|
||||
intensity = self.intensity
|
||||
self.icon.blit(display, 0 if display.columns == 32 else 4, 0)
|
||||
|
||||
# Render text
|
||||
if self.remaining_frames >= n:
|
||||
text = '{:.2g}\'c'.format(self.temperature)
|
||||
else:
|
||||
text = '{:.2g}m/s'.format(self.wind_speed)
|
||||
self.render_text(text)
|
||||
if display.columns <= 16:
|
||||
text = '{:.1g}m/s'.format(self.wind_speed)
|
||||
display.render_text(PixelFont, text, 9 if display.columns == 32 else 0, 1 if display.columns == 32 else 10, intensity)
|
||||
|
||||
display.render()
|
||||
if self.remaining_frames == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def render_text(self, text, x_off = 8+1, y_off = 1):
|
||||
"""
|
||||
Render text with the pixel font
|
||||
"""
|
||||
display = self.display
|
||||
f = self.font
|
||||
w = f.width
|
||||
h = f.height
|
||||
alphabet = f.alphabet
|
||||
font = f.data
|
||||
for i in range(len(text)):
|
||||
digit = text[i]
|
||||
if digit in '.-\'' or text[i-1] in '.':
|
||||
x_off -= 1
|
||||
data_offset = alphabet.find(digit)
|
||||
if data_offset < 0:
|
||||
data_offset = 0
|
||||
tmp = data_offset * w * h
|
||||
font_byte = tmp >> 3
|
||||
font_bit = tmp & 7
|
||||
for row in range(h):
|
||||
for col in range(w):
|
||||
c = 0
|
||||
if font[font_byte] & (1<<font_bit):
|
||||
c = 255
|
||||
font_bit += 1
|
||||
if font_bit == 8:
|
||||
font_byte += 1
|
||||
font_bit = 0
|
||||
display.put_pixel(x_off+col, y_off+row, c, c, c)
|
||||
x_off += w
|
||||
def reset_icon(self):
|
||||
if not self.icon:
|
||||
return
|
||||
self.icon.reset()
|
||||
self.num_frames = self.icon.num_frames
|
||||
self.remaining_frames = self.num_frames*2
|
||||
t_icon = self.icon.length_total()
|
||||
# Ensure a minimum display time
|
||||
for i in range(1,6):
|
||||
if t_icon*i >= 4000:
|
||||
break
|
||||
self.remaining_frames += self.num_frames
|
||||
|
||||
def get_forecast(self, f, validTime):
|
||||
"""
|
||||
Extract temperature, windspeed, weather symbol from JSON response
|
||||
"""
|
||||
timeStr = None
|
||||
tempStr = None
|
||||
wsStr = None
|
||||
symbStr = None
|
||||
while True:
|
||||
v = f.read(1)
|
||||
if v != b'"':
|
||||
continue
|
||||
s = self.next_string(f)
|
||||
if not timeStr:
|
||||
if not s.startswith(b'validTime'):
|
||||
continue
|
||||
timeStr = self.next_string(f, 2)
|
||||
if not timeStr.startswith(validTime):
|
||||
timeStr = None
|
||||
continue
|
||||
elif not tempStr and s == b't':
|
||||
tempStr = self.next_array_entry(f)
|
||||
elif not wsStr and s == b'ws':
|
||||
wsStr = self.next_array_entry(f)
|
||||
elif not symbStr and s == b'Wsymb2':
|
||||
symbStr = self.next_array_entry(f)
|
||||
elif timeStr and tempStr and wsStr and symbStr:
|
||||
break
|
||||
return (tempStr, wsStr, symbStr)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Debug API
|
||||
scene = WeatherScene(None, None)
|
||||
scene.reset()
|
||||
def next_string(self, f, start_at = 0):
|
||||
"""
|
||||
Extract string value from JSON
|
||||
"""
|
||||
if start_at:
|
||||
f.read(start_at)
|
||||
stash = bytearray()
|
||||
while True:
|
||||
c = f.read(1)
|
||||
if c == b'"':
|
||||
break
|
||||
stash.append(c[0])
|
||||
return bytes(stash)
|
||||
|
||||
def next_array_entry(self, f):
|
||||
"""
|
||||
Extract first array entry from JSON
|
||||
"""
|
||||
in_array = False
|
||||
stash = bytearray()
|
||||
while True:
|
||||
c = f.read(1)
|
||||
if not in_array:
|
||||
if c != b'[':
|
||||
continue
|
||||
in_array = True
|
||||
continue
|
||||
if c == b']' or c == b' ' or c == b',':
|
||||
break
|
||||
stash.append(c[0])
|
||||
return bytes(stash)
|
||||
|
||||
Reference in New Issue
Block a user