13 KiB
Eiswolfs Flightradar (CYD) — Project Context for Claude Code
Read this file FIRST before making any code changes. It contains the things you need to know to avoid repeating the same bugs that have been encountered and fixed during development.
What is this project?
A live ADS-B flight radar on an ESP32 "Cheap Yellow Display" (CYD, ESP32-2432S028), 240x320 touch TFT. Shows nearby aircraft on a rotating radar screen with a detail panel (model, altitude, speed, route, squawk), proximity LED alert, WiFi management (up to 3 networks), location presets (including remote locations worldwide), airline filter, flight logbook with statistics, and menus/splash screen with a subtle twinkling star animation.
Current version: v3.6.3. Public repo: https://github.com/Eiswolf-BG/eiswolfs-flightradar-CYD
Who uses this?
Alex — a complete beginner at Arduino/embedded development, working with VS Code + PlatformIO on a Mac. Please respond in a way that is accessible; avoid assuming familiarity with technical jargon.
Tech Stack
- PlatformIO,
platform = espressif32,board = esp32dev,framework = arduino - TFT_eSPI (display), XPT2046_Touchscreen (touch), ArduinoJson, TinyGPSPlus
- Dual-core design: networking (WiFi, ADS-B polling) on Core 0, display/touch on Core 1 — the UI never blocks on network requests.
- Data sources: adsb.fi (aircraft positions), hexdb.io (model lookups), ip-api.com (IP geolocation)
⚠️ MOST IMPORTANT GOTCHA: Custom font is baseline-anchored
The built-in TFT_eSPI fonts (GLCD, Font2 etc.) are pure ASCII. For
umlauts/accents (German, Turkish, French, Spanish, Italian)
there is a self-generated font (src/ui_font.h, activated globally
via tft.setFreeFont(&UiFont11pt) in main.cpp::setup(), which applies
to EVERY print()/drawString() call throughout the app).
The critical difference from built-in fonts: With
setCursor(x, y); print(...), the y coordinate for our font is the baseline,
NOT the top edge as with the old GLCD font. Text grows
UPWARD from y (by the ascent, roughly 9px at Size 1, roughly 16-18px at
Size 2), not downward.
This has caused the following bugs in the past (all fixed, but be careful with new code!):
- Values of
ythat are too small (e.g.setCursor(10, 2)) → text protrudes out of or is clipped at the top of the screen/container. Rule of thumb: y should never be less than ~14 at Size 1, never less than ~24-26 at Size 2 (depending on the container). - Input fields/boxes: Baseline must be near the BOTTOM edge of the box, not in the middle as one might be used to from the old font.
- Two text sizes in quick succession within a tight layout (label at Size 1 directly above value at Size 2) are error-prone — in the statistics screen this was deliberately changed to a UNIFORM size (only color distinguishes label/value), which is more robust.
drawString()withsetTextDatum(MC_DATUM)(centered) is NOT affected — TFT_eSPI handles centering correctly regardless of whether the font is baseline- or top-anchored. Only rawsetCursor()+print()is the danger zone.- Long multi-line text (e.g. explanatory text) should NEVER rely on TFT_eSPI's
built-in auto-wrap — it breaks mid-word. Instead,
use the
layoutWrapped()helper (implemented once each inlocation_presets_screen.cppandwifi_manage_screen.cpp, handles word-wise wrapping based on actual pixel width plus optional scrolling).
If new screens or text areas are added: test more than once (ideally with a photo of the actual display) before considering the code done.
i18n (6 languages)
src/i18n.h:enum class StringId— every fixed UI text has an ID.src/i18n_en.h,i18n_de.h,i18n_fr.h,i18n_tr.h,i18n_es.h,i18n_it.h: each astatic const char* const[]array, in exactly the same order as the enum.- Each file has a
static_assertat the end checking the array size againstStringId::COUNT— if the build fails due to a failing static_assert, at least one language file is missing an entry or has one too many. New StringId → add in ALL 6 files at the same position, otherwise the mapping shifts. - Language names themselves (
I18n::languageName()) are stored separately ini18n.cpp, with correct native special characters (e.g. "Français", "Türkçe", "Español").
UI Conventions (please follow for new screens)
- Color scheme: Black background, green frame/text
(
TFT_BLACK/TFT_GREEN), active/selected entries inverted (green fill, black text). Destructive actions (cancel/delete) in red (TFT_RED). - Each screen typically has a local
struct Rectwithcontains(x,y)and adrawButton()helper function (copy-paste pattern from existing screens, no shared Rect/Button module — this is intentional, to keep each screen independently runnable). - Star animation (
src/menu_stars.h/.cpp): Runs in the background on EVERY black menu/splash screen. New screens should callMenuStars::reset()once on enter andMenuStars::update(tft)in every wait/idle loop (the loop otherwise runs idle since the function internally throttles itself to ~60ms). - Wait loop pattern for touch input:
TouchInput::Point tap; while (true) { if (TouchInput::wasTapped(tap)) break; MenuStars::update(tft); delay(20); } - Menu structure: Main menu → 4 categories (Region & Language, WiFi/Network, System, Flight Options) → subpages each. WiFi/Network currently has NO submenu anymore, jumps directly into network management.
- Many screens with text input (airline code, coordinates, WiFi password)
have a "?" info button at the top right that opens a scrollable
explanation screen (pattern:
location_presets_screen.cppandwifi_manage_screen.cpp, each with its ownlayoutWrapped()copy).
Other fixed values (Config::...)
- Proximity alert radius: 3 km
- Emergency squawks: 7500 (hijack), 7600 (radio failure), 7700 (emergency)
- Radar ranges: 10/25/50/100 km
- ADS-B fetch interval: every 8 seconds
- Altitude color coding: Green <10,000ft, Yellow 10-30,000ft, Red >30,000ft
- Max. 3 WiFi networks, max. 3 location presets, max. 10 filtered airlines
Code Style
- All comments in English. Use clear, concise English for implementation notes and rationale.
- Build check after EVERY change:
pio runin the project directory, check for BOTH warnings AND errors (not just "compiles"). - Always prefer least-invasive changes — do not rename existing functions/names without reason.
Language: Project outward-facing text always in English
All outwardly visible text must ALWAYS be written in English — regardless of which language the conversation with Alex is conducted in. This applies to:
README.mdindex.html(website/flasher page)- GitHub release notes / descriptions
- Commit messages
- Any other descriptive or bugfix text that is publicly visible (e.g. on GitHub)
Exception: The firmware UI itself remains multilingual as before
(i18n_de/en/fr/tr/es/it.h) — this rule only applies to the
project's outward-facing presentation (repo, release notes, website), not
the app interface on the device.
Known open points / possible next steps
No acute open bugs known (as of v3.6.3). Possible ideas for later, if the user asks: add info buttons to more menus, possibly more languages, possibly export/share logbook data.
Standard Workflow: Push & Release
IMPORTANT — when this workflow starts: The complete release workflow (README update, commit, tag, push) may ONLY be started when Alex EXPLICITLY asks for it (e.g. "let's push", "can we release", "run the release workflow"). A simple "yes" in response to a follow-up question (e.g. about a CLAUDE.md change or some other detail) is NOT a request to start the release workflow. For small fixes/changes, ONLY build and flash (see the "Auto-flash after every successful build" section below), but do NOT commit/tag/push until explicitly asked.
Once the workflow is explicitly requested, automatically follow these steps in order:
-
Determine the version number: The new version number ALWAYS comes exactly from Alex — he names it in the push request (e.g. via Claude/the sandbox assistant: "Alex wants to push version X.Y.Z"). Enter THAT EXACT number into
Config::APP_VERSION(src/config.h) — never increment, guess, or derive it from the last version (not even for small patches). This also applies to larger jumps (e.g. 2.6 -> 3.0) that Alex may deliberately and intentionally make — always adopt the stated number 1:1 without making assumptions. If no explicit version number was given in the push request, ask Alex rather than guessing. Only AFTER entering the correct number: do a cleanpio runbuild, THEN the rest of the known workflow (README, tag, index.html, bin files, commit/push). (Here, intentionally build ONLY, do NOT flash — exception to the otherwise applicable auto-flash rule, see the "Auto-flash after every successful build" section below. The test device should stay on the current version so the new release can be tested via OTA.) -
Check whether any new/changed features have been added since the last commit that are visible to end users (new menu items, changed behavior, new screens) — if so, update README.md accordingly (same style: emoji headings, anchor links between the "Features" list and the deep-dive sections, short examples where useful). Purely internal bugfixes/refactorings with no visible user impact do not need a README entry.
-
Commit the code (meaningful commit message).
-
If it is a version jump: Create a git tag with the version number + description of changes.
-
Check whether
index.html(web flasher) still shows the old version number — if so, update it. NOT OPTIONAL, must NEVER be skipped on any release push — not even for small patch versions. Always treat as a fixed double-step together with Step 1: whenever the README (or even just the version number) is updated to a new version, ALWAYS check and updateindex.htmlin the same pass. 4b. Keep the web flasher version selection current: Before overwriting the root.binfiles with the new build, archive the CURRENT (still old) bootloader.bin/partitions.bin/firmware.bin into a new folderversions/vOLD/(vOLD = the version number that index.html showed before this update) — also put amanifest.jsonthere (identical content to the root manifest.json, seeversions/v3.6.2/manifest.jsonas a template). Then in theversions/folder keep only the 2 newest version folders (sorted by version number, not file date) — delete older folders. Then update the version dropdown inindex.html(<select id="versionSelect">): 3 options — the new current version (value="manifest.json",data-version="vNEW", text "vNEW (latest)") plus the two versions now remaining inversions/(value="versions/vX.Y.Z/manifest.json", newest first). -
Check whether
bootloader.bin,firmware.bin,partitions.binin the project root match the current build in.pio/build/esp32dev/— if not, copy them from there. -
Commit and push all these changes (code + README + web flasher files) together (
git push, plusgit push origin vX.Y.Zif a tag was created). -
Create a GitHub Release AND upload the
.binfile in one step (via theghCLI, set up and authenticated since v2.7.5 — seegh auth status). The release asset is simply calledfirmware.bin, no renaming needed — most users download via the web flasher anyway; the asset is only relevant for the few people who need a.binfile directly via a CYD launcher app (the filename doesn't matter for that):gh release create vX.Y.Z .pio/build/esp32dev/firmware.bin \ --repo Eiswolf-BG/eiswolfs-flightradar-CYD \ --title "vX.Y.Z" \ --notes "<Release-notes-text>"The release notes text comes from the respective push request (the same text used for the tag message) — if no text was provided in the push request, derive it from
git logsince the last tag, as was done previously for the tag message. -
Brief summary at the end: what was committed/tagged/pushed, whether the README was updated (and if so, which sections), and the URL of the created GitHub Release. The GitHub Release step is now fully automatic — no manual follow-up needed from Alex anymore, except if
ghshould ever lose authentication (then rungh auth loginagain, see above).
Auto-flash after every successful build
As soon as pio run (build) completes successfully without errors, ALWAYS
flash immediately afterward (pio run --target upload) without asking —
unless the user explicitly says "build only, don't flash" or similar.
Shortly after, confirm that the upload was also successful (including
the "[SUCCESS]" line at the end).
EXCEPTION: Within the Push & Release routine (see the "Standard Workflow: Push & Release" section above), do NOT auto-flash, even after a successful build — there, the build is intentionally run only. Reason: Alex wants to keep the test device on the current version so the new release can subsequently be tested via the OTA update functionality, rather than flashing it directly via cable. For all other purposes (normal development/testing, individual fixes), the auto-flash rule continues to apply unchanged.