chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:34:07 +08:00
commit ef5d664fe5
27 changed files with 1339 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
app/bin/
app/pde.jar
build/macosx/work/
arduino-core/bin/
arduino-core/arduino-core.jar
hardware/arduino/bootloaders/caterina_LUFA/Descriptors.o
hardware/arduino/bootloaders/caterina_LUFA/Descriptors.lst
hardware/arduino/bootloaders/caterina_LUFA/Caterina.sym
hardware/arduino/bootloaders/caterina_LUFA/Caterina.o
hardware/arduino/bootloaders/caterina_LUFA/Caterina.map
hardware/arduino/bootloaders/caterina_LUFA/Caterina.lst
hardware/arduino/bootloaders/caterina_LUFA/Caterina.lss
hardware/arduino/bootloaders/caterina_LUFA/Caterina.elf
hardware/arduino/bootloaders/caterina_LUFA/Caterina.eep
hardware/arduino/bootloaders/caterina_LUFA/.dep/
build/*.zip
build/*.tar.bz2
build/windows/work/
build/windows/*.zip
build/windows/*.tgz
build/windows/*.tar.bz2
build/windows/libastylej*
build/windows/liblistSerials*
build/windows/arduino-*.zip
build/windows/dist/*.tar.gz
build/windows/dist/*.tar.bz2
build/windows/launch4j-*.tgz
build/windows/launch4j-*.zip
build/windows/launcher/launch4j
build/windows/WinAVR-*.zip
build/macosx/arduino-*.zip
build/macosx/dist/*.tar.gz
build/macosx/dist/*.tar.bz2
build/macosx/*.tar.bz2
build/macosx/libastylej*
build/macosx/appbundler*.jar
build/macosx/appbundler*.zip
build/macosx/appbundler
build/macosx/appbundler-1.0ea-arduino?
build/macosx/appbundler-1.0ea-arduino*.zip
build/macosx/appbundler-1.0ea-upstream*.zip
build/linux/work/
build/linux/dist/*.tar.gz
build/linux/dist/*.tar.bz2
build/linux/*.tgz
build/linux/*.tar.xz
build/linux/*.tar.bz2
build/linux/*.zip
build/linux/libastylej*
build/linux/liblistSerials*
build/shared/arduino-examples*
build/shared/reference*.zip
build/shared/Edison*.zip
build/shared/Galileo*.zip
build/shared/WiFi101-Updater-ArduinoIDE-Plugin*.zip
test-bin
*.iml
.idea
.DS_Store
.directory
hardware/arduino/avr/libraries/Bridge/examples/XivelyClient/passwords.h
avr-toolchain-*.zip
/app/nbproject/private/
/arduino-core/nbproject/private/
/app/build/
/arduino-core/build/
manifest.mf
nbbuild.xml
nbproject
.pio
.vscode
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Anthony Sturdy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+227
View File
@@ -0,0 +1,227 @@
<h1 align=center>
📡 Micro Radar
</h1>
<h6 align=center>
a tiny open-source flight radar for your desk
</h6>
<p align=center>
<img src="https://github.com/user-attachments/assets/2ccb2063-d15c-4180-8e3c-ae3a81c814ff" alt="drawing" width="400"/>
</p>
<p align=center>
<a href="#prerequisites">PREREQUISITES</a> - <a href="#assembly">ASSEMBLY</a> - <a href="#usage">USAGE</a> - <a href="#faq">FAQ</a>
</p>
## Prerequisites
At the core of this project is the ESP32-C3 module with an integrated 240x240 IPS screen. No soldering required. The module does all the heavy lifting.
I used dark grey PLA filament for the print, but any colour will work just fine. For the lens (optional but recommended), you'll need clear-drying epoxy to secure it (not super glue, which will fog up the lens. Ask me how I know.)
### Tools you'll need
- Small screwdriver (for M2 screws)
- Soldering iron (for setting the threaded inserts)
Set up a clean, organised workspace before you start. You'll be handling small components and epoxy, so a dedicated area helps. Keep a damp cloth nearby for cleaning if needed, and ensure your soldering iron has adequate ventilation.
### Shopping List
Everything you need is below. I've linked products I used and recommend for ease of build, though alternatives exist on Amazon and elsewhere. If you deviate from this hardware, you may need to modify the enclosure and/or code.
- [ ] [1.28" Round GC9A01 240x240 IPS Display Module with ESP32-C3 (no-touch)](https://www.aliexpress.com/item/1005008482665220.html)
- [ ] [USB-C Ribbon Extension Cable (5cm, CMUP-CFPCB-BK)](https://www.aliexpress.com/item/1005005371248824.html)
- [ ] [M2 Heat-set Threaded Inserts (+ soldering iron)](https://www.aliexpress.com/item/1005008493831823.html)
- [ ] [32.5mm Round Mineral Glass Lens (optional, recommended)](https://www.aliexpress.com/item/1005004783554496.html)
- [ ] [Gorilla Epoxy (necessary for fitting lens, useful anyway)](https://www.amazon.co.uk/Gorilla-Glue-25ml-Epoxy/dp/B009NQQJFC)
### Accounts / API
This project uses OpenSky's API for retrieving flight data.
I highly recommend making an account, as it's free, and allows the radar to make many more requests per day (400 -> 4000), which makes the live view much more accurate. However, it isn't necessary if you prefer.
You can sign up [here](https://opensky-network.org), or search "OpenSky".
Further info on what to do with the account is in the usage section.
## Assembly
Once you've got all the parts, assembly typically takes 1-2 hours (excluding print time).
**I strongly recommend reading the [Usage](#usage) section before you start assembly.** It'll help with troubleshooting if anything goes wrong. You might want to test the firmware and your hardware before closing everything up.
### Step 1: 3D Print
<img width="400" alt="FFCBBECA-6165-4138-8C84-16AB375511A2_1_105_c" src="https://github.com/user-attachments/assets/21c0753c-7d7c-425c-bdf6-0df037a8fdaa" />
Print all four STLs from `./hardware/stl/`:
- Main enclosure
- Front plate
- Bezel
- 2 spacers
### Step 2: Heat-set Threaded Inserts
**You'll need:** Soldering iron, M2 threaded inserts
Start with the front plate: insert 2mm M2 threaded inserts into the larger holes using the soldering iron.
<img width="400" alt="IMG_7882" src="https://github.com/user-attachments/assets/defcfb2c-cdff-4bf1-84b9-7fceeefb0caf" />
Next, the two spacers. These might warp slightly, that's fine. Insert 6mm M2 inserts into each.
<img width="400" alt="IMG_7887" src="https://github.com/user-attachments/assets/73b95049-5f12-4e2b-983a-5242c05f9106" />
Finally, the main enclosure. Insert 5mm M2 inserts.
<img width="400" alt="IMG_7891" src="https://github.com/user-attachments/assets/e36f3eec-31b5-468e-8451-9c428eaf9c21" />
Et voilà.
<img width="400" alt="IMG_7896" src="https://github.com/user-attachments/assets/97337223-223c-4531-90e1-f511adfb3d66" />
### Step 3 (Optional): Fitting the Lens
<img width="400" alt="IMG_7902" src="https://github.com/user-attachments/assets/e555f787-ca87-4558-b1eb-107f9071f96e" />
**You'll need:** Clear-drying epoxy, small applicator (match or cocktail stick works)
This is the fiddliest bit. Keep it neat and you'll avoid frustration:
- Apply epoxy to the front plate, not the lens
- Lower the front plate onto the lens (easier to manage excess epoxy)
- Have a cleaner ready for the edges (I used nail polish remover, your mileage may vary)
- Less is more with epoxy
- Work on a surface that won't bond to epoxy
<img width="400" alt="IMG_7911" src="https://github.com/user-attachments/assets/aa497389-efd5-45c3-84dc-c997232889ac" />
Let the epoxy cure according to its label before moving on.
### Step 4: Bezel
**You'll need:** 2x5mm M2 screws, 2x10mm M2 screws
Secure the bezel to the front plate using 2x5mm M2 screws through the threaded inserts you added earlier.
<img width="400" alt="IMG_7914" src="https://github.com/user-attachments/assets/37a3502a-83e1-4552-a399-9a914e0ec973" />
Screw 2x10mm M2 screws through the remaining two holes. They should protrude from the back.
<img width="400" alt="IMG_7915" src="https://github.com/user-attachments/assets/9ccfe5f2-347d-4563-a2b1-eb5e65e1d83f" />
Peel the protective film off the screen and position it over the lens. The screws you just inserted will guide the display into place.
Before clamping it down with the spacers, **make sure the antenna is attached to the module**. Press it down firmly onto the flat surface until it clicks. Orientation doesn't matter, but you do need this connection otherwise you won't get any WiFi signal.
<img width="400" alt="IMG_7917" src="https://github.com/user-attachments/assets/ee53aac0-d119-4941-a814-f7ef23ffe7a0" />
<img width="400" alt="IMG_7920" src="https://github.com/user-attachments/assets/0d4d7d86-9787-4972-aa55-8ae43c9a078b" />
Now screw the spacers into those protruding screws to clamp the module in place. I recommend keeping the board plugged in at this point to help with alignment. Don't use much force, too much pressure will stress the screen.
Once you're happy with the alignment, you can add a small amount of epoxy around the module to lock it in place permanently (optional, but recommended if you're happy with the build).
### Step 5: Final Assembly
**You'll need:** USB-C ribbon extension cable, 4x7mm M2 screws, optional rubber feet
Attach the USB-C ribbon cable to the case with the provided nuts and bolts.
<img width="400" alt="IMG_7921" src="https://github.com/user-attachments/assets/f40a7943-c880-4718-9e69-c87a4f5d33aa" />
<img width="400" alt="IMG_7923" src="https://github.com/user-attachments/assets/2daccb36-421f-4a3e-812a-51dae4444d4e" />
If you like, remove the supports from the bottom and insert rubber feet.
<img width="400" alt="IMG_7924" src="https://github.com/user-attachments/assets/fdeb69f2-ec0d-441e-95ca-abd7523f7c61" />
Plug in the board, then attach the front plate using 4x7mm M2 screws.
<img width="400" alt="IMG_7925" src="https://github.com/user-attachments/assets/40da22d9-447d-4ad0-a500-02f862050e5c" />
Done!
<img width="400" alt="IMG_7930" src="https://github.com/user-attachments/assets/989fb56f-dacc-4bf5-a9ab-cb1311e534e4" />
## Usage
### Flashing the Firmware
You'll need [VS Code](https://code.visualstudio.com/) with the [PlatformIO IDE extension](https://marketplace.visualstudio.com/items?itemName=platformio.platformio-ide) installed. Once installed, restart VS Code, open the repository folder, and dependencies will pull in automatically.
Plug the board in via USB-C, then hit the upload button (→) in the bottom status bar. If the board doesn't reboot with the new firmware automatically, hold the BOOT button on the back and press RESET once, then release BOOT.
The board should auto-detect, but if you hit an upload failure, check that the correct board is selected in the status bar. If it still won't upload, try:
- Disconnect and reconnect the USB cable
- Check that your cable supports data transfer (some USB-C cables are charge-only)
- Try a different USB port on your computer
Read more about PlatformIO [here](https://docs.platformio.org/en/latest/).
### First Boot
On first boot, the radar broadcasts a WiFi hotspot called `MicroRadar-Setup`. Connect to it from your phone or laptop and a configuration page will appear automatically (or go to your browser if it doesn't). Enter your WiFi credentials and hit save. The board will restart and connect to your network.
If the hotspot doesn't appear straight away, give it a moment. If it still hasn't appeared after 30 seconds, exit the WiFi settings on your device and go back in to force a refresh. It'll usually show up then.
### Configuration
Once connected to your network, the radar config is accessible at [http://microradar.local](http://microradar.local) from any device on the same network.
Here you can set:
- **Location** (latitude and longitude): the centre point of your radar
- **Radar radius**: how wide the scan extends (in degrees, 2 degrees is the limit to avoid rate limiting)
- **Display options**: toggle visual elements
- **OpenSky credentials**: your client ID and secret (if you've made an account - again, highly recommend!)
<img width="400" alt="image" src="https://github.com/user-attachments/assets/45e6219c-2672-4197-baad-16ae08180b58" />
If you've made an OpenSky account (which I highly recommend), you can find your credentials under your account settings at opensky-network.org. With authentication, you get 4000 requests per day instead of 400, making the live view much more accurate. Read more about the API [here](https://opensky-network.org).
This configuration page is accessible anytime the device is connected to WiFi, so you can tweak settings whenever you want.
That's it! Once you've configured everything, you should see a live view of all flights over your location. Enjoy :)
<img width="400" alt="IMG_7935" src="https://github.com/user-attachments/assets/118b9a1c-c2c0-488d-b638-d8684a30b1d7" />
## FAQ
> the port is busy or doesn't exist
Restart VS Code *after* plugging in the device. If VS Code was already open, it may default to a stale port from before the device was connected.
If that doesn't work, look for the button with a small "Plug" icon on VS Code's bottom bar (it might say "auto", "cu.usbmodem101", or similar). Click it and select the option that shows your device's name.
<br/><br/>
> the 3D print failed
If you're using a Bambu Lab printer, make sure you're opening the `.3mf` file, since it includes the correct print bed and settings.
Using a different printer? Open an [Issue](../../issues) and I'll try to help where I can.
<br/><br/>
> `ModuleNotFoundError: No module named 'intelhex'` when building
This appears to be a Windows-specific issue. Either of these should fix it:
**Option A:**
1. Open the PlatformIO terminal (PlatformIO sidebar → Miscellaneous → PlatformIO Core CLI)
2. Run `pip install intelhex`
3. Rebuild
**Option B:**
1. Open a new terminal in VS Code (Terminal → New Terminal)
2. Run `python -m pip install intelhex`
3. Rebuild
## Notes
> Designed and developed as part of a wedding present for a mate who loves aviation (congratulations to both him and his wife!)
> Inspired by [therealhacksaw](https://www.instagram.com/therealhacksaw/)'s desk radar
> Built with ♥︎ in London
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`AnthonySturdy/micro-radar`
- 原始仓库:https://github.com/AnthonySturdy/micro-radar
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
https://cad.onshape.com/documents/5fe6331db863c786c05e7bea/w/35885a962c877ffb6e0905d9/e/482fbce2122c007cf5094e5f
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "LGFX.h"
void DrawScanLines(LGFX_Sprite& buf, const int x0, const int y0, const int x1, const int y1, const int thickness, const int trailBrightness, const int spacing)
{
float dx = x1 - x0;
float dy = y1 - y0;
float len = sqrt(dx * dx + dy * dy);
// perpendicular unit vector
float px = -dy / len;
float py = dx / len;
for (int i = 0; i <= thickness; i++) {
// 1.0 at centre, 0.0 at edges
float t = i / (float)(thickness);
uint8_t brightness = (uint8_t)(t * trailBrightness);
buf.drawLine(
x0, y0,
x1 + (px * (i * spacing)), y1 + (py * (i * spacing)),
lgfx::color888(0, brightness, 0)
);
}
buf.drawLine(
x0, y0,
x1 + (px * (thickness * spacing)), y1 + (py * (thickness * spacing)),
lgfx::color888(0, 200, 0)
);
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <ArduinoJson.h>
#include <vector>
namespace JsonParser {
template<typename T>
T Parse(const JsonVariant& doc);
template<typename T>
std::vector<T> ParseArray(const JsonArray& array) {
std::vector<T> results;
results.reserve(array.size());
for (JsonVariant item : array) {
results.push_back(Parse<T>(item));
}
return results;
}
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <LovyanGFX.hpp>
class LGFX : public lgfx::LGFX_Device
{
lgfx::Panel_GC9A01 _panel;
lgfx::Bus_SPI _bus;
lgfx::Light_PWM _light;
public:
LGFX(void)
{
{
auto cfg = _bus.config();
cfg.spi_host = SPI2_HOST;
cfg.freq_write = 27000000;
cfg.pin_miso = -1;
cfg.pin_mosi = 7;
cfg.pin_sclk = 6;
cfg.pin_dc = 2;
_bus.config(cfg);
_panel.setBus(&_bus);
}
{
auto cfg = _panel.config();
cfg.pin_cs = 10;
cfg.pin_rst = -1;
cfg.pin_busy = -1;
// cfg.rgb_order = true;
_panel.config(cfg);
}
{
auto cfg = _light.config();
cfg.pin_bl = 3;
cfg.invert = false;
_light.config(cfg);
_panel.setLight(&_light);
}
setPanel(&_panel);
}
};
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <WiFiManager.h>
namespace WiFiManagerHelpers
{
constexpr const char* WiFiManagerName = "MicroRadar-Setup";
static void ConfigureWiFiManager(WiFiManager& wm, LGFX& tft)
{
wm.setTitle("Micro Radar - Setup WiFi");
wm.setCustomHeadElement("<style>body{background:#111;color:#00ff00;font-family:monospace;} div:has(> a){background:#00ff00;} a:hover{color:#111;}</style>");
wm.setAPCallback([&tft](WiFiManager* wifiManager) {
tft.fillScreen(lgfx::color888(0, 0, 0));
tft.setTextColor(lgfx::color888(0, 255, 0));
const int lineHeight = tft.fontHeight() + 10;
const int screenSize = 240;
tft.drawCenterString("- SETUP -", screenSize / 2, screenSize / 2 - lineHeight);
tft.drawCentreString("Connect to this WiFi hotspot:", screenSize / 2, screenSize / 2);
tft.drawCenterString(WiFiManagerName, screenSize / 2, screenSize / 2 + lineHeight);
}
);
}
}
+22
View File
@@ -0,0 +1,22 @@
[env:esp32-c3-devkitm-1]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
monitor_port = /dev/cu.usbmodem1101
upload_port = /dev/cu.usbmodem1101
upload_protocol = esptool
build_unflags =
-std=gnu++11
build_flags =
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
-std=gnu++17
-Wall
board_build.partitions = huge_app.csv
lib_deps =
lovyan03/LovyanGFX
tzapu/WiFiManager
mathieucarbou/ESP Async WebServer
mathieucarbou/AsyncTCP
bblanchon/ArduinoJson
+170
View File
@@ -0,0 +1,170 @@
#include "AircraftManager.h"
constexpr int SCREEN_SIZE = 240;
constexpr int SCREEN_SIZE_DIV_2 = (SCREEN_SIZE / 2);
#include <ArduinoJson.h>
void AircraftManager::Initialise()
{
// get centre point + radius
lat = configServer.GetStoredString("latitude").toDouble();
lon = configServer.GetStoredString("longitude").toDouble();
rad = configServer.GetStoredString("radius").toDouble();
// configuration
const String renderText = configServer.GetStoredString("infotext");
const String renderTris = configServer.GetStoredString("triangle");
if (!renderText.isEmpty()) displayInfoText = renderText == "true" ? true : false;
if (!renderTris.isEmpty()) displayTriangles = renderTris == "true" ? true : false;
// calculate how often we can call OpenSky API before being rate limited
constexpr int MS_PER_DAY = 24 * 60 * 60 * 1000;
constexpr int ANONYMOUS_TOKENS_PER_DAY = 400;
constexpr int AUTHED_TOKENS_PER_DAY = 4000;
constexpr int TOKEN_BUFFER = 3;
int dailyRequestBudget = ANONYMOUS_TOKENS_PER_DAY - TOKEN_BUFFER; // non-authed tokens minus buffer
const String token = authHandler.GetValidToken(configServer.GetStoredString("opensky-id"), configServer.GetStoredString("opensky-secret"));
if (!token.isEmpty())
dailyRequestBudget = AUTHED_TOKENS_PER_DAY - TOKEN_BUFFER; // authed tokens minus buffer
fetchInterval = MS_PER_DAY / dailyRequestBudget;
}
void AircraftManager::Update()
{
unsigned long now = millis();
// fetch cycle
if (now - lastFetch >= fetchInterval) {
lastFetch = now;
// auth
const String token = authHandler.GetValidToken(
configServer.GetStoredString("opensky-id"),
configServer.GetStoredString("opensky-secret")
);
std::vector<std::pair<String, String>> headers = {};
if (!token.isEmpty()) headers.push_back({ "Authorization", "Bearer " + token });
// request
HttpResult result = http.Get(
"https://opensky-network.org/api/states/all",
{
{"lamin", String(lat - rad)},
{"lamax", String(lat + rad)},
{"lomin", String(lon - rad)},
{"lomax", String(lon + rad)}
},
headers
);
// If request failed, skip this update
if (!result.success) {
Serial.print("[WARN] OpenSky API request failed: ");
Serial.println(result.errorMessage);
return;
}
// track
JsonDocument doc;
deserializeJson(doc, result.response);
auto aircraft = JsonParser::ParseArray<Aircraft>(doc["states"]);
now = millis(); // override with post-parse timestamp
for (auto& ac : aircraft) {
auto it = trackedAircraft.find(ac.icao24);
if (it == trackedAircraft.end())
trackedAircraft.emplace(ac.icao24, TrackedAircraft{ ac, now });
else
it->second.Update(ac, now);
}
// remove any planes that disappeared from the feed
for (auto it = trackedAircraft.begin(); it != trackedAircraft.end(); ) {
bool aircraftPresent = std::any_of(aircraft.begin(), aircraft.end(), [&](const Aircraft& ac) { return ac.icao24 == it->first; });
if (!aircraftPresent)
it = trackedAircraft.erase(it);
else
++it;
}
}
}
void AircraftManager::Draw(LGFX_Sprite& backbuffer)
{
DrawRadarCircles(backbuffer);
for (auto& [icao, tracked] : trackedAircraft) {
if (tracked.state.onGround) continue;
tracked.Tick();
auto [predLat, predLon] = tracked.GetDisplayPosition();
auto [x, y] = ProjectCoordinateToScreen(predLat, predLon);
if (displayInfoText)
DrawAircraftInfo(backbuffer, x, y, tracked);
if (displayTriangles)
DrawAircraftTriangle(backbuffer, x, y, tracked);
else
backbuffer.fillCircle(x, y, 3, lgfx::color888(0, 255, 0));
}
}
void AircraftManager::DrawRadarCircles(LGFX_Sprite& backbuffer) const
{
constexpr int CENTRE = SCREEN_SIZE_DIV_2 - 1;
constexpr int OUTER = SCREEN_SIZE_DIV_2 - 1;
backbuffer.drawCircle(CENTRE, CENTRE, OUTER, lgfx::color888(0, 200, 0));
backbuffer.drawCircle(CENTRE, CENTRE, (OUTER / 3) * 2, lgfx::color888(0, 64, 0));
backbuffer.drawCircle(CENTRE, CENTRE, OUTER / 3, lgfx::color888(0, 32, 0));
}
std::pair<int, int> AircraftManager::ProjectCoordinateToScreen(float predLat, float predLon) const
{
const float dLon = predLon - lon;
const float dLat = predLat - lat;
const float normLon = (dLon + rad) / (2.0f * rad);
const float normLat = (dLat + rad) / (2.0f * rad);
const int x = static_cast<int>(normLon * SCREEN_SIZE);
const int y = static_cast<int>(SCREEN_SIZE - (normLat * SCREEN_SIZE));
return { x, y };
}
void AircraftManager::DrawAircraftInfo(LGFX_Sprite& backbuffer, int x, int y, const TrackedAircraft& tracked) const
{
const int lineHeight = tft.fontHeight() + 1;
backbuffer.setTextSize(1);
backbuffer.setTextColor(lgfx::color888(0, 128, 0));
backbuffer.drawString(tracked.state.callsign, x + 5, y + 5);
backbuffer.drawString(String(tracked.state.velocity) + "m/s", x + 5, y + 5 + lineHeight);
backbuffer.drawString(String(tracked.state.baroAltitude) + "m", x + 5, y + 5 + lineHeight * 2);
}
void AircraftManager::DrawAircraftTriangle(LGFX_Sprite& backbuffer, int x, int y, const TrackedAircraft& tracked) const
{
const float dx = std::sin(radians(tracked.state.trueTrack));
const float dy = -std::cos(radians(tracked.state.trueTrack));
const float px = -dy;
const float py = dx;
constexpr float TRIANGLE_LENGTH = 6.0f;
constexpr float TRIANGLE_WIDTH = 3.0f;
const float tipX = x + dx * TRIANGLE_LENGTH;
const float tipY = y + dy * TRIANGLE_LENGTH;
const float leftX = x - dx * TRIANGLE_LENGTH * 0.5f + px * TRIANGLE_WIDTH * 0.5f;
const float leftY = y - dy * TRIANGLE_LENGTH * 0.5f + py * TRIANGLE_WIDTH * 0.5f;
const float rightX = x - dx * TRIANGLE_LENGTH * 0.5f - px * TRIANGLE_WIDTH * 0.5f;
const float rightY = y - dy * TRIANGLE_LENGTH * 0.5f - py * TRIANGLE_WIDTH * 0.5f;
backbuffer.fillTriangle(tipX, tipY, leftX, leftY, rightX, rightY, lgfx::color888(0, 255, 0));
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <map>
#include "models/TrackedAircraft.h"
#include "ConfigurationWebServer.h"
#include "OpenSkyAuthTokenHandler.h"
#include "LGFX.h"
class AircraftManager
{
private:
double lat = 0.0;
double lon = 0.0;
double rad = 0.2;
std::map<String, TrackedAircraft> trackedAircraft;
bool displayInfoText = true;
bool displayTriangles = true;
unsigned long fetchInterval = 0;
unsigned long lastFetch = 999999;
ConfigurationWebServer& configServer;
OpenSkyAuthTokenHandler& authHandler;
HttpRequestManager& http;
LGFX& tft;
void DrawRadarCircles(LGFX_Sprite& backbuffer) const;
std::pair<int, int> ProjectCoordinateToScreen(float predLat, float predLon) const;
void DrawAircraftInfo(LGFX_Sprite& backbuffer, int x, int y, const TrackedAircraft& tracked) const;
void DrawAircraftTriangle(LGFX_Sprite& backbuffer, int x, int y, const TrackedAircraft& tracked) const;
public:
AircraftManager(ConfigurationWebServer& config, OpenSkyAuthTokenHandler& auth, HttpRequestManager& httpManager, LGFX& tftGfx)
: configServer(config), authHandler(auth), http(httpManager), tft(tftGfx)
{
}
~AircraftManager() = default;
void Initialise();
void Update();
void Draw(LGFX_Sprite& backbuffer);
};
+217
View File
@@ -0,0 +1,217 @@
#include "ConfigurationWebServer.h"
#include <ESPmDNS.h>
// HTML stored in flash
// %PLACEHOLDER% tokens are substituted at serve time by the template processor
static const char CONFIG_HTML[] PROGMEM = R"(
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Configure Micro Radar</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.3.0"></script>
</head>
<body class="font-mono bg-gray-900 text-green-500 min-h-screen p-4 sm:p-0 text-md sm:text-sm">
<fieldset class="border border-green-500 p-5 w-full max-w-2xl mx-auto sm:m-10">
<legend class="px-2">Configure Micro Radar</legend>
<form id="cfg" action="/save" method="POST" class="flex flex-col gap-4 sm:gap-2">
<div class="flex flex-col sm:flex-row gap-4 sm:gap-5">
<label class="flex flex-col sm:flex-row gap-2 flex-1">
<span>Latitude:</span>
<input
name="latitude"
type="number"
min="-90"
step="0.000001"
max="90"
value='%LATITUDE%'
class="border border-green-500 bg-gray-900 w-full px-3 py-2 text-lg sm:text-base sm:px-1 sm:py-0">
</label>
<label class="flex flex-col sm:flex-row gap-2 flex-1">
<span>Longitude:</span>
<input
name="longitude"
type="number"
min="-180"
step="0.000001"
max="180"
value='%LONGITUDE%'
class="border border-green-500 bg-gray-900 w-full px-3 py-2 text-lg sm:text-base sm:px-1 sm:py-0">
</label>
</div>
<label class="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<span>Radius (in &deg;):</span>
<input
name="radius"
type="number"
min="0.000001"
step="0.000001"
max="2.499999"
value='%RADIUS%'
class="flex-1 border border-green-500 bg-gray-900 w-full px-3 py-2 text-lg sm:text-base sm:px-1 sm:py-0">
</label>
<label class="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<span>OpenSkyAPI Client ID:</span>
<input
name="opensky-id"
value='%OPENSKY_ID%'
class="flex-1 border border-green-500 bg-gray-900 w-full px-3 py-2 text-lg sm:text-base sm:px-1 sm:py-0">
</label>
<label class="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<span>OpenSkyAPI Client Secret:</span>
<input
name="opensky-secret"
value='%OPENSKY_SECRET%'
class="flex-1 border border-green-500 bg-gray-900 w-full px-3 py-2 text-lg sm:text-base sm:px-1 sm:py-0">
</label>
<div class="flex flex-col sm:flex-row gap-4 sm:justify-between">
<label class="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<span>Radar sweep:</span>
<input
name="scanline"
type="checkbox"
%SCANLINE%
class="px-3 sm:px-1 accent-green-500">
</label>
<label class="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<span>Aircraft Info:</span>
<input
name="infotext"
type="checkbox"
%INFOTEXT%
class="px-3 sm:px-1 accent-green-500">
</label>
<label class="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<span>Directional Aircraft:</span>
<input
name="triangle"
type="checkbox"
%TRIANGLE%
class="px-3 sm:px-1 accent-green-500">
</label>
</div>
<div class="flex flex-col sm:flex-row gap-4 sm:gap-5">
<input
type="submit"
value="Save"
class="bg-green-500 text-black mt-4 px-4 py-3 text-lg sm:text-base sm:px-2 sm:py-0 self-start cursor-pointer">
<div id="result" class="mt-4 px-1 sm:px-10"></div>
</div>
</form>
</fieldset>
<script>
document.getElementById('cfg').addEventListener('submit', function(e) {
e.preventDefault();
fetch(this.action, { method: 'POST', body: new FormData(this) })
.then(r => r.text())
.then(html => document.getElementById('result').innerHTML = html);
});
</script>
</body>
</html>
)";
void ConfigurationWebServer::Initialise() {
// start mDNS and check result
if (!MDNS.begin("microradar")) {
Serial.println("[WARN] Failed to start mDNS. Continuing without mDNS...");
}
// Handle visit to config web server
server.on("/", HTTP_GET, [&](AsyncWebServerRequest* request) {
Serial.println("[GET] Handling request to config web server...");
// read all values up front so the processor lambda can capture by value
prefs.begin("config", true);
const String latitude = prefs.getString("latitude", "");
const String longitude = prefs.getString("longitude", "");
const String radius = prefs.getString("radius", "1.0");
const String openskyClientId = prefs.getString("opensky-id", "");
String openskySecret = prefs.getString("opensky-secret", "");
const String scanlineEnabled = prefs.getString("scanline", "true");
const String infoTextEnabled = prefs.getString("infotext", "true");
const String triangleEnabled = prefs.getString("triangle", "true");
prefs.end();
// mask secret before sending to client
std::fill(openskySecret.begin(), openskySecret.end(), '*');
// template processor called once per %PLACEHOLDER% token found in CONFIG_HTML.
AsyncWebServerResponse* response = request->beginResponse(
200, "text/html",
(const uint8_t*)CONFIG_HTML, sizeof(CONFIG_HTML) - 1,
[latitude, longitude, radius, openskyClientId, openskySecret, scanlineEnabled, infoTextEnabled, triangleEnabled]
(const String& var) -> String {
if (var == "LATITUDE") return latitude;
if (var == "LONGITUDE") return longitude;
if (var == "RADIUS") return radius;
if (var == "OPENSKY_ID") return openskyClientId;
if (var == "OPENSKY_SECRET") return openskySecret;
if (var == "SCANLINE") return scanlineEnabled == "true" ? "checked" : "";
if (var == "INFOTEXT") return infoTextEnabled == "true" ? "checked" : "";
if (var == "TRIANGLE") return triangleEnabled == "true" ? "checked" : "";
return "";
}
);
request->send(response);
}
);
// Handle save submission to web server
server.on("/save", HTTP_POST, [&](AsyncWebServerRequest* request) {
Serial.println("[POST] Handling form submission to config web server...");
// safe parameter retrieval helper lambda
auto TrySaveParam = [request, this](const char* paramName) {
const auto* param = request->getParam(paramName, true);
if (param == nullptr)
return false;
prefs.putString(paramName, param->value());
return true;
};
prefs.begin("config", false);
TrySaveParam("latitude");
TrySaveParam("longitude");
TrySaveParam("radius");
TrySaveParam("opensky-id");
const auto* param = request->getParam("opensky-secret", true);
if (param != nullptr) {
const String& secret = param->value();
if (secret.indexOf('*') == -1) { // Special handling for secret: don't overwrite with masked value
prefs.putString("opensky-secret", secret);
}
}
prefs.putString("scanline", request->hasParam("scanline", true) ? "true" : "false");
prefs.putString("triangle", request->hasParam("triangle", true) ? "true" : "false");
prefs.putString("infotext", request->hasParam("infotext", true) ? "true" : "false");
prefs.end();
request->send(200, "text/html", "Saved - restarting device...");
ESP.restart();
}
);
server.begin();
}
const String ConfigurationWebServer::GetStoredString(const char* key)
{
prefs.begin("config", true);
const String value = prefs.getString(key, "");
prefs.end();
return value;
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <ESPAsyncWebServer.h>
#include <Preferences.h>
class ConfigurationWebServer {
private:
AsyncWebServer server;
Preferences prefs;
public:
ConfigurationWebServer() : server(80), prefs() {}
ConfigurationWebServer(int port) : server(port), prefs() {}
void Initialise();
[[nodiscard]] const String GetStoredString(const char* key);
};
+88
View File
@@ -0,0 +1,88 @@
#include "HttpRequestManager.h"
String HttpRequestManager::BuildQueryString(const std::vector<std::pair<String, String>>& params) const
{
if (params.empty())
return "";
String queryStream = "?";
bool first = true;
for (const auto& [key, value] : params)
{
if (!first)
queryStream += "&";
queryStream += key + "=" + value;
first = false;
}
return queryStream;
}
HttpResult HttpRequestManager::Get(const String& url, const std::vector<std::pair<String, String>>& params, const std::vector<std::pair<String, String>>& headers) {
HttpResult result{ false, 0, "", "" };
const String queryParams = BuildQueryString(params);
const String fullUrl = url + queryParams;
http.begin(fullUrl);
// add headers to request
for (const auto& header : headers) {
http.addHeader(header.first, header.second);
}
// send request and handle response
int responseCode = http.GET();
result.statusCode = responseCode;
if (responseCode > 0) {
result.success = true;
result.response = http.getString();
}
else {
result.success = false;
result.errorMessage = http.errorToString(responseCode);
Serial.print("[GET] HTTP Error (");
Serial.print(responseCode);
Serial.print("): ");
Serial.println(result.errorMessage);
}
http.end();
return result;
}
HttpResult HttpRequestManager::Post(const String& url, const String& body, const std::vector<std::pair<String, String>>& headers)
{
HttpResult result{ false, 0, "", "" };
http.begin(url);
// add headers to request
for (const auto& header : headers) {
http.addHeader(header.first, header.second);
}
// send request and handle response
int responseCode = http.POST(body);
result.statusCode = responseCode;
if (responseCode > 0) {
result.success = true;
result.response = http.getString();
}
else {
result.success = false;
result.errorMessage = http.errorToString(responseCode);
Serial.print("[POST] HTTP Error (");
Serial.print(responseCode);
Serial.print("): ");
Serial.println(result.errorMessage);
}
http.end();
return result;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <HTTPClient.h>
#include <vector>
struct HttpResult {
bool success; // Whether the request succeeded
int statusCode; // HTTP status code (0 if network error)
String response; // Response body (empty on error)
String errorMessage; // Error description if success == false
};
class HttpRequestManager
{
private:
HTTPClient http;
String BuildQueryString(const std::vector<std::pair<String, String>>& params) const;
public:
HttpRequestManager() = default;
~HttpRequestManager() = default;
[[nodiscard]] HttpResult Get(const String& url, const std::vector<std::pair<String, String>>& params = {}, const std::vector<std::pair<String, String>>& headers = {});
[[nodiscard]] HttpResult Post(const String& url, const String& body = "", const std::vector<std::pair<String, String>>& headers = {});
};
+55
View File
@@ -0,0 +1,55 @@
#include "OpenSkyAuthTokenHandler.h"
#include <ArduinoJson.h>
String OpenSkyAuthTokenHandler::FetchBearerToken(const String& url, const String& clientId, const String& clientSecret)
{
String body = "grant_type=client_credentials";
body += "&client_id=" + clientId;
body += "&client_secret=" + clientSecret;
const HttpResult resp = http.Post(
url,
body,
{
{"Content-Type", "application/x-www-form-urlencoded"}
}
);
if (!resp.success) {
Serial.print("[ERROR] OpenSky token request failed: ");
Serial.println(resp.errorMessage);
return "";
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, resp.response);
if (error) {
Serial.print("[ERROR] OpenSky token response JSON parse failed: ");
Serial.println(error.f_str());
return "";
}
const JsonVariant token = doc["access_token"];
if (!token.is<String>()) {
Serial.println("[WARN] Missing or non-string 'access_token' in OpenSky API response");
return "";
}
return token.as<String>();
}
const String OpenSkyAuthTokenHandler::GetValidToken(const String& clientId, const String& clientSecret)
{
const String url = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
if (clientId.isEmpty() || clientSecret.isEmpty())
return "";
if (bearerToken.isEmpty() || millis() > tokenExpiry) {
bearerToken = FetchBearerToken(url, clientId, clientSecret);
tokenExpiry = millis() + (29 * 60 * 1000); // 29 mins, 1 min buffer
}
return bearerToken;
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "HttpRequestManager.h"
class OpenSkyAuthTokenHandler
{
private:
HttpRequestManager& http;
String bearerToken = "";
unsigned long tokenExpiry = 0; // millis() timestamp
String FetchBearerToken(const String& url, const String& clientId, const String& clientSecret);
public:
OpenSkyAuthTokenHandler(HttpRequestManager& httpRequestManager) : http(httpRequestManager) {}
~OpenSkyAuthTokenHandler() = default;
[[nodiscard]] const String GetValidToken(const String& clientId, const String& clientSecret);
};
+88
View File
@@ -0,0 +1,88 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <WiFiManager.h>
#include "LGFX.h"
#include "WiFiManagerHelpers.h"
#include "ConfigurationWebServer.h"
#include "HttpRequestManager.h"
#include "OpenSkyAuthTokenHandler.h"
#include "AircraftManager.h"
#include "DrawHelpers.h"
#include "models/Aircraft.h"
#include "models/TrackedAircraft.h"
// Optional hard-coded Wi-Fi credentials. Leave both blank to skip pre-baking them and use the setup hotspot instead.
const char* preconfiguredWifiSsid = "";
const char* preconfiguredWifiPassword = "";
constexpr int SCREEN_SIZE = 240;
constexpr int SCREEN_SIZE_DIV_2 = (SCREEN_SIZE / 2);
LGFX tft;
LGFX_Sprite backbuffer(&tft);
WiFiManager wm;
ConfigurationWebServer configServer;
HttpRequestManager http;
OpenSkyAuthTokenHandler authHandler(http);
AircraftManager aircraftManager(configServer, authHandler, http, tft);
void setup()
{
Serial.begin(115200);
// delay(1000); // avoids immediate serial output being cut off - uncomment if needed
// initialise LGFX + screen
tft.init();
tft.invertDisplay(true);
pinMode(3, OUTPUT);
digitalWrite(3, HIGH);
backbuffer.setColorDepth(8);
backbuffer.createSprite(SCREEN_SIZE, SCREEN_SIZE);
// establish WiFi connection
tft.fillScreen(lgfx::color888(0, 0, 0));
tft.setTextColor(lgfx::color888(0, 255, 0));
tft.drawCentreString("Connecting to WiFi...", SCREEN_SIZE / 2, SCREEN_SIZE / 2);
WiFiManagerHelpers::ConfigureWiFiManager(wm, tft);
if (strlen(preconfiguredWifiSsid) > 0) {
WiFi.begin(preconfiguredWifiSsid, preconfiguredWifiPassword);
WiFi.waitForConnectResult();
}
wm.autoConnect(WiFiManagerHelpers::WiFiManagerName);
// begin background server for configuration
configServer.Initialise();
// initialise aircraft manager
aircraftManager.Initialise();
}
void loop()
{
aircraftManager.Update();
// draw cycle
backbuffer.fillScreen(lgfx::color888(0, 0, 0));
String renderScanlines = configServer.GetStoredString("scanline");
if (renderScanlines.isEmpty() || renderScanlines == "true") {
DrawScanLines(backbuffer,
SCREEN_SIZE_DIV_2 - 1,
SCREEN_SIZE_DIV_2 - 1,
SCREEN_SIZE_DIV_2 - 1 + (std::cos(millis() / 3000.0f) * SCREEN_SIZE_DIV_2),
SCREEN_SIZE_DIV_2 - 1 + (std::sin(millis() / 3000.0f) * SCREEN_SIZE_DIV_2),
20, 128, 5
);
}
aircraftManager.Draw(backbuffer);
backbuffer.pushSprite(0, 0);
}
+30
View File
@@ -0,0 +1,30 @@
#include "models/Aircraft.h"
namespace JsonParser {
template<>
Aircraft Parse<Aircraft>(const JsonVariant& state) {
Aircraft a;
a.icao24 = state[0].isNull() ? "" : state[0].as<String>();
a.callsign = state[1].isNull() ? "" : state[1].as<String>();
a.originCountry = state[2].isNull() ? "" : state[2].as<String>();
a.timePosition = state[3].isNull() ? 0 : state[3].as<long>();
a.lastContact = state[4].isNull() ? 0 : state[4].as<long>();
a.longitude = state[5].isNull() ? 0.0f : state[5].as<float>();
a.latitude = state[6].isNull() ? 0.0f : state[6].as<float>();
a.baroAltitude = state[7].isNull() ? 0.0f : state[7].as<float>();
a.onGround = state[8].as<bool>();
a.velocity = state[9].isNull() ? 0.0f : state[9].as<float>();
a.trueTrack = state[10].isNull() ? 0.0f : state[10].as<float>();
a.verticalRate = state[11].isNull() ? 0.0f : state[11].as<float>();
// state[12] = sensors, skipped
a.geoAltitude = state[13].isNull() ? 0.0f : state[13].as<float>();
a.squawk = state[14].isNull() ? "" : state[14].as<String>();
a.spi = state[15].isNull() ? false : state[15].as<bool>();
a.positionSource = state[16].isNull() ? 0 : state[16].as<int>();
a.category = state[17].isNull() ? 0 : state[17].as<int>();
return a;
}
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#include <vector>
#include "JsonParser.h"
// Maps to OpenSky /states/all response
// Field indices match the documented state vector array order
struct Aircraft {
String icao24; // [0] unique ICAO 24-bit transponder address
String callsign; // [1] flight callsign (8 chars), may be null
String originCountry; // [2] country inferred from ICAO address
long timePosition; // [3] unix timestamp of last position update, null if >15s ago
long lastContact; // [4] unix timestamp of last message received from transponder
float longitude; // [5] WGS-84 longitude in decimal degrees
float latitude; // [6] WGS-84 latitude in decimal degrees
float baroAltitude; // [7] barometric altitude in metres
bool onGround; // [8] true if surface position report
float velocity; // [9] ground speed in m/s
float trueTrack; // [10] heading in degrees clockwise from north
float verticalRate; // [11] climb/descent rate in m/s (positive = climbing)
// [12] sensors — null unless filtered by sensor, skipped
float geoAltitude; // [13] geometric altitude in metres
String squawk; // [14] transponder squawk code
bool spi; // [15] special purpose indicator flag
int positionSource; // [16] 0=ADS-B, 1=ASTERIX, 2=MLAT, 3=FLARM
int category; // [17] aircraft category (0=unknown, see docs for full list)
};
namespace JsonParser {
template<>
Aircraft Parse<Aircraft>(const JsonVariant& state);
}
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "Aircraft.h"
struct TrackedAircraft {
Aircraft state;
unsigned long lastSeen;
// blending state
float blendFromLat = 0.0f;
float blendFromLon = 0.0f;
float blendAlpha = 1.0f; // 1.0 = blend complete, no interpolation active
unsigned long lastTick = 0;
// first appearance, no blend needed
TrackedAircraft(const Aircraft& ac, unsigned long now)
: state(ac), lastSeen(now),
blendFromLat(ac.latitude),
blendFromLon(ac.longitude),
blendAlpha(1.0f) {
}
// subsequent update — blend from current visual position
void Update(const Aircraft& newState, unsigned long now) {
// capture visual position at moment of update before switching state
auto [curLat, curLon] = GetDisplayPosition();
blendFromLat = curLat;
blendFromLon = curLon;
blendAlpha = 0.0f; // restart blend
state = newState;
lastSeen = now;
}
void Tick() {
unsigned long now = millis();
float deltaSeconds = (now - lastTick) / 1000.0f;
lastTick = now;
const float blendSpeed = 0.15f; // lower = slower, higher = faster
blendAlpha = min(blendAlpha + deltaSeconds * blendSpeed, 1.0f);
}
std::pair<float, float> GetDisplayPosition() const {
auto [deadLat, deadLon] = PredictPosition();
if (blendAlpha >= 1.0f)
return { deadLat, deadLon };
// ease-in-out for smoother feel
float t = blendAlpha * blendAlpha * (3.0f - 2.0f * blendAlpha);
return {
blendFromLat + t * (deadLat - blendFromLat),
blendFromLon + t * (deadLon - blendFromLon)
};
}
std::pair<float, float> PredictPosition() const {
float dataAgeOnArrival = 0.0f;
if (state.timePosition > 0 && state.lastContact > 0)
dataAgeOnArrival = (float)(state.lastContact - state.timePosition);
float localElapsed = (millis() - lastSeen) / 1000.0f;
float dt = localElapsed + dataAgeOnArrival;
float headingRad = radians(state.trueTrack);
const float latMetersPerDeg = 111320.0f;
float deltaLat = (state.velocity * dt * cos(headingRad)) / latMetersPerDeg;
float deltaLon = (state.velocity * dt * sin(headingRad)) / (latMetersPerDeg * cos(radians(state.latitude)));
return { state.latitude + deltaLat, state.longitude + deltaLon };
}
};