Culver’s rotates a different frozen custard flavor every day at every location, and the flavors are not synchronized between stores. Four restaurants within a reasonable drive means four different answers to “is it worth going tonight,” and the only way to find out has been to open the app four times.
So it went on the wall tablets. The data part took one commit. The dashboard took six, and five of them were wrong for a reason I could not see.
The API that isn’t an API#
Culver’s publishes no developer API, but their store locator is a plain unauthenticated JSON endpoint that the website itself calls, and it returns everything needed in a single request:
GET https://www.culvers.com/api/locator/getLocations?lat=<lat>&long=<long>&radius=40000&limit=4Two quirks cost me more time than the rest of the integration combined.
The parameters must be named exactly lat and long. Not latitude, not
location, not q. Anything else returns a success-shaped failure:
{"isSuccessful":false,"message":"No location data","data":{"meta":{"code":200}}}Note the "code":200 nested inside. HTTP 200, isSuccessful: false, no data. If you
are only checking status codes you will conclude there are no Culver’s in Wisconsin.
radius is in meters. Passing 25 on the assumption it meant miles is also a
200, also well-formed, and also empty:
totalResults: 0 geofences: 040000 is about 25 miles, which from a typical Madison-area address reaches 21 stores.
What comes back is one geofence object per restaurant, sorted nearest-first, each
carrying the store’s flavor of the day inline:
'Madison, WI - Todd Dr'
flavor: OREO® Cookie Overload
slug: img-Oreo-Overload.png
addr: 2102 West Beltline Hwy., Madison, WI
desc: Our delicious Chocolate Fresh Frozen Custard loaded with OREO® Cookies...
'Madison, WI - Northport Dr'
flavor: Mint Cookie
slug: img-Mint-Oreo-updated.png
'Madison, WI - Cottage Grove Rd'
flavor: Caramel Chocolate Pecan
slug: img-Caramel-Chocolate-Pecan3.png
'Madison, WI - Mineral Point Rd'
flavor: Caramel Turtle
slug: img-Caramel-Turtle.Waffle-Cone.pngThe flavorOfDaySlug prefixed with https://cdn.culvers.com/menu-item-detail/ gives
you the product photo — a ~300 KB transparent 800x800 PNG. Flavor name, description,
photo, address, and coordinates, all from one GET. For a service with no API this is
an unusually good one.
Leaving 39% of the payload on the floor#
Each geofence also ships the store’s delivery-area polygon. For a four-store response
that is 5,578 bytes of coordinates out of 14,487 total — 39% of the payload is
polygons I will never draw. Anything pulled into json_attributes lands in the state
machine and then in the recorder database, every 30 minutes, forever, so the sensors
name their attributes explicitly and geometry is simply not on the list:
rest:
- resource_template: >-
https://www.culvers.com/api/locator/getLocations?lat={{ state_attr('zone.home', 'latitude') }}&long={{ state_attr('zone.home', 'longitude') }}&radius=40000&limit=4
scan_interval: 1800
headers:
user-agent: Home Assistant
sensor:
- name: Culvers Store 1
unique_id: culvers_store_1
icon: mdi:ice-cream
value_template: >-
{% set gf = value_json.get('data', {}).get('geofences', []) %}
{{ gf[0].metadata.flavorOfDayName if gf | count > 0 else 'Unknown' }}
json_attributes_path: "$.data.geofences[0]"
json_attributes: &store_attrs
- description
- geometryCenter
- metadatageometryCenter is kept, and it is the one that earns its place: it lets the template
below compute real driving-adjacent distance from home rather than trusting the API’s
sort order.
One sensor per store, then folded back into one#
There are four nearly identical rest sensors, one per store, differing only in the
array index. That looks like something a loop should handle, and the reason it can’t is
json_attributes_path: it addresses exactly one object per sensor. A single sensor
holding the whole payload could expose the flavors but not each store’s attributes
separately.
So the four are helpers, and a template sensor folds them into the one entity the dashboard actually consumes:
template:
- sensor:
- name: Culvers Flavors
unique_id: culvers_flavors
state: "{{ states('sensor.culvers_store_1') }}"
attributes:
locations: >-
{% set ns = namespace(items=[]) %}
{% for e in ['sensor.culvers_store_1', 'sensor.culvers_store_2',
'sensor.culvers_store_3', 'sensor.culvers_store_4'] %}
{% set m = state_attr(e, 'metadata') %}
{% set c = state_attr(e, 'geometryCenter') %}
{% if m %}
{% set ns.items = ns.items + [{
'name': state_attr(e, 'description'),
'flavor': states(e),
'description': m.flavorOfTheDayDescription | default('', true),
'image': ('https://cdn.culvers.com/menu-item-detail/' ~ m.flavorOfDaySlug)
if m.flavorOfDaySlug else '',
'address': m.street ~ ', ' ~ m.city ~ ', ' ~ m.state,
'miles': ((distance(c.coordinates[1], c.coordinates[0]) or 0) | round(1))
if c else 0,
'closed': m.isTemporarilyClosed | default(false, true) }] %}
{% endif %}
{% endfor %}
{{ ns.items }}coordinates is GeoJSON, so it is [long, lat] and distance() wants (lat, long) —
hence the reversed indices. The result is sensor.culvers_flavors with a clean
locations[] list the dashboard loops over.
One deliberate choice here: this uses the rest platform rather than rest_command
plus a trigger template. Both can express the same thing, but rest_command is not a
loaded domain in this instance, so adding it would have made the package require a full
Home Assistant restart on every edit. As written it reloads with the rest.reload and
template.reload actions, which matters a lot when you are about to iterate on the
frontend six times.
The dashboard, take one through five#
The obvious card for “loop over a list of things” is a markdown card, since it templates its own content. So: a table of stores, each with a photo, flavor, distance, and description, styled with inline CSS.
It rendered with enormous photos. Full column width, one store per screen on the tablets.
I tuned the grid. repeat(auto-fit, minmax(230px, 1fr)) let tiles grow to fill the row,
so I pinned it to repeat(4, 1fr) and added min-width: 0 — grid items default to
min-width: auto and refuse to shrink below their content. Still enormous. I scaled the
photos down explicitly. Still enormous.
Every fix I made was correct and none of them did anything, which is the specific frustration that eventually makes you go read the card’s source.
The markdown card sanitizes with js-xss#
Home Assistant’s markdown card runs its rendered output through
js-xss before it reaches the DOM, and the
whitelist is per-element and strict. div and span allow no attributes at all.
img allows [src, alt, title, width, height, loading]. style and class are on no
element’s list.
Every inline style I had written in three consecutive revisions was being silently
stripped in transit. The photos were not oversized because my CSS was wrong — they were
at their natural 800×800, constrained only by the card’s own img { max-width: 100% },
because my CSS was never there.
Nothing warns you. The markdown goes in, the styles come out, and the card renders something plausible enough that you blame yourself.
Rebuilding with what survives#
The fix was to stop writing CSS and start writing 1998 HTML — the attributes on the whitelist are the ones that work:
- a
<table>for the columns, withwidth=on the cells width=104on each<img>- 2% spacer cells for gutters, because the card sets
table { border-collapse: collapse }, which ignoresborder-spacing
That rendered correctly. It also looked like 1998, so the next step was installing
card-mod, which injects styles
directly into ha-markdown’s shadow root — downstream of the sanitizer entirely. That
restored the tile look, and the plain table left in the content became a genuine
fallback: if card-mod ever fails to load, the card degrades to a readable table of 104px
photos rather than 800px ones.
One more layout casualty followed. Each store had a rounded background panel, which read
as a box nested inside the card’s own rounded box, and adjacent panels butted together
because border-collapse: collapse was still ignoring the gutters. Dropping the panels
fixed both problems at once.
Take six: stop fighting the card#
By this point the card was styled correctly and still wrong in two ways that no amount of CSS could fix.
First, a markdown card is one ha-card. Four stores drawn as columns inside it read
as tiles nested in a tile no matter how they are styled.
Second, and fatal: sanitized markdown content cannot carry a tap_action. The only
click a markdown card can offer is a plain <a>, and the wall tablets run Fully Kiosk,
which will not follow a link off-dashboard. Tapping a flavor to see the full description
was the entire point of tapping a flavor.
So the markdown card went away, replaced by four mushroom-template-cards in a
layout-card grid. These are real cards, so the grid separates them with actual card
chrome, and the tap fires browser_mod.popup and never navigates:
- type: custom:mushroom-template-card
layout: vertical
multiline_secondary: true
picture: >-
{% set l = (state_attr('sensor.culvers_flavors', 'locations') or [])[0:1] | first | default({}, true) %}
{{ l.get('image', '') }}
primary: >-
{% set l = (state_attr('sensor.culvers_flavors', 'locations') or [])[0:1] | first | default({}, true) %}
{{ l.get('flavor', '—') }}
secondary: |-
{%- set l = (state_attr('sensor.culvers_flavors', 'locations') or [])[0:1] | first | default({}, true) -%}
{{ l.get('name', '').split(',')[0] }} · {{ l.get('miles', 0) }} mi
{{ l.get('description', '') }}
tap_action:
action: fire-dom-event
browser_mod:
service: browser_mod.popup
data:
title: Flavor of the Day
content:
type: markdown
content: |-
{%- set l = (state_attr('sensor.culvers_flavors', 'locations') or [])[0:1] | first | default({}, true) -%}
<img src="{{ l.get('image', '') }}" width="280">
### {{ l.get('flavor', '—') }}
**{{ l.get('name', '') }}** · {{ l.get('miles', 0) }} mi
{{ l.get('description', '') }}
{{ l.get('address', '') }}
card_mod:
style: |
ha-card {
--icon-size: 116px;
--icon-border-radius: 8px;
}A few things worth extracting from that:
The popup body is a markdown card, and it has to be. browser_mod does not template
its service data, so {{ }} in the popup’s data would arrive as a literal string. A
markdown card templates its own content, so the flavor has to be resolved inside the
popup card rather than passed into it.
--icon-border-radius: 8px is not cosmetic. Mushroom’s avatar slot is a circle by
default, and a circle clips the corners off a waffle cone cutout.
[0:1] | first | default({}, true) instead of [0]. Indexing an empty list throws
and takes the whole card down; the slice-then-default idiom yields an empty dict and the
card renders blank instead of broken.
The grid is one column below 600px and four above, so phones stack and the wall tablets get a single row.
Caveats#
The store count is now hardcoded at four. The markdown version looped over the sensor,
so changing limit= in the REST call was a one-line change and the card followed. The
four-card version does not. Adding a fifth store means a fifth REST sensor, a fifth entity
in the template list, and a fifth copy of a 40-line card. The default({}, true) guard
keeps a card blank rather than broken if the locator returns fewer than four, but it
cannot invent a card for a fifth. This was a deliberate trade for real card chrome and a
working tap_action, and it is the thing I would revisit first.
This depends on an endpoint nobody promised me. It is the store locator Culver’s own
site calls, so it is unlikely to vanish quietly, but there is no contract here. If the
response shape changes, the value_template guards degrade to Unknown rather than
throwing — that is the extent of the safety net.
The 30-minute poll is more than necessary. The flavor of the day changes once per day
per store. scan_interval: 1800 is 48 requests a day against someone else’s server for
data that changes once. A daily poll shortly after opening would be the polite version;
I have not made that change yet.
js-xss stripping is silent by design, and it applies to more than this card. If you have ever written inline styles into a Home Assistant markdown card and quietly concluded you are bad at CSS, you were not — check the whitelist before you check yourself.