Skip to content

Commit 91b43bd

Browse files
New App: Culver's (#3060)
* Create manifest.yaml * Create culvers.star * Use placeholder image if location-based slug not set * Use Culver's locator API instead; use placeholder image only when necessary
1 parent cd8e517 commit 91b43bd

File tree

2 files changed

+349
-0
lines changed

2 files changed

+349
-0
lines changed

apps/culvers/culvers.star

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
"""
2+
Applet: Culver's
3+
Summary: Today's Culver's flavor
4+
Description: Get today's flavor at Culver's Frozen Custard.
5+
Author: Josiah Winslow
6+
"""
7+
8+
load("encoding/base64.star", "base64")
9+
load("encoding/json.star", "json")
10+
load("http.star", "http")
11+
load("render.star", "render")
12+
load("schema.star", "schema")
13+
14+
WIDTH = 64
15+
HEIGHT = 32
16+
DELAY = 35
17+
TTL_SECONDS = 60 * 30 # 30 minutes
18+
19+
WHITE = "#ffffff"
20+
CULVERS_BLUE = "#005696"
21+
LIGHT_BLUE = "#7fb5d0"
22+
23+
NAME_HEIGHT = 6
24+
FLAVOR_HEIGHT = HEIGHT - 6
25+
FLAVOR_TARGET_WIDTH = 42
26+
IMAGE_HEIGHT = 40
27+
IMAGE_PAD = (-8, -1, 0, 0)
28+
FONTS = ["10x20", "6x10-rounded", "tb-8", "tom-thumb"]
29+
30+
# Default location is "Culver's of Sauk City, WI - Phillips Blvd"
31+
DEFAULT_RESTAURANT_LOCATION = "-89.729866027832,43.2706871032715"
32+
DEFAULT_BG_COLOR = WHITE
33+
IMAGE_PLACEHOLDER = base64.decode("""
34+
R0lGODlhKAAoAPcAAAAAAAchTQchTg0jSwoiTA8mTwckVQkjUAsmUgwlUgwmUw0mUwklVQsnVw8oUQs
35+
pWA8rXBAnVxApUhcsUhAqVhUtVhQtVxUtVxguVhEtWxUtWxUuWhcuXBkvWBguWxkvXBoxWRoyWxsyXB
36+
ozXBszXBs0XR00XBElYRcyYBkzYhs1YBs2Yh04ZB8xaiA4YCI7ZSM9ZyA6aCE9aSJBbihCbS1GcC5Hc
37+
SVLdylIcyxKdCxMdyxPejVNdzJPeThSez1UfEBYfy5WgilahjBfiipgjDljjzRokjxokzRvmj17pDp+
38+
p0JbgUZdgk1ghUplikxnjFFihlBkiFFliVFmiVRnildqi1lrjVlsjl9ujU9rkE9skFtokWBzk2RykmF
39+
0k2V4l2t6nD2Hrz2IsD+KsjmPuT2Su3CBn0yCq0aIsUONs0+Ks1SPt0GRuEKTukOUvEWWvEaXv0iTuU
40+
iVu0qXvUmZv3KEoHeIo0uawEybwU6ew06fxFugxliiyluozVut02Cpy2Cx1miy2W633Xe513C+42/C5
41+
33F5XLD6HTF63nM73vM73nR9nrS93vT93/S9X/T9n7U+IGQqoeUrYeVrpKatoC/26Otway0yK+4yLG7
42+
y7K8y7fAz7vC0ofE4IbN65DJ45rR6Z/W74DU94HU94fW943X94LV+IHX+oPY+4Xa/IXb/ofc/4jf/5f
43+
d+p7e+a3d8rTe8Yjg/4vl/6fh+rDi+Ljl+Lnn+8DJ1MjP2srR28vS3MzR3c3T3dDU3tfc5Mfp98/s98
44+
Dp+8nt/NHv/Mbw/9Hw/dfz/t32/+Lm6+Pm7Onr8O7w8+T2/uT4/+r4/uj7/+/+//X2+Pn7+/v8/Pz8/
45+
fz9/f39/v79/v7+/v7+/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
46+
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
47+
AAAAAAAAAAAAAACH5BAEKAAAALAAAAAAoACgAAAj/AAEIHEiwoMGDCBMqXMiwocOHECNKnEixokWDAw
48+
IEIHDR4AYLAyOwYPIFzBQaDEB23NDEh4UQKMz40kZT2zJLNCh0ROFLWQwWmmrSjLaFErIfOitKoKItE
49+
wRM2qYJnWapBRhkNFROzLBJmw2mUoXSTHbi0iUGFBf8oMkpmdiaUjnt0kbDwcQDZqZJoyl1mt+/fmte
50+
KTCRwaS3iIXaSQpxgQEbXrh4mUy5MmXJNgwsgMhiCpcrU0KLHk069BUuVWQ8lHFrF65csGPD1kW7du3
51+
XvXjYbUgixZQrVoILF16luHHjVGJsgLhiyRMn0KNDf6KlunUtWbQ4YRGxgxA2Y8KL/x8Thg+iQocOoV
52+
efSA2HiB+C6KFzp74bOHjgAFp1CtWpVqmccoosZywH0QQ3yDHHHHLAUckfbOzhSSONiHKKLbQ08kgqR
53+
xAGEQYyxEHHHHCAAk0nZIBCTCmzDMOMMMaQAokoQQwgEQto4MFGJc4UE8orwgTDjDPDDFNMMAImMsNE
54+
KSSR3y/D2NIMNMcYY4srrZByioapCLLCRBwggUcezbxCCyygVGIIJBQ+Ioooj6yyx3sSbTBEHmx8Asw
55+
gZbhBhh+ouPkmnLGoEcFEHdyAxxxswMHGgnIAcsojlFYaSxEeRoRBCkbgoUceedSnX4CopMIKK6mskU
56+
IIFPW2AxFIKFMRRhpp9JHIIYQEwgcaRaRAgkUuUNEFFED0kAMOOuzQww9ANIEFFTB05MISXNQRySTYY
57+
htJHV4w8UJHBC2wgAUlmFCCBQg8AO667Lbr7rvwxttuQAA7
58+
""")
59+
60+
def get_image(url):
61+
rep = http.get(url, ttl_seconds = TTL_SECONDS)
62+
if rep.status_code != 200:
63+
return None
64+
65+
return rep.body()
66+
67+
def get_flavor_info(restaurant_location):
68+
# HACK The Culver's locator API also returns the flavor of the day
69+
# at each Culver's location it returns. We can therefore feed the
70+
# lat/long of the Culver's location back into this API to get the
71+
# flavor of the day at that location.
72+
# REVIEW This code doesn't validate that we have the correct
73+
# restaurant; should it? Each restaurant has a unique "slug" (i.e.
74+
# ID), and an earlier version of this app used to save it instead of
75+
# the lat/long.
76+
lng, lat = restaurant_location.split(",")
77+
rep = http.get(
78+
(
79+
"https://culvers.com/api/locator/getLocations?lat=%s&long=%s&" +
80+
"radius=100&limit=1"
81+
) % (lat, lng),
82+
ttl_seconds = TTL_SECONDS,
83+
)
84+
if rep.status_code != 200:
85+
return {
86+
"error": "Culver's status code: %s" % rep.status_code,
87+
}
88+
j = rep.json()
89+
90+
# Check whether restaurant exists
91+
restaurants = j["data"]["geofences"]
92+
if not restaurants:
93+
return {
94+
"error": "Restaurant not found",
95+
}
96+
97+
restaurant = restaurants[0]
98+
restaurant_name = "Culver's of " + restaurant["description"]
99+
100+
# Check whether flavor exists
101+
# REVIEW The only time I've seen a blank flavor name is with a store
102+
# that's "coming soon".
103+
fotd_name = restaurant["metadata"]["flavorOfDayName"]
104+
if not fotd_name:
105+
return {
106+
"error": "Flavor not found",
107+
}
108+
109+
fotd_image = "https://cdn.culvers.com/menu-item-detail/%s?h=%d" % (
110+
restaurant["metadata"]["flavorOfDaySlug"],
111+
IMAGE_HEIGHT,
112+
)
113+
114+
return {
115+
"restaurant": restaurant_name,
116+
"flavor": fotd_name,
117+
"image": fotd_image,
118+
}
119+
120+
def render_shrink_wrapped_text(
121+
content,
122+
font = "tb-8",
123+
width = 0,
124+
height = 0,
125+
linespacing = 0,
126+
color = "#ffffff",
127+
align = "left"):
128+
# The text width should be at least as wide as the widest word
129+
min_width = max([
130+
render.Text(content = word, font = font).size()[0]
131+
for word in content.split()
132+
])
133+
134+
# Use min width if width is uninitialized
135+
if width <= 0:
136+
width = min_width
137+
138+
# Clamp the text width in between the calculated minimum and the
139+
# device's maximum
140+
width = min(max(width, min_width), WIDTH)
141+
142+
return render.WrappedText(
143+
content = content,
144+
font = font,
145+
width = width,
146+
height = height,
147+
linespacing = linespacing,
148+
color = color,
149+
align = align,
150+
)
151+
152+
def get_wrapped_text_height(wrapped_text):
153+
content = wrapped_text.content
154+
font = wrapped_text.font
155+
width = wrapped_text.width
156+
linespacing = wrapped_text.linespacing
157+
158+
# Set initial height to height of one line
159+
height = render.Text(content = "", font = font).size()[1]
160+
if width <= 0:
161+
return height
162+
163+
words = []
164+
165+
# For each word in the text
166+
for word in content.split():
167+
# Add that word to this line and render it
168+
words.append(word)
169+
line = " ".join(words)
170+
rendered_line = render.Text(content = line, font = font)
171+
line_width, line_height = rendered_line.size()
172+
173+
# If the line is longer than the allowed width
174+
if line_width > width:
175+
# Move this word to the next line
176+
words = [word]
177+
height += linespacing + line_height
178+
179+
return height
180+
181+
def main(config):
182+
if "restaurant_location" in config:
183+
restaurant_location = json.decode(
184+
config["restaurant_location"],
185+
)["value"]
186+
else:
187+
restaurant_location = DEFAULT_RESTAURANT_LOCATION
188+
189+
bg_color = config.get("bg_color", DEFAULT_BG_COLOR)
190+
191+
flavor_info = get_flavor_info(restaurant_location)
192+
193+
if "error" in flavor_info:
194+
# Render "ERROR" with scrolling error message
195+
return render.Root(
196+
delay = DELAY,
197+
child = render.Box(
198+
color = bg_color,
199+
child = render.Column(
200+
cross_align = "center",
201+
children = [
202+
render.Text(
203+
content = "ERROR",
204+
font = "tb-8",
205+
color = CULVERS_BLUE,
206+
),
207+
render.Marquee(
208+
width = WIDTH,
209+
align = "center",
210+
delay = 20,
211+
child = render.Text(
212+
content = flavor_info["error"],
213+
font = "tom-thumb",
214+
color = CULVERS_BLUE,
215+
),
216+
),
217+
],
218+
),
219+
),
220+
)
221+
222+
# Try rendering flavor text in each font in order of decreasing size
223+
flavor_texts = [
224+
render_shrink_wrapped_text(
225+
content = flavor_info["flavor"],
226+
font = font,
227+
width = FLAVOR_TARGET_WIDTH,
228+
color = CULVERS_BLUE,
229+
align = "center",
230+
)
231+
for font in FONTS
232+
]
233+
234+
# Find the first flavor text that fits within the desired space
235+
# (falling back to the smallest one if none fit)
236+
flavor_text = flavor_texts[-1]
237+
for flavor_text in flavor_texts:
238+
if (
239+
flavor_text.width <= FLAVOR_TARGET_WIDTH and
240+
get_wrapped_text_height(flavor_text) <= FLAVOR_HEIGHT
241+
):
242+
break
243+
244+
flavor_image = get_image(flavor_info["image"]) or IMAGE_PLACEHOLDER
245+
246+
return render.Root(
247+
delay = DELAY,
248+
child = render.Stack(
249+
children = [
250+
# Background
251+
render.Box(color = bg_color),
252+
# Flavor of the day image
253+
render.Padding(
254+
pad = IMAGE_PAD,
255+
child = render.Image(
256+
src = flavor_image,
257+
height = IMAGE_HEIGHT,
258+
),
259+
),
260+
# Restaurant name
261+
render.Marquee(
262+
width = WIDTH,
263+
child = render.Text(
264+
content = flavor_info["restaurant"],
265+
font = "tom-thumb",
266+
color = CULVERS_BLUE,
267+
height = NAME_HEIGHT,
268+
),
269+
),
270+
# Flavor of the day name
271+
render.Padding(
272+
pad = (WIDTH - flavor_text.width, 6, 0, 0),
273+
child = render.Marquee(
274+
width = flavor_text.width,
275+
height = FLAVOR_HEIGHT,
276+
scroll_direction = "vertical",
277+
align = "center",
278+
delay = 40,
279+
child = flavor_text,
280+
),
281+
),
282+
],
283+
),
284+
)
285+
286+
def get_restaurants(location):
287+
loc = json.decode(location)
288+
289+
# Search for all Culver's restaurants within 40,233 m (25 mi)
290+
rep = http.get(
291+
(
292+
"https://culvers.com/api/locator/getLocations?lat=%s&long=%s&" +
293+
"radius=40233&limit=10"
294+
) % (loc["lat"], loc["lng"]),
295+
ttl_seconds = TTL_SECONDS,
296+
)
297+
if rep.status_code != 200:
298+
return []
299+
j = rep.json()
300+
301+
return [
302+
schema.Option(
303+
display = restaurant["description"],
304+
value = ",".join([
305+
str(v)
306+
for v in restaurant["geometryCenter"]["coordinates"]
307+
]),
308+
)
309+
for restaurant in j["data"]["geofences"]
310+
]
311+
312+
def get_schema():
313+
options_bg_color = [
314+
schema.Option(
315+
display = "White",
316+
value = WHITE,
317+
),
318+
schema.Option(
319+
display = "Light blue",
320+
value = LIGHT_BLUE,
321+
),
322+
]
323+
324+
return schema.Schema(
325+
version = "1",
326+
fields = [
327+
schema.LocationBased(
328+
id = "restaurant_location",
329+
name = "Culver's location",
330+
desc = "The location of the Culver's restaurant.",
331+
icon = "iceCream",
332+
handler = get_restaurants,
333+
),
334+
schema.Dropdown(
335+
id = "bg_color",
336+
name = "Background color",
337+
desc = "The color of the background.",
338+
icon = "brush",
339+
default = options_bg_color[0].value,
340+
options = options_bg_color,
341+
),
342+
],
343+
)

apps/culvers/manifest.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
id: culvers
3+
name: Culver's
4+
summary: Today's Culver's flavor
5+
desc: Get today's flavor at Culver's Frozen Custard.
6+
author: Josiah Winslow

0 commit comments

Comments
 (0)