Acknowledged
CIQQA-4764

Watch face: `onUpdate` runs in always-on before `onEnterSleep`, and far more often than once a minute

Device: epix Pro (Gen 2) 47mm (epix2pro47mm), software 27.18
SDK: 9.2.0 (connectiq-sdk-mac-9.2.0-2026-06-09-92a1605b2)
App type: watch face, AMOLED, minApiLevel 5.0.0; sideloaded and from the store (beta)


SUMMARY

On an epix Pro (Gen 2), the sleep callbacks of WatchUi.WatchFace do not keep the contract the API documentation describes:

1. onUpdate is called in always-on before onEnterSleep has been called. A watch face that tracks always-on with a flag set in onEnterSleep, as the SDK's Analog sample does, draws its high-power scene in an always-on frame. Over about 18 hours this happened 375 times.

2. onEnterSleep runs after the display has already switched to always-on. Inside the callback, System.getDisplayMode() already returns DISPLAY_MODE_LOW_POWER.

3. In always-on, onUpdate is called far more often than once a minute. It averaged about 16 calls a minute over 18 hours, with runs of one call every 1,000 ms.

On AMOLED the first one has a visible cost. A watch face's high-power scene typically lights far more than 10% of the screen, so the burn-in protector shuts the always-on screen off. It then stays off, across later sleeps, until the watch face is restarted, for example by opening the activity list and backing out. To the user, the always-on watch face disappears and does not come back.


DOCUMENTED BEHAVIOUR

From Toybox.WatchUi.WatchFace (SDK 9.2.0):

"After this period in high power mode (typically about ten seconds), the system will call onEnterSleep() to notify the application that it is preparing to enter low power mode. During low power mode the system will call onUpdate() at the top of every minute."

"onEnterSleep(): The device is entering low power mode. Terminate any active timers and prepare for once-per-minute updates."

The SDK's Analog sample relies on that order. _isAwake is set only in onEnterSleep and onExitSleep, and onUpdate reads it to decide what to draw.


OBSERVED

A diagnostic build of the watch face recorded, in every callback and every onUpdate, the value of System.getDisplayMode() (H, L or O) and of a flag set in onEnterSleep and cleared in onExitSleep (lp). The face calls WatchUi.requestUpdate() once in each sleep callback and nowhere else, and uses no timers.


1. onUpdate in always-on before onEnterSleep

Counters kept in Application.Storage, over about 18 hours (2026-10-06 13:40 to 2026-10-07 07:25):

watch face views created 19
onEnterSleep calls 212
onExitSleep calls 203
onUpdate calls with getDisplayMode() == DISPLAY_MODE_LOW_POWER
and onEnterSleep not yet called 375

The counters do not show whether, in those frames, onEnterSleep came late or did not come at all, for example because the watch face was started while the device was already in low power. The reproducer below counts the two cases separately.


2. onEnterSleep after the display mode has changed

From the log, GARMIN/Apps/LOGS/<app>.TXT; t is System.getTimer():

10-06 14:53:48 t=8692798 mode=H exitSleep
10-06 14:53:52 t=8696876 mode=L enterSleep
10-06 14:53:52 t=8696902 mode=L update scene=low lp=true
...
10-06 14:56:05 t=8830155 mode=L enterSleep

A second log, 2026-10-07 08:08 to 08:12, shows the same:

10-07 08:08:57 t=70802352 mode=L enterSleep
10-07 08:09:03 t=70808076 mode=L enterSleep

All four calls into always-on across the two logs ran with the display mode already L. The documentation's "preparing to enter low power mode" suggests the callback should come first. (Each log has one more onEnterSleep, in H right after onHide, as the face gave way to another screen.)

On waking, the order is the one the documentation gives ("the transition to high power mode has occurred"). In all four wakes the logs show from the start, one to three frames were drawn in H before onExitSleep:

10-06 14:55:48 t=8812439 mode=L update scene=low lp=true
10-06 14:55:48 t=8813058 mode=H update scene=low lp=true
10-06 14:55:48 t=8813090 mode=H update scene=low lp=true
10-06 14:55:48 t=8813135 mode=H update scene=low lp=true
10-06 14:55:48 t=8813176 mode=H exitSleep


3. onUpdate rate in always-on

- The logs: 135 onUpdate calls in L across about 3 minutes of always-on on 2026-10-06, and 133 across about 3 minutes on 2026-10-07. Of the gaps between consecutive calls, 100 and 109 were exactly 1,000 ms, in unbroken runs such as 14:54:34 to 14:55:21 (47 calls a second apart). The 2026-10-07 log was taken on the wrist and off USB: the cable went in at 08:12, where the log ends in onHide and app.stop.

- The counters: about 16,000 always-on frames in about 1,070 minutes, which averages 16 a minute.

The face's own requestUpdate() in onEnterSleep accounts for one of those calls per sleep, about 200 in total.


IMPACT

A watch face that follows the documented pattern and the Analog sample cannot tell which scene to draw in those 375 frames. On AMOLED that is the difference between a burn-in-safe always-on screen and one the protector shuts off. The fault looks random to the user and is not reported anywhere: nothing crashes, CIQ_LOG.YML is empty, and the woken screen works normally.

Reading System.getDisplayMode() in onUpdate avoids it. With the face changed to draw the always-on scene whenever either the flag or getDisplayMode() says low power, the always-on screen has not been lost since, in the same 18 hours that produced the 375 frames.


REPRODUCER

Attached: sleep-repro, a minimal watch face (two source files). It follows the documented pattern and counts, in Application.Storage, every frame in which the pattern and getDisplayMode() disagree. It shows the counters on the woken screen and draws its own always-on screen from getDisplayMode(), so it does not trip the protector itself.

1. make build (or: monkeyc -f monkey.jungle -d epix2pro47mm -y <key> -o SleepRepro.prg)
2. Copy SleepRepro.prg to GARMIN/Apps, and create an empty GARMIN/Apps/LOGS/SleepRepro.TXT for the event log.
3. Select it as the watch face, with the display set to always-on, and wear the watch for a day.
4. Raise the wrist and read the counters.

If the documentation held, early and earlyNone would stay at 0, enterLow would stay at 0, maxMin would stay at 1 or 2, and busyMin would count only the minutes in which the device fell asleep.


QUESTIONS

1. Is onEnterSleep guaranteed before the first onUpdate in low power? If not, the documentation and the Analog sample should say to read System.getDisplayMode() in onUpdate instead.

2. Is onEnterSleep called when a watch face is started while the device is already in low power?

3. Is "once a minute in low power" still the intended onUpdate rate? It matters for battery and for any watch face that advances one step per onUpdate.

CODE

Self-contained repro project can be provided upon request.