Skip to content

Add Xingzhi Cube 1.83" TFT WiFi (2mic) board support#31

Open
nguyenduchoai wants to merge 13 commits into
HermannBjorgvin:mainfrom
nguyenduchoai:add-xingzhi-cube-183-board
Open

Add Xingzhi Cube 1.83" TFT WiFi (2mic) board support#31
nguyenduchoai wants to merge 13 commits into
HermannBjorgvin:mainfrom
nguyenduchoai:add-xingzhi-cube-183-board

Conversation

@nguyenduchoai

Copy link
Copy Markdown

Summary

Adds a third reference port: Xingzhi Cube 1.83" TFT WiFi (2-mic variant) — ESP32-S3-N16R8 + 284×240 landscape display + 3 physical buttons.

Despite "ST7789" on every forum/silkscreen reference for this board, the panel is actually NV3023. Both Arduino_GFX and TFT_eSPI ST7789 init sequences silently fail — the panel needs a specific ~30-command init that lives in the xiaozhi-esphome project. This port transcribes that sequence into a small hand-rolled SPI driver (~190 lines, no GFX library dependency for this board).

Two commits

  1. hal: add display_hal_lv_color_format() — new HAL function so boards whose MIPI-SPI controller wants pixels MSB-first (like NV3023) can ask LVGL to write swapped bytes directly, avoiding a per-board CPU byte-swap in draw_bitmap. Existing ports return LV_COLOR_FORMAT_RGB565 and behave identically.
  2. Add Xingzhi Cube 1.83" TFT WiFi (2mic) board support — the actual board folder + platformio env + CLAUDE.md entry.

Board hardware summary

  • MCU: ESP32-S3-N16R8 (16 MB flash + 8 MB OPI PSRAM)
  • Display: NV3023 at 284×240 landscape, CASET column offset 36, MADCTL 0xA0 (no BGR bit), no INVON
  • Backlight: GPIO13 via LEDC PWM (plain digitalWrite does NOT light it)
  • Buttons: BOOT/GPIO0 → primary (Space), VOL_DOWN/GPIO40 → secondary (Shift+Tab), VOL_UP/GPIO39 → cycle screens (routed through power_hal, no PMU on this board)
  • Battery: ADC2 voltage divider on GPIO17 + charging-status on GPIO38
  • Power latch: GPIO21 must be HIGH or the board self-shuts on USB unplug
  • No touch, no IMU, no PMU IC, no IO expander

What was tested

  • ✅ ESP32-S3 boots, USB-CDC enumerates, BLE advertises with correct MAC
  • BoardCaps reports Xingzhi Cube 1.83, 284x240
  • ✅ NV3023 init sequence accepted; backlight lights via LEDC PWM
  • ✅ LVGL splash renders Clawd at correct geometry and orientation
  • ✅ Primary R/G/B fillScreen colors render correctly
  • ✅ Builds clean: pio run -e xingzhi_cube_183 → Flash 19.8%, RAM 31.3%

Known limitations

  • Buttons (GPIO 0 / 39 / 40) and battery ADC verified by code path only — not on real BLE HID receiver yet.
  • LVGL anti-aliased mid-tones render slightly desaturated on this panel (Clawd's salmon body shows as warm brown). Acceptable for a status display; can be tuned later via NV3023 gamma registers (0xE0 / 0xE3 / 0xE5 in the init sequence).

Test plan for reviewers

  • pio run -e waveshare_amoled_216 still builds (no regressions from HAL change)
  • pio run -e waveshare_amoled_18 still builds
  • pio run -e xingzhi_cube_183 builds and produces a valid factory.bin
  • Flash a real Xingzhi Cube 1.83" device → splash renders, BLE advertises
  • (Optional) Verify the 3 buttons emit Space / Shift+Tab / cycle-screens via BLE HID

🤖 Generated with Claude Code

nguyenduchoai and others added 3 commits May 22, 2026 09:17
…ixels

Most ST7789/CO5300/SH8601 ports consume RGB565 pixels in host-endian
byte order, but some MIPI-SPI controllers (notably NV3023) expect
bytes MSB-first. Rather than per-board byte-swapping inside
draw_bitmap, expose the format through a new HAL function and wire
main.cpp to forward it into lv_display_set_color_format(). Existing
ports return LV_COLOR_FORMAT_RGB565 (no behavior change); ports that
need MSB-first can return LV_COLOR_FORMAT_RGB565_SWAPPED and stream
buffers verbatim.

Template gains a small comment pointing new ports at the choice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ESP32-S3-N16R8 + 1.83" 284x240 landscape panel + 3 buttons. Despite
its silkscreen and the chip name floating around in forum posts, the
panel is actually NV3023, not ST7789 — both Arduino_GFX and TFT_eSPI
init sequences silently fail. Driver is hand-rolled (~190 lines) and
transcribes the init sequence from the xiaozhi-esphome project:
https://github.com/RealDeco/xiaozhi-esphome/blob/main/devices/Xingzhi/xingzhi-cube-1.83-2mic.yaml

Port summary:
- Hand-rolled NV3023 driver over Arduino SPI (no GFX library dep)
- 284x240 landscape with CASET column offset 36
- MADCTL 0xA0 (orientation only, no BGR bit) + no INVON; pairs with
  LV_COLOR_FORMAT_RGB565_SWAPPED so the panel sees MSB-first bytes
- Backlight on GPIO13 via LEDC PWM (DC level alone does not light it)
- GPIO21 power-latch held high in board_init() so the board stays on
  off USB power
- Three buttons: BOOT (GPIO0) primary, VOL_DOWN (GPIO40) secondary,
  VOL_UP (GPIO39) routed through power_hal as the screen-cycle button
  (no PMU on this board)
- Battery percent from ADC2 GPIO17 voltage divider, charging-status
  from GPIO38; no IMU, no touch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The NV3023 on this board never gives clean colors with the standard
INVOFF/MADCTL knobs — anti-aliased mid-tones drift through several
hues across configurations, and pure black/white pixels come out with
inverted polarity in some configs. After exhausting the obvious
init-sequence tweaks, ship a high-contrast monochrome path:

- display_hal_draw_bitmap thresholds each pixel's max channel against
  MONO_THRESHOLD (150 / 255) — bright → white, dark → black
- Output is pre-inverted before going to the panel to land white on
  bright pixels and black on the background, matching what LVGL meant
- INVOFF (0x20) added to the init sequence to keep panel polarity in
  a known state

Result: Clawd reads as a crisp white silhouette on solid black,
text stays sharp, and the splash + Usage + Bluetooth screens are
fully readable. Define MONOCHROME_RENDER can be commented out in
display.cpp to fall back to native (color-shifted) output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nguyenduchoai

Copy link
Copy Markdown
Author

Pushed a follow-up commit (bffb238) that switches this board to a high-contrast monochrome render in display_hal_draw_bitmap.

The NV3023 on this specific hardware never settles into clean colors with the standard INVOFF / MADCTL / LV_COLOR_FORMAT_RGB565* combinations — anti-aliased mid-tones drift through several hues across init permutations, and pure black/white pixels come out with inverted polarity in some configs. After exhausting the obvious knobs, the firmware now thresholds each pixel's max channel against MONO_THRESHOLD (150/255), pre-inverts the result, and pushes pure black or pure white. Clawd reads as a crisp white silhouette on solid black; text + battery icon stay sharp.

#define MONOCHROME_RENDER in display.cpp can be commented out for anyone who wants to keep exploring color tuning later — no other boards see this change.

… boards

Previously, the splash-screen PWR-press handler unconditionally called
splash_next() to cycle animations. That assumes a tap on the
touchscreen is the way to leave splash — fine for the AMOLED boards,
but on the Xingzhi Cube 1.83 (no touch panel) the user has no other
way to reach the Usage screen and is stuck on splash forever.

Add a BoardCaps.has_touch field (true for both Waveshare ports, false
for Xingzhi). When false, the splash-screen PWR-press now calls
ui_toggle_splash() instead of splash_next(), routing the user
straight to Usage (and back, since toggle remembers prev_non_splash).
The cycle behavior on Usage / Bluetooth is unchanged for everyone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nguyenduchoai

Copy link
Copy Markdown
Author

Discovered + fixed one more issue during real-hardware verification (5e97d41):

On the splash screen, PWR-press was hard-coded to call splash_next() (cycle animations), with the implicit assumption that a touch tap is the way to leave splash. On the Xingzhi Cube — which has no touch panel — that left the user stuck on splash forever with no way to reach Usage or Bluetooth.

Added BoardCaps.has_touch (true for both Waveshare ports, false for Xingzhi). When has_touch == false, the splash-screen PWR-press now routes to ui_toggle_splash() instead, taking the user straight to Usage. Cycle behavior on Usage ↔ Bluetooth is unchanged.

Verified on hardware: BOOT (GPIO 0), VOL_UP (GPIO 39), VOL_DOWN (GPIO 40) all toggle correctly, and PWR now successfully transitions Splash → Usage → Bluetooth on the Xingzhi.

The Usage screen previously hardcoded font_tiempos_56 for the title
and font_styrene_48 / _28 for the percent and reset labels — sizes
designed for the 368-px and 480-px panels. On the new 284x240
Xingzhi board the title overlapped with the 80x80 logo, the reset
text overlapped the progress bar, and the percent number didn't fit
inside the panel.

Layout struct now carries the Usage-screen fonts (title / pct / pill
/ reset / anim), a bar height, pill padding, and a show_chrome flag.
compute_layout() picks them per breakpoint:

- height >= 460 → large (unchanged, same fonts as before)
- height >= 280 → compact (unchanged)
- otherwise     → tiny: styrene_24 title, styrene_28 pct, styrene_12
                  pill, styrene_20 reset, mono_18 anim, 12-px bar,
                  show_chrome=false (logo + battery icons hidden so
                  the title + content fit in 240 vertical pixels)

ui_show_screen() and apply_battery_visibility() now respect
show_chrome — when off, both icons stay hidden on every screen.

Verified with screenshot.sh on real hardware: title, two usage
panels, and the animation status footer all fit within 284x240.
Existing Waveshare layouts are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nguyenduchoai

Copy link
Copy Markdown
Author

Followed up with 4efb07d — adds a third UI breakpoint ("tiny", height < 280) so the Usage screen also re-scales for the 284×240 Xingzhi panel.

Previously the Usage-screen widgets hardcoded font_tiempos_56 / font_styrene_48 / _28, sized for the 368-px and 480-px panels. On the Xingzhi the title overlapped the 80×80 logo, the reset text overlapped the bar, and the percent number didn't fit inside its panel.

Layout struct now carries Usage-screen fonts, bar height, pill padding, and a show_chrome flag. The new tiny breakpoint hides logo + battery icons (no room) and picks tighter fonts; the existing large/compact branches keep their previous values verbatim, so Waveshare boards see no behavioral change.

Verified on real Xingzhi hardware with screenshot.sh: title, two usage panels, and the "Divining…" footer all fit within 284×240 without overlap. Reset text bumped to styrene_20 for legibility — easy to read on the panel.

Touchless boards can opt into a global "rotate screens" timer by
setting BoardCaps.auto_cycle_ms > 0. main.cpp's loop calls
ui_cycle_screen() at that cadence, skipping splash (user toggles
in/out of it manually) and pausing while idle_is_asleep() so a dark
panel doesn't burn cycles.

PWR-press resets the auto-cycle phase, so manual interaction never
fights with the timer — a tap gives the user a full interval on
whichever screen they just moved to.

xingzhi_cube_183 opts in at 5000 ms. Existing Waveshare ports leave
auto_cycle_ms = 0 and see no behavioral change.

Verified on hardware: after pressing VOL_UP into Usage, the panel
flips between Usage and Bluetooth every ~5 s as long as the display
is awake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nguyenduchoai

Copy link
Copy Markdown
Author

Added one more commit (880c3a0) — BoardCaps.auto_cycle_ms for opt-in screen auto-cycling. Touchless boards can set this to > 0 to have main.cpp call ui_cycle_screen() on a global timer; existing ports leave it at 0 and behave unchanged.

The Xingzhi opts in at 5000 ms: after VOL_UP toggles out of splash into Usage, the panel auto-flips Usage ↔ Bluetooth every 5 seconds. The timer pauses on splash and while idle_is_asleep(), and is reset by every manual PWR-press so taps never fight the cadence.

Verified on hardware.

(The user also asked about adding Codex usage tracking on the same screens — that's a meaningful scope expansion since OpenAI doesn't expose Claude-style *-utilization headers, so it'd need a separate daemon path, BLE payload extension, and UI rework. Keeping it out of this PR; happy to open a separate issue if there's interest in pursuing it.)

nguyenduchoai and others added 2 commits May 22, 2026 14:20
The Bluetooth screen overlapped its own text on 284×240: the 48×48
bluetooth glyph crowded the "Connected" status, the 48×48 trash icon
overran the "Reset Bluetooth" label, and the two-line credit footer
collided with the reset zone.

Add Layout flags bt_show_icons and bt_show_credits. The tiny
breakpoint sets both to false so the panel becomes:

  [title]  Bluetooth
  [panel]  Connected
           Device: Claude Controller
           Address: 90:70:69:...:FE:FD
  [reset]  Reset Bluetooth        (text-only)

init_bluetooth_screen() now shifts the status label flush-left when
icons are off, tightens vertical row spacing, and only emits the
credit footer when bt_show_credits is true. Existing Waveshare
layouts keep both flags true → no behavioral change.

Verified with screenshot.sh on real Xingzhi hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Codex CLI hits chatgpt.com/backend-api/codex/responses and gets
back the same shape of rate-limit signal Claude Code exposes:

  x-codex-primary-used-percent       (5-hour window)
  x-codex-primary-reset-after-seconds
  x-codex-secondary-used-percent     (weekly / 7d window)
  x-codex-secondary-reset-after-seconds

Daemon: load_codex_auth() reads ~/.codex/auth.json for the ChatGPT
access token + account id, then poll_codex() makes a minimal
streaming call to /codex/responses and parses the headers above.
Codex polling is best-effort — if the token is missing/expired the
poll returns None and Claude data still ships on its own.

BLE payload gets four optional fields:

  cs / csr / cw / cwr   (mirror s/sr/w/wr but for Codex)

Firmware: new SCREEN_USAGE_CODEX (title "Codex", 5h pill, 7d pill,
same panel chrome as the Claude usage screen). ui_cycle_screen()
weaves it in only after the daemon has actually sent Codex data
(have_codex_data flag in ui.cpp), so a Codex-less setup never sees
an empty "Codex" screen. ui_tick_anim() runs the spinner on both
usage screens.

Verified end-to-end on the Xingzhi: daemon poll_codex() returned
cs=3, cw=14 against a real ChatGPT Pro account, and the device
renders the new screen with correct geometry on the tiny layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nguyenduchoai

Copy link
Copy Markdown
Author

One more commit (c6acb4f) — full Codex usage tracking, end-to-end.

Turns out the Codex CLI (https://github.com/openai/codex) hits chatgpt.com/backend-api/codex/responses and the response carries the same shape of rate-limit signal Claude does:

```
x-codex-primary-used-percent → cs (5-hour window)
x-codex-primary-reset-after-seconds → csr
x-codex-secondary-used-percent → cw (weekly / 7d window)
x-codex-secondary-reset-after-seconds → cwr
```

Daemon: load_codex_auth() reads ~/.codex/auth.json for the ChatGPT access token + account id; poll_codex() makes a minimal SSE call to /codex/responses and parses the headers above. Codex polling is best-effort — if the token is missing/expired the poll returns None and Claude data still ships on its own. Verified against a real ChatGPT Pro account: cs=3, cw=14.

Firmware: new SCREEN_USAGE_CODEX (title "Codex", "5h"/"7d" pills, same panel chrome as the Claude Usage screen). ui_cycle_screen() weaves it in only after the daemon has sent Codex data — have_codex_data flag — so a Codex-less setup never sees an empty "Codex" screen. Auto-cycle then rotates: Usage(Claude) → Usage(Codex) → Bluetooth → Usage…

Screenshot from the Xingzhi at 284×240 (test data injected for QA):

  • "3% / 5h" with "Resets in 3h 38m"
  • "14% / 7d" with "Resets in 4d 17h"
  • "* Elucidating…" spinner footer

Happy to drop this commit if you'd prefer to keep this PR scoped strictly to the board port — it's clean enough to lift to a separate PR.

Antigravity ships a `language_server` binary that talks to Google's
cloud-code API for the user and exposes a GetUserStatus RPC on a
random localhost port. We poll THAT instead of the upstream Google
API — no OAuth refresh needed and we get monthly prompt + flow
credit balances, plan name, and per-model quotas directly.

The endpoint is auto-discovered: pgrep for `language_server`, scrape
its --csrf_token from `ps -ww`, lsof its listening sockets (with -a
for AND-mode), then POST to:

  /exa.language_server_pb.LanguageServerService/GetUserStatus

over both http and https variants (one of the two listening ports
serves each scheme).

The daemon adds these payload fields when Antigravity is running:

  as / asr  — prompt-credit % used + reset minutes
  aw / awr  — flow-credit % used + reset minutes
  apn       — short plan name ("Ultra", "Pro", …)
  apf       — formatted prompt count e.g. "500/50K"
  aff       — formatted flow count   e.g. "100/150K"

Firmware: new SCREEN_USAGE_ANTIG (title "Antig <plan>", panels
"Prompt" + "Flow"). The credit count strings render in place of the
reset clock — monthly credits don't have a useful short reset.

ui_cycle_screen() now weaves Antigravity into the rotation only when
have_antig_data is true, mirroring the Codex screen logic. So users
with only Claude see Usage ↔ Bluetooth, Codex-only adds the Codex
screen, and a full-stack user with all three services sees:

  Splash → Usage → Codex → Antigravity → Bluetooth → …

LaunchAgent fix: pgrep / ps / lsof are now resolved via /usr/bin and
/usr/sbin absolute paths so the daemon works under launchd's stripped
PATH (where /usr/sbin isn't included by default).

Verified end-to-end on the Xingzhi: device shows "Antig Ultra" with
99% / 500/50K prompt and 100% / 100/150K flow, fed by live data from
the user's Google AI Ultra subscription.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nguyenduchoai

Copy link
Copy Markdown
Author

Final commit (61898ee) — Antigravity (Google's agent IDE) tracking.

Turns out Antigravity ships a `language_server` binary that already talks to Google's cloud-code API on the user's behalf and exposes a `GetUserStatus` RPC on a random localhost port. Polling that locally means no OAuth refresh dance is needed and we get monthly prompt + flow credit balances, plan name, and per-model quotas directly:

```
{
"userStatus": {
"planStatus": {
"planInfo": {
"monthlyPromptCredits": 50000,
"monthlyFlowCredits": 150000,
...
},
"availablePromptCredits": 500,
"availableFlowCredits": 100
},
"userTier": { "name": "Google AI Ultra", ... }
}
}
```

The daemon auto-discovers the endpoint (pgrep `language_server` → scrape `--csrf_token` from `ps -ww` → `lsof` for the listening port → POST to `/exa.language_server_pb.LanguageServerService/GetUserStatus`) and adds `as`/`asr`/`aw`/`awr`/`apn`/`apf`/`aff` to the BLE payload.

Firmware: new `SCREEN_USAGE_ANTIG` with title "Antig <plan>", panels "Prompt" + "Flow", credit counts ("500/50K") rendered in place of the reset clock. The cycle order with all three services is now:

```
Splash → Usage(Claude) → Usage(Codex) → Usage(Antigravity) → Bluetooth → …
```

Each "extra" screen is only inserted once we've seen its data, so a Claude-only user still just sees Usage ↔ Bluetooth.

Verified live on Xingzhi against my actual Google AI Ultra subscription — device shows `Antig Ultra` / `99% / 500/50K` / `100% / 100/150K`.

Maintainer: if this scope is too wide for a single board-port PR, all four Codex/Antigravity commits (`c6acb4f`, `61898ee`, plus the screen + auto-cycle infra) sit on the back of the branch and can be cherry-picked into a follow-up PR. The base port commits (`ec0395f` through `5e97d41`) are independent.

nguyenduchoai and others added 4 commits May 22, 2026 15:22
Antigravity's GetUserStatus RPC also ships a per-model quota list
(Claude Sonnet 4.6, Gemini 3.5 Flash, etc.) with remainingFraction
and per-model resetTime. The daemon now formats up to 8 of those as
compact "Name|status" strings:

  Sonnet 4.6|20%       (partial quota)
  GPT-OSS 120B M|ok    (full quota)
  G3.5 Flash H|47m     (depleted, refreshes in 47 minutes)

Names are aggressively abbreviated to fit 284 px width:
  "Gemini 3.5 Flash (Medium)" → "G3.5 Flash M"
  "Claude Sonnet 4.6 (Thinking)" → "Sonnet 4.6"

Firmware: new SCREEN_USAGE_ANTIG_MODELS shows the 8 rows stacked
vertically using L.usage_reset_font. ui_cycle_screen() weaves it in
right after the existing Antigravity credit screen, so the full
cycle on a 3-service setup is:

  Splash → Usage(Claude) → Codex → Antig credits → Antig models →
  Bluetooth → …

The new screen is part of the same have_antig_data gate — Claude /
Codex-only setups never see it.

Verified on the Xingzhi with screenshot.sh: 7 model rows fit cleanly
in 284×240 alongside the title and footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously each row was a single label rendering "Name|status",
which left the status text floating wherever the name happened to
end — short names had their status mid-row, long names had it
crammed near the right edge.

Each row now uses two labels: the name is anchored to the left
margin (COL_TEXT) and the status is anchored to the right margin
(COL_DIM). The pipe separator is split off in firmware so the
daemon payload stays unchanged. Status values now line up vertically
in a clean right-flush column:

  Sonnet 4.6           20%
  GPT-OSS 120B M        ok
  G3.5 Flash H         47m

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… breakpoint

The earlier commits in this branch added a new HAL function
(\`display_hal_lv_color_format\`), two new BoardCaps fields (\`has_touch\`,
\`auto_cycle_ms\`), a third "tiny" UI breakpoint (\`height < 280\`), and a
third reference port (Xingzhi) that hand-rolls a non-AMOLED display
driver. The porting docs hadn't caught up — fix that here.

- \`hal-contract.md\`: add a row for \`display_hal_lv_color_format\` on the
  display HAL table; add the tiny breakpoint to the responsive-UI
  section.
- \`capability-flags.md\`: split a new "Runtime-only BoardCaps fields"
  section for \`has_touch\` and \`auto_cycle_ms\` — they don't have
  \`BOARD_HAS_*\` macros because no per-board source dead-strips on them.
- \`adding-a-board.md\`: broaden the "Hardware you need" lists to cover
  non-AMOLED displays, no-touch boards, and bare-Li-Po power; expand
  the reference-port table to mention Xingzhi for each HAL file
  (display.cpp without Arduino_GFX, touch.cpp stub, power.cpp without
  PMU, imu.cpp full stub).
- README.md: bump "Two boards" to "Three boards" and link the Xingzhi
  board README.

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two convenience tweaks for the always-plugged-in Xingzhi use case:

power.cpp: power_hal_is_vbus_in() now unconditionally returns true.
The previous implementation read GPIO38 (CHRG status), but that pin
only goes HIGH while the charger is actively pushing current —
drops back to LOW the moment the battery tops off, and is ambiguous
when no battery is fitted. There's no dedicated VBUS-sense pin on
the kit, so report "on USB power" outright. Combined with the
default IDLE_SLEEP_WHEN_CHARGING=false, the screen no longer blanks
after 30 min while the cube sits on the desk. (Trade-off: no idle
sleep on battery-only runs either, which this kit isn't really
designed for.)

ui.cpp + board_caps.h: new BoardCaps.cycle_skip_bluetooth flag.
When true, ui_cycle_screen() routes "after extras" straight back to
SCREEN_USAGE instead of stopping at SCREEN_BLUETOOTH. The Bluetooth
screen still exists and is reachable through a direct
ui_show_screen() call — only the auto-rotation skips it. The
Xingzhi sets the flag; existing Waveshare ports default to false
and behave exactly as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant