Loading 3D model… large boards can take a moment.
Info Orbs is a premium, open-source multi-display desk accessory powered by an ESP32. It features five round GC9A01 displays to show real-time weather, stocks, clocks, and custom data with a sleek, interactive interface.
TFT_eSPI library and LittleFS asset management.Each widget can be toggled and configured through the web interface or config.h.
The core of the system, offering multiple visual styles.
0.jpg to 11.jpg) stored in LittleFS.Provides current conditions and a 3-day forecast.
Track your favorite assets in real-time.
/USD), and Forex (via /EUR).Display your investment portfolio performance fetched from a self-hosted Parqet proxy.
A global perspective on time across all five screens simultaneously.
Europe/London) and automatically tracks Daylight Saving Time transitions without any external API.GB, US), and business-hours start/end.Display arbitrary data pushed from your home automation system.
Pull any JSON endpoint and render its fields directly on the displays.
displays array (one entry per screen), each describing text, colour, and icon.interval field in the response.A digital rain screensaver inspired by The Matrix that activates across all five round screens.
Add some personality to your desk with a pair of animated photorealistic eyes.
Info Orbs hosts a powerful web server accessible via your local network.
Connect your device to WiFi. Once connected, access the interface at:
http://info-orbs.local/param)The configuration portal allows you to fine-tune every aspect of your Info Orbs. Settings are categorized into sections. Some parameters are hidden by default and can be revealed by clicking "Show Advanced Parameters".
Europe/Berlin, America/New_York).0 to disable auto-cycling).pool.ntp.org).CustomClock0 to CustomClock9) and customize their individual color tints and tick colors.Latitude, Longitude, and a Display Name.Station ID and Station Name.AAPL,TSLA,BTC/USD,EUR/GBP).US, GB, ES).[!IMPORTANT] Apply Changes: After making any changes in the
/parampage, you must click Save. The device will automatically restart to apply the new configuration.
/browse)The integrated file manager provides direct access to the device's LittleFS flash storage.
0.jpg to 11.jpg) into a folder.0.jpg through 11.jpg) into a folder named /CustomClock0/, /CustomClock1/, etc./buttons) & APIControl your Orbs from your phone or PC through a virtual interface or programmatically.
Short, Medium, and Long presses for the Left, Middle, and Right buttons.GET /button?name=right&state=shortGET /browse?dir=/ (List files as HTML)POST /fetchFromUrl (Trigger asset download)| Component | ESP32 Pin | Component | ESP32 Pin |
|---|---|---|---|
| SDA (MOSI) | GPIO 17 | Screen 1 CS | GPIO 13 |
| SCLK | GPIO 23 | Screen 2 CS | GPIO 33 |
| DC | GPIO 19 | Screen 3 CS | GPIO 32 |
| RST | GPIO 18 | Screen 4 CS | GPIO 25 |
| Button Left | GPIO 26 | Screen 5 CS | GPIO 21 |
| Button Middle | GPIO 27 | Busy LED | GPIO 2 |
| Button Right | GPIO 14 |
Detailed instructions can be found in the Firmware Install Guide.
firmware/config/config.h.template to config.h.Upload task. Assets in the data directory are handled by automated scripts.This section documents significant engineering changes made to the firmware to resolve system crashes and improve long-term stability. These changes are relevant for contributors and advanced users who want to understand the internals.
Problem: The original firmware ran all processing — WiFi management, HTTP requests, widget rendering, and button handling — on a single core in a monolithic loop. This caused the watchdog timer to trip during slow network operations and made the UI unresponsive during data fetches.
Solution: The workload is now split across both ESP32 cores:
| Core | Responsibilities |
|---|---|
| Core 0 | NetworkTask — WiFiManager processing, HTTP task execution, task response handling |
| Core 1 | loop() — Button polling, widget update/draw, time sync |
The NetworkTask is created with xTaskCreateStaticPinnedToCore() using a statically allocated stack and task buffer. This avoids heap fragmentation during startup and guarantees the task can always be created regardless of runtime heap state.
setup() completes on Core 1
└─ WiFi connects
└─ Widgets initialized
└─ NetworkTask spawned on Core 0
└─ wifiManager->process()
└─ TaskManager::processAwaitingTasks()
└─ TaskManager::processTaskResponses()
Problem: Button debouncing was performed directly inside the GPIO interrupt service routine (ISR). Calling millis() and executing debounce logic inside an ISR is illegal in FreeRTOS — it can deadlock or produce corrupted timer state.
Solution: ISRs now do one thing only: call vTaskNotifyGiveFromISR() to wake a dedicated buttonHandlerTask. All debounce logic runs in that task's context on Core 1, safely outside the ISR.
GPIO IRQ fires (ISR)
└─ vTaskNotifyGiveFromISR() → wakes buttonHandlerTask
└─ button.updateState(millis()) ← debounce here, safely
└─ widgetSet->buttonPressed()
Problem: The global taskSemaphore was taken by one FreeRTOS task and released by a different task. FreeRTOS binary semaphores with ownership violations cause an assertion failure: xQueueGenericSend queue.c:832.
Solution: The shared semaphore was replaced with a local taskLimitSemaphore scoped entirely within TaskManager. The semaphore is always taken and given by the same task, eliminating the ownership violation.
Problem: The ESP32 WiFi stack logs internal events via wifi_log(), which is routed through the esp-insights diagnostics wrapper (__wrap_esp_log_write). When the WiFi timer ISR fires, it calls wifi_log(), which tries to flush stdio and acquire the UART mutex. Because lock_init_generic cannot initialize a FreeRTOS mutex from within an interrupt context, it calls abort().
The exact crash chain decoded from the backtrace:
timer_process_alarm → ieee80211_timer_process → wifi_log
→ __wrap_esp_log_write → vprintf → _fflush_r → uart_write
→ _lock_acquire_recursive → lock_init_generic → abort()
Solution: WiFi and related component logs are suppressed at the ESP-IDF level immediately after Serial.begin(), before WiFi is initialized:
esp_log_level_set("wifi", ESP_LOG_NONE);
esp_log_level_set("phy_init", ESP_LOG_NONE);
esp_log_level_set("esp32-hal-adc", ESP_LOG_NONE);
This cuts the logging path entirely, preventing the ISR from ever reaching lock_init_generic.
Problem: The ESP32 hardware shares ADC2 with the WiFi radio. Any analogRead() call on an ADC2 pin (GPIO 0–9, 12–15) while WiFi is active fails with ESP_ERR_TIMEOUT and logs an error every call. Two locations in the codebase called analogRead(0) to seed the random number generator.
Solution: All analogRead(0) calls were replaced with esp_random(), which reads from the ESP32's hardware random number generator — a true entropy source that is available regardless of WiFi state:
| File | Before | After |
|---|---|---|
EyesWidget.cpp | randomSeed(analogRead(0)) | randomSeed(esp_random()) |
NTPClient.cpp | randomSeed(analogRead(0)) | randomSeed(esp_random()) |
Problem: During rapid widget switching, the FreeType font cache manager (_ftc_manager) is unloaded and reloaded on every font change. Under transient heap pressure (e.g., immediately after an HTTP response), loadFont() can fail. The previous code then called drawString() with a NULL _ftc_manager, causing FreeType to dereference a null function pointer and crash with:
Guru Meditation Error: Core 1 panic'ed (InstrFetchProhibited)
PC: 0x00000000
Root cause detail: setFont() called loadFont() multiple times in a retry loop without calling unloadFont() between attempts. Each failed attempt left _ftc_manager in a partially-initialized state. The next retry called FTC_Manager_New() on top of that corrupted state, leaking the previous manager and fragmenting the heap further — making subsequent attempts less likely to succeed.
Solution: A tryLoadFont() helper was introduced that always calls unloadFont() first, guaranteeing a clean FreeType state before every attempt. A m_pendingFont field tracks the desired font independently of whether it is currently loaded.
setFont(FONT_X)
└─ m_pendingFont = FONT_X ← intent recorded unconditionally
└─ tryLoadFont(FONT_X)
└─ m_render.unloadFont() ← always clean slate
└─ m_render.loadFont(...) ← single attempt
└─ if failed: m_curFont = NONE ← safe, guarded
drawString(...)
└─ if m_curFont == NONE:
└─ tryLoadFont(m_pendingFont) ← retry each frame
└─ if still fails: skip frame ← no crash, heals next draw
└─ else: render normally
This means a transient font-load failure results in at most one blank frame — the widget self-heals on the very next draw cycle without any manual intervention, instead of showing permanently black screens or crashing.
Problem: TrueType/OpenType font rendering using the FreeType library is highly resource-intensive on the ESP32. Several factors contributed to heap fragmentation and memory exhaustion during widget rendering:
OpenFontRender instances, each maintaining its own static lambda closures and caching system. This consumed excessive static memory and duplicated FreeType's shared glyph manager (FTC_Manager) cache.OpenFontRender used std::queue<FT_UInt32> (backed by std::deque) to buffer Unicode codepoints. This allocated many small independent heap nodes per character on every frame, generating thousands of dynamic allocations per second.drawHString in "skip mode" to compute vertical alignment corrections (yMin), doubling font rendering calls and dynamic allocations.Solution: A comprehensive font-rendering optimization was implemented:
OpenFontRender array with a single consolidated m_render instance in ScreenManager. This eliminated duplicate glyph managers and saved ~2 KB of permanent heap.drawHString: Converted local queues inside OpenFontRender::drawHString() to static std::vector variables that utilize .clear() and .reserve(). Since rendering is sequential on Core 1, this thread-safe change reduces heap allocations per character to zero.ScreenManager. It caches vertical correction factors (yMin), bypassing redundant bounding-box calculations for identical strings.TFT_eSPI legacy fonts, preventing FreeType cache evictions.Problem: FreeRTOS tasks and standard library containers dynamically allocated on the heap led to instability. Standard heap allocations (xTaskCreate and std::set) can fail under memory pressure and contribute to long-term fragmentation.
Solution: Migrated dynamic allocations to compile-time static memory:
StaticTask_t buffer and a static StackType_t stack buffer (12000 words/48 KB) in TaskManager. Switched TaskManager::startWorkerTask to use xTaskCreateStaticPinnedToCore(). This permanently reserves the task stack in .bss during compilation, recovering 12 KB (words) of dynamic heap and ensuring the networking task can always launch.std::set<String> for tracking active request URLs with a static String activeUrls[10] array. Safe thread access is maintained using a simple linear scan, avoiding red-black tree node overhead and reclaiming ~3 KB of heap space per cycle.Problem: Standard ESP32 SSL clients (such as WiFiClientSecure using mbedTLS) require large buffers (often 16 KB RX / 16 KB TX) for TLS handshakes, which can consume over 30-40 KB of contiguous heap space. Under transient heap pressure, HTTPS requests to weather and stock APIs would fail (HTTP error -1) or cause system crashes.
Solution: Switched to a custom low-heap wrapper based on ESP_SSLClient (BearSSL):
ESP_SSLClientWrapper) inheriting from WiFiClient that embeds ESP_SSLClient. It configures tiny BearSSL internal buffer sizes (5120 bytes RX and 1024 bytes TX), reducing the total heap footprint for an active TLS socket to less than 15 KB.syncWriteError()) between the wrapper and ESP_SSLClient's internal stream. This ensures write errors and socket close statuses are accurately propagated to HTTPClient instead of failing silently.ESP_SSLClient::connect().SSL_MIN_HEAP = 15000 bytes), the request is gracefully skipped and queued with a timeout error, preventing mbedTLS/BearSSL from crashing due to allocation failures.Problem: Over a 15-minute runtime, the free heap and largest contiguous memory block steadily declined. This was caused by a memory leak of ~44 bytes per second.
GlobalTime::calculateActiveTimezoneOffset(), which was invoked once per second in the main loop.TZ by calling setenv("TZ", "UTC0", 1) and then restored it via setenv("TZ", savedTz, 1).newlib C library used by ESP-IDF, calling setenv with changing string lengths leaks memory because it dynamically reallocates new entries in the environment array but does not reclaim old ones.Solution: Replaced environment-variable switching with pure calendar arithmetic:
tmToSeconds() that converts a standard struct tm structure directly into raw epoch seconds using Gregorian calendar arithmetic:
$$ \text{seconds} = f(\text{year}, \text{month}, \text{day}, \text{hour}, \text{minute}, \text{second}) $$localtime_r() (which relies on the system's already-configured POSIX timezone) and subtracts the raw UTC epoch to compute the offset arithmetically:
struct tm tmLocal;
localtime_r(&utcEpoch, &tmLocal);
int offset = (int)(tmToSeconds(&tmLocal) - utcEpoch);
Problem: The firmware previously emitted verbose debug messages from widgets directly to the serial port without any level-based control. This created excessive serial traffic and high overhead. Furthermore, monitoring heap usage and tracking memory leaks across different widgets during API calls and deserialization required ad-hoc print statements.
Solution: A unified widget logging and heap diagnostic framework was introduced:
Log.* and Serial.* outputs with WIDGET_LOG_ERROR, WIDGET_LOG_WARN, WIDGET_LOG_INFO, WIDGET_LOG_TRACE, and WIDGET_LOG_TRACE_RAW macros. All macros prepend the respective widget tag (e.g., [WiFi], [Clock], [Weather], [Stock], [Parqet], [MQTT], [5Zone]) for consistent serial formatting.WIDGET_DEBUG_LEVEL directive in config.h. Standard levels include WIDGET_LOG_LEVEL_SILENT (default), WIDGET_LOG_LEVEL_ERROR, WIDGET_LOG_LEVEL_WARN, WIDGET_LOG_LEVEL_INFO, and WIDGET_LOG_LEVEL_TRACE. In silent mode, all widget logs and diagnostics compile down to no-ops, ensuring zero runtime or binary size overhead.WIDGET_HEAP_SNAP(prefix, label) macro for widgets, and integrated the system-level HEAP_SNAP(label) macro. Both trace ESP32 heap metadata (total free heap, largest free block size, and fragmentation percentage) at key lifecycle points (initialization, network fetches, and JSON deserialization).HEAP_SNAP/SHOW_MEMORY_USAGE) check the global web portal toggle isDebugEnabled_global() before emitting outputs, ensuring all logs are strictly controlled by the runtime debug flag.Created with ❤️ by the Info Orbs Community.
No comments yet. Be the first to ask about this board.