Repository navigation
Expand file tree
/
Copy pathboot.py
More file actions
executable file
·186 lines (158 loc) · 7.49 KB
/
Copy pathboot.py
File metadata and controls
executable file
·186 lines (158 loc) · 7.49 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
185
186
# Copyright (c) 2024-2026 Michael Czeiszperger
"""boot.py — runs before code.py; decides who owns the CIRCUITPY filesystem.
Two buttons, both active-low switches to ground with NO external pull-up (see
the MatrixPortal S3 pinout: "the up and down buttons do not have any pull-up
resistors connected to them"). The internal pull-up is therefore MANDATORY, and
constructing a DigitalInOut actively tears down the pull the pad booted with
(the ESP32-S3 port sets GPIO_PULLUP_DISABLE in its construct()), so a pin read
without an explicit ``pull`` is undefined — not "usually high".
That mattered: the DOWN check was already hardened in 2026-07 after four
consecutive hands-free boots read a not-yet-charged line as pressed. The UP
check kept a bare single-sample read of a floating pin behind a branch that
DELETES the WiFi credentials, the settings, and error_log — i.e. a data-loss
coin flip on every boot, which also destroyed the only field evidence we had.
Both buttons now go through _button_pressed(): pull up, let the line charge,
require every sample across a debounce window to be low, and release the pin.
Nothing here writes the filesystem on a normal boot beyond the (deliberate)
factory reset and the one-time purge of the retired OTA scratch directories.
Boot status is recorded in NVM byte 243 so the app can log what happened here
on the next boot — boot.py has no display and its stdout only reaches
boot_out.txt, which is invisible to a device running headless in someone's
kitchen. See src/app.py _log_boot_provenance().
"""
import board
import digitalio
import storage
import os
import time
# ---- NVM contract (bytes above the diagnostics ledger, which ends at 202) ----
# 240/241 belong to safemode.py (magic + auto-reset streak).
# 242 = last CircuitPython safe-mode reason code (written by safemode.py).
# 243 = this file's boot status flags, below. The app reads and clears both.
_NVM_BOOT_FLAGS = 243
_FLAG_DEPLOY_MODE = 0x01 # DOWN held: filesystem read-only to the device
_FLAG_FACTORY_RESET = 0x02 # UP held: credentials/settings/log deleted
_FLAG_BOOT_FAULT = 0x04 # this file raised; see boot_out.txt for the traceback
_FLAG_PURGED_OTA = 0x08 # stale /updates or /backup removed
def _nvm_set(flags):
"""OR ``flags`` into the boot-status byte. Never raises."""
try:
import microcontroller
nvm = microcontroller.nvm
if nvm is not None and len(nvm) > _NVM_BOOT_FLAGS:
nvm[_NVM_BOOT_FLAGS] = nvm[_NVM_BOOT_FLAGS] | flags
except Exception:
pass
def _button_pressed(pin, samples, gap=0.01, settle=0.05):
"""True only if ``pin`` reads LOW on EVERY sample across the window.
Enables the internal pull-up (the board has none), waits ``settle`` for the
line to charge through its own capacitance, then requires a solid low —
one stray sample is enough to say "not pressed", because every caller here
is destructive or changes who owns the flash. The pin is always released:
a leaked DigitalInOut would make the pin unavailable for the rest of the
VM's life, and CircuitPython does NOT free pins on garbage collection.
"""
button = digitalio.DigitalInOut(pin)
try:
button.direction = digitalio.Direction.INPUT
button.pull = digitalio.Pull.UP
time.sleep(settle)
for i in range(samples):
if button.value: # high = released
return False
if i + 1 < samples:
time.sleep(gap)
return True
finally:
button.deinit()
def remove_file(filename):
try:
os.remove(filename)
except OSError:
print(f"File {filename} could not be deleted.")
def _rmtree(path):
"""Recursively delete ``path``. Best-effort; never raises."""
try:
entries = os.listdir(path)
except OSError:
return False # missing (the normal case) or unreadable
for name in entries:
child = path + "/" + name
try:
is_dir = os.stat(child)[0] & 0x4000
except OSError:
continue
if is_dir:
_rmtree(child)
else:
try:
os.remove(child)
except OSError:
pass
try:
os.rmdir(path)
except OSError:
pass
return True
def _purge_retired_ota(readonly, root=""):
"""Delete the /updates and /backup scratch trees left by the removed OTA.
Over-the-air updating was removed from the device on 2026-07-25, but
nothing ever cleaned up after it: neither deploy.sh nor the USB install zip
touches root-level directories, so any board that once attempted an OTA
still carries /updates/apply_started and a /backup snapshot.
Until this ran, boot.py answered that marker by copying a months-old
/backup over the live tree — no version check, no hash, truncating each
live file in place, wrapped in no try — every normal boot. A partial copy
left a mixed-version tree and re-ran forever, because the markers were only
cleared AFTER the copy finished. Deleting the trees retires the mechanism
instead of guarding it: with OTA gone, nothing in the shipping product
reads, writes, or needs them.
"""
if readonly:
return # deploy session: never fight the host
purged = False
for path in ("/updates", "/backup"):
path = root + path # root is "" on device; tests sandbox it
try:
os.stat(path) # normal boot cost: two failing stats
except OSError:
continue
print("boot: removing retired OTA directory", path)
if _rmtree(path):
purged = True
if purged:
_nvm_set(_FLAG_PURGED_OTA)
def _factory_reset(root=""):
"""Delete WiFi credentials, settings, and the log. ``root`` is "" on device.
settings.json.tmp is the staged copy from SettingsManager's atomic save,
and load_settings() falls back to it when the live file is missing — which
is precisely the state this function creates. Miss it and the reset quietly
restores the settings it just deleted.
"""
for name in ("secrets.py", "settings.json", "settings.json.tmp", "error_log"):
remove_file(root + "/" + name if root else name)
def _main():
# Deploy mode: DOWN held hands the flash to the host computer. The second
# argument is readonly-to-CircuitPython, so True here means the Mac can
# write and the device cannot (serial prints "Drive mount logic is: True").
drive_state = _button_pressed(board.BUTTON_DOWN, samples=5)
print(f"Drive mount logic is: {drive_state}")
storage.remount("/", drive_state)
_nvm_set(_FLAG_DEPLOY_MODE if drive_state else 0)
_purge_retired_ota(drive_state)
# Factory reset: UP held clears WiFi + settings. Destructive and silent —
# there is no display here and no confirmation — so it demands a SUSTAINED
# press (~500 ms of unbroken low) rather than the single sample that used
# to fire it by accident.
if _button_pressed(board.BUTTON_UP, samples=25, gap=0.02):
print("boot: UP held - clearing WiFi credentials and settings")
_nvm_set(_FLAG_FACTORY_RESET)
_factory_reset()
try:
_main()
except Exception as _e:
# boot.py failing must not take the app down with it: CircuitPython prints
# the traceback to boot_out.txt and runs code.py regardless, so the app
# still gets a chance to come up and report. Leave a breadcrumb it can see.
print("boot: FAILED -", repr(_e))
_nvm_set(_FLAG_BOOT_FAULT)