The cabinet clock
A permanent clock in the corner of a ZHZN cabinet's screen, visible during fullscreen ad playback and through every sale state. Two audiences: a shopper standing at the machine who uses it as a clock, and venue staff who read a ticking clock as "this machine is alive" — the cheapest liveness signal there is, and the one that needs no login.
The hard part is not drawing it. It is refusing to draw it when the time would be wrong, because a shopper believes a clock and has no way to check it.
How a ZHZN screen is rendered
A ZHZN cabinet's screen is a web page, on both flavours of cabinet:
| Cabinet | What draws the screen | Where the clock lives |
|---|---|---|
ZHZN Android (zhzn-vending-kiosk/android-app) |
our KioskActivity, a WebView filling a FrameLayout, showing the hosted storefront (kioskUiUrl) or the bundled offline-store.html / status.html |
a native TextView sibling of the WebView, ClockOverlay.kt |
ZHZN Linux (zhzn-vending-kiosk/agent, the Python agent) |
a browser kiosk pointed at the hosted storefront; the agent itself is headless | in the page, from kiosk_clock.overlay_html(), fed by the agent's loopback GET /clock |
The Android row is the path the live fleet runs today: kioskUiUrl is empty on
the deployed cabinets, so the WebView shows the bundled offline-store.html and
the clock beside it is the native view. The Linux row is wired on the device side
— the agent caches the verdict and serves it on loopback GET /clock — but the
page half is a helper looking for a caller: overlay_html() is a self-contained
drop-in and no storefront in this repo renders it yet, because the hosted
storefront is not served from here. Whoever serves that page inlines it; until
then, the browser flavour has no clock.
On Android the clock is deliberately not markup inside the page. The page is
not ours to rely on: the activity swaps between the hosted storefront, the
offline store and the status screen on network failure, and a fullscreen ad
creative can promote itself above anything in the document. A sibling view in
the activity's FrameLayout survives every one of those transitions, cannot be
covered by page content, and repaints without touching the video plane.
The Reyeah path is different and deliberately out of scope. A Reyeah cabinet
runs the vendor's own APK (com.ruiye.jd, rebuilt and re-pointed at our cloud);
its activities compose the screen, and everything we send it — the skin through
/apk/getStyle, the attract loop through /apk/getAd — is values and image URLs
its firmware caches (app/routes/device.py, and §4 of
the manufacturers manual). Nothing in this repo
composites that screen, which leaves three options and no good one:
- Burn the time into a creative. A poster is cached by the firmware for a day, so it would show a time that is wrong by up to a day. Worse than no clock, which is the thing this whole feature refuses to do.
- Smali-patch a view into the vendor activities. Technically open to us — we already rebuild that APK — but it puts a permanently-visible element inside a decompiled third-party UI whose layout we do not own, and it would have to be re-landed on every vendor build.
- Ask the manufacturer. The honest route if a customer ever asks for it.
So Reyeah cabinets show no clock, on purpose. ZHZN is where the screen is ours.
Where the clock sits
Top-right, POSITION in app/kiosk_clock.py, and the corner is a decision
rather than a default:
- Top-left is the admin exit gesture's invisible hot zone (seven taps in a
96 dp corner,
KioskActivity.HOT_ZONE_DP). Putting the only permanently lit element of the screen on it advertises the way out of kiosk mode. - The bottom edge carries the scrolling product grid in the storefront and the primary action button in every sale overlay — Cancel, Try again, Done. A floating element there sits on a touch target, and a tap absorbed by a decoration is indistinguishable from a dead machine.
- Top-right holds chrome-level status text in the grid and nothing at all in
the card prompt, success, decline and refund overlays, all of which centre
their content. Screenshots of each state are in
docs/evidence/kiosk-clock/.
The overlay is pointer-events: none in the page and isClickable = false /
isFocusable = false natively, so a touch inside its bounds reaches whatever is
underneath regardless.
Legibility over an advert nobody vetted
Ad creatives are uncontrolled and change every few seconds. The clock uses an opaque plate, not a scrim: a 60% dark wash over a pure-white creative still leaves light text on light ground, whereas an opaque fill makes the text's background independent of what is playing.
That leaves the plate's own edge, which is the harder half. No single edge
colour contrasts with every creative — a creative can be that colour. So the
edge is two colours at once, a near-black plate inside a near-white hairline,
far enough apart in luminance that whichever one the advert matches, the other
stands out. test_no_possible_advert_colour_can_hide_both_the_plate_and_its_edge
sweeps all 256 luminances and pins the worst case at about 4:1, above the 3:1
large-text floor. The box-shadow is polish only; some vendor WebViews drop it,
and nothing depends on it rendering.
Which time
The time a shopper wants is the venue's, never the server's and never UTC.
kiosk_clock.resolve_zone orders sources by how much each actually knows:
machine.timeZone— an IANA zone an operator recorded on the placement, validated against the tz database at the edge. They are standing in the venue and we are not, so this outranks every inference, including the plausibility gate below.- The placement — derived from the territory codes the geocoder stored on
the machine (
geo.country,geo.subdivision), and only where that territory lies wholly inside one zone. The thirteen US states that straddle a boundary and the countries that look single-zone but are not (ES has the Canaries, PT the Azores, NZ the Chathams) are absent from the tables on purpose: absence means "ask somebody". - The device's own OS zone — the weakest source, and the one the installer set on site.
Coordinates deliberately do not derive a zone. Turning a point into an IANA
zone needs boundary polygons, and the cheap substitute — nearest representative
city — is wrong exactly at zone boundaries, which is where a wrong clock is both
most likely and least likely to be noticed. Coordinates are used for what they
can prove with no dataset at all: whether a claimed zone is possible at that
longitude. A cabinet still carrying the factory's Asia/Shanghai in a Chicago
mall diverges from solar time by more than
MAX_SOLAR_DIVERGENCE_HOURS and is refused.
Both inferred tiers face that gate, not just the device. A placement whose country was stored and whose machine was then moved and re-pinned would otherwise put a previous continent's wall clock on the screen with full confidence — the quiet twin of the factory-zone bug, and invisible to a check that only looked at what the cabinet reported. Only the operator's own recorded zone is exempt: a pin dropped in the wrong place is far likelier than an operator naming the wrong zone for a room they are standing in, and gating their choice would make a mis-pinned machine unfixable from the one control built to fix it.
All three tiers are live. Tier 2 stopped being latent when the geocoder began
persisting the placement's territory: geocode._feature_to_hit now keeps
country, subdivision and postcode alongside the coordinates, and
machine_geocode.fields_for writes them onto the machine record — or clears them,
when an address stops resolving, because a stale country keeps deriving the
previous venue's clock long after the pin has gone from the map.
The stored form is codes, never names, and app/territory.py is the single
place that decides them:
| Field | Form | Why |
|---|---|---|
geo.country |
ISO 3166-1 alpha-2 (GB) |
Photon returns both country ("United Kingdom") and countrycode. The name's spelling depends on the lang the caller asked for, so a venue filed under it would stop matching the moment something asked in French. |
geo.subdivision |
ISO 3166-2 (US-TX) |
Prefixed rather than bare, because the field is worldwide by construction and a lone TX stops being unambiguous as soon as a second country is mapped. |
geo.postcode |
as returned | Already an identifier rather than display text, so it is kept verbatim — including SW1A 2AA and 153-0063, which a US-shaped normaliser would mangle. |
Subdivisions are US-only and that is the honest answer rather than a gap: Photon has no ISO 3166-2 field at all, and the US is the only country whose subdivisions the zone tables are keyed by. Storing "New South Wales" in a field the platform reads as a code would be storing a display string under a promise it cannot keep.
Two things about the US field are worth knowing because fixtures hide both. The
live planet index returns either the full name or the bare USPS code for
state — "Texas" and "California" for some boundaries, NY, IL, HI, PR
and DC for others, decided by OSM tagging rather than by anything in the
request. Both are accepted; reading only the names silently drops the subdivision
for roughly half of US placements, so tier 2 would fail in New York and Hawaii
while looking perfectly healthy in Texas. scripts/kiosk_clock_zone_probe.py is
what caught that, and is the way to re-check it.
Existing machines are not backfilled. Their components fill in when their
address is next resolved. The address-labelling work skipped rewriting stored
labels because location is a field operators hand-correct, and that argument
does not apply here — nobody edits a territory code. The reason is different
and narrower: the only recompute that would add information is re-geocoding, and
re-running the whole fleet through the index moves pins that are currently
correct, because the index has been reloaded since those addresses resolved. A
cheaper recompute from the legacy state/zip columns would add nothing, since
kiosk_clock._country_of already reads those at resolve time for exactly the
machines that have them. So the trade is "a pin that is right today may move" set
against "a clock that is blank today fills in on the next address save", and the
pin wins.
Extending the single-zone table is verified, not assumed. A country wrongly
listed there produces a confidently wrong clock on every cabinet in it, which is
strictly worse than the blank corner absence gives.
scripts/verify_single_zone_countries.py reads the tz database's shipped
zone.tab, takes every zone attributed to a country and samples their offsets
right through a year. The test is wall-clock equivalence rather than "exactly one
zone id", because that is the property a screen cares about: Germany has two ids
(Europe/Busingen is the exclave inside Switzerland) and one clock, while Spain
has three ids and two. Run it before adding a row; it also re-checks that the
deliberate exclusions are still exclusions.
Format follows the venue, not the operator: hour_cycle() maps the resolved
zone's country to 12- or 24-hour, so a US operator's first machine in Berlin
reads 19:05 to the person in front of it. Seconds are never shown.
An untrustworthy device clock
A cabinet's own clock may be whatever the factory or the installer left, and a crated unit can arrive with a flat RTC. A confidently-displayed wrong time is worse than a blank corner, so the cabinet has to earn the right to show one.
Every register, config and heartbeat call carries the cabinet's
deviceTime and its OS zone. The gateway compares the reading against its own
clock and answers with a verdict:
| Verdict | Meaning | Screen |
|---|---|---|
verified |
within MAX_SKEW_SECONDS (30 s — half the smallest unit displayed) |
shows the time |
skewed |
the cabinet's clock disagrees with ours | blank |
unverified |
the cabinet has never proved its clock | blank |
implausible_zone |
the claimed zone cannot describe the cabinet's longitude | blank |
serverTime rides along whatever the verdict, because these boards frequently
have no NTP route out of the venue's network: the beat is then the only clock
reference the cabinet will ever see, and one that can set itself is verified on
the very next beat instead of never.
Offline
The cabinet renders from its own hardware clock, always. The gateway's block only says which wall clock and whether it may — nothing in the render path touches the network, so an outage does not blank the clock.
The verdict is cached on the device (Android: ConfigStore.clockPolicy, replayed
at boot; Linux: the agent's in-memory policy behind loopback GET /clock) and
stamped with when it arrived. It expires after TRUST_HORIZON_SECONDS
(30 days): a quartz RTC at the usual ±20 ppm drifts about 1.7 s a day, so it
takes roughly a month to accumulate the half-minute that would change a
displayed digit. Past that the cabinet stops claiming to know the time rather
than drifting quietly for a year. Both renderers enforce the horizon themselves
(CabinetClock.text, and the page's own repaint), so a screen left up for months
expires the verdict without needing the cloud's permission.
A beat that carries no verdict — or never lands — leaves the last one standing; a
dropped beat is not evidence the clock went bad. A beat that carries a bad
reading is the opposite: the gateway drops the machine's clockVerifiedAt stamp,
so the reading-less calls that follow (an older agent build, a bare config fetch)
answer unverified instead of resurrecting a measurement the cabinet has since
failed.
Repaint, tearing and burn-in
The clock updates once a minute, aligned to the wall-clock minute boundary.
Once a minute because a per-second repaint over fullscreen video costs a
composite every frame it lands on and buys a digit nobody reads; aligned to the
boundary because a plain 60-second timer started at :47 leaves the screen
showing the previous minute for 47 seconds of every minute, forever.
- Android:
Intent.ACTION_TIME_TICK, which the system broadcasts on the boundary, so the cabinet neither polls nor drifts.ACTION_TIME_CHANGEDandACTION_TIMEZONE_CHANGEDare also handled, so a technician correcting the clock sees the screen agree before they walk away. - Browser:
setTimeoutre-armed to the next boundary each tick, neversetInterval.
Tearing is avoided by keeping the repaint off the video player's layer. Natively
the clock is a different view entirely. In the page it is promoted to its own
compositing layer (transform: translateZ(0)), so the digit change never forces
a recomposite of the frame underneath.
That is the argument; the measurement is
docs/evidence/kiosk-clock/playback.json. A real H.264 creative playing
fullscreen loses no frames to the clock even when it is forced to repaint on every
animation frame — 1800× its real rate — and the control run with no clock at all
drops the same few start-up frames. Not measured on the cabinet. The RK3568's
vendor WebView composites differently from desktop Chromium, and only a cabinet
can answer for it (dumpsys gfxinfo on the kiosk process); what the measurement
rules out is a repaint that is expensive by construction.
Burn-in is not a real concern on this hardware. The T3568 board drives the
door panel over LVDS at 1920×1080 (zhzn-vending-kiosk/docs/t3568-board.md, from
the vendor's user guide), and LVDS is an LCD interface — the pixels are a
backlight behind a shutter, so what they suffer is temporary image persistence,
not the permanent emitter wear that makes a static element a hazard on OLED. No
pixel-shifting or periodic repositioning is implemented and none is warranted.
The honest limit on that: what is documented is the interface, because neither repo records the panel's part number — worth capturing at factory QA. If a cabinet ever ships with an OLED door, this is the paragraph to revisit, and the answer would be to shift the plate a few pixels on a slow cycle rather than to drop the clock.
Ads and sales are untouched
- Nothing in the ad loop or its timing is modified. Proof of play is a report
the player posts when a creative finishes showing, and impressions are derived
from it (
plays × impressions_per_play, see ads) — nothing samples what is actually on the panel, so an element composited above the player cannot change the count. The clock also plays no media and holds no decoder. - The vend flow is untouched: the clock has no touch target, no handler and no entry in the dispatch path.
- The gateway's
clockblock is a sibling ofconfig, not a key inside it.configis the fleet's env-var contract, pinned value by value againstdocs/reference/zhzn-agent.env.example, and the clock is neither a knob nor the same for two cabinets in different cities.
Operating it
Usually: nothing. Saving the venue's address is what sets the clock. The geocoder stores the territory, tier 2 derives the zone, and an operator who never opens the control gets a correct clock in every single-zone country.
When the address cannot settle it — a multi-zone country, a US state that
straddles a boundary, or a building the index placed one country over — the
machine page carries the control. It is under Location, beside the address
that decides it (components/MachineClockCard.tsx, and SetTimeZoneSheet in
the Flutter app's machine detail screen).
The card leads with why, because a blank clock has three unrelated causes and three different fixes:
| What it says | What is wrong | Fix |
|---|---|---|
| "The address hasn't been placed in a country yet" | no geo.country — the lookup failed, or the pin came from GPS |
save the address again, or choose a zone |
| "Country spans more than one time zone" | the territory resolved and genuinely cannot settle the zone | choose the venue's zone |
| "The zone the address implies can't be right where this machine is pinned" | the stored country and the pin disagree | re-save the address |
| "The machine is reporting zone, which can't be right where it's pinned" | the cabinet still carries the factory's zone | nothing — it is already refused; the derived or chosen zone is used instead |
| "this machine's own clock has never been checked against ours" | the zone is settled; trust.state is not verified |
wait for the cabinet to call in with a deviceTime |
The picker offers the derived zone, the cabinet's own and the zones possible at
that longitude before the full tz database, because someone placing a machine
knows the venue is in Auckland and not that the answer might be spelled
America/Indiana/Petersburg. A choice overrides derivation; Clear drops it
and falls back. Only identifiers the backend itself listed can be submitted, and
it re-validates anyway — an unknown zone is refused with 400 rather than stored
and discovered later as a blank screen in a venue.
Over HTTP, if you need it: PATCH /api/v1/machines/{machineNo} with
{"timeZone": "America/Chicago"} records one and {"timeZone": ""} clears it —
an empty string rather than null, so a clear is distinguishable from a PATCH
that simply is not about the time zone. GET /api/v1/machines/{machineNo}/clock
returns the same view the console renders, plus the candidate shortlist and the
zone list. Every machine payload also carries a compact clock block, so
timeZoneSource and trust.state are readable without a second call. What a
cabinet is being told is on its /zhzn/config response.
Where the code is
| Concern | File |
|---|---|
| Zone resolution, trust verdict, palette, HTML overlay | app/kiosk_clock.py |
| Territory extraction — the single country-extraction path | app/territory.py |
| Persisting the placement's territory | app/geocode.py, app/machine_geocode.py, app/store.py |
| Gateway responses carrying the block | app/routes/zhzn.py |
Operator-recorded zone, clock block, /clock route |
app/routes/machines.py, app/store.py |
| Operator control | kiosk-x-operator/components/MachineClockCard.tsx, flutter/lib/screens/machine_detail.dart |
| Verifying the single-zone table, live-geocoder probe | scripts/verify_single_zone_countries.py, scripts/kiosk_clock_zone_probe.py |
| Android policy + native overlay | zhzn-vending-kiosk/.../ui/CabinetClock.kt, ui/ClockOverlay.kt |
| Linux agent cache + loopback endpoint | zhzn-vending-kiosk/agent/agent.py, agent/localctl.py |
| Tests | tests/test_kiosk_clock.py, tests/test_machine_time_zone.py, CabinetClockTest.kt, agent/test_cabinet_clock.py |
| Rendered evidence | docs/evidence/kiosk-clock/ |