diff --git a/README.md b/README.md
index 0318b3f4f..d95ef26c3 100644
--- a/README.md
+++ b/README.md
@@ -1,209 +1,117 @@
-# Plane Radar
+# ESP32 Plane Radar & Aviator Chronometer (2.1" Round Display Edition)
-
+
+
+
-**3D printed case (STL + assembly):** [MakerWorld](https://makerworld.com/en/models/2872376-esp32-plane-radar-live-ads-b-on-a-round-display#profileId-3207083) · **Firmware:** [Releases](https://github.com/MatixYo/ESP32-Plane-Radar/releases)
+Live **ADS-B Flight Radar** & **Aviator Chronometer** desktop console running on an **ESP32-C3 Super Mini**, featuring a **2.1″ 360×360 round SPI TFT display (GC9B72)**, an **EC11 rotary encoder**, and a custom **3D-printable 18° tilted desk console enclosure**.
-Firmware for an **ESP32-C3 Super Mini** and a **1.28″ round GC9A01** display (240×240). Shows a circular **ADS-B radar** around your configured location, with **WiFiManager** for first-time setup.
+---
-## What it does
+## Key Features
-1. **Wi‑Fi setup** (if needed) — captive portal on AP **`PlaneRadar-Setup`**
-2. **Radar** — live aircraft from [adsb.fi](https://opendata.adsb.fi/) on a sonar-style grid
+- **2.1″ Round 360×360 GC9B72 SPI Display**: High-resolution, vibrant circular display driven by [LovyanGFX](https://github.com/lovyan03/LovyanGFX) for buttery-smooth 40 MHz SPI DMA rendering.
+- **Live ADS-B Flight Tracking**: Real-time aircraft tracking from [adsb.fi](https://opendata.adsb.fi/) plotted on a circular aviation sonar grid with altitude, speed vector, callsign, and aircraft type.
+- **EC11 Rotary Encoder Navigation**:
+ - **Rotate**: Smoothly adjust radar zoom range (5, 10, 15, 25 km) or browse individual aircraft targets.
+ - **Click**: Cycle between **Radar Mode**, **Aircraft Details Screen**, and **Aviator Chronometer Clock Mode**.
+- **Aviator Chronometer Mode**: Authentic aviation clock face with date window, digital time, second hand, and automatic NTP internet time synchronization (CET/CEST supported).
+- **Wi‑Fi Setup & Configuration Portal**: Built-in captive portal (`PlaneRadar-Setup` AP / `http://plane-radar.local`) to configure Wi-Fi, radar center coordinates, units (km / miles), and runway overlays.
+- **3D Printable Desktop Enclosure**: Fully parametric OpenSCAD model (`cad/plane_radar_case.scad`) and ready-to-print STL files (`cad/plane_radar_case-front.stl`, `cad/plane_radar_case-back.stl`) optimized for face-down FDM printing without supports.
-After Wi‑Fi is saved, the device reconnects automatically; the radar runs in the main loop with periodic ADS-B updates (~5 s).
+---
-## Controls (BOOT, GPIO 9, active LOW)
+## Hardware & Wiring
-| Action | Effect |
-|--------|--------|
-| **Short tap** | Cycle range preset (5 → 10 → 15 → 25 km); saved to flash |
-| **Hold 3 s** | Clear Wi‑Fi, location, and units; reboot into setup portal |
+### Components
-During setup you can also hold BOOT at power-on to force a credential reset (same as the long press).
+| Component | Description |
+|-----------|-------------|
+| **Microcontroller** | ESP32-C3 Super Mini |
+| **Display** | 2.1″ Round 360×360 TFT LCD (GC9B72 driver, 10-pin SPI) |
+| **Controls** | EC11 Rotary Encoder with push button (e.g. KY-040 module) |
+| **Enclosure** | 3D-printed case (OpenSCAD / STL included in `cad/`) |
-## Wi‑Fi setup portal
+### Pinout Mapping (ESP32-C3 Super Mini)
-**First-time setup** (no saved Wi‑Fi):
+#### Display (GC9B72 2.1" Round 10-Pin SPI)
+| Display Pin | ESP32-C3 GPIO | Notes |
+|-------------|---------------|-------|
+| **VCC / VDD** | **3V3** | 3.3V Power |
+| **GND** | **GND** | Ground |
+| **SCL (SCLK)** | **GPIO 4** | SPI Clock |
+| **SDA (MOSI)** | **GPIO 3** | SPI Data |
+| **RST (RES)** | **GPIO 0** | Display Reset |
+| **DC** | **GPIO 10** | Data / Command |
+| **CS** | **GPIO 1** | Chip Select |
+| **BL (LEDA)** | **3V3** (or NC) | Backlight Power |
-1. Connect to **`PlaneRadar-Setup`**
-2. Open **`http://plane-radar.local`** (preferred) or **`http://192.168.4.1`** — both are shown on the yellow setup screen; captive portal may open automatically
-3. Set home Wi‑Fi, then save
+#### Rotary Encoder (EC11)
+| Encoder Pin | ESP32-C3 GPIO | Notes |
+|-------------|---------------|-------|
+| **CLK (A)** | **GPIO 5** | Quadrature Channel A |
+| **DT (B)** | **GPIO 6** | Quadrature Channel B |
+| **SW (Button)**| **GPIO 7** | Push Button (Active LOW) |
+| **GND** | **GPIO 8** | Virtual GND (driven LOW by firmware) |
+| **+ (VCC)** | **GPIO 2** | Virtual 3.3V VCC (driven HIGH by firmware) |
-**Reconfigure anytime** (after the device is on your network):
+#### System Controls
+| Control | ESP32-C3 GPIO | Action |
+|---------|---------------|--------|
+| **BOOT Button** | **GPIO 9** | Hold 3 s to reset Wi-Fi credentials & launch setup portal |
-1. Open **`http://plane-radar.local`** or **`http://`** (e.g. from your router or serial log at boot)
-2. Change Wi‑Fi, location, units, or runway overlay; save
+---
-The same portal runs on the setup AP and on the device’s LAN IP while connected to Wi‑Fi. mDNS hostname is `plane-radar` → **plane-radar.local** (`kPortalHostname` in `config.h`). Some clients resolve `.local` slowly; use the IP if needed.
+## Controls & Operating Modes
-**Custom fields** (stored in NVS):
+| Control | Action | Effect |
+|---------|--------|--------|
+| **Rotary Knob** | **Turn** | Adjust radar range (5 ↔ 10 ↔ 15 ↔ 25 km) or browse aircraft |
+| **Rotary Knob** | **Click** | Switch display mode: **Radar** $\rightarrow$ **Aircraft Details** $\rightarrow$ **Chronometer** |
+| **BOOT (GPIO 9)** | **Hold 3 s** | Reset Wi-Fi configuration and reboot into captive setup portal |
-| Field | Purpose |
-|-------|---------|
-| **Latitude / Longitude** | Radar center and ADS-B query position (defaults in `config.h` until set) |
-| **Display distances in miles** | Ring scale label in **mi** instead of **km** (e.g. `6mi` vs `10km`) |
-| **Show airport runways** | Major-airport runway overlay on the radar (off to hide) |
+---
-After a reset, the device reboots and shows the setup screen immediately (no “Connecting” loop on stale credentials).
+## Wi‑Fi Setup Portal
-## Radar display
+1. On first boot (or after holding BOOT for 3 s), connect your phone or PC to the Wi-Fi network **`PlaneRadar-Setup`**.
+2. A captive portal should automatically open. If not, browse to **`http://plane-radar.local`** or **`http://192.168.4.1`**.
+3. Select your home Wi-Fi network, enter the password, and configure:
+ - **Latitude / Longitude**: Your home radar center coordinates.
+ - **Units**: Distances in kilometers (`km`) or miles (`mi`).
+ - **Runways Overlay**: Toggle major airport runway strips on/off.
+4. Save and restart. The radar will automatically connect and begin tracking flights.
-### Grid
+---
-- Dark blue background, subdued green rings and crosshairs
-- White **N / S / E / W** at the bezel; range label on the **east** spoke (ring 3 = ¾ of outer radius)
-- White center dot
+## 3D Printed Console Case
-Layout and colors: `include/ui/radar_theme.h`.
+The repository includes a custom parametric console enclosure in the `cad/` directory:
-### Range presets
+- **`cad/plane_radar_case.scad`**: Parametric OpenSCAD source file.
+ - Supports `part = "front"`, `part = "back"`, and `part = "test_bezel"`.
+- **`cad/plane_radar_case-front.stl`**: Main console body with 18° front tilt, internal 2.0mm display collar, locating pin studs, snap-fit tabs, and sliding rails for ESP32-C3. Designed to print face-down on a textured PEI plate without supports.
+- **`cad/plane_radar_case-back.stl`**: Recessed rear cover plate with ventilation louvers, USB-C cutout, and countersunk M2.5/M3 screw holes.
-| Ring 3 label | Outer radius (aircraft scale) |
-|------------|-------------------------------|
-| 5 km / 3 mi | ~6.7 km |
-| 10 km / 6 mi | ~13.3 km (default) |
-| 15 km / 9 mi | ~20 km |
-| 25 km / 16 mi | ~33.3 km |
+---
-Preset and miles/km choice persist across reboot (`planeradar` NVS namespace).
+## Building and Flashing
-### Runways
-
-- Major airports from OurAirports (`large_airport`); all open runway strips in range (helipads excluded)
-- Teal runway lines with one ICAO label per airport (e.g. `KJFK`); toggle in the Wi‑Fi setup portal
-- Update the embedded list: `python3 scripts/build_large_airports.py`
-
-### Aircraft
-
-- **Inside the outer ring** — red heading triangle, magenta speed vector (clipped at the ring), callsign / type / altitude tags
-- **Outside the ring** (still within ADS-B fetch) — small **red dot on the screen rim** at the correct bearing (direction cue; not distance-accurate past the ring)
-- **Tags** — placed toward the **center**: west (left) → tag on the **right** of the symbol; east (right) → tag on the **left**
-
-As range decreases (or aircraft approach), targets move inward; beyond-ring dots become full symbols when they cross the outer ring.
-
-### ADS-B
-
-- Source: `https://opendata.adsb.fi/api/v3/`
-- Fetch radius: `ui::radar::fetchRadiusKm()` — scales with the active preset to roughly the screen edge (so rim dots have data)
-- Poll interval: `kAdsbFetchIntervalMs` (5 s) in `config.h`
-- Ground aircraft hidden by default (`kAdsbShowGroundAircraft`)
-
-## Configuration
-
-Edit **`include/config.h`** for hardware and behavior:
-
-| Area | Keys / notes |
-|------|----------------|
-| Portal | `kPortalApName`, `kPortalIp`, `kPortalHostname` / `kPortalHostUrl` (mDNS; needs `-DWM_MDNS` in `platformio.ini`) |
-| Wi‑Fi timing | connect attempts, reconnect grace, portal timeout (`0` = no timeout) |
-| BOOT | `kBootPin`, `kBootResetHoldMs`, `kBootTapMinMs` |
-| Display SPI | pins, `kDisplayInvert`, `kDisplayRgbOrder`, `kDisplaySpiWriteHz` |
-| Default location | `kDefaultRadarLat`, `kDefaultRadarLon` (until portal overrides) |
-| ADS-B | `kAdsbFetchIntervalMs`, `kAdsbShowGroundAircraft` |
-
-Range presets: `include/ui/radar_range.h` (`kRangePresets`).
-
-## Project layout
-
-```
-include/
- config.h
- hardware/
- lgfx_config.hpp
- display.h
- display_font.h
- data/
- large_airports.h
- ui/
- radar_theme.h
- radar_range.h
- radar_display.h
- runway_overlay.h
- status_screens.h
- services/
- wifi_setup.h
- radar_location.h
- adsb_client.h
-data/
- ui_font.vlw — embedded smooth UI font (Noto Sans Bold)
-scripts/
- build_large_airports.py
-src/
- main.cpp
- data/
- large_airports_data.cpp
- hardware/
- ui/
- services/
-```
-
-## Wiring (GC9A01 ↔ ESP32-C3 Super Mini)
-
-| Display | ESP32-C3 |
-|---------|----------|
-| VCC | 3V3 |
-| GND | GND |
-| RST | GPIO **0** |
-| CS | GPIO **1** |
-| DC | GPIO **10** |
-| SDA (MOSI) | GPIO **3** |
-| SCL (SCLK) | GPIO **4** |
-| BOOT (user) | GPIO **9** |
-
-## Build
+### PlatformIO
```bash
-pio run -t upload
-pio device monitor
-```
-
-- PlatformIO env: **`supermini`**
-- Serial: **115200** baud
-- USB CDC on boot enabled in `platformio.ini` for the Super Mini
+# Build and upload firmware to ESP32-C3 Super Mini
+pio run -e supermini -t upload
-### Web-flashable release image
-
-Single `.bin` for [esptool-js](https://espressif.github.io/esptool-js/) and similar tools (ESP32-C3, 4 MB, flash at **0x0**):
-
-```bash
-chmod +x scripts/merge-firmware.sh # once
-./scripts/merge-firmware.sh
-```
-
-Writes `release/plane-radar-merged.bin`. Skip rebuild if firmware is already built:
-
-```bash
-./scripts/merge-firmware.sh --no-build
-```
-
-Or via PlatformIO only (output: `.pio/build/supermini/firmware-merged.bin`):
-
-```bash
-pio run -e supermini
-pio run -t merge -e supermini
-```
-
-Put the board in download mode (hold **BOOT**, tap **RESET**), then flash with Chrome/Edge over USB.
-
-### CI and releases (GitHub Actions)
-
-| Workflow | When | Output |
-|----------|------|--------|
-| [Build](.github/workflows/build.yml) | Push / PR to `main` | Artifact `plane-radar-supermini` (merged + split `.bin` files, ~90 days) |
-| [Release](.github/workflows/release.yml) | Git tag `v*` (e.g. `v1.0.0`) | GitHub Release asset `plane-radar-v1.0.0.bin` + `.sha256` |
-
-To ship a version users can download:
-
-```bash
-git tag v1.0.0
-git push origin v1.0.0
+# Open serial monitor (115200 baud)
+pio device monitor
```
-The release workflow builds firmware in CI and attaches the merged image to the release. Download from **Releases** on GitHub, then flash at **0x0** (ESP32-C3, 4 MB).
+---
-## Dependencies
+## Credits & Acknowledgments
-- [LovyanGFX](https://github.com/lovyan03/LovyanGFX)
-- [WiFiManager](https://github.com/tzapu/WiFiManager)
-- [ArduinoJson](https://github.com/bblanchon/ArduinoJson)
+- Based on the original [ESP32-Plane-Radar](https://github.com/MatixYo/ESP32-Plane-Radar) by [MatixYo](https://github.com/MatixYo).
+- Flight data provided by [adsb.fi](https://opendata.adsb.fi/).
+- Graphics engine powered by [LovyanGFX](https://github.com/lovyan03/LovyanGFX).
+- Airport database sourced from [OurAirports](https://ourairports.com/).
diff --git a/cad/2.1 inch display - dimensions.avif b/cad/2.1 inch display - dimensions.avif
new file mode 100644
index 000000000..a3d308f10
Binary files /dev/null and b/cad/2.1 inch display - dimensions.avif differ
diff --git a/cad/plane_radar_case-back.stl b/cad/plane_radar_case-back.stl
new file mode 100644
index 000000000..91ef2cefe
Binary files /dev/null and b/cad/plane_radar_case-back.stl differ
diff --git a/cad/plane_radar_case-front.stl b/cad/plane_radar_case-front.stl
new file mode 100644
index 000000000..e8a479f20
Binary files /dev/null and b/cad/plane_radar_case-front.stl differ
diff --git a/cad/plane_radar_case.scad b/cad/plane_radar_case.scad
new file mode 100644
index 000000000..90a1e1e3c
--- /dev/null
+++ b/cad/plane_radar_case.scad
@@ -0,0 +1,371 @@
+// ====================================================================
+// Plane Radar & Aviator Chronometer — Desktop Console Case
+// For 2.1" Round 360x360 Display (60mm PCB), ESP32-C3 Super Mini & EC11
+// Parametric OpenSCAD Model
+// ====================================================================
+
+$fn = 72; // Smooth circular curves
+
+// ====================================================================
+// Configuration Parameters (in millimeters)
+// ====================================================================
+
+// --- Case Dimensions ---
+case_w = 74.0; // Overall width
+case_h = 112.0; // Overall height
+case_base_d = 58.0; // Depth at table base
+corner_r = 4.0; // Corner rounding radius
+wall = 2.4; // Shell wall thickness
+tilt_deg = 18.0; // Front face tilt angle back from vertical
+// --- Display Cutout (GC9B72 2.1" 10-pin module) ---
+disp_pcb_dia = 59.24; // Exact PCB outer diameter from drawing
+disp_collar_od = 60.4; // Support collar OD (provides ~0.6mm clearance for 59.24mm PCB)
+disp_glass_dia = 55.52; // Outer diameter of LCD glass (55.52mm ±0.1mm)
+disp_view_dia = 57.0; // Aperture window opening (increased to 57.0mm for smooth fit of 56mm glass/LCM)
+disp_active_dia = 52.92; // LCD active display area (2.08" ~ 2.1" active screen)
+disp_standoff_h = 2.0; // Collar height / depth
+disp_center_s = 73.0; // Center position along angled front face
+
+tab_hole_pitch = 23.88; // Exact center-to-center hole pitch from drawing
+tab_hole_x = 11.94; // tab_hole_pitch / 2 (±11.94mm from center)
+tab_hole_y = -29.8; // Y position of 2-R1 holes at tab junction
+tab_w = 32.0; // Tab cutout width (32.0mm for clean vertical wall transition)
+tab_notch_w = 24.5; // Clearance notch width for 10-pin header (header is 22.86mm wide)
+
+
+// --- Rotary Encoder (EC11 / KY-040) ---
+enc_center_s = 22.0; // Center of knob along angled front face
+enc_hole_dia = 7.5; // M7 threaded bushing hole diameter
+
+// --- ESP32-C3 Super Mini (Internal Base Mount) ---
+esp_w = 18.5; // Width
+esp_l = 23.0; // Length
+usbc_w = 11.5; // USB-C hole width
+usbc_h = 6.5; // USB-C hole height
+
+// --- Rear Cover Fasteners ---
+screw_hole_dia = 2.8; // Holes for M2.5 or M3 screws
+screw_post_dia = 7.0; // Outer diameter of corner screw bosses
+
+// Display Mounting Style:
+// "pins" = Locating peg studs (displej se nasadí na kolíčky a zajistí kapkou tavného lepidla / zatavením)
+// "screws" = Pilot holes for M2 self-tapping screws
+disp_mount_type = "pins";
+disp_pin_dia = 1.85; // Locating pin diameter (drawing specifies 2-R1 = Ø2.0mm holes)
+disp_pin_h = 2.8; // Pin height above the 2.0mm standoff shelf
+
+
+
+// ====================================================================
+// Select Part to Render:
+// "front" = Main console enclosure (Ready to export/print face-down)
+// "back" = Rear cover plate with ventilation & USB-C cutout
+// "test_bezel" = Quick 5-minute test swatch (only screen hole + collar + pins)
+// "all" = Both parts side-by-side for visual check
+// ====================================================================
+part = "front";
+
+// ====================================================================
+// Computed Geometry Helpers
+// ====================================================================
+// Exact geometric shift for the sphere centers to make front face tilt angle exactly tilt_deg
+shift_y = (case_h - 2 * corner_r) * tan(tilt_deg);
+
+// Module for positioning features perpendicular to the tilted front face
+// In the children's frame:
+// X = horizontal across face (-X = left, +X = right)
+// Y = vertical along face (+Y = up toward top, -Y = down toward encoder/base)
+// +Z = normal pointing OUTWARDS towards user (Z = 0 is outer front surface)
+// -Z = normal pointing INWARDS into the cavity (Z <= -wall is inside cavity)
+module on_front_face(s_pos) {
+ // Exact tangent point on front surface at distance s_pos along slanted face
+ face_y = corner_r * (1 - cos(tilt_deg)) + s_pos * sin(tilt_deg);
+ face_z = corner_r * (1 + sin(tilt_deg)) + s_pos * cos(tilt_deg);
+ translate([case_w / 2, face_y, face_z])
+ rotate([90 - tilt_deg, 0, 0])
+ children();
+}
+// 3D Solid Outer Shell with 18-degree tilted front and crisp vertical/flat rear rim
+module outer_shell() {
+ hull() {
+ // Front 4 rounded corners (spheres for smooth 3D rounded front face & 18° tilt)
+ translate([corner_r, corner_r, corner_r])
+ sphere(r = corner_r);
+ translate([case_w - corner_r, corner_r, corner_r])
+ sphere(r = corner_r);
+ translate([corner_r, shift_y + corner_r, case_h - corner_r])
+ sphere(r = corner_r);
+ translate([case_w - corner_r, shift_y + corner_r, case_h - corner_r])
+ sphere(r = corner_r);
+
+ // Rear vertical cylinders (keeps outer side corners rounded R=4mm, but ensures
+ // the rear opening plane Y = case_base_d and top/bottom planes are 100% crisp and flat)
+ translate([corner_r, case_base_d - corner_r, 0])
+ cylinder(r = corner_r, h = case_h);
+ translate([case_w - corner_r, case_base_d - corner_r, 0])
+ cylinder(r = corner_r, h = case_h);
+ }
+}
+
+// Inner Cavity subtracted from the rear
+module inner_cavity() {
+ hull() {
+ // Front inner corners (matching the 18° tilt)
+ translate([wall + corner_r/2, wall + corner_r/2, wall])
+ cylinder(r = corner_r/2, h = 1);
+ translate([case_w - wall - corner_r/2, wall + corner_r/2, wall])
+ cylinder(r = corner_r/2, h = 1);
+ translate([wall + corner_r/2, shift_y + wall + corner_r/2, case_h - wall])
+ cylinder(r = corner_r/2, h = 1);
+ translate([case_w - wall - corner_r/2, shift_y + wall + corner_r/2, case_h - wall])
+ cylinder(r = corner_r/2, h = 1);
+
+ // Rear inner opening (extends straight back through Y = case_base_d + 10)
+ translate([wall + corner_r/2, case_base_d + 10, wall])
+ cylinder(r = corner_r/2, h = 1);
+ translate([case_w - wall - corner_r/2, case_base_d + 10, wall])
+ cylinder(r = corner_r/2, h = 1);
+ translate([wall + corner_r/2, case_base_d + 10, case_h - wall])
+ cylinder(r = corner_r/2, h = 1);
+ translate([case_w - wall - corner_r/2, case_base_d + 10, case_h - wall])
+ cylinder(r = corner_r/2, h = 1);
+ }
+}
+
+module front_enclosure() {
+ col_size = 8.0; // Width & height of square corner column
+ hole_depth = 14.0; // Screw hole depth from rear plate
+ cover_t = 1.6; // Rear cover thickness
+ col_rear_y = case_base_d - cover_t - 0.4; // Recessed by cover_t + 0.4mm for flush seating
+
+ difference() {
+ // ============================================================
+ // 1. ALL POSITIVE SOLIDS (Hollow shell + Columns + Collar + Rails)
+ // ============================================================
+ union() {
+ // Main hollow shell
+ difference() {
+ outer_shell();
+ inner_cavity();
+ }
+
+ // 4 Corner Columns for rear cover screws (solid pillars clipped within shell)
+ intersection() {
+ outer_shell();
+ union() {
+ // Bottom-Left & Bottom-Right Corners
+ translate([0, 0, 0])
+ cube([wall + col_size, col_rear_y, wall + col_size]);
+ translate([case_w - wall - col_size, 0, 0])
+ cube([wall + col_size, col_rear_y, wall + col_size]);
+
+ // Top-Left & Top-Right Corners
+ translate([0, 0, case_h - wall - col_size])
+ cube([wall + col_size, col_rear_y, wall + col_size]);
+ translate([case_w - wall - col_size, 0, case_h - wall - col_size])
+ cube([wall + col_size, col_rear_y, wall + col_size]);
+ }
+ }
+
+ // Internal Display Support Collar, Tabs & Mounting Bosses/Pins
+ on_front_face(disp_center_s)
+ display_internal_features();
+
+ // Internal ESP32-C3 Mount Rails (Continuous from front wall)
+ esp_x = case_w / 2 - esp_w / 2;
+ esp_y = case_base_d - wall - esp_l - 2;
+ esp_rail_h = 7.0;
+ rails_y_start = wall;
+ rails_y_len = case_base_d - wall - 2 - rails_y_start;
+
+ translate([esp_x - 1.6, rails_y_start, wall]) {
+ // Left guide rail
+ cube([1.6, rails_y_len, esp_rail_h]);
+ // Right guide rail
+ translate([esp_w + 1.6, 0, 0])
+ cube([1.6, rails_y_len, esp_rail_h]);
+ // Forward stop barrier
+ translate([0, esp_y - rails_y_start, 0])
+ cube([esp_w + 3.2, 1.8, 3.5]);
+ }
+ }
+
+ // ============================================================
+ // 2. NEGATIVE CUTOUTS (Windows, chamfers, notches, holes)
+ // ============================================================
+
+ // Display Aperture Window, Chamfer, Notch & Pilot Holes
+ on_front_face(disp_center_s)
+ display_negative_cutouts();
+
+ // Rotary Encoder Shaft Hole (Clean 7.5mm through-hole)
+ on_front_face(enc_center_s)
+ cylinder(d = enc_hole_dia, h = 40, center = true);
+
+ // USB-C Port Cutout on rear bottom wall
+ translate([case_w / 2 - usbc_w / 2, case_base_d - wall - 5, wall + 1.5])
+ cube([usbc_w, wall + 10, usbc_h]);
+
+ // 4 Rear Cover Screw Holes (Drilled into column shelves)
+ hole_x_l = wall + col_size / 2;
+ hole_x_r = case_w - wall - col_size / 2;
+ hole_z_b = wall + col_size / 2;
+ hole_z_t = case_h - wall - col_size / 2;
+
+ for (hx = [hole_x_l, hole_x_r]) {
+ for (hz = [hole_z_b, hole_z_t]) {
+ translate([hx, case_base_d - cover_t - hole_depth, hz])
+ rotate([-90, 0, 0])
+ cylinder(d = screw_hole_dia, h = hole_depth + 1);
+ }
+ }
+ }
+}
+
+// ====================================================================
+// Shared Display Mount Modules (Identical in front enclosure & test swatch)
+// ====================================================================
+module display_internal_features() {
+ // 1. Support collar following the circular PCB rim, ending cleanly at tab intersection
+ difference() {
+ translate([0, 0, -wall - disp_standoff_h])
+ cylinder(d = disp_collar_od, h = disp_standoff_h + 0.6);
+ // Rectangular cutout for the 32.0mm PCB tab and flex ribbon
+ // Starts at Y = -24.5mm (exact clean vertical cut matching tab width 32mm)
+ translate([-tab_w / 2, -60, -wall - disp_standoff_h - 1])
+ cube([tab_w, 60 - 24.5, disp_standoff_h + 3]);
+ }
+
+ // 2. Snap-fit alignment guide tabs (2.8mm height above collar with 0.35mm retention bead)
+ tab_wall_t = 1.6;
+ tab_h_above = 2.8; // Extends 2.8mm above collar (covers 1.6mm PCB + 1.2mm retention lip)
+ for (a = [45, 90, 135, 215, 325]) {
+ rotate([0, 0, a]) {
+ // Main vertical tab wall
+ translate([disp_collar_od / 2, -2.5, -wall - disp_standoff_h - tab_h_above])
+ cube([tab_wall_t, 5.0, disp_standoff_h + tab_h_above + 0.6]);
+
+ // Snap-fit retention bead (0.35mm inward ramp at top edge)
+ translate([disp_collar_od / 2 - 0.35, -2.0, -wall - disp_standoff_h - tab_h_above])
+ hull() {
+ cube([0.35, 4.0, 0.7]);
+ translate([0.35, 0, 0.7])
+ cube([0.01, 4.0, 0.4]);
+ }
+ }
+ }
+
+ // 3. Two dedicated support boss pads for bottom tab mounting holes (Pitch 23.88mm, tx = ±11.94mm, ty = -29.8mm)
+
+ for (tx = [-tab_hole_x, tab_hole_x]) {
+ translate([tx, tab_hole_y, -wall - disp_standoff_h])
+ cylinder(d = 4.8, h = disp_standoff_h + 0.6);
+
+ // Locating studs projecting through PCB holes (2-R1 = Ø2.0mm holes in PCB)
+ if (disp_mount_type == "pins") {
+ translate([tx, tab_hole_y, -wall - disp_standoff_h - disp_pin_h])
+ cylinder(d = disp_pin_dia, h = disp_pin_h + 0.1);
+ }
+ }
+}
+
+
+
+module display_negative_cutouts() {
+ // Display Aperture Window (Cuts through front wall and collar)
+ cylinder(d = disp_view_dia, h = 40, center = true);
+
+ // Display Bezel Chamfer
+ translate([0, 0, 0.5])
+ cylinder(d1 = disp_view_dia, d2 = disp_view_dia + 3.5, h = 3.0);
+
+ // Optional Screw Pilot Holes (Only active if disp_mount_type == "screws")
+ if (disp_mount_type == "screws") {
+ for (tx = [-tab_hole_x, tab_hole_x]) {
+ translate([tx, tab_hole_y, -wall - disp_standoff_h - 1])
+ cylinder(d = 1.8, h = disp_standoff_h + 3.0);
+ }
+ }
+}
+// Quick Test Swatch (Flat 5-minute print for fit verification)
+// ====================================================================
+module test_bezel_swatch() {
+ swatch_w = 66.0;
+ swatch_h = 70.0;
+
+ // Flat on XY print bed (front face at Z=0, collar & pins pointing straight up in +Z)
+ rotate([180, 0, 0]) {
+ difference() {
+ union() {
+ translate([-swatch_w / 2, -swatch_h / 2 - 2.0, -wall])
+ cube([swatch_w, swatch_h, wall]);
+
+ display_internal_features();
+ }
+ display_negative_cutouts();
+ }
+ }
+}
+
+// ====================================================================
+// Part 2: Rear Backplate Cover (Printable Flat on Bed)
+// ====================================================================
+module back_cover() {
+ cover_t = 1.6; // 1.6mm thickness (sits flush within cavity without sticking out)
+ cover_w = case_w - 2 * wall - 0.4;
+ cover_h = case_h - 2 * wall - 0.4;
+ cover_corner_r = 1.0; // Small 1.0mm corner radius to fully fill inner cavity corners (no gap)
+ col_size = 8.0;
+
+ difference() {
+ // Flat rectangular plate with small 1.0mm rounded corners
+ hull() {
+ translate([cover_corner_r, cover_corner_r, 0])
+ cylinder(r = cover_corner_r, h = cover_t);
+ translate([cover_w - cover_corner_r, cover_corner_r, 0])
+ cylinder(r = cover_corner_r, h = cover_t);
+ translate([cover_w - cover_corner_r, cover_h - cover_corner_r, 0])
+ cylinder(r = cover_corner_r, h = cover_t);
+ translate([cover_corner_r, cover_h - cover_corner_r, 0])
+ cylinder(r = cover_corner_r, h = cover_t);
+ }
+
+ // 4 Corner Screw Holes (Direct through-holes for standard button/pan head screws)
+ boss_inset_x = col_size / 2 - 0.2;
+ boss_inset_y = col_size / 2 - 0.2;
+
+ for (cx = [boss_inset_x, cover_w - boss_inset_x]) {
+ for (cy = [boss_inset_y, cover_h - boss_inset_y]) {
+ translate([cx, cy, -1])
+ cylinder(d = screw_hole_dia + 0.4, h = cover_t + 2);
+ }
+ }
+
+ // Horizontal Chimney Ventilation Louvers (Convection cooling)
+ for (i = [0:5]) {
+ translate([12, 32 + i * 11, -1])
+ cube([cover_w - 24, 2.5, cover_t + 2]);
+ }
+
+ // USB-C Cable Notch at bottom center
+ translate([cover_w / 2 - usbc_w / 2, -1, -1])
+ cube([usbc_w, 8, cover_t + 2]);
+ }
+}
+
+
+// ====================================================================
+// Render Output
+// ====================================================================
+if (part == "front") {
+ front_enclosure();
+} else if (part == "back") {
+ back_cover();
+} else if (part == "test_bezel") {
+ test_bezel_swatch();
+} else {
+ // Both parts side-by-side for preview
+ front_enclosure();
+ translate([case_w + 15, 0, 0])
+ back_cover();
+}
diff --git a/images/plane_radar_console.png b/images/plane_radar_console.png
new file mode 100644
index 000000000..d3f1de7a5
Binary files /dev/null and b/images/plane_radar_console.png differ
diff --git a/include/config.h b/include/config.h
index 6555c0342..1723fff8d 100644
--- a/include/config.h
+++ b/include/config.h
@@ -29,20 +29,32 @@ constexpr unsigned long kBootResetHoldMs = 3000UL;
/** Ignore BOOT taps shorter than this (debounce). */
constexpr unsigned long kBootTapMinMs = 40UL;
-// --- Display: GC9A01 1.28" round 240×240 (SPI) ---
+// --- Rotary Encoder (EC11 / KY-040) ---
+constexpr gpio_num_t kEncoderPinClk = GPIO_NUM_5;
+constexpr gpio_num_t kEncoderPinDt = GPIO_NUM_6;
+constexpr gpio_num_t kEncoderPinSw = GPIO_NUM_7;
+constexpr gpio_num_t kEncoderPinGnd = GPIO_NUM_8; // Virtual GND (beside GPIO 7)
+constexpr gpio_num_t kEncoderPinVcc = GPIO_NUM_2; // Virtual 3.3V (VCC)
+
+// --- NTP Timezone (Central Europe / Prague) ---
+constexpr char kNtpTimezone[] = "CET-1CEST,M3.5.0,M10.5.0/3";
+constexpr char kNtpServer1[] = "pool.ntp.org";
+constexpr char kNtpServer2[] = "time.google.com";
+
+// --- Display: GC9B72 2.1" round 360×360 (SPI) ---
constexpr gpio_num_t kDisplayPinRst = GPIO_NUM_0;
constexpr gpio_num_t kDisplayPinCs = GPIO_NUM_1;
constexpr gpio_num_t kDisplayPinDc = GPIO_NUM_10;
constexpr gpio_num_t kDisplayPinMosi = GPIO_NUM_3; // display SDA
constexpr gpio_num_t kDisplayPinSclk = GPIO_NUM_4; // display SCL
-constexpr int kDisplayWidth = 240;
-constexpr int kDisplayHeight = 240;
+constexpr int kDisplayWidth = 360;
+constexpr int kDisplayHeight = 360;
constexpr uint32_t kDisplaySpiWriteHz = 40000000;
-// GC9A01 modules often need invert + BGR for correct black/green output
-constexpr bool kDisplayInvert = true;
-constexpr bool kDisplayRgbOrder = true;
+// GC9B72 display configuration
+constexpr bool kDisplayInvert = false;
+constexpr bool kDisplayRgbOrder = false;
// --- Radar center defaults (overridden via WiFi setup portal) ---
constexpr double kDefaultRadarLat = 52.3676;
diff --git a/include/hardware/encoder.h b/include/hardware/encoder.h
new file mode 100644
index 000000000..2e111712c
--- /dev/null
+++ b/include/hardware/encoder.h
@@ -0,0 +1,23 @@
+#pragma once
+
+namespace hardware::encoder {
+
+enum class Rotation {
+ NONE = 0,
+ CW, // Clockwise (doprava / zoom out)
+ CCW // Counter-clockwise (doleva / zoom in)
+};
+
+enum class ButtonAction {
+ NONE = 0,
+ CLICK,
+ DOUBLE_CLICK,
+ LONG_PRESS
+};
+
+void init();
+Rotation pollRotation();
+ButtonAction pollButtonAction();
+bool pollButtonClick();
+
+} // namespace hardware::encoder
diff --git a/include/hardware/lgfx_config.hpp b/include/hardware/lgfx_config.hpp
index 4f1484b6a..fe072e5f3 100644
--- a/include/hardware/lgfx_config.hpp
+++ b/include/hardware/lgfx_config.hpp
@@ -5,10 +5,10 @@
#include "config.h"
-/** LovyanGFX device: GC9A01 on SPI. Pin values come from config.h. */
+/** LovyanGFX device: GC9B72 on SPI. Pin values come from config.h. */
class LGFX : public lgfx::LGFX_Device {
lgfx::Bus_SPI _bus;
- lgfx::Panel_GC9A01 _panel;
+ lgfx::Panel_GC9B72 _panel;
public:
LGFX() {
@@ -27,6 +27,13 @@ class LGFX : public lgfx::LGFX_Device {
auto cfg = _panel.config();
cfg.pin_cs = static_cast(config::kDisplayPinCs);
cfg.pin_rst = static_cast(config::kDisplayPinRst);
+ cfg.panel_width = config::kDisplayWidth;
+ cfg.panel_height = config::kDisplayHeight;
+ cfg.memory_width = config::kDisplayWidth;
+ cfg.memory_height = config::kDisplayHeight;
+ cfg.offset_x = 0;
+ cfg.offset_y = 0;
+ cfg.offset_rotation = 0;
cfg.invert = config::kDisplayInvert;
cfg.rgb_order = config::kDisplayRgbOrder;
_panel.config(cfg);
@@ -34,3 +41,4 @@ class LGFX : public lgfx::LGFX_Device {
setPanel(&_panel);
}
};
+
diff --git a/include/services/adsb_client.h b/include/services/adsb_client.h
index 2b5dd787c..c72f87afe 100644
--- a/include/services/adsb_client.h
+++ b/include/services/adsb_client.h
@@ -13,6 +13,14 @@ struct Aircraft {
char callsign[9];
char type[5];
char alt[12];
+ char hex[7]; // ICAO hex code (e.g. "49D2E1")
+ char reg[12]; // Tail registration (e.g. "OK-HEU")
+ char desc[24]; // Model description / name
+ char squawk[6]; // Squawk code (e.g. "7000")
+ float baro_rate_fpm; // Vertical climb/descent rate in ft/min
+ bool on_ground; // True if on ground
+ float dist_km; // Distance from radar center in km
+ float bearing_deg; // Bearing from radar center (0-360 deg)
};
constexpr size_t kMaxAircraft = 64;
diff --git a/include/services/time_service.h b/include/services/time_service.h
new file mode 100644
index 000000000..45dd784ba
--- /dev/null
+++ b/include/services/time_service.h
@@ -0,0 +1,11 @@
+#pragma once
+
+#include
+
+namespace services::time {
+
+void init();
+bool isSynced();
+bool getLocalTimeInfo(struct tm* info);
+
+} // namespace services::time
diff --git a/include/ui/aircraft_detail.h b/include/ui/aircraft_detail.h
new file mode 100644
index 000000000..67db8bced
--- /dev/null
+++ b/include/ui/aircraft_detail.h
@@ -0,0 +1,10 @@
+#pragma once
+
+#include "services/adsb_client.h"
+
+namespace ui {
+
+void aircraftDetailInit();
+void aircraftDetailDraw(const services::adsb::Aircraft& ac);
+
+} // namespace ui
diff --git a/include/ui/clock_display.h b/include/ui/clock_display.h
new file mode 100644
index 000000000..c5b57fb31
--- /dev/null
+++ b/include/ui/clock_display.h
@@ -0,0 +1,10 @@
+#pragma once
+
+namespace ui {
+
+void clockDisplayInit();
+void clockDisplayDraw(bool full_redraw = true);
+void clockDisplayNextTheme();
+void clockDisplayPrevTheme();
+
+} // namespace ui
diff --git a/include/ui/radar_display.h b/include/ui/radar_display.h
index 5ab3bb292..f773c1717 100644
--- a/include/ui/radar_display.h
+++ b/include/ui/radar_display.h
@@ -1,5 +1,7 @@
#pragma once
+#include "hardware/display.h"
+
namespace ui {
/** Draw the static sonar/radar grid (black disc, green overlay, labels). */
@@ -8,4 +10,11 @@ void radarDisplayDraw();
/** Redraw aircraft only (blits cached grid; no full-screen clear). */
void radarDisplayRefreshAircraft();
+/** Set selection state for target cycling and highlighted rendering on radar. */
+void radarSetSelection(bool select_mode, int selected_aircraft_idx);
+
+/** Shared offscreen sprite buffer to avoid duplicate heap allocation. */
+lgfx::LGFX_Sprite& sharedFrameSprite();
+bool ensureSharedFrameSprite();
+
} // namespace ui
diff --git a/include/ui/radar_range.h b/include/ui/radar_range.h
index a95c53275..4fadb35b8 100644
--- a/include/ui/radar_range.h
+++ b/include/ui/radar_range.h
@@ -29,6 +29,7 @@ constexpr RangePreset kRangePresets[] = {
{10.0f, 10.0f * kRing3ToOuterKm},
{15.0f, 15.0f * kRing3ToOuterKm},
{25.0f, 25.0f * kRing3ToOuterKm},
+ {50.0f, 50.0f * kRing3ToOuterKm},
};
constexpr size_t kRangePresetCount =
@@ -36,8 +37,10 @@ constexpr size_t kRangePresetCount =
/** Load saved range and distance units from flash. Call once after boot. */
void rangeInit();
-/** Cycle preset and save to flash. */
+/** Cycle preset up (CW) and save to flash. */
void rangeNext();
+/** Cycle preset down (CCW) and save to flash. */
+void rangePrev();
const RangePreset& rangeCurrent();
uint8_t rangeIndex();
/** ADSB fetch radius (km): scaled to screen edge so beyond-ring dots have data. */
diff --git a/include/ui/radar_theme.h b/include/ui/radar_theme.h
index 2d763a638..7bbdf86e5 100644
--- a/include/ui/radar_theme.h
+++ b/include/ui/radar_theme.h
@@ -4,12 +4,12 @@
namespace ui::radar {
-constexpr int kSize = 240;
+constexpr int kSize = 360;
constexpr int kCenterX = kSize / 2;
constexpr int kCenterY = kSize / 2;
-/** Outermost grid ring (inside edge labels). */
-constexpr int kGridOuterRadius = 107;
+/** Outermost grid ring (inside edge labels) for 360x360 (leaves 20px bezel). */
+constexpr int kGridOuterRadius = 160;
/** N: offset from top edge (top_center, negative = up). */
constexpr int kCardinalNorthOffsetY = -1;
@@ -17,28 +17,28 @@ constexpr int kCardinalNorthOffsetY = -1;
constexpr int kCardinalSouthOffsetY = 3;
/** Gap between scale label right edge and outer ring on the east spoke (px). */
-constexpr int kScaleGapFromOuterRing = 6;
+constexpr int kScaleGapFromOuterRing = 8;
/** Target cap height (px) for N/S/E/W. */
-constexpr int kCardinalLabelHeightPx = 14;
+constexpr int kCardinalLabelHeightPx = 18;
/** Scale label is this many px shorter than cardinals. */
-constexpr int kScaleBelowCardinalPx = 3;
+constexpr int kScaleBelowCardinalPx = 4;
constexpr int kRingCount = 4;
/** Shared grid stroke: drawWideLine half-width (~2 px total); rings use the same px count. */
constexpr float kGridStrokeHalfWidth = 1.0f;
-constexpr int kCenterDotRadius = 2;
+constexpr int kCenterDotRadius = 3;
-/** Filled aircraft symbol (nose triangle). */
-constexpr int kAircraftNoseLenPx = 8;
-constexpr int kAircraftTailLenPx = 3;
-constexpr int kAircraftTailHalfPx = 4;
+/** Filled aircraft symbol (nose triangle). Scaled for 360x360. */
+constexpr int kAircraftNoseLenPx = 11;
+constexpr int kAircraftTailLenPx = 4;
+constexpr int kAircraftTailHalfPx = 6;
/** Track vector: ground distance covered in this many seconds at current gs. */
constexpr float kAircraftTrackHorizonSec = 60.0f;
/** Minimum visible vector when gs > 0 (px). */
-constexpr int kAircraftSpeedLineMinPx = 2;
+constexpr int kAircraftSpeedLineMinPx = 3;
/** Track line length uses this outer_km, not the active range preset. */
constexpr float kAircraftTrackRefOuterKm = 13.3f;
/** Shorter than full 60 s horizon at ref scale; ×1.5 length boost applied. */
@@ -46,21 +46,21 @@ constexpr float kAircraftTrackLengthScale = 1.5f / 5.0f;
/** drawWideLine half-width for speed vectors (~2 px total). */
constexpr float kAircraftTrackLineHalfWidth = 1.0f;
-constexpr float kRunwayLineWidthPx = 2.0f;
+constexpr float kRunwayLineWidthPx = 2.5f;
constexpr float kRunwayLineHalfWidth = kRunwayLineWidthPx * 0.5f;
constexpr int kRunwayLabelHeightPx = kCardinalLabelHeightPx;
-constexpr int kRunwayLabelGapPx = 3;
+constexpr int kRunwayLabelGapPx = 4;
/** Gap from triangle edge to tag block (px). */
-constexpr int kAircraftLabelGapPx = 1;
+constexpr int kAircraftLabelGapPx = 2;
/** Keep symbol centroid inside outer ring by at least this inset (px). */
constexpr int kAircraftInsideRingInsetPx =
kAircraftNoseLenPx + kAircraftTailHalfPx + 1;
/** Beyond-ring traffic: bearing cues on screen rim (correct direction, fixed radius). */
-constexpr int kBeyondRingDotRadiusPx = 4;
-constexpr int kBeyondRingScreenMarginPx = 2;
+constexpr int kBeyondRingDotRadiusPx = 5;
+constexpr int kBeyondRingScreenMarginPx = 3;
/** Target cap height (px) for aircraft tags (bold, slightly above scale label). */
-constexpr int kAircraftTagLabelHeightPx = 13;
+constexpr int kAircraftTagLabelHeightPx = 16;
/** RGB565 palette targets (applied in initPalette). */
constexpr uint8_t kBgR = 4;
diff --git a/platformio.ini b/platformio.ini
index c81ce535f..06cac7352 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -1,4 +1,4 @@
-; ESP32-C3 Super Mini + 1.28" round GC9A01 (240×240)
+; ESP32-C3 Super Mini + 2.1" round GC9B72 (360×360)
; Board in Arduino IDE: "ESP32C3 Dev Module" or "MakerGO ESP32 C3 SuperMini"
[env:supermini]
@@ -6,6 +6,8 @@ platform = espressif32@6.5.0
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
+upload_port = COM4
+monitor_port = COM4
board_build.partitions = partitions/plane_radar.csv
extra_scripts = post:scripts/merge_firmware.py
@@ -21,6 +23,6 @@ build_flags =
-DWM_MDNS
lib_deps =
- lovyan03/LovyanGFX@^1.2.7
+ https://github.com/lovyan03/LovyanGFX.git
tzapu/WiFiManager@^2.0.17
bblanchon/ArduinoJson@^7.4.2
diff --git a/src/hardware/encoder.cpp b/src/hardware/encoder.cpp
new file mode 100644
index 000000000..a397fd930
--- /dev/null
+++ b/src/hardware/encoder.cpp
@@ -0,0 +1,170 @@
+#include "hardware/encoder.h"
+
+#include
+#include "config.h"
+
+namespace hardware::encoder {
+
+namespace {
+
+portMUX_TYPE s_encoder_mux = portMUX_INITIALIZER_UNLOCKED;
+
+// Table-based quadrature decoder
+const int8_t kEncoderTable[16] = {
+ 0, // 00 -> 00
+ -1, // 00 -> 01
+ 1, // 00 -> 10
+ 0, // 00 -> 11
+ 1, // 01 -> 00
+ 0, // 01 -> 01
+ 0, // 01 -> 10
+ -1, // 01 -> 11
+ -1, // 10 -> 00
+ 0, // 10 -> 01
+ 0, // 10 -> 10
+ 1, // 10 -> 11
+ 0, // 11 -> 00
+ 1, // 11 -> 01
+ -1, // 11 -> 10
+ 0 // 11 -> 11
+};
+
+volatile uint8_t s_prev_state = 0x03;
+volatile int8_t s_accum = 0;
+volatile int16_t s_rot_steps = 0;
+volatile unsigned long s_last_rot_ms = 0;
+
+volatile bool s_btn_is_down = false;
+volatile unsigned long s_btn_down_ms = 0;
+volatile uint8_t s_tap_count = 0;
+volatile unsigned long s_last_tap_ms = 0;
+volatile bool s_long_press_reported = false;
+
+void IRAM_ATTR isrEncoder() {
+ const uint8_t clk = digitalRead(config::kEncoderPinClk);
+ const uint8_t dt = digitalRead(config::kEncoderPinDt);
+ const uint8_t curr = (clk << 1) | dt;
+ const unsigned long now = millis();
+
+ portENTER_CRITICAL_ISR(&s_encoder_mux);
+ s_last_rot_ms = now; // Mark rotation activity to suppress cross-talk misclicks!
+
+ if (curr != (s_prev_state & 0x03)) {
+ const uint8_t index = ((s_prev_state & 0x03) << 2) | curr;
+ const int8_t step = kEncoderTable[index];
+ s_accum += step;
+ s_prev_state = curr;
+
+ if (curr == 0x03) {
+ if (s_accum >= 2) {
+ s_rot_steps++;
+ } else if (s_accum <= -2) {
+ s_rot_steps--;
+ }
+ s_accum = 0;
+ } else if (s_accum >= 4) {
+ s_rot_steps++;
+ s_accum = 0;
+ } else if (s_accum <= -4) {
+ s_rot_steps--;
+ s_accum = 0;
+ }
+ }
+ portEXIT_CRITICAL_ISR(&s_encoder_mux);
+}
+
+void IRAM_ATTR isrButton() {
+ const bool down = (digitalRead(config::kEncoderPinSw) == LOW);
+ const unsigned long now = millis();
+
+ portENTER_CRITICAL_ISR(&s_encoder_mux);
+ // Completely reject button triggers while rotating (cross-talk suppression)
+ if (now - s_last_rot_ms < 200) {
+ s_btn_is_down = false;
+ portEXIT_CRITICAL_ISR(&s_encoder_mux);
+ return;
+ }
+
+ if (down) {
+ s_btn_is_down = true;
+ s_btn_down_ms = now;
+ s_long_press_reported = false;
+ } else if (s_btn_is_down) {
+ const unsigned long held = now - s_btn_down_ms;
+ // Human tap must be sustained for at least 35ms (filtering bounce) and less than 600ms
+ if (held >= 35 && held < 600) {
+ s_tap_count++;
+ s_last_tap_ms = now;
+ }
+ s_btn_is_down = false;
+ }
+ portEXIT_CRITICAL_ISR(&s_encoder_mux);
+}
+
+} // namespace
+
+void init() {
+ // Virtual GND pin: GPIO 8 is held LOW (0V)
+ pinMode(config::kEncoderPinGnd, OUTPUT);
+ digitalWrite(config::kEncoderPinGnd, LOW);
+
+ // Virtual VCC pin: GPIO 2 is held HIGH (3.3V)
+ pinMode(config::kEncoderPinVcc, OUTPUT);
+ digitalWrite(config::kEncoderPinVcc, HIGH);
+
+ pinMode(config::kEncoderPinClk, INPUT_PULLUP);
+ pinMode(config::kEncoderPinDt, INPUT_PULLUP);
+ pinMode(config::kEncoderPinSw, INPUT_PULLUP);
+
+ uint8_t clk = digitalRead(config::kEncoderPinClk);
+ uint8_t dt = digitalRead(config::kEncoderPinDt);
+ s_prev_state = (clk << 1) | dt;
+
+ attachInterrupt(digitalPinToInterrupt(config::kEncoderPinClk), isrEncoder, CHANGE);
+ attachInterrupt(digitalPinToInterrupt(config::kEncoderPinDt), isrEncoder, CHANGE);
+ attachInterrupt(digitalPinToInterrupt(config::kEncoderPinSw), isrButton, CHANGE);
+}
+
+Rotation pollRotation() {
+ Rotation result = Rotation::NONE;
+ portENTER_CRITICAL(&s_encoder_mux);
+ if (s_rot_steps > 0) {
+ result = Rotation::CW;
+ s_rot_steps--;
+ } else if (s_rot_steps < 0) {
+ result = Rotation::CCW;
+ s_rot_steps++;
+ }
+ portEXIT_CRITICAL(&s_encoder_mux);
+ return result;
+}
+
+ButtonAction pollButtonAction() {
+ ButtonAction act = ButtonAction::NONE;
+ portENTER_CRITICAL(&s_encoder_mux);
+ const unsigned long now = millis();
+
+ // If turning knob, discard any noise pulses on button
+ if (now - s_last_rot_ms < 200) {
+ s_tap_count = 0;
+ s_btn_is_down = false;
+ } else if (s_btn_is_down && !s_long_press_reported && (now - s_btn_down_ms >= 700)) {
+ s_long_press_reported = true;
+ s_tap_count = 0;
+ act = ButtonAction::LONG_PRESS;
+ } else if (s_tap_count >= 2) {
+ s_tap_count = 0;
+ act = ButtonAction::DOUBLE_CLICK;
+ } else if (s_tap_count == 1 && (now - s_last_tap_ms >= 280)) {
+ s_tap_count = 0;
+ act = ButtonAction::CLICK;
+ }
+ portEXIT_CRITICAL(&s_encoder_mux);
+ return act;
+}
+
+bool pollButtonClick() {
+ return pollButtonAction() == ButtonAction::CLICK;
+}
+
+} // namespace hardware::encoder
diff --git a/src/main.cpp b/src/main.cpp
index 797aecc09..14854c4ff 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,5 +1,5 @@
/**
- * Plane Radar — WiFi setup, then radar UI on the round GC9A01 display.
+ * Plane Radar — WiFi setup, radar UI, target telemetry and analog clock on 360x360 round display.
*/
#include
@@ -7,57 +7,243 @@
#include "config.h"
#include "hardware/display.h"
+#include "hardware/encoder.h"
#include "services/adsb_client.h"
#include "services/radar_location.h"
+#include "services/time_service.h"
#include "services/wifi_setup.h"
+#include "ui/aircraft_detail.h"
+#include "ui/clock_display.h"
#include "ui/radar_display.h"
#include "ui/radar_range.h"
#include "ui/status_screens.h"
namespace {
-bool g_radar_visible = false;
+enum class AppMode {
+ RADAR,
+ CLOCK,
+ DETAIL
+};
+
+enum class RadarControlMode {
+ ZOOM,
+ SELECT
+};
+
+AppMode g_app_mode = AppMode::RADAR;
+RadarControlMode g_radar_ctrl = RadarControlMode::ZOOM;
+int g_selected_aircraft_idx = -1;
+
+bool g_screen_ready = false;
unsigned long g_wifi_down_since = 0;
unsigned long g_last_reconnect_ms = 0;
unsigned long g_last_adsb_fetch_ms = 0;
+unsigned long g_last_select_action_ms = 0;
+time_t g_last_clock_sec = 0;
-void showRadarIfConnected() {
+void showCurrentScreen() {
if (WiFi.status() != WL_CONNECTED) {
- g_radar_visible = false;
+ g_screen_ready = false;
return;
}
- ui::radarDisplayDraw();
- g_radar_visible = true;
+
+ if (g_app_mode == AppMode::RADAR) {
+ ui::radarSetSelection(g_radar_ctrl == RadarControlMode::SELECT,
+ g_selected_aircraft_idx);
+ ui::radarDisplayDraw();
+ } else if (g_app_mode == AppMode::CLOCK) {
+ ui::clockDisplayDraw(true);
+ } else if (g_app_mode == AppMode::DETAIL) {
+ const size_t n = services::adsb::aircraftCount();
+ if (n > 0 && g_selected_aircraft_idx >= 0 &&
+ g_selected_aircraft_idx < static_cast(n)) {
+ ui::aircraftDetailDraw(
+ services::adsb::aircraftList()[g_selected_aircraft_idx]);
+ } else {
+ g_app_mode = AppMode::RADAR;
+ g_radar_ctrl = RadarControlMode::ZOOM;
+ ui::radarSetSelection(false, -1);
+ ui::radarDisplayDraw();
+ }
+ }
+ g_screen_ready = true;
}
-void onRangeTap() {
- ui::radar::rangeNext();
+void onRangeStep(bool next) {
+ if (next) {
+ ui::radar::rangeNext();
+ } else {
+ ui::radar::rangePrev();
+ }
char range_label[12];
ui::radar::formatCurrentRing3Label(range_label, sizeof(range_label));
Serial.printf("Range: %s (outer ~%.0f km)\n", range_label,
ui::radar::rangeCurrent().outer_km);
- if (g_radar_visible && WiFi.status() == WL_CONNECTED) {
- ui::radarDisplayDraw();
+ if (g_screen_ready && g_app_mode == AppMode::RADAR &&
+ WiFi.status() == WL_CONNECTED) {
+ showCurrentScreen();
}
}
void handleBootButton() {
bootButtonPollLongPress();
if (bootButtonConsumeTap()) {
- onRangeTap();
+ if (g_app_mode == AppMode::RADAR &&
+ g_radar_ctrl == RadarControlMode::SELECT) {
+ g_radar_ctrl = RadarControlMode::ZOOM;
+ g_selected_aircraft_idx = -1;
+ showCurrentScreen();
+ } else {
+ onRangeStep(true);
+ }
+ }
+}
+
+void handleEncoder() {
+ using hardware::encoder::ButtonAction;
+ using hardware::encoder::Rotation;
+
+ const Rotation rot = hardware::encoder::pollRotation();
+ if (rot != Rotation::NONE) {
+ const bool is_cw = (rot == Rotation::CW);
+ const size_t n = services::adsb::aircraftCount();
+
+ if (g_app_mode == AppMode::RADAR) {
+ if (g_radar_ctrl == RadarControlMode::ZOOM) {
+ onRangeStep(is_cw);
+ } else {
+ // In SELECT mode: cycle through aircraft 0 .. n-1, and index n (-1) is back to zoom
+ g_last_select_action_ms = millis();
+ if (n > 0) {
+ int current = (g_selected_aircraft_idx < 0)
+ ? static_cast(n)
+ : g_selected_aircraft_idx;
+ if (is_cw) {
+ current = (current + 1) % (n + 1);
+ } else {
+ current = (current + n) % (n + 1);
+ }
+ g_selected_aircraft_idx =
+ (current == static_cast(n)) ? -1 : current;
+ showCurrentScreen();
+ }
+ }
+ } else if (g_app_mode == AppMode::DETAIL) {
+ // In DETAIL mode: knob directly flips to next/previous aircraft!
+ if (n > 0) {
+ if (is_cw) {
+ g_selected_aircraft_idx = (g_selected_aircraft_idx + 1) % n;
+ } else {
+ g_selected_aircraft_idx = (g_selected_aircraft_idx + n - 1) % n;
+ }
+ showCurrentScreen();
+ }
+ } else if (g_app_mode == AppMode::CLOCK) {
+ if (is_cw) {
+ ui::clockDisplayNextTheme();
+ } else {
+ ui::clockDisplayPrevTheme();
+ }
+ ui::clockDisplayDraw(false);
+ }
+ }
+
+ const ButtonAction act = hardware::encoder::pollButtonAction();
+ if (act == ButtonAction::DOUBLE_CLICK) {
+ // Double click toggles between RADAR and CLOCK from any screen!
+ if (g_app_mode == AppMode::CLOCK) {
+ g_app_mode = AppMode::RADAR;
+ g_last_adsb_fetch_ms = 0; // Immediately fetch fresh aircraft
+ Serial.println("Mode: RADAR (double click)");
+ } else {
+ g_app_mode = AppMode::CLOCK;
+ Serial.println("Mode: CLOCK (double click)");
+ }
+ showCurrentScreen();
+ } else if (act == ButtonAction::CLICK) {
+ const size_t n = services::adsb::aircraftCount();
+
+ if (g_app_mode == AppMode::CLOCK) {
+ ui::clockDisplayNextTheme();
+ ui::clockDisplayDraw(false);
+ } else if (g_app_mode == AppMode::DETAIL) {
+ // Click in detail returns back to radar (in SELECT mode)
+ g_app_mode = AppMode::RADAR;
+ g_radar_ctrl = RadarControlMode::SELECT;
+ g_last_select_action_ms = millis();
+ Serial.println("Detail -> Radar");
+ showCurrentScreen();
+ } else if (g_app_mode == AppMode::RADAR) {
+ if (g_radar_ctrl == RadarControlMode::ZOOM) {
+ // Single click toggles to SELECT mode (starting at "STISK = ZPET" so another click returns to ZOOM)
+ g_radar_ctrl = RadarControlMode::SELECT;
+ g_selected_aircraft_idx = -1;
+ g_last_select_action_ms = millis();
+ Serial.println("Radar: ZOOM -> SELECT mode");
+ showCurrentScreen();
+ } else {
+ // In SELECT mode:
+ g_last_select_action_ms = millis();
+ if (g_selected_aircraft_idx >= 0 &&
+ g_selected_aircraft_idx < static_cast(n)) {
+ // Open DETAIL of selected aircraft!
+ g_app_mode = AppMode::DETAIL;
+ Serial.printf("Radar -> DETAIL (%s)\n",
+ services::adsb::aircraftList()[g_selected_aircraft_idx]
+ .callsign);
+ showCurrentScreen();
+ } else {
+ // Click while on [ VYBER LETADLA (STISK=ZPET) ] -> returns to ZOOM!
+ g_radar_ctrl = RadarControlMode::ZOOM;
+ g_selected_aircraft_idx = -1;
+ Serial.println("Radar: SELECT -> ZOOM mode");
+ showCurrentScreen();
+ }
+ }
+ }
+ } else if (act == ButtonAction::LONG_PRESS) {
+ // Long press immediately returns to ZOOM mode from anywhere
+ if (g_app_mode == AppMode::DETAIL) {
+ g_app_mode = AppMode::RADAR;
+ g_radar_ctrl = RadarControlMode::ZOOM;
+ g_selected_aircraft_idx = -1;
+ showCurrentScreen();
+ } else if (g_app_mode == AppMode::RADAR &&
+ g_radar_ctrl == RadarControlMode::SELECT) {
+ g_radar_ctrl = RadarControlMode::ZOOM;
+ g_selected_aircraft_idx = -1;
+ showCurrentScreen();
+ }
}
}
+void appPoll() {
+ handleEncoder();
+ handleBootButton();
+ wifiLoop();
+}
+
void fetchAndDrawAircraft() {
const float fetch_km = ui::radar::fetchRadiusKm();
if (!services::adsb::fetchUpdate(services::location::lat(),
services::location::lon(), fetch_km)) {
handleBootButton();
+ handleEncoder();
return;
}
- ui::radarDisplayRefreshAircraft();
+
+ if (g_app_mode == AppMode::RADAR) {
+ ui::radarSetSelection(g_radar_ctrl == RadarControlMode::SELECT,
+ g_selected_aircraft_idx);
+ ui::radarDisplayRefreshAircraft();
+ } else if (g_app_mode == AppMode::DETAIL) {
+ showCurrentScreen();
+ }
+
handleBootButton();
+ handleEncoder();
}
} // namespace
@@ -66,30 +252,35 @@ void setup() {
Serial.begin(115200);
delay(500);
Serial.println();
- Serial.println("Plane Radar");
+ Serial.println("Plane Radar, Target Telemetry & Aviator Chronometer");
bootButtonInit();
+ hardware::encoder::init();
displayInit();
+ ui::clockDisplayInit();
+ ui::aircraftDetailInit();
+
if (wifiShowsSetupScreenOnBoot()) {
statusScreenPortal();
}
services::location::init();
ui::radar::rangeInit();
- services::adsb::setPollFn(wifiLoop);
+ services::adsb::setPollFn(appPoll);
if (wifiSetupConnect()) {
- showRadarIfConnected();
+ showCurrentScreen();
}
}
void loop() {
handleBootButton();
+ handleEncoder();
wifiLoop();
if (WiFi.status() != WL_CONNECTED) {
- if (g_radar_visible) {
+ if (g_screen_ready) {
Serial.println("WiFi lost — will reconnect");
- g_radar_visible = false;
+ g_screen_ready = false;
}
if (g_wifi_down_since == 0) {
@@ -102,16 +293,38 @@ void loop() {
g_last_reconnect_ms = millis();
if (wifiReconnect()) {
g_wifi_down_since = 0;
- showRadarIfConnected();
+ showCurrentScreen();
}
}
} else {
g_wifi_down_since = 0;
- if (!g_radar_visible) {
- showRadarIfConnected();
- } else if (millis() - g_last_adsb_fetch_ms >= config::kAdsbFetchIntervalMs) {
- g_last_adsb_fetch_ms = millis();
- fetchAndDrawAircraft();
+ if (!g_screen_ready) {
+ showCurrentScreen();
+ } else {
+ if (g_app_mode == AppMode::RADAR || g_app_mode == AppMode::DETAIL) {
+ if (millis() - g_last_adsb_fetch_ms >= config::kAdsbFetchIntervalMs) {
+ g_last_adsb_fetch_ms = millis();
+ fetchAndDrawAircraft();
+ }
+ } else if (g_app_mode == AppMode::CLOCK) {
+ // Redraw analog clock once per second for authentic chronograph tick
+ time_t now_sec = time(nullptr);
+ if (now_sec != g_last_clock_sec) {
+ g_last_clock_sec = now_sec;
+ ui::clockDisplayDraw(false);
+ }
+ }
+
+ // Auto-timeout for SELECT mode: after 10 seconds of inactivity, return to ZOOM mode
+ if (g_app_mode == AppMode::RADAR &&
+ g_radar_ctrl == RadarControlMode::SELECT) {
+ if (millis() - g_last_select_action_ms >= 10000) {
+ Serial.println("SELECT mode timeout (10s) -> ZOOM mode");
+ g_radar_ctrl = RadarControlMode::ZOOM;
+ g_selected_aircraft_idx = -1;
+ showCurrentScreen();
+ }
+ }
}
}
diff --git a/src/services/adsb_client.cpp b/src/services/adsb_client.cpp
index dcbbfcbea..db33fdd88 100644
--- a/src/services/adsb_client.cpp
+++ b/src/services/adsb_client.cpp
@@ -194,6 +194,17 @@ void fillTagFields(Aircraft* ac, const JsonObject& plane) {
}
copyJsonStringTrimmed(plane, "t", ac->type, sizeof(ac->type));
+ copyJsonStringTrimmed(plane, "hex", ac->hex, sizeof(ac->hex));
+ copyJsonStringTrimmed(plane, "r", ac->reg, sizeof(ac->reg));
+ copyJsonStringTrimmed(plane, "desc", ac->desc, sizeof(ac->desc));
+ copyJsonStringTrimmed(plane, "squawk", ac->squawk, sizeof(ac->squawk));
+
+ ac->on_ground = isOnGround(plane);
+ ac->baro_rate_fpm = 0.0f;
+ if (!readJsonFloat(plane, "baro_rate", &ac->baro_rate_fpm)) {
+ readJsonFloat(plane, "geom_rate", &ac->baro_rate_fpm);
+ }
+
formatAltitudeTag(plane, ac->alt, sizeof(ac->alt));
}
@@ -254,6 +265,10 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) {
return true;
}
+ constexpr float kKmPerDeg = 111.0f;
+ constexpr float kDegToRad = 3.14159265f / 180.0f;
+ const float center_lat_rad = static_cast(center_lat) * kDegToRad;
+
size_t n = 0;
for (JsonObject plane : ac) {
if (n >= kMaxAircraft) {
@@ -272,11 +287,34 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) {
s_aircraft[n].track_deg = pickTrackHeading(plane);
s_aircraft[n].gs_knots = pickGroundSpeed(plane);
fillTagFields(&s_aircraft[n], plane);
+
+ // Calculate distance and bearing from radar center
+ const float dx = static_cast(s_aircraft[n].lon - center_lon) *
+ kKmPerDeg * cosf(center_lat_rad);
+ const float dy = static_cast(s_aircraft[n].lat - center_lat) * kKmPerDeg;
+ s_aircraft[n].dist_km = sqrtf(dx * dx + dy * dy);
+ float brg = atan2f(dx, dy) * 57.2957795f;
+ if (brg < 0.0f) {
+ brg += 360.0f;
+ }
+ s_aircraft[n].bearing_deg = brg;
+
++n;
}
+ // Sort aircraft closest first
+ for (size_t i = 1; i < n; ++i) {
+ const Aircraft key = s_aircraft[i];
+ size_t j = i;
+ while (j > 0 && s_aircraft[j - 1].dist_km > key.dist_km) {
+ s_aircraft[j] = s_aircraft[j - 1];
+ --j;
+ }
+ s_aircraft[j] = key;
+ }
+
s_aircraft_count = n;
- Serial.printf("adsb: %u aircraft\n", static_cast(n));
+ Serial.printf("adsb: %u aircraft (sorted closest first)\n", static_cast(n));
return true;
}
diff --git a/src/services/time_service.cpp b/src/services/time_service.cpp
new file mode 100644
index 000000000..8c077f3c8
--- /dev/null
+++ b/src/services/time_service.cpp
@@ -0,0 +1,45 @@
+#include "services/time_service.h"
+
+#include
+#include
+#include "config.h"
+
+namespace services::time {
+
+namespace {
+
+bool s_initialized = false;
+
+void timeSyncCallback(struct timeval* tv) {
+ Serial.println("time: NTP sync completed");
+}
+
+} // namespace
+
+void init() {
+ if (s_initialized) {
+ return;
+ }
+ sntp_set_time_sync_notification_cb(timeSyncCallback);
+ configTzTime(config::kNtpTimezone, config::kNtpServer1, config::kNtpServer2);
+ s_initialized = true;
+ Serial.println("time: NTP service initialized");
+}
+
+bool isSynced() {
+ time_t now = 0;
+ ::time(&now);
+ return now > 1704067200; // Past 2024
+}
+
+bool getLocalTimeInfo(struct tm* info) {
+ time_t now = 0;
+ ::time(&now);
+ if (now < 1704067200) {
+ return false;
+ }
+ localtime_r(&now, info);
+ return true;
+}
+
+} // namespace services::time
diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp
index c3d5ae674..2e37a73ba 100644
--- a/src/services/wifi_setup.cpp
+++ b/src/services/wifi_setup.cpp
@@ -261,7 +261,7 @@ void stopLanWebPortal() {
void prepareSta() {
WiFi.setTxPower(WIFI_POWER_8_5dBm);
WiFi.mode(WIFI_STA);
- WiFi.setSleep(WIFI_PS_NONE);
+ WiFi.setSleep(WIFI_PS_MIN_MODEM);
WiFi.setAutoReconnect(true);
}
diff --git a/src/ui/aircraft_detail.cpp b/src/ui/aircraft_detail.cpp
new file mode 100644
index 000000000..381f5c6ba
--- /dev/null
+++ b/src/ui/aircraft_detail.cpp
@@ -0,0 +1,278 @@
+#include "ui/aircraft_detail.h"
+
+#include
+#include
+#include
+
+#include "config.h"
+#include "hardware/display.h"
+#include "ui/radar_display.h"
+
+namespace ui {
+
+namespace {
+
+constexpr int kCenterX = config::kDisplayWidth / 2;
+constexpr int kCenterY = config::kDisplayHeight / 2;
+
+// Aviation color palette (Glass Cockpit EFIS style)
+constexpr uint16_t kColBg = 0x0000; // Black
+constexpr uint16_t kColCyan = 0x07FF; // Crisp cyan
+constexpr uint16_t kColGreen = 0x07E0; // Radar green
+constexpr uint16_t kColAmber = 0xFDA0; // Warm cockpit amber
+constexpr uint16_t kColYellow = 0xFFE0; // Bright neon yellow
+constexpr uint16_t kColRed = 0xF800; // Warning red
+constexpr uint16_t kColWhite = 0xFFFF; // Pure white
+constexpr uint16_t kColDim = 0x7BEF; // Slate gray label
+constexpr uint16_t kColPanelBg = 0x0862; // Dark navy/gray panel
+constexpr uint16_t kColPanelBorder = 0x1945; // Subtle border
+
+const char* cardinalName(float deg) {
+ while (deg < 0.0f) deg += 360.0f;
+ while (deg >= 360.0f) deg -= 360.0f;
+ const int val = static_cast((deg + 22.5f) / 45.0f) % 8;
+ switch (val) {
+ case 0: return "S";
+ case 1: return "SV";
+ case 2: return "V";
+ case 3: return "JV";
+ case 4: return "J";
+ case 5: return "JZ";
+ case 6: return "Z";
+ case 7: return "SZ";
+ default: return "S";
+ }
+}
+
+const char* friendlyModelName(const char* type, const char* desc) {
+ if (desc != nullptr && desc[0] != '\0' && strlen(desc) > 3) {
+ return desc;
+ }
+ if (strcmp(type, "B738") == 0) return "Boeing 737-800";
+ if (strcmp(type, "B737") == 0) return "Boeing 737";
+ if (strcmp(type, "B739") == 0) return "Boeing 737-900";
+ if (strcmp(type, "B38M") == 0) return "Boeing 737 MAX 8";
+ if (strcmp(type, "B39M") == 0) return "Boeing 737 MAX 9";
+ if (strcmp(type, "A320") == 0) return "Airbus A320";
+ if (strcmp(type, "A20N") == 0) return "Airbus A320neo";
+ if (strcmp(type, "A321") == 0) return "Airbus A321";
+ if (strcmp(type, "A21N") == 0) return "Airbus A321neo";
+ if (strcmp(type, "A319") == 0) return "Airbus A319";
+ if (strcmp(type, "A332") == 0) return "Airbus A330-200";
+ if (strcmp(type, "A333") == 0) return "Airbus A330-300";
+ if (strcmp(type, "A359") == 0) return "Airbus A350-900";
+ if (strcmp(type, "B77W") == 0) return "Boeing 777-300ER";
+ if (strcmp(type, "B789") == 0) return "Boeing 787-9";
+ if (strcmp(type, "B788") == 0) return "Boeing 787-8";
+ if (strcmp(type, "AT76") == 0) return "ATR 72-600";
+ if (strcmp(type, "DH8D") == 0) return "Dash 8 Q400";
+ if (strcmp(type, "C172") == 0) return "Cessna 172 Skyhawk";
+ if (strcmp(type, "C152") == 0) return "Cessna 152";
+ if (strcmp(type, "E190") == 0) return "Embraer E190";
+ if (strcmp(type, "E195") == 0) return "Embraer E195";
+ if (strcmp(type, "BCS3") == 0) return "Airbus A220-300";
+ if (strcmp(type, "GLID") == 0) return "Větroň (Glider)";
+ if (strcmp(type, "EC35") == 0) return "Eurocopter EC135";
+ if (strcmp(type, "H145") == 0) return "Airbus H145";
+ return type[0] != '\0' ? type : "Neznámý typ";
+}
+
+} // namespace
+
+void aircraftDetailInit() {
+ // Uses sharedFrameSprite() from radar_display
+}
+
+void aircraftDetailDraw(const services::adsb::Aircraft& ac) {
+ if (!ensureSharedFrameSprite()) {
+ return;
+ }
+ lgfx::LGFX_Sprite& sprite = sharedFrameSprite();
+
+ sprite.fillScreen(kColBg);
+
+ // Outer bezel ring with cardinal ticks
+ sprite.drawCircle(kCenterX, kCenterY, 175, kColPanelBorder);
+ sprite.drawFastVLine(kCenterX, 5, 8, kColGreen);
+ sprite.drawFastVLine(kCenterX, 347, 8, kColGreen);
+ sprite.drawFastHLine(5, kCenterY, 8, kColGreen);
+ sprite.drawFastHLine(347, kCenterY, 8, kColGreen);
+
+ // Top header badge
+ sprite.setTextDatum(textdatum_t::top_center);
+ sprite.setTextColor(kColCyan, kColBg);
+ sprite.setFont(&fonts::Font0);
+ sprite.drawString("● AIRCRAFT TELEMETRY ●", kCenterX, 22);
+
+ // Callsign (Large Bold)
+ sprite.setTextColor(kColYellow, kColBg);
+ sprite.setFont(&fonts::FreeSansBold12pt7b);
+ char cs[16];
+ if (ac.callsign[0] != '\0') {
+ snprintf(cs, sizeof(cs), "%s", ac.callsign);
+ } else {
+ snprintf(cs, sizeof(cs), "HEX:%s", ac.hex[0] != '\0' ? ac.hex : "???");
+ }
+ sprite.drawString(cs, kCenterX, 42);
+
+ // Registration & Hex transponder code
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColAmber, kColBg);
+ char sub_ident[48];
+ snprintf(sub_ident, sizeof(sub_ident), "REG: %s | ICAO: %s",
+ ac.reg[0] != '\0' ? ac.reg : "--",
+ ac.hex[0] != '\0' ? ac.hex : "--");
+ sprite.drawString(sub_ident, kCenterX, 72);
+
+ // Divider line
+ sprite.drawFastHLine(40, 88, 280, kColPanelBorder);
+ sprite.drawFastHLine(120, 88, 120, kColCyan);
+
+ // Grid Data Cards
+ // Column 1: X = 48 to 170 | Column 2: X = 190 to 312
+ constexpr int kCol1X = 50;
+ constexpr int kCol2X = 188;
+ constexpr int kBoxW = 122;
+ constexpr int kBoxH = 48;
+
+ // Box 1: TYPE & MODEL (Row 1, Col 1)
+ constexpr int kRow1Y = 96;
+ sprite.fillRoundRect(kCol1X, kRow1Y, kBoxW, kBoxH, 4, kColPanelBg);
+ sprite.drawRoundRect(kCol1X, kRow1Y, kBoxW, kBoxH, 4, kColPanelBorder);
+ sprite.setTextDatum(textdatum_t::top_left);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("TYPE / MODEL", kCol1X + 6, kRow1Y + 4);
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ sprite.setTextColor(kColWhite, kColPanelBg);
+ sprite.drawString(ac.type[0] != '\0' ? ac.type : "--", kCol1X + 6, kRow1Y + 16);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColCyan, kColPanelBg);
+ char model_buf[20];
+ snprintf(model_buf, sizeof(model_buf), "%.16s", friendlyModelName(ac.type, ac.desc));
+ sprite.drawString(model_buf, kCol1X + 6, kRow1Y + 34);
+
+ // Box 2: SQUAWK (Row 1, Col 2)
+ sprite.fillRoundRect(kCol2X, kRow1Y, kBoxW, kBoxH, 4, kColPanelBg);
+ sprite.drawRoundRect(kCol2X, kRow1Y, kBoxW, kBoxH, 4, kColPanelBorder);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("SQUAWK", kCol2X + 6, kRow1Y + 4);
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ const bool is_emergency = (strcmp(ac.squawk, "7700") == 0 || strcmp(ac.squawk, "7600") == 0);
+ sprite.setTextColor(is_emergency ? kColRed : kColAmber, kColPanelBg);
+ sprite.drawString(ac.squawk[0] != '\0' ? ac.squawk : "----", kCol2X + 6, kRow1Y + 16);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(is_emergency ? kColRed : kColDim, kColPanelBg);
+ sprite.drawString(is_emergency ? "! EMERGENCY !" : "Transponder", kCol2X + 6, kRow1Y + 34);
+
+ // Box 3: ALTITUDE (Row 2, Col 1)
+ constexpr int kRow2Y = 152;
+ sprite.fillRoundRect(kCol1X, kRow2Y, kBoxW, kBoxH, 4, kColPanelBg);
+ sprite.drawRoundRect(kCol1X, kRow2Y, kBoxW, kBoxH, 4, kColPanelBorder);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("ALTITUDE", kCol1X + 6, kRow2Y + 4);
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ sprite.setTextColor(kColGreen, kColPanelBg);
+ sprite.drawString(ac.alt[0] != '\0' ? ac.alt : "GND", kCol1X + 6, kRow2Y + 16);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ if (ac.on_ground) {
+ sprite.drawString("Na zemi (GND)", kCol1X + 6, kRow2Y + 34);
+ } else {
+ // Parse feet to meters
+ int feet = 0;
+ sscanf(ac.alt, "%d", &feet);
+ char m_buf[20];
+ snprintf(m_buf, sizeof(m_buf), "~%d m", static_cast(feet * 0.3048f));
+ sprite.drawString(m_buf, kCol1X + 6, kRow2Y + 34);
+ }
+
+ // Box 4: VERTICAL SPEED (Row 2, Col 2)
+ sprite.fillRoundRect(kCol2X, kRow2Y, kBoxW, kBoxH, 4, kColPanelBg);
+ sprite.drawRoundRect(kCol2X, kRow2Y, kBoxW, kBoxH, 4, kColPanelBorder);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("V/S (STOUPÁNÍ)", kCol2X + 6, kRow2Y + 4);
+ char vs_buf[20];
+ const float vs = ac.baro_rate_fpm;
+ uint16_t vs_color = kColDim;
+ const char* vs_sub = "LEVEL (rovný)";
+ if (vs > 100.0f) {
+ snprintf(vs_buf, sizeof(vs_buf), "+%d fpm", static_cast(vs));
+ vs_color = kColGreen;
+ vs_sub = "⬆ Stoupá";
+ } else if (vs < -100.0f) {
+ snprintf(vs_buf, sizeof(vs_buf), "%d fpm", static_cast(vs));
+ vs_color = kColAmber;
+ vs_sub = "⬇ Klesá";
+ } else {
+ snprintf(vs_buf, sizeof(vs_buf), "0 fpm");
+ vs_color = kColWhite;
+ vs_sub = "Rovný let";
+ }
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ sprite.setTextColor(vs_color, kColPanelBg);
+ sprite.drawString(vs_buf, kCol2X + 6, kRow2Y + 16);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(vs_color, kColPanelBg);
+ sprite.drawString(vs_sub, kCol2X + 6, kRow2Y + 34);
+
+ // Box 5: SPEED (Row 3, Col 1)
+ constexpr int kRow3Y = 208;
+ sprite.fillRoundRect(kCol1X, kRow3Y, kBoxW, kBoxH, 4, kColPanelBg);
+ sprite.drawRoundRect(kCol1X, kRow3Y, kBoxW, kBoxH, 4, kColPanelBorder);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("GROUND SPEED", kCol1X + 6, kRow3Y + 4);
+ char gs_buf[20];
+ snprintf(gs_buf, sizeof(gs_buf), "%d kt", static_cast(ac.gs_knots));
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ sprite.setTextColor(kColWhite, kColPanelBg);
+ sprite.drawString(gs_buf, kCol1X + 6, kRow3Y + 16);
+ char kmh_buf[20];
+ snprintf(kmh_buf, sizeof(kmh_buf), "~%d km/h", static_cast(ac.gs_knots * 1.852f));
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString(kmh_buf, kCol1X + 6, kRow3Y + 34);
+
+ // Box 6: HEADING & TRACK (Row 3, Col 2)
+ sprite.fillRoundRect(kCol2X, kRow3Y, kBoxW, kBoxH, 4, kColPanelBg);
+ sprite.drawRoundRect(kCol2X, kRow3Y, kBoxW, kBoxH, 4, kColPanelBorder);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("KURZ / SMĚR", kCol2X + 6, kRow3Y + 4);
+ char hdg_buf[20];
+ snprintf(hdg_buf, sizeof(hdg_buf), "%d° (%s)", static_cast(ac.track_deg),
+ cardinalName(ac.track_deg));
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ sprite.setTextColor(kColWhite, kColPanelBg);
+ sprite.drawString(hdg_buf, kCol2X + 6, kRow3Y + 16);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("Track over ground", kCol2X + 6, kRow3Y + 34);
+
+ // Full-width Radar Vector Panel (Row 4)
+ constexpr int kRow4Y = 272;
+ constexpr int kVecW = 264;
+ constexpr int kVecH = 44;
+ constexpr int kVecX = (config::kDisplayWidth - kVecW) / 2;
+ sprite.fillRoundRect(kVecX, kRow4Y, kVecW, kVecH, 5, kColPanelBg);
+ sprite.drawRoundRect(kVecX, kRow4Y, kVecW, kVecH, 5, kColCyan);
+ sprite.setTextDatum(textdatum_t::middle_center);
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColDim, kColPanelBg);
+ sprite.drawString("POLOHA VŮČI VAŠEMU RADARU", kCenterX, kRow4Y + 12);
+ char vec_str[64];
+ snprintf(vec_str, sizeof(vec_str), "VZDÁLENOST: %.1f km | AZIMUT: %d° (%s)",
+ ac.dist_km, static_cast(ac.bearing_deg), cardinalName(ac.bearing_deg));
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextColor(kColYellow, kColPanelBg);
+ sprite.drawString(vec_str, kCenterX, kRow4Y + 30);
+
+ sprite.pushSprite(0, 0);
+ tft.setTextDatum(textdatum_t::top_left);
+}
+
+} // namespace ui
diff --git a/src/ui/clock_display.cpp b/src/ui/clock_display.cpp
new file mode 100644
index 000000000..94ba8aff1
--- /dev/null
+++ b/src/ui/clock_display.cpp
@@ -0,0 +1,271 @@
+#include "ui/clock_display.h"
+
+#include
+#include
+#include
+
+#include "config.h"
+#include "hardware/display.h"
+#include "services/time_service.h"
+#include "ui/radar_display.h"
+
+namespace ui {
+
+namespace {
+
+constexpr int kCenterX = config::kDisplayWidth / 2;
+constexpr int kCenterY = config::kDisplayHeight / 2;
+constexpr float kDegToRad = 0.0174532925f;
+
+// Clock themes
+struct ClockTheme {
+ const char* name;
+ uint16_t bg_color;
+ uint16_t dial_ring_color;
+ uint16_t minute_tick_color;
+ uint16_t hour_tick_color;
+ uint16_t numeral_color;
+ uint16_t hour_hand_color;
+ uint16_t minute_hand_color;
+ uint16_t second_hand_color;
+ uint16_t date_bg_color;
+ uint16_t date_text_color;
+ uint16_t digital_color;
+};
+
+const ClockTheme kThemes[] = {
+ // 0: Classic Aviation (Crisp White & Radar Orange)
+ {"Aviation", 0x0000, 0x2104, 0x5AEB, 0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF, 0xFD00, 0x18C3, 0xFFFF, 0xFD00},
+ // 1: Radar Emerald (Night Vision)
+ {"Radar NVG", 0x0000, 0x0200, 0x03E0, 0x07E0, 0x07E0, 0x07E0, 0x07E0, 0xFFE0, 0x0200, 0x07E0, 0x07E0},
+ // 2: Cockpit Amber
+ {"Cockpit Amber", 0x0000, 0x2940, 0x8400, 0xFDA0, 0xFDA0, 0xFDA0, 0xFDA0, 0xF800, 0x2940, 0xFDA0, 0xFDA0},
+ // 3: Tactical Cyan
+ {"Tactical Cyan", 0x0000, 0x0188, 0x2314, 0x7FFF, 0x7FFF, 0x7FFF, 0x7FFF, 0xFD00, 0x098A, 0x7FFF, 0x7FFF},
+};
+
+constexpr size_t kThemeCount = sizeof(kThemes) / sizeof(kThemes[0]);
+uint8_t s_theme_idx = 0;
+
+const char* kCzechDays[] = {"NE", "PO", "UT", "ST", "CT", "PA", "SO"};
+const char* kCzechMonths[] = {"LED", "UNO", "BRE", "DUB", "KVE", "CER",
+ "CRC", "SRP", "ZAR", "RIJ", "LIS", "PRO"};
+
+void drawHand(lgfx::LGFX_Sprite& sprite, float angle_deg, float length, float tail_len,
+ float width, uint16_t color) {
+ const float rad = (angle_deg - 90.0f) * kDegToRad;
+ const float cos_a = cosf(rad);
+ const float sin_a = sinf(rad);
+
+ const float norm_rad = angle_deg * kDegToRad;
+ const float cos_n = cosf(norm_rad);
+ const float sin_n = sinf(norm_rad);
+
+ const float half_w = width * 0.5f;
+
+ const float tip_x = kCenterX + length * cos_a;
+ const float tip_y = kCenterY + length * sin_a;
+
+ const float tail_x = kCenterX - tail_len * cos_a;
+ const float tail_y = kCenterY - tail_len * sin_a;
+
+ const float side1_x = kCenterX + half_w * cos_n - (tail_len * 0.4f) * cos_a;
+ const float side1_y = kCenterY + half_w * sin_n - (tail_len * 0.4f) * sin_a;
+
+ const float side2_x = kCenterX - half_w * cos_n - (tail_len * 0.4f) * cos_a;
+ const float side2_y = kCenterY - half_w * sin_n - (tail_len * 0.4f) * sin_a;
+
+ // Draw tapered sword hand
+ sprite.fillTriangle(tip_x, tip_y, side1_x, side1_y, side2_x, side2_y, color);
+ sprite.fillTriangle(tail_x, tail_y, side1_x, side1_y, side2_x, side2_y, color);
+}
+
+void drawSecondHand(lgfx::LGFX_Sprite& sprite, float angle_deg, uint16_t color) {
+ const float rad = (angle_deg - 90.0f) * kDegToRad;
+ const float cos_a = cosf(rad);
+ const float sin_a = sinf(rad);
+
+ const float tip_len = 156.0f;
+ const float tail_len = 32.0f;
+
+ const float tip_x = kCenterX + tip_len * cos_a;
+ const float tip_y = kCenterY + tip_len * sin_a;
+
+ const float tail_x = kCenterX - tail_len * cos_a;
+ const float tail_y = kCenterY - tail_len * sin_a;
+
+ // Main thin needle line
+ sprite.drawWideLine(tail_x, tail_y, tip_x, tip_y, 1.5f, color);
+
+ // Counterweight disc near the tail
+ const float cw_x = kCenterX - 20.0f * cos_a;
+ const float cw_y = kCenterY - 20.0f * sin_a;
+ sprite.fillCircle(cw_x, cw_y, 4, color);
+ sprite.drawCircle(cw_x, cw_y, 4, 0x0000);
+}
+
+void renderClockFace(lgfx::LGFX_Sprite& sprite, const struct tm& tm) {
+ const auto& th = kThemes[s_theme_idx];
+
+ sprite.fillScreen(th.bg_color);
+
+ // Bezel ring
+ sprite.drawCircle(kCenterX, kCenterY, 178, th.dial_ring_color);
+ sprite.drawCircle(kCenterX, kCenterY, 177, th.dial_ring_color);
+ sprite.drawCircle(kCenterX, kCenterY, 168, th.dial_ring_color);
+ sprite.drawCircle(kCenterX, kCenterY, 115, th.dial_ring_color);
+
+ // 60 minute / second ticks
+ for (int i = 0; i < 60; ++i) {
+ const float rad = (i * 6.0f - 90.0f) * kDegToRad;
+ const float cos_a = cosf(rad);
+ const float sin_a = sinf(rad);
+
+ if (i % 5 == 0) {
+ // Hour marks (12 bold ticks)
+ const float r_inner = (i % 15 == 0) ? 148.0f : 154.0f;
+ const float r_outer = 175.0f;
+ const float x1 = kCenterX + r_inner * cos_a;
+ const float y1 = kCenterY + r_inner * sin_a;
+ const float x2 = kCenterX + r_outer * cos_a;
+ const float y2 = kCenterY + r_outer * sin_a;
+ sprite.drawWideLine(x1, y1, x2, y2, (i % 15 == 0) ? 3.5f : 2.5f, th.hour_tick_color);
+ } else {
+ // Minute marks (48 fine ticks)
+ const float r_inner = 166.0f;
+ const float r_outer = 175.0f;
+ const float x1 = kCenterX + r_inner * cos_a;
+ const float y1 = kCenterY + r_inner * sin_a;
+ const float x2 = kCenterX + r_outer * cos_a;
+ const float y2 = kCenterY + r_outer * sin_a;
+ sprite.drawWideLine(x1, y1, x2, y2, 1.2f, th.minute_tick_color);
+ }
+ }
+
+ // Hour numerals (12, 1 .. 11)
+ sprite.setFont(&fonts::FreeSansBold12pt7b);
+ sprite.setTextColor(th.numeral_color);
+ sprite.setTextDatum(textdatum_t::middle_center);
+
+ const char* numerals[] = {"12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"};
+ for (int i = 0; i < 12; ++i) {
+ // Skip 3 if we place date complication there, or draw all
+ if (i == 3) continue; // Leave room for date window
+
+ const float rad = (i * 30.0f - 90.0f) * kDegToRad;
+ const float r_num = 134.0f;
+ const int x = static_cast(kCenterX + r_num * cosf(rad));
+ const int y = static_cast(kCenterY + r_num * sinf(rad));
+ sprite.drawString(numerals[i], x, y);
+ }
+
+ // Date Complication at 3 o'clock
+ {
+ const int box_w = 68;
+ const int box_h = 28;
+ const int box_x = kCenterX + 70;
+ const int box_y = kCenterY - box_h / 2;
+
+ sprite.fillRoundRect(box_x, box_y, box_w, box_h, 4, th.date_bg_color);
+ sprite.drawRoundRect(box_x, box_y, box_w, box_h, 4, th.hour_tick_color);
+
+ char date_buf[16];
+ const int wday = (tm.tm_wday >= 0 && tm.tm_wday < 7) ? tm.tm_wday : 0;
+ snprintf(date_buf, sizeof(date_buf), "%s %d", kCzechDays[wday], tm.tm_mday);
+
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ sprite.setTextColor(th.date_text_color);
+ sprite.setTextDatum(textdatum_t::middle_center);
+ sprite.drawString(date_buf, box_x + box_w / 2, kCenterY);
+ }
+
+ // Sub-dial insignia under 12 o'clock
+ sprite.setFont(&fonts::Font0);
+ sprite.setTextSize(1);
+ sprite.setTextColor(th.minute_tick_color);
+ sprite.setTextDatum(textdatum_t::middle_center);
+ sprite.drawString("PLANE RADAR", kCenterX, kCenterY - 62);
+ sprite.drawString("CHRONOMETER", kCenterX, kCenterY - 50);
+
+ // Digital time readout at 6 o'clock
+ char dig_buf[16];
+ snprintf(dig_buf, sizeof(dig_buf), "%02d:%02d:%02d", tm.tm_hour, tm.tm_min, tm.tm_sec);
+ sprite.setFont(&fonts::FreeSansBold9pt7b);
+ sprite.setTextColor(th.digital_color);
+ sprite.setTextDatum(textdatum_t::middle_center);
+ sprite.drawString(dig_buf, kCenterX, kCenterY + 65);
+
+ // Calculate hand angles
+ const float hour_deg = (tm.tm_hour % 12 + tm.tm_min / 60.0f + tm.tm_sec / 3600.0f) * 30.0f;
+ const float min_deg = (tm.tm_min + tm.tm_sec / 60.0f) * 6.0f;
+ const float sec_deg = tm.tm_sec * 6.0f;
+
+ // Draw Hour Hand
+ drawHand(sprite, hour_deg, 92.0f, 20.0f, 8.0f, th.hour_hand_color);
+ // Center core outline on hour hand
+ drawHand(sprite, hour_deg, 84.0f, 16.0f, 4.0f, th.bg_color);
+
+ // Draw Minute Hand
+ drawHand(sprite, min_deg, 138.0f, 24.0f, 6.0f, th.minute_hand_color);
+ drawHand(sprite, min_deg, 130.0f, 20.0f, 3.0f, th.bg_color);
+
+ // Draw Second Hand
+ drawSecondHand(sprite, sec_deg, th.second_hand_color);
+
+ // Center Pivot Cap
+ sprite.fillCircle(kCenterX, kCenterY, 8, th.bg_color);
+ sprite.drawCircle(kCenterX, kCenterY, 8, th.hour_tick_color);
+ sprite.fillCircle(kCenterX, kCenterY, 5, th.second_hand_color);
+ sprite.fillCircle(kCenterX, kCenterY, 2, th.bg_color);
+}
+
+} // namespace
+
+void clockDisplayInit() {
+ services::time::init();
+}
+
+void clockDisplayDraw(bool /*full_redraw*/) {
+ struct tm tm_info;
+ const bool has_time = services::time::getLocalTimeInfo(&tm_info);
+
+ if (!has_time) {
+ // Default preview time (10:10:30) while waiting for NTP
+ tm_info.tm_hour = 10;
+ tm_info.tm_min = 10;
+ tm_info.tm_sec = 30;
+ tm_info.tm_wday = 5;
+ tm_info.tm_mday = 4;
+ tm_info.tm_mon = 8;
+ }
+
+ if (ensureSharedFrameSprite()) {
+ lgfx::LGFX_Sprite& frame = sharedFrameSprite();
+ renderClockFace(frame, tm_info);
+
+ if (!has_time) {
+ frame.setFont(&fonts::FreeSansBold9pt7b);
+ frame.setTextColor(0xFFE0);
+ frame.setTextDatum(textdatum_t::middle_center);
+ frame.drawString("SYNCHRONIZING...", kCenterX, kCenterY - 25);
+ }
+
+ frame.pushSprite(0, 0);
+ }
+}
+
+void clockDisplayNextTheme() {
+ s_theme_idx = static_cast((s_theme_idx + 1) % kThemeCount);
+ clockDisplayDraw(true);
+}
+
+void clockDisplayPrevTheme() {
+ if (s_theme_idx > 0) {
+ s_theme_idx--;
+ } else {
+ s_theme_idx = static_cast(kThemeCount - 1);
+ }
+ clockDisplayDraw(true);
+}
+
+} // namespace ui
diff --git a/src/ui/radar_display.cpp b/src/ui/radar_display.cpp
index 3ececd6e8..42037c5f4 100644
--- a/src/ui/radar_display.cpp
+++ b/src/ui/radar_display.cpp
@@ -52,6 +52,8 @@ int s_scale_label_h = 0;
lgfx::LovyanGFX* s_draw = &tft;
LGFX_Sprite s_frame(&tft);
bool s_frame_ready = false;
+bool s_select_mode_active = false;
+int s_selected_aircraft_idx = -1;
class DrawScope {
public:
@@ -405,7 +407,8 @@ int measureTagBlockWidth(const services::adsb::Aircraft& plane) {
return max_w;
}
-void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) {
+void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane,
+ bool is_highlighted = false) {
initTagLabelMetrics();
applyTagStyle();
@@ -430,20 +433,29 @@ void drawAircraftTag(int x, int y, const services::adsb::Aircraft& plane) {
}
ly = std::max(1, std::min(ly, radar::kSize - block_h - 1));
+ if (is_highlighted) {
+ const int bx = tag_on_right ? anchor_x - 3 : anchor_x - block_w - 3;
+ s_draw->fillRoundRect(bx, ly - 2, block_w + 6, block_h + 4, 3, 0x0000);
+ s_draw->drawRoundRect(bx, ly - 2, block_w + 6, block_h + 4, 3, 0xFFE0);
+ }
+
if (plane.callsign[0] != '\0') {
- s_draw->setTextColor(radar::kColorLabel, radar::kColorBackground);
+ s_draw->setTextColor(is_highlighted ? 0xFFE0 : radar::kColorLabel,
+ radar::kColorBackground);
s_draw->drawString(plane.callsign, anchor_x, ly);
}
ly += line_h;
if (plane.type[0] != '\0') {
- s_draw->setTextColor(radar::kColorTagType, radar::kColorBackground);
+ s_draw->setTextColor(is_highlighted ? 0xFFE0 : radar::kColorTagType,
+ radar::kColorBackground);
s_draw->drawString(plane.type, anchor_x, ly);
}
ly += line_h;
if (plane.alt[0] != '\0') {
- s_draw->setTextColor(radar::kColorTagAltitude, radar::kColorBackground);
+ s_draw->setTextColor(is_highlighted ? 0xFFE0 : radar::kColorTagAltitude,
+ radar::kColorBackground);
s_draw->drawString(plane.alt, anchor_x, ly);
}
}
@@ -536,13 +548,30 @@ void drawAircraft() {
const size_t i = items[d].index;
const int x = items[d].x;
const int y = items[d].y;
+ const bool is_sel =
+ (s_select_mode_active && s_selected_aircraft_idx == static_cast(i));
+
+ if (is_sel) {
+ // Draw bright yellow targeting reticle around selected aircraft
+ s_draw->drawCircle(x, y, 14, 0xFFE0);
+ s_draw->drawCircle(x, y, 15, 0xFFE0);
+ s_draw->drawFastHLine(x - 20, y, 7, 0xFFE0);
+ s_draw->drawFastHLine(x + 14, y, 7, 0xFFE0);
+ s_draw->drawFastVLine(x, y - 20, 7, 0xFFE0);
+ s_draw->drawFastVLine(x, y + 14, 7, 0xFFE0);
+ }
+
drawSpeedVector(x, y, planes[i].nose_deg, planes[i].track_deg,
- planes[i].gs_knots, radar::kColorTrackVector);
- drawHeadingTriangle(x, y, planes[i].nose_deg, radar::kColorAircraft);
+ planes[i].gs_knots,
+ is_sel ? 0xFFE0 : radar::kColorTrackVector);
+ drawHeadingTriangle(x, y, planes[i].nose_deg,
+ is_sel ? 0xFFE0 : radar::kColorAircraft);
}
for (size_t d = 0; d < draw_count; ++d) {
const size_t i = items[d].index;
- drawAircraftTag(items[d].x, items[d].y, planes[i]);
+ const bool is_sel =
+ (s_select_mode_active && s_selected_aircraft_idx == static_cast(i));
+ drawAircraftTag(items[d].x, items[d].y, planes[i], is_sel);
}
}
@@ -664,12 +693,67 @@ bool ensureFrameSprite() {
return true;
}
s_frame.setColorDepth(16);
- if (!s_frame.createSprite(radar::kSize, radar::kSize)) {
- Serial.println("radar: frame sprite alloc failed");
- return false;
+ if (s_frame.createSprite(radar::kSize, radar::kSize)) {
+ s_frame_ready = true;
+ return true;
}
- s_frame_ready = true;
- return true;
+ Serial.println("radar: 16-bit frame sprite alloc failed, trying 8-bit");
+ s_frame.setColorDepth(8);
+ if (s_frame.createSprite(radar::kSize, radar::kSize)) {
+ s_frame_ready = true;
+ return true;
+ }
+ Serial.println("radar: frame sprite alloc failed, drawing directly to display");
+ return false;
+}
+
+void drawModeBanner(lgfx::LovyanGFX* gfx) {
+ if (gfx == nullptr) return;
+ const size_t n = services::adsb::aircraftCount();
+ const services::adsb::Aircraft* planes = services::adsb::aircraftList();
+
+ gfx->setTextDatum(textdatum_t::bottom_center);
+ gfx->setFont(&fonts::Font0);
+
+ char buf[48];
+ uint16_t text_col = 0x07E0;
+ uint16_t border_col = 0x03E0;
+
+ if (s_select_mode_active) {
+ if (s_selected_aircraft_idx >= 0 &&
+ s_selected_aircraft_idx < static_cast(n)) {
+ const auto& p = planes[s_selected_aircraft_idx];
+ snprintf(buf, sizeof(buf), "[ CIL %d/%d: %s %.1fkm ]",
+ s_selected_aircraft_idx + 1, static_cast(n),
+ p.callsign[0] != '\0' ? p.callsign : (p.hex[0] != '\0' ? p.hex : "AC"),
+ p.dist_km);
+ text_col = 0xFFE0;
+ border_col = 0xFFE0;
+ } else {
+ snprintf(buf, sizeof(buf), "[ VYBER LETADLA (STISK=ZPET) ]");
+ text_col = 0x07FF;
+ border_col = 0x07FF;
+ }
+ } else {
+ char range_label[16];
+ radar::formatCurrentRing3Label(range_label, sizeof(range_label));
+ snprintf(buf, sizeof(buf), "[ MERITKO: %s ]", range_label);
+ text_col = 0x07E0;
+ border_col = 0x03E0;
+ }
+
+ const int tw = gfx->textWidth(buf);
+ constexpr int pad_x = 8;
+ constexpr int pad_y = 3;
+ const int h = gfx->fontHeight() + pad_y * 2;
+ const int w = tw + pad_x * 2;
+ const int bx = (radar::kSize - w) / 2;
+ const int by = 346 - h;
+
+ gfx->fillRoundRect(bx, by, w, h, 4, 0x0000);
+ gfx->drawRoundRect(bx, by, w, h, 4, border_col);
+ gfx->setTextColor(text_col, 0x0000);
+ gfx->drawString(buf, radar::kCenterX, 346 - pad_y);
}
// Double-buffered frame: composite the grid AND aircraft into the off-screen
@@ -680,6 +764,7 @@ void renderFrame() {
{
const DrawScope scope(s_frame);
drawAircraft();
+ drawModeBanner(&s_frame);
}
s_frame.pushSprite(0, 0);
tft.setTextDatum(textdatum_t::top_left);
@@ -700,6 +785,7 @@ void radarDisplayDraw() {
const DrawScope scope(tft);
drawStaticGrid(tft);
drawAircraft();
+ drawModeBanner(&tft);
tft.setTextDatum(textdatum_t::top_left);
}
@@ -714,4 +800,17 @@ void radarDisplayRefreshAircraft() {
radarDisplayDraw();
}
+void radarSetSelection(bool select_mode, int selected_aircraft_idx) {
+ s_select_mode_active = select_mode;
+ s_selected_aircraft_idx = selected_aircraft_idx;
+}
+
+lgfx::LGFX_Sprite& sharedFrameSprite() {
+ return s_frame;
+}
+
+bool ensureSharedFrameSprite() {
+ return ensureFrameSprite();
+}
+
} // namespace ui
diff --git a/src/ui/radar_range.cpp b/src/ui/radar_range.cpp
index d8bf002e2..3d9d2de2e 100644
--- a/src/ui/radar_range.cpp
+++ b/src/ui/radar_range.cpp
@@ -78,6 +78,15 @@ void rangeNext() {
saveRangeIndex();
}
+void rangePrev() {
+ if (s_range_index > 0) {
+ s_range_index--;
+ } else {
+ s_range_index = static_cast(kRangePresetCount - 1);
+ }
+ saveRangeIndex();
+}
+
const RangePreset& rangeCurrent() { return kRangePresets[s_range_index]; }
uint8_t rangeIndex() { return s_range_index; }
diff --git a/src/ui/status_screens.cpp b/src/ui/status_screens.cpp
index af3713516..fe0317be3 100644
--- a/src/ui/status_screens.cpp
+++ b/src/ui/status_screens.cpp
@@ -13,14 +13,14 @@
namespace {
-constexpr int kLineGap = 6;
+constexpr int kLineGap = 8;
const int kCenterX = config::kDisplayWidth / 2;
const int kCenterY = config::kDisplayHeight / 2;
constexpr int kSpinnerDotCount = 10;
-constexpr int kSpinnerRadius = 113;
-constexpr int kSpinnerDotRadius = 2;
-constexpr int kSpinnerEraseRadius = 4;
+constexpr int kSpinnerRadius = 170;
+constexpr int kSpinnerDotRadius = 3;
+constexpr int kSpinnerEraseRadius = 6;
constexpr float kSpinnerStepDeg = 6.0f;
struct SpinnerDot {
@@ -31,7 +31,7 @@ struct SpinnerDot {
char s_connecting_ssid[33];
char s_ssid_line[33];
-constexpr int kConnectingTextMaxWidthPx = 220;
+constexpr int kConnectingTextMaxWidthPx = 330;
float s_spinner_angle_deg = -90.0f;
SpinnerDot s_spinner_dots[kSpinnerDotCount];
bool s_connecting_text_drawn = false;