Smiley Terminal

Luckfox 86 Panel · native C/LVGL port

RV1106 · uClibc Production
Project documentation

A satisfaction kiosk, rebuilt native for a chip that can't run Android.

The public rates a restroom with one tap on three faces. Opinions are queued locally and synced to the WorkDone backend over verified HTTPS. The panel boots straight into the app, self-heals, survives power loss, and is field-configurable without rebuilding the binary.

01Hardware

Luckfox Pico 86Panel W — a wall-mount HMI built around Rockchip's RV1106, running Buildroot Linux on uClibc. No Android runtime; the original Compose UI was rebuilt on LVGL.

The 86 panel running the Smiley opinion screen
The panel in service — the idle opinion screen on the 720×720 IPS: Smiley lockup and clock up top, the three faces, the Lubeca mark and pairing QR below, and the backend Ok status line. Native LVGL, no Android.
ItemValue
SoCRockchip RV1106G3 — single-core Cortex-A7, RISC-V co-proc, NPU
RAM256 MB DDR3L (~236 MB usable)
Display4" IPS, 720×720, 32 bpp — /dev/fb0
TouchGoodix capacitive, I²C — /dev/input/event0
AudioRV1106 internal codec (I²S) — /dev/snd/pcmC0D0p
CameraNone (SoC has an ISP but this panel has no sensor)
ConnectivityWiFi, Ethernet RJ45, USB-C gadget (RNDIS + ADB), RS485, 2× relays
PowerUSB-C 5V (bench) or 6–30 V DC terminal (wall install)

Partitions (eMMC)

MountFSSizeUse
/ext4 rw5.8 GBrootfs, app binary /root/caritas
/userdataext4 rw238 MBpersistent data — DB, config, CA file
/tmptmpfs118 MBRAM — logs (reset on reboot)

The rootfs and /userdata are read-write and journaled — changes survive reboots and the filesystem is crash-safe on power loss.

02Architecture

A single process (caritas) with two threads:

  • UI thread (main.c) — the LVGL render loop; never blocks on I/O.
  • Network thread (net.c) — all HTTP/TLS. A bad or dead network can never freeze the display.

They share the SQLite connection (compiled THREADSAFE=1) and communicate through a condition variable (net_request_sync) and a poll flag (net_pair_state).

Modules (src/)

FileResponsibility
main.cinit (fbdev, LVGL, DB, TZ), start UI + network thread, run loop
ui.call screens + state machine, technical menu, chrome, brightness, sound
faces.cdraws the three faces and the Smiley brand mark
fbdev.c / evdev.cminimal framebuffer display + touch pointer drivers
db.cSQLite: opinion queue, config cache, reasons — power-loss hardened
http.cHTTP/1.1 + HTTPS (mbedTLS, cert verify), chunked decode, connect timeout
sync.csync_run (push/drain/deltas) + prov_vincular (pairing)
net.cnetwork thread, backend target, sync scheduling
util.cclient_uuid (kernel RNG) + ISO-8601 timestamp

Vendored dependencies (third_party/)

All compiled with the SDK's uClibc toolchain — no runtime library surprises.

  • sqlite — offline queue + config
  • cjson — JSON build/parse
  • mbedtls 3.6.7 LTS — TLS client
  • bcrypt (crypt_blowfish) — verify the technical PIN against the backend hash

03Build environment

The Luckfox SDK only supports Ubuntu 22.04 x86_64. On Windows that means WSL2.

  • WSL2 distro Ubuntu-22.04 (needed wsl --update to expose versioned names), user pi.
  • SDK cloned at ~/luckfox-pico — an external dependency, not vendored in this repo.
  • Cross toolchain: arm-rockchip830-linux-uclibcgnueabihf.
  • LVGL v8.2 source comes from the SDK; lv_conf.h is our config (32-bit colour, Latin fonts, QR, image widget).

04Building & deploying

From the repo root, inside WSL:

make -j16                                    # build/caritas — ARM uClibc ELF
adb push build/caritas /root/caritas
adb shell 'chmod +x /root/caritas; killall caritas'   # watchdog relaunches
Gotcha: after changing lv_conf.h, force-rebuild the LVGL archive — rm -rf build/lvgl build/liblvgl.a && make -j16. The Makefile doesn't track that header.

Autostart & watchdog

/etc/init.d/S99smiley runs a watchdog loop with escalating backoff on crash loops, and sets a link-local IP on usb0 for admin SSH. The factory demo (86UI_demo) is disabled by moving S99lvgl out of /etc/init.d — busybox rcS runs anything matching S*, so a .disabled suffix isn't enough.

05Runtime behaviour

Opinión flow

Ported faithfully from the Android OpinionViewModel:

Caritas  --tap Bien / Más o menos------------->  Gracias
Caritas  --tap Mal (mostrar_motivos)--------->  Motivos --reason / omitir-->  Gracias
                                                Motivos --10s timeout------>  Gracias
Gracias  --2s-->  Caritas     (input locked until 3s from the tap)

client_uuid + ts_local_device are generated at tap time, before any I/O, and the opinion is fsync'd to SQLite before "¡Gracias!" shows — so a visible thank-you always means a persisted vote.

Idle chrome & extras

  • Smiley lockup, live clock (Argentina −03), diagnostic line, QR, and a tri-colour WiFi indicator (red = no WiFi, blue = associated, green = backend reachable).
  • Attract mode: the three faces do a gentle staggered bob to invite taps.
  • Brightness: full during 07–22h, dimmed at night — no idle-dimming, since an idle kiosk must stay inviting.
  • Sound: a soft 420 Hz eased beep on tap. The codec output path is enabled at boot (it starts muted).

Technical menu (hidden)

10 taps on the Smiley logo within 2.5s → PIN → menu: brightness, sound, WiFi, backend server URL, re-pair, restart.

Kiosk hardening

  • Watchdog restart on crash; nightly reboot at 04:00 (clears the tmpfs log + RAM).
  • Quiet 24/7 logging — sync only logs when it drains opinions.
  • Votes durable across power loss (synchronous=FULL + journal_mode=TRUNCATE + ext4 journal). Verified with a real physical power cut.

06Sync protocol

Speaks SYNC_PROTOCOL_SMILEY (/api/v1/smiley/*), pull-based JSON, auth via X-Device-Uuid + X-Api-Key headers.

EndpointPurpose
GET /pingpublic health / connection check
POST /vincularpairing with a one-time code → returns device_uuid, api_key, identity
POST /syncpush pending opinions, apply config/reason deltas, drain the acked ones
GET /estadolight last-cleaning refresh

The backend is behind Cloudflare and responds chunked, so the client decodes chunked transfer-encoding.

07Configuration

config_local (SQLite key/value)

KeySet byMeaning
device_uuid, api_keyvinculardevice credentials
pin_tecnico_hashsync (backend)bcrypt $2a$ hash for the technical PIN
last_syncsyncserver timestamp of the last sync
auto_brillomenubrightness schedule on/off (default on)
sonido_localmenutap beep on/off (default on)
auto_rebootmanualset 0 to disable the nightly reboot

Files (/userdata/)

FilePurpose
smiley.dbthe SQLite database
backend.confoverride the backend URL (http:// or https://); absent = production
cacert.pemoverride the TLS CA bundle; absent = embedded bundle

08Admin recipes

SSH / console

dropbear listens on :22. Since the panel and your laptop are usually on different WiFi subnets, the reliable path is the USB cable:

ssh root@169.254.148.50      # link-local set on usb0 at boot

adb shell over USB also works. Change the default root password in production (passwd).

Point at a local / test backend (no TLS)

Menu → Servidor → type http://<ip>:<port> (plain http:// skips TLS). Or by file:

echo "http://192.168.0.10:8080" > /userdata/backend.conf
killall caritas
# restore production:  rm /userdata/backend.conf && killall caritas

Refresh the TLS CA bundle (~yearly, or if sync breaks on cert errors)

curl -o cacert.pem https://curl.se/ca/cacert.pem            # on your PC
scp cacert.pem root@169.254.148.50:/userdata/cacert.pem     # to the device
ssh root@169.254.148.50 killall caritas                     # apply

Revert to the embedded bundle: rm /userdata/cacert.pem && killall caritas.

WiFi & PIN

WiFi: menu → Configurar WiFi → pick a network → keyboard for the password. PIN: verified against the backend's bcrypt hash when present, else pin_local, else 1234.

09Security

  • TLS verification is on (VERIFY_REQUIRED) — a forged/self-signed cert is rejected (verified against expired.badssl.com / self-signed.badssl.com).
  • PIN is checked with real bcrypt against the backend hash.
  • api_key is stored in plaintext on the eMMC — the RV1106 has no hardware keystore. Mitigable, not eliminable without a secure element.
  • The USB link-local + open SSH is convenient for admin but means anyone with the cable and root password has a shell — change the password and treat physical access accordingly.

10Troubleshooting

SymptomCause / fix
SSH over WiFi times outpanel & laptop on different subnets — use the USB IP 169.254.148.50
WiFi indicator red / bluered = not associated; blue = associated but backend unreachable
sync: error de rednetwork/backend down; recovers automatically
No soundcodec output path — check amixer -c 0 and that a speaker is connected
Build: $VAR empty via wsl -- bash -cMSYS drops variables — use literal paths; MSYS_NO_PATHCONV=1 for adb device paths
adb push then permission deniedpush resets the exec bit — chmod +x after every push

Logs live in /tmp/smiley.log (tmpfs, reset on reboot): backend:, ping:, vincular:, sync:, db: lines.

11Known issues

Backend, server-side (not the device): POST /sync returns OK but doesn't persist the terminal's ultimo_sync. The reference Android tablet shows the same symptom, so the bug is in SmileySyncService, not here.

Future: hardware watchdog (/dev/watchdog not exposed), colour calibration, backend-pushed CA updates.