-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsafemode.py
More file actions
184 lines (160 loc) · 7.33 KB
/
Copy pathsafemode.py
File metadata and controls
184 lines (160 loc) · 7.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# Copyright (c) 2024-2026 Michael Czeiszperger
"""Safe-mode escape hatch: turn a watchdog bite back into a reboot — forever.
CircuitPython treats a watchdog reset as "your code is broken" and boots into
safe mode WITHOUT running code.py — on a fielded, headless box that means a
dark panel until a human pulls the plug (observed 2026-07-19: a >60 s network
stall bit the 60 s watchdog around 04:00 and the box sat dark until morning).
The watchdog is the app's universal backstop — any network call can hang at
any time — so its bite must end in a reboot into the app, never a parking lot.
This file runs INSTEAD of code.py in safe mode and, for every reason except a
deliberate USER-requested safe mode, resets the board back into the normal
boot path after a delay.
There is NO park limit (design invariant 2026-07-19: nothing ever parks; the
device is a frivolous wait-times display and availability trumps everything).
Instead the delay ESCALATES with the consecutive-reset counter in NVM (bytes
240/241, above the diagnostics ledger — see scrollkit.utils.diagnostics
SAFEMODE_RESERVED_START): fast first, so a transient bite self-heals in
seconds, then backing off so a persistently sick build settles into a slow
forever-retry instead of a fast reboot loop. The app zeroes the counter after
10 minutes of stable running or a healthy refresh
(ThemeParkApp._clear_safemode_streak).
Two things this file must do besides resetting, both learned from a customer
whose sign "flashed then went black" for months (2026-08):
1. RECORD THE REASON. safe_mode_reason is readable only while safe mode is
running; once we reset it is gone forever. It was being thrown away, so the
single most valuable fact about a dark panel — brownout vs watchdog vs
filesystem corruption — never reached anyone. It now goes to NVM byte 242,
and the app logs it to error_log on the next boot.
2. LOOK ALIVE. Safe mode never initializes the matrix, so the panel is dark
for the whole delay and the box is indistinguishable from a dead one. The
onboard NeoPixel pulses instead, which is also why the ceiling came down
from 900 s to 120 s: a 15-minute blackout taught customers to yank the
power, and yanking power mid-write is a way to corrupt the filesystem and
cause the next safe mode.
Before the reset the radio is forced off (warm-radio law: WiFi-driver state
can ride through a bare microcontroller.reset() and poison the next session
with errno-16 connect failures — the reset must be COLD).
Ships with the app as a root file (like boot.py). Safe mode SKIPS boot.py, so
the flash is host-writable here — this file must never write to the
filesystem; NVM only.
"""
import time
import microcontroller
import supervisor
_OFF_MAGIC = 240 # one byte: 0x5A marks the counter as initialized
_OFF_COUNT = 241 # one byte: consecutive safe-mode auto-resets
_OFF_REASON = 242 # one byte: 1-based index into _REASONS, 255 = unknown
_MAGIC = 0x5A
DELAYS = ((5, 10), (10, 60)) # (count ceiling, delay s); beyond -> DELAY_MAX_S
DELAY_MAX_S = 120 # slow forever-retry; short enough to look like retrying
# CircuitPython's SafeModeReason members. Stored as an index because NVM gives
# us one byte and the names are long. src/app.py mirrors this tuple to decode
# it — keep the two in the same order, append only.
_REASONS = (
"NONE", "BROWNOUT", "HARD_FAULT", "INTERRUPT_ERROR", "NLR_JUMP_FAIL",
"FLASH_WRITE_FAIL", "GC_ALLOC_OUTSIDE_VM", "NO_CIRCUITPY", "NO_HEAP",
"PROGRAMMATIC", "SAFE_MODE_PY_ERROR", "SDK_FATAL_ERROR", "STACK_OVERFLOW",
"USB_BOOT_DEVICE_NOT_INTERFACE_ZERO", "USER", "WATCHDOG",
)
def _delay_for(count):
"""Escalating auto-reset delay: resets 1-5 wait 10 s (a transient watchdog
bite self-heals in seconds, and the pause is a developer USB window),
6-10 wait 60 s, and everything after waits DELAY_MAX_S — forever."""
for ceiling, delay in DELAYS:
if count <= ceiling:
return delay
return DELAY_MAX_S
def _reason_code(reason):
"""1-based index into _REASONS for ``reason``; 255 if we don't know it."""
name = str(reason).upper()
if "." in name:
name = name.rsplit(".", 1)[1]
for i, known in enumerate(_REASONS):
if name == known:
return i + 1
return 255
def _open_pixel():
"""The onboard NeoPixel as (pin, write) — or None if unavailable.
neopixel_write is a CORE module, so this needs nothing from /lib, which
matters: safe mode is exactly when the library tree may be the problem.
"""
try:
import board
import digitalio
import neopixel_write
pin = digitalio.DigitalInOut(board.NEOPIXEL)
pin.direction = digitalio.Direction.OUTPUT
return pin, neopixel_write.neopixel_write
except Exception:
return None
def _wait(seconds):
"""Sleep ``seconds``, pulsing the status LED amber so the box reads as
'recovering' rather than 'dead'. Falls back to a plain sleep."""
pixel = _open_pixel()
if pixel is None:
time.sleep(seconds)
return
pin, write = pixel
step = 0.05
steps = max(1, int(seconds / step))
try:
for i in range(steps):
# Triangle ramp over ~2 s, peaking at a dim 24/255 — this is a
# status light in a dark room, not a lamp.
phase = i % 40
level = phase if phase < 20 else 40 - phase
level = level * 24 // 20
write(pin, bytearray((level // 3, level, 0))) # GRB: amber
time.sleep(step)
except Exception:
time.sleep(step * steps)
finally:
try:
write(pin, bytearray((0, 0, 0)))
pin.deinit()
except Exception:
pass
reason = supervisor.runtime.safe_mode_reason
print("safemode.py: reason =", reason)
nvm = microcontroller.nvm
# Record the reason FIRST, before the delay and before any decision about
# whether to reset — a power cut during the wait must not cost us the one fact
# this file exists to capture.
try:
if nvm is not None and len(nvm) > _OFF_REASON:
nvm[_OFF_REASON] = _reason_code(reason)
except Exception as _e:
print("safemode.py: could not record reason:", _e)
# Deliberate (button/user-requested) safe mode is the ONE respected park.
# Prefer the enum identity; fall back to the name for CP versions where the
# enum shape differs.
_user = False
try:
_user = reason == supervisor.SafeModeReason.USER
except Exception:
_user = str(reason).upper().endswith("USER")
if _user:
print("safemode.py: user-requested safe mode - not auto-resetting")
else:
count = 0
if nvm is not None and len(nvm) > _OFF_COUNT:
if nvm[_OFF_MAGIC] != _MAGIC:
nvm[_OFF_MAGIC] = _MAGIC
nvm[_OFF_COUNT] = 0
count = nvm[_OFF_COUNT]
nvm[_OFF_COUNT] = min(count + 1, 255) # saturate, never wrap
count += 1
delay = _delay_for(count)
print("safemode.py: auto-reset #%d in %d s - rebooting to the app"
% (count, delay))
_wait(delay)
# Warm-radio law: drop the radio before the reset so stuck WiFi-driver
# state cannot ride into the next session. Best-effort — the radio is
# usually uninitialized in safe mode and the import may fail; reset anyway.
try:
import wifi
wifi.radio.enabled = False
time.sleep(0.5)
except Exception:
pass
microcontroller.reset()