This file records implementation details, fragile constraints, and debugging patterns that are easy to miss when changing SafraSense firmware behavior. Keep new notes here when a feature has non-obvious memory, timing, browser, protocol, or hardware constraints that future agents should not rediscover from scratch.
When adding a note, prefer concrete symptoms, causes, safe patterns, and exact files or commands over broad advice.
All user-facing documentation lives in src/docs/docs.cpp as PROGMEM strings.
Four accordion sections (SafraSense, Raiznet, Hidroponia, Glossário) are served
from the same source to both the local dashboard (/docs) and the captive portal
(/docs). The shared API is:
appendDocsContent(String& out, Language lang)— renders a TOC block followed by the four accordion sections; both portals call this and wrap it in their own shell HTML.buildDocsPortalPage(Language lang)— complete standalone HTML page with inline CSS and JS for the captive portal.
i18n structure. Content is organized around struct DocLang, one instance per
language (DOCS_PT, DOCS_EN, future DOCS_ES). Each instance holds:
- Short navigation strings:
page_title,page_subtitle,back_link,toc_title,s1_title–s4_title. - Body pointers:
s1_body–s4_body, each pointing to astatic const char[] PROGMEMarray with inner HTML.
getDocLang(Language) returns the right struct via a switch. LANG_PT returns
DOCS_PT, LANG_EN returns DOCS_EN, and untranslated languages currently fall
back to DOCS_EN. To add a translation: add a DocLang instance with the
translated strings and bodies, then add a case LANG_XX: return DOCS_XX;.
TOC and anchor navigation. appendDocsContent() renders a .doc-toc box
before the sections. Each <details> element gets an id (doc-s1…doc-s4).
A small inline script (in both DOCS_THEME_JS and DOCS_FOOTER_HTML) listens
to clicks on .doc-toc a and sets details.open = true so the target section
opens when navigated to via anchor link.
Adding content: edit only docs.cpp. No changes to http_local.cpp or
wifi_setup.cpp are needed unless navigation or shell structure changes.
CSS for the docs sections is in:
LOCAL_PORTAL_CSSinhttp_local.cpp(.doc-section,.doc-body,.doc-toc, etc.) — uses local portal CSS variables (--primary,--bg-inset).DOCS_PORTAL_CSSindocs.cpp(standalone version for the captive portal) — defines its own CSS variables and mirrors all doc-specific classes.
Keep both in sync when adding new doc-specific CSS classes. The .doc-toc styles
intentionally differ slightly (background token) between the two portals.
Flash cost at time of writing: ~38 KB above the pre-docs baseline (74.9 % of 1,966,080 bytes). The glossary section alone accounts for ~18 KB of that.
The WiFiManager captive portal is sensitive to large inline HTML. The identity section disappeared more than once when the mnemonic QR code was embedded directly in the page:
- Inline SVG QR code made the page too large.
- Inline QR bit strings in
data-*attributes also made the page fragile. - Large identity HTML inside a
WiFiManagerParametermade the section vanish. - Large inline JavaScript in
setCustomHeadElement()also made the page fragile.
Keep the initial HTML small. The current pattern is:
- Put only a small
<div id="identity-root"></div>placeholder inheaderHtml. - Serve the full identity section from
/identity/section?lang=.... - Keep the custom head to CSS plus
<script src="/portal.js"></script>. - Serve the large portal JavaScript from
/portal.js. - Render an empty QR
<canvas>in the endpoint-rendered identity section. - Fetch
/identity/current?lang=...after the page loads. - Return only JSON with
mnemonic,qrSize, and compactqrBits. - Draw the QR in browser JavaScript.
- Generate the smallest QR version that fits, currently trying versions 6 through 12 with low error correction and explicit byte-capacity checks before calling the QR library.
Do not move the QR matrix, full identity section, or large JavaScript back into
headerHtml or setCustomHeadElement() unless the whole portal is re-tested on
the ESP.
The mnemonic draft must not be generated before the Wi-Fi captive portal is active. ESP32 random quality depends on hardware entropy availability, and the RF subsystem being active is the condition we rely on here.
Current flow:
setupWifi()loads identity state but clears the portal draft mnemonic./identity/currentgenerates the first draft seed only when the portal page requests it./identity/rerollgenerates a new draft seed on demand.- The draft is persisted only when the user saves the Wi-Fi configuration.
Keep generateOwnerIdentity() out of early setup paths unless a new entropy
source is explicitly enabled and documented.
generateOwnerIdentity() and importOwnerIdentity() should not write to NVS by
themselves. They only mutate the in-memory DeviceIdentity.
Persist identity with saveIdentity(id) only after the user confirms and saves
the initial configuration. This is what makes reset/configuration startup create
a fresh draft while still saving the final chosen identity.
The identity screen needs to follow the selected portal language, including the
BIP-39 wordlist. The browser stores the selected language and calls
/identity/current?lang=...; the firmware regenerates the draft if the language
changes.
Be careful when changing this: regenerating on language change also changes the underlying entropy and words, not just their display labels.
Only the real Wi-Fi save submit should open the save confirmation modal. Identity
buttons such as copy, save QR, and reroll are type="button" and must not be
treated as submit actions.
The button press effect is a brief scale-down transform. Avoid reintroducing active color changes on the identity screen unless the whole button style is reviewed across the portal.
Android can download the generated QR image from the button action. iOS Safari usually opens the generated image in a new tab; the user can then long-press and save it. This is expected browser behavior for the current no-backend-download approach.
The recovery tab keeps QR decoding out of the initial HTML. The browser loads the
chosen image, draws it to a temporary canvas, resizes it to at most 240 px on the
longest side, thresholds it to a 1-bit bitmap, Base64-encodes the packed bitmap,
and posts it to /identity/decode-qr?w=...&h=....
The firmware decodes the Base64 body, expands the packed bitmap into the
vendored quirc decoder buffer through src/identity/qr_decode.cpp, and then
runs QR detection. Keep the 240 px limit unless RAM usage is re-tested on the
ESP32. The WebServer plain body path stores request data as a String, so do
not send raw binary bitmap bytes directly; 0x00 bytes can truncate the body.
Firmware logs are controlled by src/logging/logging.h and are disabled by
default. Set SAFRASENSE_LOG_LEVEL at build time to enable them: 1 for errors,
2 for warnings, 3 for info, and 4 for debug details such as payload size,
heap, and decoder state. Log tags are module names such as qr. Do not log
mnemonic words or QR payload contents.
Manual identity recovery is intentionally endpoint-driven. Do not load BIP-39
wordlists into the portal HTML or browser JavaScript; keep the wordlists in
firmware and query them from src/identity/identity.cpp.
Current behavior:
/identity/validatereturnscomplete,partial, word count, missing count, and up to 6suggestionsfor the current prefix.- Validation is case-insensitive; imported mnemonics are normalized to lowercase before saving.
- Empty import text is allowed only while advanced Raiznet connectivity is disabled. When enabled, the identity block controls whether saving is allowed.
- Compatible but incomplete input is yellow and keeps save disabled until 12 words are complete.
- A word or prefix outside the selected language wordlist is red and blocks save.
- Duplicate mnemonic words are invalid and show a duplicate-word error.
- Validation/autocomplete requests are debounced in the browser. The current debounce is 120 ms.
- The browser autocompletes the active word only when the endpoint has exactly one suggestion and the current typed prefix is still compatible with the prefix that triggered the request. This prevents stale responses from replacing a word after the user has erased or changed its beginning.
- Backspace removes the whole current word only when that word is already a complete valid word. Invalid or incomplete words delete one character at a time so the user can correct typos.
- Suggestion chips are rendered in a fixed-height row above the textarea. The browser measures available width and only displays the chips that fit on one row, so the layout does not jump or clip a second line.
- Tapping a suggestion chip replaces only the active word and appends a trailing space.
Known continuation work:
- Keep suggestion requests debounced; the current validation debounce is in the
portal JavaScript inside
setupIdentityBackupActions(). - Re-test Japanese and Chinese carefully before changing tokenization. The current implementation assumes words are separated by spaces, matching the QR and displayed mnemonic format used by this portal.
The initial Wi-Fi page now has a general Configuração Inicial title and a
Configurações avançadas section with the checkbox Conectar a servidores raiznet.
When the checkbox is unchecked:
- The identity UI and server fields are hidden.
- The hidden server fields are disabled so they are not submitted.
- Identity validation is cleared and ignored.
- Saving Wi-Fi settings uses the simple save confirmation and must not be blocked by invalid or incomplete identity text.
When the checkbox is checked:
- The identity section is fetched from
/identity/section?lang=.... Nome do Sensorstays outside the advanced section and remains visible even when Raiznet connectivity is disabled.- Server settings are stored in hidden JSON inputs (
ext_serversandloc_servers) so the portal can manage multiple entries without adding many WiFiManager parameters. - Inside the advanced body, server settings are shown first under the
Servidoressubsection. That subsection containsLista de servidores externosandLista de servidores locaisareas. The Arateki action is disabled while the Arateki server chip is present and is re-enabled only after that chip is removed. - The mnemonic card is shown below under
Identificação. - Switching the identity tab from
Recuperarback toCriarclears the pending recovery validation state, because the generated words inCriarare the active identity source. - Identity validation and the master-key save warning become active.
The DOM manipulation here is intentionally explicit. WiFiManager renders custom
parameters with extra <br> elements and applies default padding to div
elements. The portal keeps the server UI browser-side, renders chips from the
hidden JSON values, and the CSS resets lateral padding on .advanced-section,
.advanced-body, .advanced-subsection, .advanced-fields, and
.advanced-field. Without these details, subsection widths can drift and the
advanced section can look narrower than the rest of the form.
Do not move the identity block and server fields back into a large static
headerHtml block. Keep the initial HTML small and let the browser assemble the
advanced section after the Wi-Fi page loads.
/portal.js must be served with send_P(...), not send(...). The portal
script is large enough that send(...) can force a full String allocation in
RAM; when that allocation fails, the browser receives no custom script and the
portal falls back to raw WiFiManager behavior: no persistent header, no custom
language handling, no loading overlay, no custom Wi-Fi select, and broken
translations.
Future HTML/JS/CSS work:
- Prefer
PROGMEMplussend_P(...)for large static HTML, JS, or CSS, especially inside the captive portal. - It is fine to keep small dynamic JSON or short status responses using
send(...). - Do not convert dynamic pages mechanically. If a page mixes static markup with runtime values, split static chunks from dynamic values or leave it alone until that page is actively being changed.
- Watch
http_local.cpp/configif that page grows; it currently builds a full HTML page in aString. - Watch
/identity/current,/identity/reroll, and/identity/decode-qrfor heap pressure if QR payloads or response bodies grow. Their risk is dynamic payload size, not static asset delivery.
Connected local portal:
- The connected dashboard at
/serves its reusable visual layer from/local.cssand its dashboard behavior from/dashboard.js, both stored inPROGMEMand sent withsend_P(...). - Keep new local-portal pages on the same tokens/classes before adding page
specific CSS. The base vocabulary intentionally mirrors
apps/prototype(eyebrow, serif/mono text, line-based metric cards, compact side chrome) while staying close to the SafraSense captive-portal colors. /and/configshare the fixed local header from/local.css: brand on the left,Inicio/Configuracoesin the center, and the theme toggle on the right. Keep route-specific utility links inside/config, not in a sidebar or dashboard footer.- The connected dashboard intentionally does not use cookies for language or
theme. Theme uses
localStoragefirst and then the browserprefers-color-scheme. Language uses?lang=...for server-rendered pages; when no query is present, the firmware readsAccept-Languageand falls back to English.LOCAL_NAV_JSmust preservelangon internal links and forms so the next page can render in the selected language before client translation. - The
/dashboard intentionally does not repeat the metric readings in a secondLeituras atuaissection. After the status strip and metric cards, the page showsServidoresandSistemaonly. - On narrow mobile screens, keep the metric cards at two readings per row. The small-screen breakpoint reduces card padding and metric typography rather than collapsing to a single column.
/configstill has its older inline style and dynamicStringassembly. Move it onto/local.cssonly when actively changing that page, because its saved values and form rows need careful escaping and naming preservation. Technical links such asStatus API,JSON, andReconectar Wi-Fibelong in/config, not in the dashboard.
The current mnemonic generation uses:
esp_fill_random(..., 16)for 128 bits of entropy.- SHA-256 of that entropy for the BIP-39 checksum.
- 12 indices of 11 bits into the selected 2048-word list.
One important limitation remains: owner_private_key is currently derived as
SHA256(mnemonic). That is not the full BIP-39 mnemonic-to-seed derivation,
which would use PBKDF2-HMAC-SHA512. Do not describe the owner key derivation as
fully BIP-39 compliant until that is changed.
PlatformIO is available at:
/home/yan/.platformio/penv/bin/pioBuild from this folder:
/home/yan/.platformio/penv/bin/pio runUpload from this folder and let PlatformIO detect the port:
/home/yan/.platformio/penv/bin/pio run -t uploadFor firmware/UI code changes in this project, the current workflow preference is to run the build and then upload immediately after the adjustment succeeds. Do not wait for a separate "suba" command unless the user explicitly asks to pause.
At this handoff, the last uploaded build was the local-portal mobile adjustment with two metric cards per row on mobile. PlatformIO reported:
- RAM:
54,400 bytesused from327,680(16.6%). - Flash:
1,433,481 bytesused from1,966,080(72.9%). - Upload port:
/dev/ttyACM1, MAC00:70:07:26:7e:90.
Telemetry Interval Drift:
A bug has been reported where telemetry readings and sends occur approximately every 5 seconds, despite TELEMETRY_INTERVAL_MS being set to 60000 (60s). Initial attempts to fix this by ensuring lastTelemetryMs is initialized with millis() in setup() did not resolve the issue. The cause is currently unknown and further investigation was paused at user request.
Metric Card Collapse: A bug has been identified where active/open metric cards (showing detailed help text) automatically collapse and close whenever a new sensor reading is received and updated on the dashboard. This occurs during the background refresh cycle. Investigation and fix were paused at user request.
Sensor Initialization Delay: A significant delay has been observed in sensor readiness upon system startup. Sensors take a considerable amount of time to begin reporting valid data after the firmware starts. No repair was requested; this is documented for awareness.
The dashboard provides a "Fazer nova leitura" button that triggers an immediate sensor read and data transmission, bypassing the standard interval.
- Mechanism: The web UI calls
POST /api/force-read, which setsgPendingAction = ACTION_FORCE_READ. - Timer Reset: In
main.cpp, whenACTION_FORCE_READis detected,lastTelemetryMsis reset tomillis() - TELEMETRY_INTERVAL_MS. This forces the next iteration of the telemetry block to evaluate as true immediately. - Safety: This reset only affects the sensor reading cycle. Independent
subsystems (Wi-Fi, HTTP server, LED animations) remain unaffected as they do
not rely on
lastTelemetryMs.