Multi-tab touch control center for the Arduino GIGA R1 WiFi + GIGA Display Shield + Arduino 4 Relays Shield, built with LVGL 9.
Design v2 "Obsidian Pulse" (APP_VERSION 2.0.0): near-black canvas, electric
cyan + violet dual accents, tinted card borders, slim dark icon rail, primary +
ghost buttons. All v1 LVGL 64KB-pool constraints still apply (lazy MQTT tab,
deferred modal close, no lv_list, modest shadows).
| Tab | What it does |
|---|---|
| Home | Status cards (WiFi, BLE, relays, system), live A0 gauge, uptime + version, red ALL OFF kill button |
| Relays | Toggle each of the 4 shield relays (pins D4 / D7 / D8 / D12), with indicator LEDs |
| Motors | 2 motor channels for an external H-bridge: speed slider (PWM), FWD/REV, enable |
| Sensors | Scrolling live chart of A0 + value bars for A0–A3 (raw + volts), update-rate control |
| IMU | Display Shield BMI270: live accelerometer (g) and gyroscope (dps) bars |
| Audio | Display Shield PDM microphone level meter + history chart; sine-tone generator out the audio jack (DAC0 / A12) with frequency slider |
| WiFi | Scan networks, tap one, type the password on the on-screen keyboard, connect / disconnect; open networks connect directly. "Other network..." lets you type a hidden/out-of-range SSID by hand. After connecting, automatically checks for a captive portal (sign-in page) and flags it if found; shows a specific error if the connect attempt fails |
| BLE | Toggle a BLE peripheral (GIGA-Control) that lets a phone control relays & motors and stream A0 |
| Settings | Display brightness, sensor update rate, system info, safety notes, and an Automation rule entry point — see the Automation section below |
| Recon | TCP connect-scan of a user-entered target IP across 8 common ports (or a custom port list), with a short scan-history strip. For authorized security testing only — see the Recon section below |
| MQTT | Connect to an MQTT broker to publish relay/sensor state and accept remote relay commands — see the MQTT section below |
| Function | Pins | Notes |
|---|---|---|
| Relays 1–4 | D4, D7, D8, D12 | Fixed by the 4 Relays Shield, active HIGH |
| Motor A | PWM D2, DIR D3 | Wire to H-bridge (e.g. L298N EN + IN) |
| Motor B | PWM D5, DIR D6 | " |
| Sensors | A0–A3 | 10-bit reads, 3.3 V reference |
| Tone out | A12 (DAC0) | Routed to the Display Shield audio jack |
| IMU | Wire1 (I2C) | BMI270 on the Display Shield |
Motor pins were chosen to not collide with the relay shield. The GIGA is a 3.3 V board — don't feed 5 V sensor outputs into the analog pins.
Everything is already set up on this board. With the GIGA plugged into the USB hub:
arduino-cli compile --fqbn arduino:mbed_giga:giga ~/GigaControlPanel arduino-cli board list # find the port (ttyACM0/1) arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:mbed_giga:giga ~/GigaControlPanel
Libraries: lvgl 9.x, Arduino_GigaDisplayTouch, Arduino_GigaDisplay,
ArduinoBLE, Arduino_BMI270_BMM150, Arduino_AdvancedAnalog, MQTT
(256dpi/arduino-mqtt, arduino-cli lib install MQTT), Arduino_Portenta_OTA
(arduino-cli lib install Arduino_Portenta_OTA, for WiFi updates) — PDM and
Arduino_H7_Video ship with the core.
If the upload fails with "No DFU capable USB device" and the board disappears
from lsusb, the dock swallowed the DFU re-enumeration — replug the GIGA's USB
cable or double-tap its RESET button, then upload again.
Once an OTA-capable build is on the board, you can update it over the WiFi it's already on — no USB, no DFU dance — with one command on jessy:
~/GigaControlPanel/tools/giga-ota.shThat script compiles the sketch, packages it as a GIGA .ota image (Arduino's
lzss.py + bin2ota.py, auto-fetched to ~/.local/share/giga-ota-tools),
serves it over plain HTTP on jessy's LAN address, and publishes the URL to
giga-control/ota/update. The GIGA (subscribed to that topic) pulls the image
over WiFi, stages it to QSPI flash, and reboots into it; progress is published
back on giga-control/ota/status. Override defaults via env vars —
BROKER=, MQTT_PORT=, HTTP_PORT=, LAN_IP= (set this if the GIGA can't
reach jessy at the auto-detected address).
Two things to know:
- Bootstrap once over USB. OTA only works after firmware that contains the OTA client is running, so the first flash with this feature still goes over USB (above). Every update after that can go over WiFi.
- USB stays the recovery path. A bad image can leave the GIGA needing the
USB DFU flash to recover — OTA removes the routine cable, not the safety net.
If a
giga-otaupdate reportsbootloader too old, run the core'sPortentaH7_updateBootloaderexample once over USB, then retry.
The update itself blocks the UI for the download (a few seconds); the sketch's hardware watchdog is fed throughout by the OTA library, so it won't trip.
The sketch accepts single-character commands for remote testing:
1–9 switch tabs, 0 jumps to tab 9 (Recon, appended after the 1-9
range so it doesn't renumber any existing tab), s run a WiFi scan
directly, w/h jump to WiFi/Home, t synthesize a tap on the Scan
button, n synthesize a tap on the first scanned network button, p/c
open/close the password modal directly (with a dummy SSID), r simulate
tapping the keyboard's checkmark (LV_EVENT_READY) with a dummy password —
exercises the real connect-and-close path without needing a physical
touch, which is how the kbCb freeze (see Caveats) was actually
reproduced and fixed. g runs the captive-portal connectivity check
directly (useful to test it without needing an actual captive-portal
network). o/y open the manual-SSID modal and submit a dummy hidden
SSID (chains into the password modal, same as tapping "Other network..."
for real). b toggles BLE, same as tapping its switch. i/k open the
Recon target-IP modal and submit a dummy target (RFC 5737 192.0.2.1,
guaranteed non-routable — safe for automated testing, though note this is
also the slowest case for a scan, see the Recon section for why). x
deliberately hangs forever with no watchdog kicks — used to verify the
watchdog actually resets the board (see Caveats); don't send it unless you
mean it, the board will reboot a few seconds later. z opens the Automation
rule modal; Z types a test spec (A2 below 300 R2 on) and submits it,
exercising the real parse/save/close path. A timestamped hb
heartbeat prints once a second while a monitor is
attached, including live LVGL heap stats (free/big/frag) — useful for
catching memory issues before they cause a freeze.
The WiFi tab's status line distinguishes:
| State | What it means |
|---|---|
| Not connected (grey) | No attempt made yet, or explicitly disconnected |
| Connecting to X ... (amber) | WiFi.begin() in progress (blocks loop() for a few seconds — normal) |
| Couldn't connect to X — check the password (red) | WiFi.begin() didn't return WL_CONNECTED (wrong password, network out of range, or the network doesn't exist) |
| Connected to X (green) | Link + DHCP OK, and a follow-up connectivity check confirmed real internet access |
| Connected to X — internet not confirmed (amber) | Link + DHCP OK, but the connectivity check didn't get the expected response — most likely a captive portal (hotel/airport/coffee-shop sign-in page), possibly just no real internet |
Captive portals: a network that requires "sign in to continue" in a browser will still let this device associate and get an IP — WiFi.begin() reports success — but blocks real traffic until sign-in completes. This device has no browser to show that page, so it can only detect the situation (a standard technique: fetch http://connectivitycheck.gstatic.com/generate_204, which a captive portal intercepts) and tell you to complete sign-in on another device (phone/laptop) connected to the same network. It cannot complete sign-in itself. This check runs automatically ~0.6s after every successful connect (including the auto-reconnect-on-boot), and can be re-run manually via the g serial command.
Hidden/manual SSID entry: tap "Other network..." on the WiFi tab to type a network name by hand (for hidden or out-of-range networks not in the scan list). Submitting it opens the normal password keyboard for that SSID — leave the password blank and submit for an open network. Built with the same create-fresh, defer-close-out-of-the-callback pattern as the password modal (see Caveats) to avoid the same class of freeze.
Known gap, not implemented, and not planned: no WPA2-Enterprise (802.1X username+password, the kind schools/corporate offices use) support. Checked the installed WiFi library headers directly — only begin(ssid) and begin(ssid, passphrase, security) exist, no EAP/802.1X anywhere. Supporting it would mean dropping below the Arduino WiFi library to raw mbed network APIs, too large an effort for what's fundamentally a home-network control panel.
Only scan hosts and networks you have explicit permission to test. This is a standard TCP port scanner, the same category of tool as nmap, and carries the same expectation of authorized use.
Tap "Set target & scan", type an IP address, submit — it TCP connect-scans 8 common ports (FTP 21, SSH 22, Telnet 23, HTTP 80, HTTPS 443, SMB 445, RDP 3389, HTTP-alt 8080) and lists each as open/closed. Type a comma-separated port list after the IP (e.g. 192.168.1.1 22,80,8080) to scan those instead — still capped at 8. Results also print to serial (recon: port <N> open/closed) for an audit trail. A strip above the results shows the last 3 scans (target + open/total count) for the current session — not persisted across a reboot.
A banner-grab step (reading the open socket's greeting right after connect()) was tried and pulled back out — it caused a delayed watchdog reset, sometimes minutes after the scan had already completed and reported success. Root cause unconfirmed (suspected: the mbed WiFi stack's connect()/read()/stop() cycle leaves a pending async operation that later faults into freed state). Not shipped; see the git history if you want to pick this back up, and soak-test any revival for several idle minutes after a scan against an open port before trusting it.
What this device's WiFi hardware genuinely cannot do: monitor mode, raw 802.11 frame access, packet injection. Checked WiFi.h/WiFi.cpp directly — the library only exposes station/AP mode via WiFiClient. This isn't a missing feature to add later; the Murata WiFi module + Arduino WiFi library don't expose that layer at all. (For that class of testing, jessy's onboard ath10k_snoc adapter does support monitor mode per iw list — see the security-toolkit notes on that device instead.)
Known limitation, found during testing, not just assumed: WiFiClient::setSocketTimeout() does not bound the connect() phase on this core — confirmed by reading MbedClient.cpp: the timeout is only ever applied for the SSL variant and for I/O after a successful connect, never before a plain connect(). Scanning a live, responsive host is fast (proven against a real host on the local network: 8 ports in ~0.12s). Scanning an unresponsive/offline host is slow — each port's connect() falls back to the underlying mbed network stack's own default timeout, observed to take tens of seconds total across 8 ports rather than the sub-second the code originally assumed, and the whole UI is unresponsive for that entire duration (the scan runs synchronously in loop(), blocking lv_timer_handler() along with everything else). It always eventually completes on its own — confirmed by letting one run to completion rather than assuming — no crash, no permanent hang. The hardware watchdog is deliberately paused for the duration of a scan specifically because of this (a slow scan against an unresponsive host isn't the kind of hang the watchdog should "fix" by rebooting mid-scan) and resumed immediately after. If this becomes an actual annoyance, the real fix is a non-blocking TCPSocket + manual poll loop instead of WiFiClient::connect(), not a different timeout call.
Connect the panel to an MQTT broker over WiFi to publish its state and accept remote relay commands, using the 256dpi/arduino-mqtt library over the existing WiFiClient. Tap Set broker, type host or host:port (defaults to 1883), then flip the connect switch. The broker address is saved to KVStore and survives a reboot; the panel does not auto-connect at boot (a blocking connect to an unreachable broker would stall startup — flip the switch when you want it).
Topics, all under the giga-control/ prefix:
| Direction | Topic | Payload |
|---|---|---|
| publish | giga-control/status |
online / offline — offline is also the Last-Will, so the broker announces an ungraceful drop |
| publish (retained) | giga-control/relays |
0–15 bitmask of the four relays; retained so a late subscriber learns current state immediately |
| publish (~1 Hz) | giga-control/sensor/a0 |
raw A0 reading |
| subscribe | giga-control/relay/set |
publish a 0–15 bitmask here to set all four relays at once — same encoding as the BLE relay characteristic |
Broker auth: if your broker requires a username/password, set them in KVStore under /kv/mqtt_user and /kv/mqtt_pass — never hardcoded in source. Left unset, the panel connects anonymously.
Same connect() caveat as the Recon scan: WiFiClient::connect() to an unreachable broker isn't bounded by any timeout on this core, so the watchdog is paused around the connect (see the Recon limitation above). A dropped connection auto-reconnects on a slow (~3 s) cadence while the switch is on.
Why the tab is built lazily (see Caveats): this 11th tab's widgets don't fit the LVGL pool as permanent objects, so they exist only while the tab is on screen. The MQTT connection is independent of the tab — it stays up as you navigate elsewhere; only the on-screen controls come and go.
On the Settings tab, tap "Automation rule" to open a small keyboard modal. Type a one-line rule and submit (the checkmark):
A0 above 512 R4 on
A0–A3— which analog input to watchabove/below(or>/<) — direction- a bare number 0–1023 — the raw-ADC threshold
R1–R4— which relay to driveon/off— whether the rule is enabled
Parsing is case- and space-tolerant, and any field you leave out keeps its current value. The modal pre-fills with the current rule, so you can edit one part and resubmit. The rule is evaluated live (every sensor tick) and drives the relay only when the desired state changes, so it never flaps or spams the BLE/MQTT relay mirrors. It's saved to KVStore and restored on boot; the Settings label shows the active rule (e.g. Auto: A0 above 512 -> Relay 4) or Automation: off.
Why a keyboard, and why it opens over the MQTT tab (see Caveats): a richer tap-to-cycle form doesn't fit — a modal on lv_layer_top can hold only ~3–4 child objects before its render OOM-hangs this 64 KB pool. A keyboard is a single widget, so it fits. But LVGL still renders the active tab behind the modal, and the Settings tab's shadowed cards starve the keyboard's render — so opening the rule modal transparently parks on the empty (lazy) MQTT tab, like the broker modal, and restores Settings on close.
| Char | UUID suffix | Size | Access | Payload |
|---|---|---|---|---|
| Relays | 19B10001 |
1 B | read/write | bit0..bit3 = relay 1..4 |
| Motors | 19B10002 |
3 B | write | [motor 0/1, speed 0–100, dir 0=REV 1=FWD] |
| Sensor | 19B10003 |
2 B | read/notify | raw A0, little-endian, ~1 Hz |
Test with nRF Connect or LightBlue on your phone.
- Never use
lv_listin this sketch. Rendering any lv_list item (lv_list_add_textorlv_list_add_button) hard-freezes the board with this core's LVGL 9 display driver — the whole UI and USB serial lock up. The WiFi network list is a plain flex column of regularlv_buttons for exactly this reason. LV_MEM_SIZEis 64KB (set in the core'slv_conf_9.h, not this repo — see "LVGL heap" below). Larger pools (128KB, 256KB) were tried to fix the original scan-freeze bug and made things worse: 256KB hard-froze the board within a second of boot, reproducibly, on a fully idle UI with no interaction at all. 128KB booted fine but froze under load. The actual fix was reducing peak allocation, not growing the pool: the WiFi scan list is capped to 8 networks (ssidCache[8][40], was 15), and the password modal is rebuilt fresh on each open/close rather than kept permanently resident (a resident keyboard widget left only ~4.8KB free indefinitely — too thin a margin for hours of continuous dashboard/IMU/audio timer churn).- A "list of items" widget list costs more LVGL pool space than it looks
like it should. The Recon results list originally scanned 16 ports;
populating 16 result rows (same
lv_obj+lv_label+ per-itemlv_obj_set_style_*pattern as the WiFi scan list) against a real host drove the LVGL heap down to 280 bytes free — a near-OOM condition, confirmed by actually running the scan rather than assuming it was fine because it compiled. Cut to 8 ports (matching the WiFi scan list's already-proven-stable cap) and it settled at a stable ~4.3KB free instead. If you're tempted to make a results list longer than 8 items anywhere in this file, verify the actual heap headroom with thehbtelemetry rather than assuming it'll be fine. - The pool is full: an 11th permanent tab does not fit, so the MQTT tab
is built lazily. Adding the MQTT tab as permanent widgets dropped idle
free from ~6.8KB to ~3.8KB and the board boot-looped — the boot-time
WiFi/captive-portal allocations tipped that margin into an OOM hang the
watchdog reset every ~29s (bisected against an empty tab body, which
idles stable at ~6.8KB). The fix is to build the MQTT tab's widgets only
while the tab is actually on screen and free them on leave, so idle/boot
free stays at the stable ~6.8KB (the connection itself lives in globals,
independent of the view). Two follow-on hazards this exposed, both fixed
by testing rather than assumption: (1) the broker keyboard modal
(~2.2KB) can't coexist with the built tab either, so opening it frees the
tab first — deferred through
loop(), never from the button's own click callback (that's the delete-in-callback freeze again); (2) the tab must be freed the instant the tab-change event fires, from atabviewLV_EVENT_VALUE_CHANGEDcallback — not oneloop()iteration later — because LVGL renders the incoming tab beforeloop()resumes, and while connected (free only ~4.5KB on this tab) that render OOM-hangs if the MQTT widgets are still allocated alongside it. General rule: any new tab from here on must be lazy, and any modal-plus-tab combo must free the tab before allocating the modal. - A modal on
lv_layer_topcan hold only ~3–4 child objects before its render OOM-hangs, and LVGL renders the active tab behind it regardless of backdrop opacity. Both bisected live on hardware while adding the Automation rule. First: a multi-widget tap-row config form (labels + a slider + a switch, or even just clickable labels) froze the render — a serial-controlled row-count sweep showed 2 rows render but ~5 hang, while a container-only modal is fine. So the rule modal is a single keyboard widget instead (its ~30 cells draw in one pass, like the proven password / broker modals). Second: even the keyboard hung when opened over the Settings tab, and making thelv_layer_topbackdrop fully opaque did not help — LVGL still renders the busy tab underneath, and its shadowed cards starve the keyboard's render. The proven working modals all open over emptied/light content (the broker frees its tab; Recon cleans its result list), so the rule modal switches to the empty lazy MQTT tab before opening and restores the prior tab on close. Third: close must free the keyboard before switching back to the heavy Settings tab, or the restore render OOM-hangs the same way (both steps run in oneloop()iteration, so nothing flashes on screen between them). Takeaway: prefer a keyboard over a bespoke widget form for any new modal here, and open it over light content. WiFiClient::setSocketTimeout()does not boundconnect()on this core. Found while testing the Recon scan, not assumed: readingMbedClient.cppshowsconnect(IPAddress, port)callssock->connect()directly on a freshly createdTCPSocket—set_timeout()is only ever wired up for the SSL connect variant and for I/O after a successful plain connect, never before one. A scan against an unresponsive host can therefore block far longer than the timeout value passed tosetSocketTimeout()would suggest (confirmed: tripped the watchdog during testing before this was understood). See the Recon section above for the actual fix (the watchdog is deliberately paused for the duration of a scan) and the real numbers observed.- A modal that gets reopened for a second use needs its old, still-
resident widgets freed before the new one is created, not after.
The Recon tab's target-IP modal reproducibly hung and watchdog-reset the
board on a second scan in the same session — not the first. Root
cause: the previous scan's 8 result rows are only freed when the next
scan starts, so reopening the modal for a second scan had to allocate a
fresh textarea + keyboard on top of a heap still holding the first
scan's full result list, and that allocation didn't fit in the
remaining contiguous space. The failure doesn't surface where it
happens —
openReconModal()itself returns cleanly and logs its own "modal: open" line — because LVGL v9 defers layout to the nextlv_timer_handler()pass, so the crash looked like a delayed, unrelated hang until traced back. This was present in the original single-scan- tested Recon tab commit too; it just took a second scan in one session to expose it. Fix: free the old result list when the modal opens, not when the next scan begins. If you add another list-plus-modal combination anywhere in this file, free the list before creating the modal, and soak-test at least two full cycles (not one) before trusting it — a single successful use proves nothing about the second one. - Closing the password modal must NOT happen synchronously from inside
the keyboard's own event callback.
kbCbfiresLV_EVENT_READYwhen the user taps the keyboard's checkmark. An earlier version of this code calledclosePwModal()(which deletes the modal, i.e. deletes the very keyboard whose event is currently dispatching) directly from withinkbCb— this reproducibly hard-froze the board, confirmed with a serial test command (r) that injects a syntheticLV_EVENT_READYwithout needing a physical touch. Wrapping the delete inlv_obj_delete_async()did not fix it — same freeze either way. The actual fix mirrors the existing "blocking WiFi work must not run inside an LVGL event callback" rule in this file:kbCbnow only captures the typed password and armskbClosePending(a countdown, same pattern asscanPending/connectPending);loop()does the actualclosePwModal()+requestConnect()a few frames later, fully outside of any LVGL event dispatch. Confirmed stable for 90+ seconds including the real open→type→checkmark→close→connect-attempt→idle sequence. - WiFi scan/connect are blocking calls run from
loop()(never from an LVGL event callback) — the screen pauses a few seconds during a scan. Normal. Serialprints are guarded byif (Serial)(theDBGmacro): on this mbed core an unguarded print blocks forever if a monitor attached once and went away.- WiFi and BLE share the GIGA's Murata radio module. Ran both simultaneously for 3+ minutes (WiFi connected + BLE advertising, switching tabs and rescanning throughout) with no issues — stable heap, no drops. If either still gets flaky in practice, fall back to using one at a time.
- Hardware watchdog (
mbed::Watchdog,drivers/Watchdog.h): started insetup()atmin(20000ms, get_max_timeout()), kicked once perloop()iteration plus explicitly aroundperformScan(),performConnect(), andcheckCaptivePortal()— the only calls that can legitimately block long enough to matter (WiFi.begin()with no explicit security re-scans internally before connecting, confirmed by readingWiFi.cpp). If the sketch ever hangs — a bug nobody's found yet, a peripheral fault, anything — the board resets itself instead of needing a manual power-cycle. Verified with a real test, not just code review: thexserial command spins forever with no kicks, anddmesgconfirmed an actual USB disconnect/reconnect (the board resetting) followed by a clean reboot. imuTimerCb/audioTimerCbonly run while their own tab is active (checked vialv_tabview_get_tab_active(tabview)) — no point polling the IMU over I2C or computing mic RMS for bars nobody can see.sensorTimerCbdeliberately stays ungated since the Dashboard's A0 gauge depends on it running on every tab.- Settings now persist across reboots via mbed KVStore (
kvstore_global_api.h, keys prefixed/kv/): WiFi SSID/password (auto-reconnects on boot), BLE enabled state, display brightness, sensor update rate. Disconnecting WiFi from the UI also forgets the saved network. ToggleEN_KVto0near the top of the sketch to stub all of this out for debugging without touching call sites. - Relays switch mains-capable contacts — treat the shield's screw terminals with respect if you put line voltage on them.
- Three status labels (
wifiStatusLbl,dashWifiLbl,sysInfoLbl) display a live SSID (up to 32 chars) or a generated message and previously had no width/wrap constraint — a long real-world SSID or the captive-portal message could overflow its card. Fixed with explicitlv_obj_set_width()+lv_label_set_long_mode(..., LV_LABEL_LONG_MODE_WRAP)on all three, the WiFi status card grew 96→118px tall for wrap headroom, and the portal/failure message text was shortened. Verified functionally (compiles, connects, fails-gracefully, no crash) but not yet visually confirmed on the actual screen — see Pending below.
- Palette lives at the top of the sketch (
C_BG_*,C_ACCENT/C_ACCENT2,C_WELL,C_ROW/C_ROW_OK). No leftover navy-indigo hardcodes. - Cards use
makeCard(..., stripeColor)— cyan (C_STRIPE) or violet (C_STRIPE2) as a border color tint only. Do not add child stripe widgets (~30 extra objects OOMs the 64KB pool → watchdog reboot loop). Dashboard layout kept at the proven v1 pool budget. - Buttons:
makeBtn(filled primary/danger) andmakeGhostBtn(outline). - Lists:
makeListPanelfor WiFi scan results + recon ports (still notlv_list— freezes this core). - Serial (first heartbeat):
GIGA Control Panel v2.0.0 design=ObsidianPulse.
- Hardware verification. Relays, motors (needs an external H-bridge), sensor voltage tracking, IMU response to physical movement, audio tone/mic, and BLE control from a real phone app.
- Visual UI check of the full Obsidian Pulse surface on the display — layout numbers are intentional; confirm spacing/contrast in person.
This lives in the core, not this repo:
~/.arduino15/packages/arduino/hardware/mbed_giga/4.6.0/libraries/Arduino_H7_Video/src/lv_conf_9.h,
currently (64 * 1024U). A arduino-cli core update/reinstall will revert
any edit here back to the stock value (also 64KB by default, so a stock
reinstall happens to match what this sketch expects — but don't assume that
stays true across core versions).
MIT — see LICENSE.