2. Give it a memory¶
At the end of tutorial 1 the app draws sixteen bars from sixteen numbers you typed into the source. It looks right and it is completely inert.
This tutorial gives it the three things that separate a picture from an app. The numbers start changing on their own. They survive a reboot. And the colour stops being your decision and becomes the user's.
No network yet. That is tutorial 3.
The lifecycle¶
An app is not a program that runs from top to bottom. It is a set of methods AWTRIX calls at different moments, and knowing which is which is most of the craft.
| Method | When it runs | May it draw? |
|---|---|---|
init() |
once, as the instance is created | no |
setup() |
once, right after the app loads | no |
loop() |
about once a second, on screen or not | no |
draw() |
every frame, roughly 40 times a second, while on screen | yes |
on_show() |
the app has just been rotated in | no |
on_hide() |
the app has just been rotated out | no |
on_button(btn) |
a button was pressed while the app is on screen | no |
should_show() |
the rotation arrived; false skips the app this turn |
no |
duration() |
the rotation arrived; return milliseconds to override the dwell time | no |
Define only the ones you need. Every method costs memory for as long as the app is installed, so an app with three good methods beats an app with ten small ones.
init() and setup() both run once and it is not obvious which to use.
init() gives members their starting values, and it is the only one of the two that
Berry itself calls, as the class is constructed. Stored values and @config settings are
already restored by then, so it can read them. setup() runs a moment later, once the
app is wired into the device, and it is where anything that reaches outside belongs:
the first HTTP request, an MQTT subscription, a log line. When in doubt, use init().
One rule matters more than the rest of the table put together:
loop() does the work. draw() paints what loop() left behind.
draw() runs about forty times a second. Anything it computes, it computes forty
times a second. loop() runs about once a second, and it keeps running while your
app is off screen, which is the whole point: the data is ready and waiting when the
rotation comes back to you.
That rule is why this app is about to grow a loop() and why draw() is going to stay
exactly as short as it is now.
Make the numbers move¶
Real numbers arrive in the next tutorial. For now, let the app invent them, so the plumbing is in place and testable.
The forecast shifts along by one hour at a time: drop the oldest value off the front, add a new one at the end.
# @name Rain
# @desc Chance of rain, hour by hour
import math
class Rain
var hours
var ticks
def init()
self.hours = []
for i : 0 .. 15
self.hours.push(0)
end
self.ticks = 0
end
def loop()
if self.ticks <= 0
self.ticks = 5
self.hours.remove(0)
self.hours.push(math.rand() % 101)
end
self.ticks -= 1
end
def draw()
clear()
var h = height()
for i : 0 .. size(self.hours) - 1
var v = self.hours[i]
var bar = v * h / 100
if bar > 0
rect_fill(i * 2, h - bar, 2, bar, hsv(208, 100, clamp(v, 25, 90)))
end
end
end
end
return Rain()
Save it and watch. The list starts as sixteen zeros and zeros draw nothing, so what you see is the chart filling from the right, one new bar every five seconds, each one shuffling left as the next arrives. Give it a minute and a half to fill up completely.
draw() did not change at all. It still reads self.hours and paints it. That is what
you are aiming for in every app you write: a draw() that knows nothing about where its
data came from.
Counting seconds¶
loop() runs about once a second, so a counter is the simplest possible timer:
def loop()
if self.ticks <= 0
self.ticks = 5 # do it again in roughly five seconds
# the work goes here
end
self.ticks -= 1
end
Roughly is the honest word. loop() is not a precise clock, and it does not try to be.
For "refresh every five minutes" that is exactly the right amount of precision, and it
costs one integer.
When you need real time, there are two different clocks and they answer different questions:
| Call | Answers |
|---|---|
now_ms() |
milliseconds since the device booted |
epoch_ms() |
milliseconds since 1970, so the actual date and time |
hour() minute() second() day() month() year() weekday() |
the local wall clock, split up |
now_ms() is the one for animation, because it advances evenly whether or not frames
do. A sweep from 0 to 1 every two seconds is (now_ms() % 2000) / 2000.0. Never animate
by incrementing a counter in draw(), because frames are not evenly spaced and the
animation will drift.
The clock is not ready at boot
All the wall-clock calls return -1 in a setup() that runs while the device is
starting up, because AWTRIX reinstalls scripts before it has fetched the time.
Guard with if hour() >= 0, or simply do the work in loop(), which always runs
with the clock available.
Keep the list from growing¶
self.hours.remove(0) before every push() is doing quiet but important work. Without
it the list grows by one value every five seconds and never stops, and every script on
the device shares one pool of memory, so a list that grows forever eventually takes the
whole panel down with it. Going easy on memory has the
measurements.
Our app removes first and then pushes, because its list is always full. When yours starts empty and fills up, push first and trim afterwards:
Either way, trim in place with remove() rather than building a new list.
Sixteen is not an arbitrary number here. The built-in bar_chart() and line_chart()
calls take at most sixteen values and drop the rest, and sixteen bars is what fits on a
32 pixel panel at a sensible width. Keeping only what you draw is a good habit
generally, and here the shape of the hardware happens to agree with it.
Survive a reboot¶
Right now a power cut leaves the panel blank until the app has ticked sixteen times.
store fixes that. It is a small key-value store, private to your app, that survives a
reboot.
Anything that survives a JSON round trip works: numbers, strings, booleans, lists and maps. The budget is 2 KB serialised per app, which is plenty for a finished value and nowhere near enough for a raw API response. Store what you worked out, never what you worked it out from.
The useful detail is the timing. The store is restored before init() runs, so an app
can show its last known value the instant the device boots instead of a placeholder:
def init()
self.hours = store.get("hours")
if self.hours == nil
self.hours = []
for i : 0 .. 15
self.hours.push(0)
end
end
self.ticks = 0
end
def loop()
if self.ticks <= 0
self.ticks = 5
self.hours.remove(0)
self.hours.push(math.rand() % 101)
store.set("hours", self.hours)
end
self.ticks -= 1
end
Writing on every update sounds expensive and is not. Writes are collected in RAM and
reach flash at most once every five seconds, so a store.set() per second is fine.
What is not fine is writing data you have not checked yet, because a bad value that
reaches the store outlives the reboot that would otherwise have cleared it.
Let the user choose the colour¶
Here is the rule that separates an app you wrote for yourself from an app you can hand
to somebody: every value the user might reasonably want to change belongs in a
# @config line, not in the source.
A @config line turns a stored value into a real field in the web UI, found under the
Apps tab, the ⋯ menu on your app's row, then Settings.
# @name Rain
# @desc Chance of rain, hour by hour
# @version 1.0
# @config tint color "Bar colour" default=#0088FF
# @config every number "Refresh" default=15 min=1 max=60 unit=min
Four things to know, and they cover almost every mistake people make with these:
Read a setting with store.get(key). There is no separate call. Settings and stored
values live in the same place, which is why the same function reads both.
Do not repeat the default in code. Write store.get("tint"), not
store.get("tint", 0x0088FF). The declared default is already the answer on the very
first frame, and a second copy in the source is one more thing to keep in sync.
Put the lines in the header, above any code. The parser stops reading tags at the
first line that is neither blank nor a comment, so a @config below your import math
is never seen at all.
Saving a setting restarts the app, so init() runs again. That is exactly where
anything derived from a setting belongs.
A color setting hands you an integer, which is precisely what the drawing calls want,
so it goes straight through with nothing in between.
Never comment out a @config line to test something
Removing the line deletes the stored value. The user's choice is gone at the next save, with no warning and no way back.
The finished script¶
# @name Rain
# @desc Chance of rain, hour by hour
# @version 1.0
# @config tint color "Bar colour" default=#0088FF
# @config every number "Refresh" default=15 min=1 max=60 unit=min
import math
class Rain
var hours # 16 percentages, oldest first
var tint # accent colour, from the user's settings
var period # loop() calls between refreshes
var ticks # counting down to the next one
def init()
self.hours = store.get("hours")
if self.hours == nil
self.hours = []
for i : 0 .. 15
self.hours.push(math.rand() % 101)
end
end
self.tint = store.get("tint")
self.period = store.get("every") * 60
self.ticks = 0
end
def loop()
if self.ticks <= 0
self.ticks = self.period
self.hours.remove(0)
self.hours.push(math.rand() % 101)
store.set("hours", self.hours)
end
self.ticks -= 1
end
def draw()
clear()
var h = height()
for i : 0 .. size(self.hours) - 1
var v = self.hours[i]
var bar = v * h / 100
if bar > 0
rect_fill(i * 2, h - bar, 2, bar, self.tint)
end
end
end
end
return Rain()
Two details in there are worth pausing on.
The first run seeds the list with sixteen random values rather than sixteen zeros.
Without that, the panel would show a single bar for the first fifteen minutes, because
loop() adds one value per refresh and zeros draw nothing. Seeding is the right call
while the data is invented. In tutorial 3, where the numbers mean
something, seeding with fiction would be dishonest, so the app starts with zeros and
draws a placeholder until the first real answer arrives.
Note what happened to the colour. hsv() gave every bar its own brightness, which was
a nice effect and is no longer ours to choose. Once the user picks a colour, the app
should paint that colour. If you want both, a second @config line asking whether to
shade by value is the honest way to offer it, rather than quietly overriding what they
picked.
With every set to 15 minutes the chart now moves too slowly to watch, which is
correct for a weather app and inconvenient for testing. Set it to 1 while you work.
What you learned¶
loop()runs once a second whether or not the app is visible, and that is where work belongs.draw()only paints.- A counter in
loop()is a perfectly good timer.now_ms()is for animation, because it advances evenly when frames do not. - Lists that grow need trimming in place, and sixteen values is the number the charts and the panel both agree on.
storesurvives reboots, holds 2 KB per app, and is restored beforeinit()runs.- Anything the user might want to change is a
# @configline read withstore.get(key), never a constant in the source.
Next¶
3. Feed it real data throws away math.rand() and fetches a real
forecast. That brings in HTTP without blocking the panel, pulling values out of a
response without drowning in it, and what an app should show in the seconds before its
first answer arrives.
Related¶
- App scripting for the full lifecycle and the
@configreference - Settings for the device settings a script can read alongside its own
- Limits for every cap in one table