|
| 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 | + ) |
0 commit comments