by Intelliverse X

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:

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:

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:

  1. 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.
  2. 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".
  3. 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.

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

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/