chore: import upstream snapshot with attribution
This commit is contained in:
+72
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`AnthonySturdy/micro-radar`
|
||||
- 原始仓库:https://github.com/AnthonySturdy/micro-radar
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
Binary file not shown.
@@ -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.
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 °):</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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user