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:
Noah
2018-12-26 20:26:05 +00:00
parent 30903a207b
commit 3e4dd4c0bc
32 changed files with 102728 additions and 1274 deletions

View File

@@ -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)