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) -plane-radar +

+ ESP32 Plane Radar Desktop Console +

-**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;