16 Commits

Author SHA1 Message Date
onur cayan 50a5735140 Organize Linux and macOS as separate applications
Platform builds / linux (ubuntu-22.04) (push) Has been cancelled
Platform builds / linux (ubuntu-24.04) (push) Has been cancelled
Platform builds / macos (push) Has been cancelled
2026-08-14 13:12:54 +03:00
onur cayan 334c07eb49 Merge macOS development branch
# Conflicts:
#	src/MainWindow.cpp
#	src/MainWindow.h
#	src/RinexNavigation.cpp
#	tests/main_window_tests.cpp
2026-08-14 13:03:28 +03:00
onur cayan 0839b8ae24 Merge Linux development branch 2026-08-14 13:03:15 +03:00
Onur Cayan 498757b502 Add source-aware UART log replay
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-08-12 13:54:11 +03:00
Onur Cayan c8d6314e48 Add source-aware UART log replay
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-08-12 13:54:06 +03:00
Onur Cayan f7fa2b3e4d Fix incomplete map tile updates
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-08-10 00:51:00 +03:00
onur 745c7d91af Normalize zero-based firmware satellite indices
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-24 12:06:12 +03:00
onur 9ced0da0b2 Normalize zero-based firmware satellite indices
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-24 12:06:09 +03:00
onur 73589204f5 Verify satellite propagation is timezone invariant 2026-07-24 11:58:24 +03:00
onur db96fd4da2 Verify satellite propagation is timezone invariant 2026-07-24 11:58:18 +03:00
onur 140b0e35b5 Show RINEX status time in local timezone 2026-07-24 11:51:12 +03:00
onur 6ffa76139c Show RINEX status time in local timezone 2026-07-24 11:50:48 +03:00
onur 73227452c3 Merge GUI updates into macOS branch
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
# Conflicts:
#	src/GlobeWidget.cpp
#	src/GlobeWidget.h
#	src/MainWindow.h
#	src/MapWidget.cpp
#	src/MapWidget.h
#	src/RinexNavigation.cpp
2026-07-24 00:14:47 +03:00
onur f012b7a52a Lock map tracking to corrected IMU output
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-18 18:06:02 +03:00
onur 7a883b801f Add native macOS settings and device integration
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-18 16:51:50 +03:00
onur e648eca324 Add polished macOS location plotter experience
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-18 15:20:37 +03:00
58 changed files with 8000 additions and 350 deletions
+24 -5
View File
@@ -1,11 +1,11 @@
name: Ubuntu build name: Platform builds
on: on:
push: push:
pull_request: pull_request:
jobs: jobs:
build-and-test: linux:
strategy: strategy:
matrix: matrix:
os: [ubuntu-22.04, ubuntu-24.04] os: [ubuntu-22.04, ubuntu-24.04]
@@ -21,10 +21,29 @@ jobs:
sudo apt-get install -y build-essential cmake qt6-base-dev zlib1g-dev sudo apt-get install -y build-essential cmake qt6-base-dev zlib1g-dev
- name: Configure - name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release run: cmake -S linux -B build/linux -DCMAKE_BUILD_TYPE=Release
- name: Build - name: Build
run: cmake --build build --parallel 2 run: cmake --build build/linux --parallel 2
- name: Test - name: Test
run: ctest --test-dir build --output-on-failure run: ctest --test-dir build/linux --output-on-failure
macos:
runs-on: macos-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Install dependencies
run: brew install cmake qt
- name: Configure
run: cmake -S macos -B build/macos -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$(brew --prefix qt)"
- name: Build
run: cmake --build build/macos --parallel 2
- name: Test
run: ctest --test-dir build/macos --output-on-failure
+1
View File
@@ -8,3 +8,4 @@ build-*/
*.autosave *.autosave
*.swp *.swp
*~ *~
uart_logs/
+48 -242
View File
@@ -1,264 +1,70 @@
# Location Plotter # Location Plotter
Qt6/C++ GUI that listens to a USB UART on Linux or macOS, parses normal debug lines, Bu `main` dali, isletim sistemi surumlerini birbirinden bagimsiz uygulama
and plots live locations on a map. agaclari olarak tutar:
The application also includes an interactive 3D Earth/orbit view. It downloads the current
stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15
minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid
file is cached for offline startup.
## Parsed fields
The parser accepts labeled values in the same line or in separate lines:
```text ```text
lat=41.0082 long=28.9784 hheight=123.45 sats=7 location_plotter/
latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7 ├── linux/ # origin/tdk_ekf tabanli Linux surumu
active_sats=8 │ ├── src/
navigation software running with 5 satellites │ ├── tests/
GDOP : 3.9576744550 │ ├── resources/
PDOP : 3.2540598660 │ ├── CMakeLists.txt
HDOP : 2.3631161789 │ ├── run.sh
VDOP : 2.2370935468 │ └── setup-ubuntu.sh
TDOP : 2.2526165852 ├── macos/ # origin/macos tabanli macOS surumu
tracking PRNs: G03, G08, 14, 21 │ ├── src/
SV=G27 │ ├── tests/
[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 │ ├── resources/
[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 │ ├── CMakeLists.txt
[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 │ ├── run.sh
lattitude : 40.78925034214206846173 │ └── setup-macos.sh
longitude : 29.46277530841727809729 └── .github/workflows/ # iki surumun CI tanimlari
height : -89.05779508221894502640
``` ```
Accepted location labels: Windows surumu geldiginde ayni seviyede `windows/` klasoru acilmalidir.
Bu commit Windows kodu veya bos bir Windows iskeleti eklemez.
- Latitude: `lat`, `latitude`, `lattitude` ## Surumler arasindaki farklar
- Longitude: `long`, `longitude`, `lon`, `lng`
- Height: `hheight`, `height`, `alt`, `altitude`
- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`,
`activeSats`, `satellites_active`, `num_sats`, `uydu`
- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP`
- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27`
- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory`
- `tracked` is accepted as the active satellite count; `used` remains visible in
the raw UART line and denotes satellites used by the WLS solution.
If a satellite count arrives without a location, the active satellite display is updated. Linux ve macOS kodlari bilincli olarak ayri tutulur. Ortak duzeltmeler iki
If a location arrives without a satellite count, the latest known satellite count is attached klasore de ayri commitlerle uygulanmalidir; bir klasordeki degisiklik digerini
to that point. otomatik olarak etkilemez.
If DOP values arrive before a location, the latest known values stay visible in the DOP
section and are attached to the next plotted point.
If latitude, longitude, and height arrive as separate consecutive lines, they are combined
into a single plotted point.
The GPS firmware's level-1 paired protocol is source-aware. Every received - `linux/`, `tdk_ekf` dalindaki Linux uygulamasidir.
`source=gps` location is added to the blue GPS series and every - `macos/`, macOS'a ozel menuler, kalici pencere/seri-port ayarlari, otomatik
`source=imu_corrected` location is added to the red corrected series at the USB UART yenileme, gelismis harita cizimi ve Blue Marble dunya dokusunu
firmware-configured UART cadence. iceren daha genis GUI varyantidir.
Paths are built independently, so alternating UART lines do not create GPS-to-IMU - Seri port adlari farklidir: Linux `/dev/ttyUSB*` ve `/dev/ttyACM*`, macOS
zigzags. A corrected point newer than one second is also preferred for map `/dev/cu.usb*` ve `/dev/tty.usb*` yollarini kullanir.
auto-follow, the current-position label and the globe receiver marker. Raw GPS - macOS ozel baud-rate ayari `IOSSIOSPEED` ile yapilir. Linux standart
outliers remain visible in blue for diagnosis without pulling the whole view away. `termios` baud sabitlerini kullanir.
When corrected points exist, CEP50, average and scatter calculations use only that - macOS uygulamasi `.app` bundle olarak paketlenir; Linux normal executable
corrected series; legacy input without a source keeps the previous behavior. uretir ve seri port izni icin `dialout` grubunu kullanir.
Synthetic `source=trajectory` points form a third independent green ground-truth - macOS varyantinda ek `map_widget_tests` testi ve yerel Blue Marble kaynagi
path. The map legend identifies all three series. vardir; Linux varyanti daha kucuk kaynak/test setini korur.
## Sentetik ESKF senaryolarini açma Bu ayrim, iki surumun bagimsiz gelistirilmesini kolaylastirir; karsiliginda
parser, RINEX veya replay gibi ortak bir hata duzeltmesi iki klasorde de
uygulanip iki test paketiyle dogrulanmalidir.
GPS deposundan 48 kombinasyonun replay loglarini üretin: ## Calistirma
Linux:
```bash ```bash
cd /home/onur/Desktop/gps cd linux
make -C soc/software/fusion/tests scenario-logs
```
Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin
`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin.
GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da
doğrudan açılabilir:
```bash
cd /home/onur/Desktop/location_plotter
cmake -S . -B build
cmake --build build -j
./build/location_plotter --replay \
/home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log
```
Tek bir özel kombinasyon üretmek için:
```bash
cd /home/onur/Desktop/gps/soc/software/fusion/tests
make platform_scenario_matrix_test
./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log
```
Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS
periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir.
## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel)
Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan:
```bash
cd location_plotter
chmod +x setup-macos.sh run.sh
./setup-macos.sh
./run.sh
```
Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin
`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine
ekler ve testleri calistirir.
macOS USB UART aygitlarini genellikle iki adla gosterir:
```text
/dev/cu.usbserial-XXXXXXXX
/dev/tty.usbserial-XXXXXXXX
```
veya bir CDC aygitinda:
```text
/dev/cu.usbmodemXXXX
/dev/tty.usbmodemXXXX
```
Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta
disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama
acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik
algilanmayan bir yol port alanina elle de yazilabilir.
Istenen port baslatmadan once ortam degiskeniyle de secilebilir:
```bash
LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh
```
Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup
olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART
donaniminizin macOS surucusunu kontrol edin.
## Ubuntu'da kurulum
Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve
testleri calistirmak icin:
```bash
git clone <depo-adresi> location_plotter
cd location_plotter
./setup-ubuntu.sh ./setup-ubuntu.sh
./run.sh ./run.sh
``` ```
`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur, macOS:
testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler.
Kurulum sirasinda `sudo` parolasi istenebilir.
Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir.
## Elle derleme
Gerekli paketler:
```bash
sudo apt update
sudo apt install build-essential cmake qt6-base-dev zlib1g-dev
```
Derleme ve test:
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
Run:
```bash ```bash
cd macos
./setup-macos.sh
./run.sh ./run.sh
``` ```
Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede Ayrintili kullanim ve telemetri formati ilgili klasorun `README.md` dosyasinda
calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir, yer alir.
ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir.
Port secici Linux'ta `/dev/ttyUSB*` ve `/dev/ttyACM*`; macOS'ta USB ile ilgili
`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa Linux'ta
`/dev/ttyUSB2`, macOS'ta `/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak
gosterilir. 50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud
rate `460800`'dur.
If the port cannot be opened, make sure the user has serial access:
```bash
sudo usermod -a -G dialout "$USER"
```
Log out and back in after changing group membership.
If the user is already listed in the `dialout` group but the current shell has not picked it
up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2`
without logging out.
## Notes
- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection,
points are still drawn over a fallback grid.
- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values
from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF.
PRNs parsed from the current navigation solve are green; other RINEX satellites are red.
Drag to rotate the globe and use the mouse wheel to zoom.
- The globe includes a shaded continent layer and a receiver-centred translucent visibility
cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees
(10 degrees by default, press Enter after typing);
satellites inside the mask get a cyan sight line and halo. Incoming positions update the
receiver marker without resetting a globe orientation chosen by dragging.
- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the
previous two UTC days if today's stream file is unavailable. Current and next-minute satellite
positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS.
- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status
line shows the UTC time of the last successful download or cache load.
- Complete UART channel snapshots such as `tracked satellites=5 prns=[...]` replace the previous
connected-PRN set, so satellites that were lost no longer remain green.
- RINEX text is parsed once per download and retained as compact ephemeris records. The static
globe is cached between camera changes, reducing CPU and allocation load during orbit animation.
- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring,
shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation.
- The main content width and the right-side dashboard/UART height are both adjustable by
dragging their splitter handles. Dashboard cards scroll independently so the UART log can
be enlarged substantially.
- `Max` controls how many recent points stay visible and are included in location
analysis. The default is 10. When the limit is reached, older points are removed
automatically.
- Location analysis shows the average of the plotted samples, the last point's deviation
from that average, mean deviation, RMS scatter, and max deviation in meters. The average
marker can be toggled on the map.
- The map layers panel can hide all telemetry overlays, show only the newest location instead
of the recent path, and independently toggle the average marker.
- GPS points and paths are blue. IMU+ESKF corrected points and their independent
path are red. Synthetic ground-truth trajectory is green; the locations table
shows the parsed source in its `Kaynak` column.
- The application calculates empirical CEP50 as the median horizontal distance of the retained
measurements from their spherical mean, then draws the circle around that measurement center.
This is the radius containing 50 percent of the retained position fixes and is independent of
the optional user reference.
- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates
are transferred to the reference fields with 15 decimal places but do not alter CEP50.
- Latitude and longitude tokens are preserved exactly as received for the table and map
label. Calculations and map projection use IEEE 754 `double` precision.
- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8,
zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates,
but the background tiles cannot add accuracy to the GNSS measurement.
- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP
cache in the application's standard cache directory.
- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not
rebuild the complete interface for every received line. The locations table uses a virtual
model and remains usable with a high `Max` value.
- If no new plotted location arrives for 10 seconds while listening, current location
points and analysis values are cleared.
+27 -13
View File
@@ -2,6 +2,10 @@ cmake_minimum_required(VERSION 3.20)
project(location_plotter VERSION 0.3.0 LANGUAGES CXX) project(location_plotter VERSION 0.3.0 LANGUAGES CXX)
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(FATAL_ERROR "linux/ yalnizca Linux icin yapilandirilabilir")
endif()
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
@@ -22,6 +26,8 @@ add_executable(location_plotter
src/MapWidget.h src/MapWidget.h
src/SerialReader.cpp src/SerialReader.cpp
src/SerialReader.h src/SerialReader.h
src/UartReplay.cpp
src/UartReplay.h
src/RinexNavigation.cpp src/RinexNavigation.cpp
src/RinexNavigation.h src/RinexNavigation.h
src/SatelliteData.h src/SatelliteData.h
@@ -30,25 +36,16 @@ add_executable(location_plotter
) )
target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
target_compile_definitions(location_plotter PRIVATE
if(APPLE) LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
set_target_properties(location_plotter PROPERTIES
MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_BUNDLE_NAME "Location Plotter"
MACOSX_BUNDLE_GUI_IDENTIFIER "com.ankasys.location-plotter"
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
) )
endif()
qt_add_resources(location_plotter "app_resources" qt_add_resources(location_plotter "app_resources"
PREFIX "/" PREFIX "/"
FILES resources/ankasys-logo.png resources/land-110m.json FILES resources/ankasys-logo.png resources/land-110m.json
) )
if(UNIX AND NOT APPLE)
target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
endif()
enable_testing() enable_testing()
@@ -79,12 +76,17 @@ add_executable(location_main_window_tests
src/GlobeWidget.h src/GlobeWidget.h
src/SerialReader.cpp src/SerialReader.cpp
src/SerialReader.h src/SerialReader.h
src/UartReplay.cpp
src/UartReplay.h
src/RinexNavigation.cpp src/RinexNavigation.cpp
src/RinexNavigation.h src/RinexNavigation.h
src/SatelliteData.h src/SatelliteData.h
src/TelemetryPoint.h src/TelemetryPoint.h
) )
target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
target_compile_definitions(location_main_window_tests PRIVATE
LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
)
target_compile_options(location_main_window_tests PRIVATE target_compile_options(location_main_window_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG> $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
) )
@@ -92,13 +94,25 @@ qt_add_resources(location_main_window_tests "test_app_resources"
PREFIX "/" PREFIX "/"
FILES resources/ankasys-logo.png resources/land-110m.json FILES resources/ankasys-logo.png resources/land-110m.json
) )
if(UNIX AND NOT APPLE)
target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
endif()
add_test(NAME location_main_window_tests COMMAND location_main_window_tests) add_test(NAME location_main_window_tests COMMAND location_main_window_tests)
set_tests_properties(location_main_window_tests PROPERTIES set_tests_properties(location_main_window_tests PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache") ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache")
add_executable(location_uart_replay_tests
tests/uart_replay_tests.cpp
src/UartReplay.cpp
src/UartReplay.h
src/LocationParser.cpp
src/LocationParser.h
src/TelemetryPoint.h
)
target_link_libraries(location_uart_replay_tests PRIVATE Qt6::Core)
target_compile_options(location_uart_replay_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
add_test(NAME location_uart_replay_tests COMMAND location_uart_replay_tests)
install(TARGETS location_plotter install(TARGETS location_plotter
BUNDLE DESTINATION . BUNDLE DESTINATION .
RUNTIME DESTINATION bin RUNTIME DESTINATION bin
+236
View File
@@ -0,0 +1,236 @@
# Location Plotter
Qt6/C++ GUI that listens to a USB UART on Linux, parses normal debug lines,
and plots live locations on a map.
The application also includes an interactive 3D Earth/orbit view. It downloads the current
stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15
minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid
file is cached for offline startup.
## Parsed fields
The parser accepts labeled values in the same line or in separate lines:
```text
lat=41.0082 long=28.9784 hheight=123.45 sats=7
latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7
active_sats=8
navigation software running with 5 satellites
GDOP : 3.9576744550
PDOP : 3.2540598660
HDOP : 2.3631161789
VDOP : 2.2370935468
TDOP : 2.2526165852
tracking PRNs: G03, G08, 14, 21
SV=G27
[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4
lattitude : 40.78925034214206846173
longitude : 29.46277530841727809729
height : -89.05779508221894502640
```
Accepted location labels:
- Latitude: `lat`, `latitude`, `lattitude`
- Longitude: `long`, `longitude`, `lon`, `lng`
- Height: `hheight`, `height`, `alt`, `altitude`
- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`,
`activeSats`, `satellites_active`, `num_sats`, `uydu`
- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP`
- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27`
- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory`
- `tracked` is accepted as the active satellite count; `used` remains visible in
the raw UART line and denotes satellites used by the WLS solution.
If a satellite count arrives without a location, the active satellite display is updated.
If a location arrives without a satellite count, the latest known satellite count is attached
to that point.
If DOP values arrive before a location, the latest known values stay visible in the DOP
section and are attached to the next plotted point.
If latitude, longitude, and height arrive as separate consecutive lines, they are combined
into a single plotted point.
The GPS firmware's level-1 paired protocol is source-aware. Every received
`source=gps` location is added to the blue GPS series and every
`source=imu_corrected` location is added to the red corrected series at the
firmware-configured UART cadence.
Paths are built independently, so alternating UART lines do not create GPS-to-IMU
zigzags. A corrected point newer than one second is also preferred for map
auto-follow, the current-position label and the globe receiver marker. Raw GPS
outliers remain visible in blue for diagnosis without pulling the whole view away.
When corrected points exist, CEP50, average and scatter calculations use only that
corrected series; legacy input without a source keeps the previous behavior.
Synthetic `source=trajectory` points form a third independent green ground-truth
path. The map legend identifies all three series.
## UART log replay
Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded
UART `.log`/`.txt` file back through the same parser used by the live serial port.
Choose the raw GPS update interval (default `200 ms`) and sensor/fused update
interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop.
Recorder timestamps are optional and do not control replay speed. The replayer
removes a leading timestamp when present, identifies `source=gps` as raw GPS and
`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the
two update timelines while retaining the original UART line order. Other UART
diagnostic lines are forwarded with the surrounding location update.
## Sentetik ESKF senaryolarini açma
GPS deposundan 48 kombinasyonun replay loglarini üretin:
```bash
cd /home/onur/Desktop/gps
make -C soc/software/fusion/tests scenario-logs
```
Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin
`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin.
GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da
doğrudan açılabilir:
```bash
cd location_plotter/linux
cmake -S . -B build
cmake --build build -j
./build/location_plotter --replay \
/home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log
```
Tek bir özel kombinasyon üretmek için:
```bash
cd /home/onur/Desktop/gps/soc/software/fusion/tests
make platform_scenario_matrix_test
./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log
```
Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS
periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir.
## Ubuntu'da kurulum
Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve
testleri calistirmak icin:
```bash
git clone <depo-adresi> location_plotter
cd location_plotter/linux
./setup-ubuntu.sh
./run.sh
```
`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur,
testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler.
Kurulum sirasinda `sudo` parolasi istenebilir.
Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir.
## Elle derleme
Gerekli paketler:
```bash
sudo apt update
sudo apt install build-essential cmake qt6-base-dev zlib1g-dev
```
Derleme ve test:
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
Run:
```bash
./run.sh
```
Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede
calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir,
ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir.
Port secici `/dev/ttyUSB*` ve `/dev/ttyACM*` aygitlarini listeler. Hic aygit
algilanmazsa `/dev/ttyUSB2` duzenlenebilir bir varsayilan yol olarak gosterilir.
50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud rate
`460800`'dur.
If the port cannot be opened, make sure the user has serial access:
```bash
sudo usermod -a -G dialout "$USER"
```
Log out and back in after changing group membership.
If the user is already listed in the `dialout` group but the current shell has not picked it
up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2`
without logging out.
## Notes
- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection,
points are still drawn over a fallback grid.
- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values
from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF.
PRNs parsed from the current navigation solve are green; other RINEX satellites are red.
Drag to rotate the globe and use the mouse wheel to zoom.
- The globe includes a shaded continent layer and a receiver-centred translucent visibility
cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees
(10 degrees by default, press Enter after typing);
satellites inside the mask get a cyan sight line and halo. Incoming positions update the
receiver marker without resetting a globe orientation chosen by dragging.
- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the
previous two UTC days if today's stream file is unavailable. Current and next-minute satellite
positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS.
- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status
line shows the system-local time of the last successful download or cache load.
- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the
previous connected-PRN set, so satellites that were lost no longer remain green. Firmware
channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`).
- RINEX text is parsed once per download and retained as compact ephemeris records. The static
globe is cached between camera changes, reducing CPU and allocation load during orbit animation.
- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring,
shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation.
- The main content width and the right-side dashboard/UART height are both adjustable by
dragging their splitter handles. Dashboard cards scroll independently so the UART log can
be enlarged substantially.
- `Max` controls how many recent points stay visible and are included in location
analysis. The default is 10. When the limit is reached, older points are removed
automatically.
- Location analysis shows the average of the plotted samples, the last point's deviation
from that average, mean deviation, RMS scatter, and max deviation in meters. The average
marker can be toggled on the map.
- The map layers panel can hide all telemetry overlays, show only the newest location instead
of the recent path, and independently toggle the average marker.
- GPS points and paths are blue. IMU+ESKF corrected points and their independent
path are red. Synthetic ground-truth trajectory is green; the locations table
shows the parsed source in its `Kaynak` column.
- The application calculates empirical CEP50 as the median horizontal distance of the retained
measurements from their spherical mean, then draws the circle around that measurement center.
This is the radius containing 50 percent of the retained position fixes and is independent of
the optional user reference.
- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates
are transferred to the reference fields with 15 decimal places but do not alter CEP50.
- Latitude and longitude tokens are preserved exactly as received for the table and map
label. Calculations and map projection use IEEE 754 `double` precision.
- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8,
zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates,
but the background tiles cannot add accuracy to the GNSS measurement.
- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP
cache in the application's standard cache directory.
- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not
rebuild the complete interface for every received line. The locations table uses a virtual
model and remains usable with a high `Max` value.
- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir
log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir;
`LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir.
- If no new plotted location arrives for 10 seconds while listening, current location
points and analysis values are cleared.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Executable
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
print_qt_install_hint() {
echo
echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:"
echo " sudo apt update"
echo " sudo apt install build-essential cmake qt6-base-dev zlib1g-dev"
echo "veya dogrudan: ./setup-ubuntu.sh"
}
if [[ "$(uname -s)" != "Linux" ]]; then
echo "Bu calistirma betigi Linux icindir." >&2
exit 1
fi
project_dir="$(pwd -P)"
if [[ -f ./build/CMakeCache.txt ]]; then
cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)"
if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then
echo "Build klasoru farkli bir proje yoluna ait. 'linux/build' klasorunu silip tekrar deneyin." >&2
exit 1
fi
fi
if ! cmake -S . -B build; then
print_qt_install_hint
exit 1
fi
cmake --build build
missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)"
if [[ -n "$missing_libs" ]]; then
echo "Eksik paylasimli kutuphaneler:"
echo "$missing_libs" | sed 's/^/ /'
print_qt_install_hint
exit 1
fi
if id -nG | tr ' ' '\n' | grep -qx dialout; then
exec ./build/location_plotter "$@"
fi
if getent group dialout | tr ':' '\n' | tail -n 1 | tr ',' '\n' | grep -qx "$(id -un)"; then
exec sg dialout -c "$(printf '%q ' ./build/location_plotter "$@")"
fi
echo "Bu kullanici dialout grubunda degil. Eklemek icin:"
echo " sudo usermod -a -G dialout $(id -un)"
echo "Sonra oturumu kapatip ac veya bu scripti tekrar calistir."
exit 1
@@ -88,6 +88,9 @@ std::optional<ParsedTelemetry> LocationParser::parseLine(const QString &line)
ParsedTelemetry telemetry; ParsedTelemetry telemetry;
telemetry.rawLine = trimmed; telemetry.rawLine = trimmed;
if (trimmed.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)) {
telemetry.source = LocationSource::ImuCorrected;
}
static const QRegularExpression sourceRegex( static const QRegularExpression sourceRegex(
QStringLiteral(R"((?:^|\s)source\s*=\s*(?<source>gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"), QStringLiteral(R"((?:^|\s)source\s*=\s*(?<source>gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"),
QRegularExpression::CaseInsensitiveOption); QRegularExpression::CaseInsensitiveOption);
@@ -234,10 +237,11 @@ QSet<QString> LocationParser::parseGpsPrns(const QString &line)
QSet<QString> result; QSet<QString> result;
const auto addPrn = [&result](QString value, bool zeroBased = false) { const auto addPrn = [&result](QString value, bool zeroBased = false) {
value = value.trimmed().toUpper(); value = value.trimmed().toUpper();
if (value.startsWith(QLatin1Char('G'))) value.remove(0, 1); const bool explicitGpsPrn = value.startsWith(QLatin1Char('G'));
if (explicitGpsPrn) value.remove(0, 1);
bool ok = false; bool ok = false;
int number = value.toInt(&ok); int number = value.toInt(&ok);
if (ok && zeroBased) ++number; if (ok && zeroBased && !explicitGpsPrn) ++number;
if (ok && number >= 1 && number <= 63) { if (ok && number >= 1 && number <= 63) {
result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0'))); result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0')));
} }
@@ -249,18 +253,31 @@ QSet<QString> LocationParser::parseGpsPrns(const QString &line)
auto gpsMatches = gpsToken.globalMatch(line); auto gpsMatches = gpsToken.globalMatch(line);
while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn"))); while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn")));
static const QRegularExpression labeledList( // Current firmware versions called their zero-based hardware channel values
QStringLiteral(R"((?<label>\bPRNS?\b|\bSVS?\b|\bSVNS?\b|\bSVIDS?\b)\s*(?:=|:|->)?\s*[\[(]?\s*(?<list>(?:G?\d{1,2})(?:\s*[,;/| ]\s*G?\d{1,2})*)\s*[\])]?)"), // "prns" in complete tracking snapshots. Keep accepting those messages while
// newer firmware uses the unambiguous "prn_indices" label.
static const QRegularExpression legacyTrackingIndices(
QStringLiteral(R"(\btracked\s+satellites\s*=\s*\d+\s+prns?\s*=)"),
QRegularExpression::CaseInsensitiveOption); QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression number(QStringLiteral(R"(G?(\d{1,2}))"), const bool legacyZeroBasedPrns = legacyTrackingIndices.match(line).hasMatch();
static const QRegularExpression labeledList(
QStringLiteral(R"((?<label>\bPRN[_ -]?INDICES\b|\bPRNS?\b|\bSVS?\b|\bSVNS?\b|\bSVIDS?\b)\s*(?:=|:|->)?\s*[\[(]?\s*(?<list>(?:G?\d{1,2})(?:\s*[,;/| ]\s*G?\d{1,2})*)\s*[\])]?)"),
QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression number(QStringLiteral(R"(G?\d{1,2})"),
QRegularExpression::CaseInsensitiveOption); QRegularExpression::CaseInsensitiveOption);
auto listMatches = labeledList.globalMatch(line); auto listMatches = labeledList.globalMatch(line);
while (listMatches.hasNext()) { while (listMatches.hasNext()) {
const QRegularExpressionMatch listMatch = listMatches.next(); const QRegularExpressionMatch listMatch = listMatches.next();
const bool zeroBased = listMatch.captured(QStringLiteral("label")).startsWith( QString label = listMatch.captured(QStringLiteral("label")).toUpper();
QStringLiteral("SVN"), Qt::CaseInsensitive); label.remove(QLatin1Char('_'));
label.remove(QLatin1Char('-'));
label.remove(QLatin1Char(' '));
const bool zeroBased = label.startsWith(QStringLiteral("SVN"))
|| label == QStringLiteral("PRNINDICES")
|| (legacyZeroBasedPrns && label.startsWith(QStringLiteral("PRN")));
auto values = number.globalMatch(listMatch.captured(QStringLiteral("list"))); auto values = number.globalMatch(listMatch.captured(QStringLiteral("list")));
while (values.hasNext()) addPrn(values.next().captured(1), zeroBased); while (values.hasNext()) addPrn(values.next().captured(0), zeroBased);
} }
return result; return result;
} }
+283 -4
View File
@@ -5,28 +5,35 @@
#include "MapWidget.h" #include "MapWidget.h"
#include "RinexNavigation.h" #include "RinexNavigation.h"
#include "SerialReader.h" #include "SerialReader.h"
#include "UartReplay.h"
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QAbstractTableModel> #include <QAbstractTableModel>
#include <QCheckBox> #include <QCheckBox>
#include <QCloseEvent>
#include <QComboBox> #include <QComboBox>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QFormLayout> #include <QFormLayout>
#include <QFile> #include <QFile>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo>
#include <QGridLayout> #include <QGridLayout>
#include <QGroupBox> #include <QGroupBox>
#include <QHeaderView> #include <QHeaderView>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QScrollArea> #include <QScrollArea>
#include <QPushButton> #include <QPushButton>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSpinBox> #include <QSpinBox>
#include <QSplitter> #include <QSplitter>
#include <QSettings>
#include <QStatusBar> #include <QStatusBar>
#include <QStyle> #include <QStyle>
#include <QTabWidget> #include <QTabWidget>
@@ -375,6 +382,7 @@ private:
MainWindow::MainWindow(QWidget *parent) MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent) : QMainWindow(parent)
, m_serial(new SerialReader(this)) , m_serial(new SerialReader(this))
, m_replay(new UartReplay(this))
, m_rinex(new RinexNavigation(this)) , m_rinex(new RinexNavigation(this))
{ {
buildUi(); buildUi();
@@ -390,6 +398,40 @@ MainWindow::MainWindow(QWidget *parent)
connect(m_serial, &SerialReader::lineReceived, this, &MainWindow::handleSerialLine); connect(m_serial, &SerialReader::lineReceived, this, &MainWindow::handleSerialLine);
connect(m_serial, &SerialReader::statusChanged, this, &MainWindow::handleSerialStatus); connect(m_serial, &SerialReader::statusChanged, this, &MainWindow::handleSerialStatus);
connect(m_serial, &SerialReader::errorOccurred, this, &MainWindow::handleSerialError); connect(m_serial, &SerialReader::errorOccurred, this, &MainWindow::handleSerialError);
connect(m_replay, &UartReplay::lineReady, this, &MainWindow::handleSerialLine);
connect(m_replay, &UartReplay::replayStarted, this,
[this](int lineCount, int locationCount) {
const QString message = QStringLiteral(
"UART replay basladi: %1 konum, %2 satir")
.arg(locationCount).arg(lineCount);
m_statusLabel->setText(message);
m_log->appendPlainText(QStringLiteral("[STATUS] ") + message);
updateConnectionControls();
});
connect(m_replay, &UartReplay::progressChanged, this,
[this](int processed, int total) {
if (processed == total || processed % 100 == 0) {
m_statusLabel->setText(QStringLiteral("UART replay: %1 / %2 satir")
.arg(processed).arg(total));
}
});
connect(m_replay, &UartReplay::replayFinished, this, [this] {
commitPendingTelemetry();
flushUiUpdates();
m_map->fitToPoints();
const QString message = QStringLiteral("UART replay tamamlandi: %1 konum")
.arg(m_points.size());
m_statusLabel->setText(message);
m_log->appendPlainText(QStringLiteral("[STATUS] ") + message);
statusBar()->showMessage(message, 8000);
updateConnectionControls();
});
connect(m_replay, &UartReplay::errorOccurred, this, [this](const QString &message) {
m_statusLabel->setText(message);
m_log->appendPlainText(QStringLiteral("[ERROR] ") + message);
statusBar()->showMessage(message, 8000);
updateConnectionControls();
});
connect(m_locationTimeoutTimer, &QTimer::timeout, this, &MainWindow::handleLocationTimeout); connect(m_locationTimeoutTimer, &QTimer::timeout, this, &MainWindow::handleLocationTimeout);
connect(m_uiRefreshTimer, &QTimer::timeout, this, &MainWindow::flushUiUpdates); connect(m_uiRefreshTimer, &QTimer::timeout, this, &MainWindow::flushUiUpdates);
connect(m_rinex, &RinexNavigation::satelliteMotionUpdated, connect(m_rinex, &RinexNavigation::satelliteMotionUpdated,
@@ -417,10 +459,21 @@ MainWindow::MainWindow(QWidget *parent)
setWindowTitle(QStringLiteral("Canli Konum Haritasi")); setWindowTitle(QStringLiteral("Canli Konum Haritasi"));
} }
void MainWindow::closeEvent(QCloseEvent *event)
{
m_replay->stop();
stopUartLogging();
QMainWindow::closeEvent(event);
}
void MainWindow::toggleSerial() void MainWindow::toggleSerial()
{ {
if (m_replay->isRunning()) {
stopUartReplay();
}
if (m_serial->isOpen()) { if (m_serial->isOpen()) {
m_serial->closePort(); m_serial->closePort();
stopUartLogging();
if (m_locationTimeoutTimer != nullptr) { if (m_locationTimeoutTimer != nullptr) {
m_locationTimeoutTimer->stop(); m_locationTimeoutTimer->stop();
} }
@@ -428,11 +481,130 @@ void MainWindow::toggleSerial()
return; return;
} }
if (m_serial->openPort(m_portCombo->currentText().trimmed(), selectedBaudRate())) { const QString port = m_portCombo->currentText().trimmed();
const int baudRate = selectedBaudRate();
if (m_serial->openPort(port, baudRate)) {
startUartLogging(port, baudRate);
updateConnectionControls(); updateConnectionControls();
} }
} }
bool MainWindow::startUartReplay(const QString &filePath, int gpsIntervalMs,
int sensorIntervalMs)
{
if (m_serial->isOpen()) {
m_serial->closePort();
stopUartLogging();
}
m_replay->stop();
resetTelemetryState();
if (!m_replay->start(filePath, gpsIntervalMs, sensorIntervalMs)) {
updateConnectionControls();
return false;
}
m_maxPointsSpin->setValue(std::clamp(m_replay->locationCount(), 1, 10000));
statusBar()->showMessage(
QStringLiteral("UART replay: GPS %1 ms, sensor/fused %2 ms")
.arg(gpsIntervalMs).arg(sensorIntervalMs), 8000);
updateConnectionControls();
return true;
}
void MainWindow::stopUartReplay()
{
if (!m_replay->isRunning()) {
return;
}
m_replay->stop();
commitPendingTelemetry();
flushUiUpdates();
const QString message = QStringLiteral("UART replay durduruldu: %1 konum")
.arg(m_points.size());
m_statusLabel->setText(message);
m_log->appendPlainText(QStringLiteral("[STATUS] ") + message);
statusBar()->showMessage(message, 5000);
updateConnectionControls();
}
bool MainWindow::startUartLogging(const QString &port, int baudRate)
{
stopUartLogging();
QString logDirectory = QString::fromLocal8Bit(qgetenv("LOCATION_PLOTTER_UART_LOG_DIR"));
if (logDirectory.isEmpty()) {
logDirectory = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR))
.filePath(QStringLiteral("uart_logs"));
} else if (QDir::isRelativePath(logDirectory)) {
logDirectory = QDir::current().absoluteFilePath(logDirectory);
}
if (!QDir().mkpath(logDirectory)) {
const QString message = QStringLiteral("UART log klasoru olusturulamadi: %1")
.arg(logDirectory);
m_log->appendPlainText(QStringLiteral("[ERROR] ") + message);
statusBar()->showMessage(message, 8000);
return false;
}
const QString fileName = QStringLiteral("uart_%1.log").arg(
QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz")));
m_uartLogPath = QDir(logDirectory).filePath(fileName);
m_uartLogFile = new QFile(m_uartLogPath, this);
if (!m_uartLogFile->open(QIODevice::WriteOnly | QIODevice::Text)) {
const QString message = QStringLiteral("UART log dosyasi acilamadi: %1")
.arg(m_uartLogFile->errorString());
m_log->appendPlainText(QStringLiteral("[ERROR] ") + message);
statusBar()->showMessage(message, 8000);
delete m_uartLogFile;
m_uartLogFile = nullptr;
m_uartLogPath.clear();
return false;
}
const QString header = QStringLiteral("[%1] [SESSION START] port=%2 baud=%3\n")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")),
port)
.arg(baudRate);
m_uartLogFile->write(header.toUtf8());
m_uartLogFile->flush();
statusBar()->showMessage(QStringLiteral("UART log kaydi: %1").arg(m_uartLogPath), 8000);
return true;
}
void MainWindow::stopUartLogging()
{
if (m_uartLogFile == nullptr) {
return;
}
if (m_uartLogFile->isOpen()) {
const QString footer = QStringLiteral("[%1] [SESSION END]\n").arg(
QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")));
m_uartLogFile->write(footer.toUtf8());
m_uartLogFile->flush();
m_uartLogFile->close();
}
delete m_uartLogFile;
m_uartLogFile = nullptr;
m_uartLogPath.clear();
}
void MainWindow::appendUartLogLine(const QString &line)
{
if (m_uartLogFile == nullptr || !m_uartLogFile->isOpen()) {
return;
}
QString cleanLine = line;
cleanLine.replace(QLatin1Char('\r'), QStringLiteral("\\r"));
cleanLine.replace(QLatin1Char('\n'), QStringLiteral("\\n"));
const QString entry = QStringLiteral("[%1] %2\n")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")),
cleanLine);
m_uartLogFile->write(entry.toUtf8());
m_uartLogFile->flush();
}
void MainWindow::clearPoints() void MainWindow::clearPoints()
{ {
if (m_locationTimeoutTimer != nullptr) { if (m_locationTimeoutTimer != nullptr) {
@@ -443,6 +615,8 @@ void MainWindow::clearPoints()
bool MainWindow::loadTelemetryLog(const QString &filePath) bool MainWindow::loadTelemetryLog(const QString &filePath)
{ {
m_replay->stop();
updateConnectionControls();
QFile file(filePath); QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
const QString message = QStringLiteral("Log acilamadi: %1").arg(file.errorString()); const QString message = QStringLiteral("Log acilamadi: %1").arg(file.errorString());
@@ -485,6 +659,95 @@ void MainWindow::openTelemetryLog()
} }
} }
void MainWindow::openUartReplay()
{
if (m_replay->isRunning()) {
stopUartReplay();
return;
}
QSettings settings;
QDialog dialog(this);
dialog.setWindowTitle(QStringLiteral("UART Log Replay"));
dialog.setMinimumWidth(620);
auto *layout = new QVBoxLayout(&dialog);
auto *form = new QFormLayout();
auto *fileRow = new QWidget(&dialog);
auto *fileLayout = new QHBoxLayout(fileRow);
fileLayout->setContentsMargins(0, 0, 0, 0);
auto *pathEdit = new QLineEdit(fileRow);
pathEdit->setObjectName(QStringLiteral("uartReplayPathInput"));
pathEdit->setText(settings.value(QStringLiteral("replay/file")).toString());
pathEdit->setPlaceholderText(QStringLiteral("UART .log veya .txt dosyasi"));
auto *browseButton = new QPushButton(QStringLiteral("Sec..."), fileRow);
fileLayout->addWidget(pathEdit, 1);
fileLayout->addWidget(browseButton);
form->addRow(QStringLiteral("Kayitli UART verisi"), fileRow);
auto *gpsInterval = new QSpinBox(&dialog);
gpsInterval->setObjectName(QStringLiteral("uartReplayGpsIntervalInput"));
gpsInterval->setRange(1, 60000);
gpsInterval->setSuffix(QStringLiteral(" ms"));
gpsInterval->setValue(settings.value(QStringLiteral("replay/gpsIntervalMs"), 200).toInt());
form->addRow(QStringLiteral("Raw GPS guncelleme"), gpsInterval);
auto *sensorInterval = new QSpinBox(&dialog);
sensorInterval->setObjectName(QStringLiteral("uartReplaySensorIntervalInput"));
sensorInterval->setRange(1, 60000);
sensorInterval->setSuffix(QStringLiteral(" ms"));
sensorInterval->setValue(settings.value(
QStringLiteral("replay/sensorIntervalMs"), 50).toInt());
form->addRow(QStringLiteral("Sensor / fused guncelleme"), sensorInterval);
layout->addLayout(form);
auto *hint = new QLabel(QStringLiteral(
"Log zaman damgasi gerektirmez. source=gps kayitlari GPS hizinda; "
"source=imu_corrected ve [FUSION] kayitlari sensor hizinda yeniden uretilir."),
&dialog);
hint->setWordWrap(true);
layout->addWidget(hint);
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Cancel | QDialogButtonBox::Ok, &dialog);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("Replay Baslat"));
layout->addWidget(buttons);
connect(browseButton, &QPushButton::clicked, &dialog, [this, pathEdit] {
QString initialPath = pathEdit->text().trimmed();
if (initialPath.isEmpty()) {
initialPath = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR))
.filePath(QStringLiteral("uart_logs"));
}
const QString filePath = QFileDialog::getOpenFileName(
this, QStringLiteral("UART replay logunu sec"), initialPath,
QStringLiteral("UART logs (*.log *.txt);;All files (*)"));
if (!filePath.isEmpty()) {
pathEdit->setText(filePath);
}
});
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
connect(buttons, &QDialogButtonBox::accepted, &dialog, [&dialog, pathEdit] {
const QFileInfo selectedFile(pathEdit->text().trimmed());
if (!selectedFile.exists() || !selectedFile.isFile()) {
QMessageBox::warning(&dialog, QStringLiteral("UART Replay"),
QStringLiteral("Gecerli bir UART log dosyasi secin."));
return;
}
dialog.accept();
});
if (dialog.exec() != QDialog::Accepted) {
return;
}
const QString filePath = QFileInfo(pathEdit->text().trimmed()).absoluteFilePath();
settings.setValue(QStringLiteral("replay/file"), filePath);
settings.setValue(QStringLiteral("replay/gpsIntervalMs"), gpsInterval->value());
settings.setValue(QStringLiteral("replay/sensorIntervalMs"), sensorInterval->value());
startUartReplay(filePath, gpsInterval->value(), sensorInterval->value());
}
void MainWindow::refreshSerialPorts() void MainWindow::refreshSerialPorts()
{ {
const QString previousPort = m_portCombo->currentText().trimmed(); const QString previousPort = m_portCombo->currentText().trimmed();
@@ -515,12 +778,13 @@ void MainWindow::refreshSerialPorts()
void MainWindow::handleSerialLine(const QString &line) void MainWindow::handleSerialLine(const QString &line)
{ {
appendUartLogLine(line);
static const QRegularExpression solveStartRegex( static const QRegularExpression solveStartRegex(
QStringLiteral(R"((?:^|\[NAV\]\s*)nav\s+solve\s+(?:start|period)\b)"), QStringLiteral(R"((?:^|\[NAV\]\s*)nav\s+solve\s+(?:start|period)\b)"),
QRegularExpression::CaseInsensitiveOption); QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression trackingSnapshotRegex( static const QRegularExpression trackingSnapshotRegex(
QStringLiteral( QStringLiteral(
R"(\btracked\s+satellites\s*=\s*\d+\s+(?:prns?|svns?)\s*=\s*\[[^\]]*\])"), R"(\btracked\s+satellites\s*=\s*\d+\s+(?:prn[_ -]?indices|prns?|svns?)\s*=\s*\[[^\]]*\])"),
QRegularExpression::CaseInsensitiveOption); QRegularExpression::CaseInsensitiveOption);
const bool solveStarted = solveStartRegex.match(line.trimmed()).hasMatch(); const bool solveStarted = solveStartRegex.match(line.trimmed()).hasMatch();
const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch(); const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch();
@@ -591,6 +855,7 @@ void MainWindow::handleSerialStatus(const QString &status)
void MainWindow::handleSerialError(const QString &message) void MainWindow::handleSerialError(const QString &message)
{ {
stopUartLogging();
flushPendingLogLines(); flushPendingLogLines();
m_statusLabel->setText(message); m_statusLabel->setText(message);
m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); m_log->appendPlainText(QStringLiteral("[ERROR] ") + message);
@@ -854,6 +1119,11 @@ void MainWindow::buildUi()
m_openLogButton = new QPushButton(QStringLiteral("Senaryo Logu Ac"), this); m_openLogButton = new QPushButton(QStringLiteral("Senaryo Logu Ac"), this);
m_openLogButton->setObjectName(QStringLiteral("openLogButton")); m_openLogButton->setObjectName(QStringLiteral("openLogButton"));
m_openLogButton->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton)); m_openLogButton->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton));
m_replayButton = new QPushButton(QStringLiteral("UART Replay"), this);
m_replayButton->setObjectName(QStringLiteral("uartReplayButton"));
m_replayButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay));
m_replayButton->setToolTip(QStringLiteral(
"Kayitli UART verisini GPS ve sensor guncelleme hizlariyla yeniden oynat"));
m_autoFollowCheck = new QCheckBox(QStringLiteral("Kamerayi takip et"), this); m_autoFollowCheck = new QCheckBox(QStringLiteral("Kamerayi takip et"), this);
m_autoFollowCheck->setChecked(true); m_autoFollowCheck->setChecked(true);
@@ -874,6 +1144,7 @@ void MainWindow::buildUi()
toolbarLayout->addWidget(m_maxPointsSpin); toolbarLayout->addWidget(m_maxPointsSpin);
toolbarLayout->addWidget(m_connectButton); toolbarLayout->addWidget(m_connectButton);
toolbarLayout->addWidget(m_openLogButton); toolbarLayout->addWidget(m_openLogButton);
toolbarLayout->addWidget(m_replayButton);
toolbarLayout->addWidget(m_clearButton); toolbarLayout->addWidget(m_clearButton);
toolbarLayout->addWidget(m_autoFollowCheck); toolbarLayout->addWidget(m_autoFollowCheck);
toolbarLayout->addWidget(zoomOutButton); toolbarLayout->addWidget(zoomOutButton);
@@ -1109,6 +1380,7 @@ void MainWindow::buildUi()
connect(m_connectButton, &QPushButton::clicked, this, &MainWindow::toggleSerial); connect(m_connectButton, &QPushButton::clicked, this, &MainWindow::toggleSerial);
connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints); connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints);
connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog); connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog);
connect(m_replayButton, &QPushButton::clicked, this, &MainWindow::openUartReplay);
connect(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts); connect(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts);
connect(m_maxPointsSpin, qOverload<int>(&QSpinBox::valueChanged), this, connect(m_maxPointsSpin, qOverload<int>(&QSpinBox::valueChanged), this,
&MainWindow::trimToMaxPoints); &MainWindow::trimToMaxPoints);
@@ -1143,10 +1415,17 @@ void MainWindow::buildUi()
void MainWindow::updateConnectionControls() void MainWindow::updateConnectionControls()
{ {
const bool open = m_serial->isOpen(); const bool open = m_serial->isOpen();
const bool replaying = m_replay->isRunning();
m_connectButton->setText(open ? QStringLiteral("Durdur") : QStringLiteral("Dinle")); m_connectButton->setText(open ? QStringLiteral("Durdur") : QStringLiteral("Dinle"));
m_connectButton->setIcon(style()->standardIcon(open ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay)); m_connectButton->setIcon(style()->standardIcon(open ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay));
m_portCombo->setEnabled(!open); m_connectButton->setEnabled(!replaying);
m_baudCombo->setEnabled(!open); m_portCombo->setEnabled(!open && !replaying);
m_baudCombo->setEnabled(!open && !replaying);
m_openLogButton->setEnabled(!replaying);
m_replayButton->setText(replaying ? QStringLiteral("Replay Durdur")
: QStringLiteral("UART Replay"));
m_replayButton->setIcon(style()->standardIcon(
replaying ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay));
} }
void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry)
@@ -18,12 +18,15 @@ class QPushButton;
class QSpinBox; class QSpinBox;
class QTableView; class QTableView;
class QTimer; class QTimer;
class QCloseEvent;
class QFile;
class MapWidget; class MapWidget;
class GlobeWidget; class GlobeWidget;
class RinexNavigation; class RinexNavigation;
class SerialReader; class SerialReader;
class TelemetryTableModel; class TelemetryTableModel;
class UartReplay;
class MainWindow : public QMainWindow { class MainWindow : public QMainWindow {
Q_OBJECT Q_OBJECT
@@ -31,6 +34,11 @@ class MainWindow : public QMainWindow {
public: public:
explicit MainWindow(QWidget *parent = nullptr); explicit MainWindow(QWidget *parent = nullptr);
bool loadTelemetryLog(const QString &filePath); bool loadTelemetryLog(const QString &filePath);
bool startUartReplay(const QString &filePath, int gpsIntervalMs = 200,
int sensorIntervalMs = 50);
protected:
void closeEvent(QCloseEvent *event) override;
private slots: private slots:
void toggleSerial(); void toggleSerial();
@@ -43,7 +51,11 @@ private slots:
void updateReferenceLocation(); void updateReferenceLocation();
void flushUiUpdates(); void flushUiUpdates();
void openTelemetryLog(); void openTelemetryLog();
void openUartReplay();
void stopUartReplay();
void refreshSerialPorts(); void refreshSerialPorts();
bool startUartLogging(const QString &port, int baudRate);
void stopUartLogging();
private: private:
void buildUi(); void buildUi();
@@ -60,9 +72,11 @@ private:
void scheduleTelemetryRefresh(); void scheduleTelemetryRefresh();
void refreshTelemetryViews(); void refreshTelemetryViews();
void flushPendingLogLines(); void flushPendingLogLines();
void appendUartLogLine(const QString &line);
int selectedBaudRate() const; int selectedBaudRate() const;
SerialReader *m_serial = nullptr; SerialReader *m_serial = nullptr;
UartReplay *m_replay = nullptr;
MapWidget *m_map = nullptr; MapWidget *m_map = nullptr;
GlobeWidget *m_globe = nullptr; GlobeWidget *m_globe = nullptr;
RinexNavigation *m_rinex = nullptr; RinexNavigation *m_rinex = nullptr;
@@ -74,6 +88,7 @@ private:
QPushButton *m_connectButton = nullptr; QPushButton *m_connectButton = nullptr;
QPushButton *m_clearButton = nullptr; QPushButton *m_clearButton = nullptr;
QPushButton *m_openLogButton = nullptr; QPushButton *m_openLogButton = nullptr;
QPushButton *m_replayButton = nullptr;
QPushButton *m_refreshRinexButton = nullptr; QPushButton *m_refreshRinexButton = nullptr;
QCheckBox *m_autoFollowCheck = nullptr; QCheckBox *m_autoFollowCheck = nullptr;
QCheckBox *m_showTelemetryCheck = nullptr; QCheckBox *m_showTelemetryCheck = nullptr;
@@ -98,6 +113,8 @@ private:
QPlainTextEdit *m_log = nullptr; QPlainTextEdit *m_log = nullptr;
QTimer *m_locationTimeoutTimer = nullptr; QTimer *m_locationTimeoutTimer = nullptr;
QTimer *m_uiRefreshTimer = nullptr; QTimer *m_uiRefreshTimer = nullptr;
QFile *m_uartLogFile = nullptr;
QString m_uartLogPath;
QVector<TelemetryPoint> m_points; QVector<TelemetryPoint> m_points;
QStringList m_pendingLogLines; QStringList m_pendingLogLines;
@@ -158,10 +158,11 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &
m_ephemerides = std::move(ephemerides); m_ephemerides = std::move(ephemerides);
setLoading(false); setLoading(false);
publishCurrentMotion(); publishCurrentMotion();
emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3 UTC") emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3")
.arg(positions.size()) .arg(positions.size())
.arg(date.toString(QStringLiteral("dd.MM.yyyy"))) .arg(date.toString(QStringLiteral("dd.MM.yyyy")))
.arg(now.time().toString(QStringLiteral("HH:mm:ss"))), false); .arg(now.toLocalTime().toString(
QStringLiteral("HH:mm:ss t"))), false);
} }
bool RinexNavigation::loadCache() bool RinexNavigation::loadCache()
@@ -176,9 +177,10 @@ bool RinexNavigation::loadCache()
if (positions.isEmpty()) return false; if (positions.isEmpty()) return false;
m_ephemerides = std::move(ephemerides); m_ephemerides = std::move(ephemerides);
publishCurrentMotion(); publishCurrentMotion();
emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2 UTC") emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2")
.arg(positions.size()) .arg(positions.size())
.arg(now.time().toString(QStringLiteral("HH:mm:ss"))), true); .arg(now.toLocalTime().toString(
QStringLiteral("HH:mm:ss t"))), true);
return true; return true;
} }
+207
View File
@@ -0,0 +1,207 @@
#include "UartReplay.h"
#include "LocationParser.h"
#include <QFile>
#include <QRegularExpression>
#include <QTimer>
#include <algorithm>
#include <limits>
UartReplay::UartReplay(QObject *parent)
: QObject(parent)
, m_timer(new QTimer(this))
{
m_timer->setSingleShot(true);
connect(m_timer, &QTimer::timeout, this, &UartReplay::emitNext);
}
bool UartReplay::isRunning() const
{
return m_running;
}
int UartReplay::locationCount() const
{
return m_locationCount;
}
bool UartReplay::start(const QString &filePath, int gpsIntervalMs, int sensorIntervalMs)
{
stop();
if (gpsIntervalMs < 1 || sensorIntervalMs < 1) {
emit errorOccurred(QStringLiteral("Replay araliklari en az 1 ms olmalidir"));
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
emit errorOccurred(QStringLiteral("UART replay logu acilamadi: %1")
.arg(file.errorString()));
return false;
}
qint64 nextGpsMs = 0;
qint64 nextSensorMs = 0;
qint64 lastDueMs = 0;
bool pendingLatitude = false;
bool pendingLongitude = false;
LocationSource pendingSource = LocationSource::Generic;
const QList<QByteArray> rawLines = file.readAll().split('\n');
m_lines.reserve(rawLines.size());
for (const QByteArray &rawLine : rawLines) {
const QString line = removeRecorderTimestamp(QString::fromUtf8(rawLine));
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))
|| line.startsWith(QStringLiteral("[SESSION START]"))
|| line.startsWith(QStringLiteral("[SESSION END]"))) {
continue;
}
Stream stream = classifyLine(line);
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
if (telemetry.has_value() && telemetry->hasLocation()) {
pendingLatitude = false;
pendingLongitude = false;
pendingSource = LocationSource::Generic;
} else if (telemetry.has_value()) {
if (telemetry->latitude.has_value()) {
if (pendingLatitude) {
pendingLongitude = false;
}
pendingLatitude = true;
pendingSource = telemetry->source;
}
if (telemetry->longitude.has_value()) {
pendingLongitude = true;
if (telemetry->source != LocationSource::Generic) {
pendingSource = telemetry->source;
}
}
if (pendingLatitude && pendingLongitude) {
stream = pendingSource == LocationSource::ImuCorrected
|| pendingSource == LocationSource::Trajectory
? Stream::Sensor : Stream::Gps;
pendingLatitude = false;
pendingLongitude = false;
pendingSource = LocationSource::Generic;
}
}
qint64 dueMs = lastDueMs;
if (stream == Stream::Gps) {
dueMs = std::max(lastDueMs, nextGpsMs);
nextGpsMs = dueMs + gpsIntervalMs;
++m_locationCount;
} else if (stream == Stream::Sensor) {
dueMs = std::max(lastDueMs, nextSensorMs);
nextSensorMs = dueMs + sensorIntervalMs;
++m_locationCount;
}
ReplayLine replayLine;
replayLine.text = line;
replayLine.delayBeforeMs = static_cast<int>(std::min<qint64>(
dueMs - lastDueMs, std::numeric_limits<int>::max()));
m_lines.push_back(std::move(replayLine));
lastDueMs = dueMs;
}
if (m_locationCount == 0) {
m_lines.clear();
emit errorOccurred(QStringLiteral(
"UART replay logunda GPS veya fused konum kaydi bulunamadi"));
return false;
}
m_running = true;
emit replayStarted(m_lines.size(), m_locationCount);
m_timer->start(0);
return true;
}
void UartReplay::stop()
{
if (!m_running && m_lines.isEmpty()) {
return;
}
m_timer->stop();
m_running = false;
m_lines.clear();
m_index = 0;
m_locationCount = 0;
}
void UartReplay::emitNext()
{
if (!m_running) {
return;
}
constexpr int MaxImmediateLines = 250;
int emitted = 0;
while (m_index < m_lines.size() && emitted < MaxImmediateLines) {
const ReplayLine &line = m_lines.at(m_index);
if (line.delayBeforeMs > 0) {
if (emitted > 0) {
emit progressChanged(m_index, m_lines.size());
}
const int delay = line.delayBeforeMs;
m_lines[m_index].delayBeforeMs = 0;
m_timer->start(delay);
return;
}
emit lineReady(line.text);
++m_index;
++emitted;
}
emit progressChanged(m_index, m_lines.size());
if (m_index >= m_lines.size()) {
finish();
} else {
m_timer->start(0);
}
}
QString UartReplay::removeRecorderTimestamp(const QString &line)
{
QString result = line.trimmed();
static const QRegularExpression timestampPrefix(
QStringLiteral(R"(^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[\.,]\d+)?\]\s*)"));
result.remove(timestampPrefix);
return result.trimmed();
}
UartReplay::Stream UartReplay::classifyLine(const QString &line)
{
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
if (!telemetry.has_value() || !telemetry->hasLocation()) {
return Stream::Diagnostic;
}
switch (telemetry->source) {
case LocationSource::Gps:
return Stream::Gps;
case LocationSource::ImuCorrected:
case LocationSource::Trajectory:
return Stream::Sensor;
case LocationSource::Generic:
default:
// Older firmware used a [FUSION] record without an explicit source.
return line.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)
? Stream::Sensor : Stream::Gps;
}
}
void UartReplay::finish()
{
m_timer->stop();
m_running = false;
m_lines.clear();
m_index = 0;
m_locationCount = 0;
emit replayFinished();
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVector>
class QTimer;
class UartReplay final : public QObject {
Q_OBJECT
public:
explicit UartReplay(QObject *parent = nullptr);
[[nodiscard]] bool isRunning() const;
[[nodiscard]] int locationCount() const;
public slots:
bool start(const QString &filePath, int gpsIntervalMs = 200,
int sensorIntervalMs = 50);
void stop();
signals:
void lineReady(const QString &line);
void replayStarted(int lineCount, int locationCount);
void progressChanged(int linesProcessed, int lineCount);
void replayFinished();
void errorOccurred(const QString &message);
private slots:
void emitNext();
private:
enum class Stream {
Diagnostic,
Gps,
Sensor
};
struct ReplayLine {
QString text;
int delayBeforeMs = 0;
};
static QString removeRecorderTimestamp(const QString &line);
static Stream classifyLine(const QString &line);
void finish();
QTimer *m_timer = nullptr;
QVector<ReplayLine> m_lines;
int m_index = 0;
int m_locationCount = 0;
bool m_running = false;
};
View File
@@ -6,15 +6,19 @@
#include <QCheckBox> #include <QCheckBox>
#include <QComboBox> #include <QComboBox>
#include <QDoubleSpinBox> #include <QDoubleSpinBox>
#include <QDir>
#include <QFile>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QMetaObject> #include <QMetaObject>
#include <QSpinBox> #include <QSpinBox>
#include <QPushButton> #include <QPushButton>
#include <QRegularExpression>
#include <QTableView> #include <QTableView>
#include <QTabWidget> #include <QTabWidget>
#include <QSplitter> #include <QSplitter>
#include <QTemporaryFile> #include <QTemporaryFile>
#include <QTemporaryDir>
#include <cassert> #include <cassert>
@@ -43,6 +47,32 @@ int main(int argc, char **argv)
MainWindow window; MainWindow window;
qunsetenv("LOCATION_PLOTTER_PORT"); qunsetenv("LOCATION_PLOTTER_PORT");
{
QTemporaryDir uartLogDirectory;
assert(uartLogDirectory.isValid());
qputenv("LOCATION_PLOTTER_UART_LOG_DIR", uartLogDirectory.path().toUtf8());
bool loggingStarted = false;
assert(QMetaObject::invokeMethod(
&window, "startUartLogging", Qt::DirectConnection,
Q_RETURN_ARG(bool, loggingStarted),
Q_ARG(QString, QStringLiteral("/dev/test-uart")), Q_ARG(int, 460800)));
assert(loggingStarted);
sendLine(window, QStringLiteral("raw uart test line"));
assert(QMetaObject::invokeMethod(&window, "stopUartLogging", Qt::DirectConnection));
qunsetenv("LOCATION_PLOTTER_UART_LOG_DIR");
const QStringList logFiles = QDir(uartLogDirectory.path()).entryList(
{QStringLiteral("uart_*.log")}, QDir::Files);
assert(logFiles.size() == 1);
QFile logFile(QDir(uartLogDirectory.path()).filePath(logFiles.constFirst()));
assert(logFile.open(QIODevice::ReadOnly | QIODevice::Text));
const QString contents = QString::fromUtf8(logFile.readAll());
assert(contents.contains(QStringLiteral("[SESSION START] port=/dev/test-uart baud=460800")));
assert(contents.contains(QRegularExpression(
QStringLiteral(R"(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] raw uart test line)"))));
assert(contents.contains(QStringLiteral("[SESSION END]")));
}
auto *table = window.findChild<QTableView *>(); auto *table = window.findChild<QTableView *>();
auto *referenceLatInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLatInput")); auto *referenceLatInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLatInput"));
auto *referenceLonInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLonInput")); auto *referenceLonInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLonInput"));
@@ -89,13 +119,13 @@ int main(int argc, char **argv)
assert(sideSplitter != nullptr); assert(sideSplitter != nullptr);
assert(sideSplitter->orientation() == Qt::Vertical); assert(sideSplitter->orientation() == Qt::Vertical);
sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prns=[3,8]")); sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]"));
assert(map != nullptr); assert(map != nullptr);
auto *globe = window.findChild<GlobeWidget *>(); auto *globe = window.findChild<GlobeWidget *>();
assert(globe != nullptr); assert(globe != nullptr);
assert(globe->observedPrns() assert(globe->observedPrns()
== QSet<QString>({QStringLiteral("G03"), QStringLiteral("G08")})); == QSet<QString>({QStringLiteral("G03"), QStringLiteral("G08")}));
sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prns=[14]")); sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prn_indices=[13]"));
assert(globe->observedPrns() == QSet<QString>({QStringLiteral("G14")})); assert(globe->observedPrns() == QSet<QString>({QStringLiteral("G14")}));
assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection, assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection,
@@ -186,6 +186,25 @@ int main(int argc, char **argv)
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G32")})); assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G32")}));
} }
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=3 prn_indices=[0,7,31]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08"),
QStringLiteral("G32")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=2 prns=[0,7]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=2 prn_indices=[0,G08]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
}
{ {
const auto parsed = LocationParser::parseLine(QStringLiteral( const auto parsed = LocationParser::parseLine(QStringLiteral(
"[OK] [NAV] DOP GDOP=7.0907891622 PDOP=5.6333862175 " "[OK] [NAV] DOP GDOP=7.0907891622 PDOP=5.6333862175 "
@@ -231,6 +250,14 @@ int main(int argc, char **argv)
assert(parsed->satellites == 5); assert(parsed->satellites == 5);
} }
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[FUSION] state=moving lat=40.7357 lon=31.5986 height=811"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(parsed->source == LocationSource::ImuCorrected);
}
{ {
const auto parsed = LocationParser::parseLine(QStringLiteral( const auto parsed = LocationParser::parseLine(QStringLiteral(
"[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=810 tracked=4 used=4")); "[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=810 tracked=4 used=4"));
@@ -257,6 +284,17 @@ int main(int argc, char **argv)
assert(closeTo(satellites[0].x, semiMajorAxis * std::cos(node), 0.1)); assert(closeTo(satellites[0].x, semiMajorAxis * std::cos(node), 0.1));
assert(closeTo(satellites[0].y, semiMajorAxis * std::sin(node), 0.1)); assert(closeTo(satellites[0].y, semiMajorAxis * std::sin(node), 0.1));
assert(closeTo(satellites[0].z, 0.0, 0.1)); assert(closeTo(satellites[0].z, 0.0, 0.1));
// A local wall-clock representation must not move the satellites.
// 11:48 at UTC+03 and 08:48 UTC represent the same physical instant.
const auto localTimeSatellites = RinexNavigation::parseGpsNavigation(
syntheticGpsNavigation(gpsLabel, gpsWeek, toe),
matchingUtc.toOffsetFromUtc(3 * 60 * 60), &error);
assert(error.isEmpty());
assert(localTimeSatellites.size() == satellites.size());
assert(closeTo(localTimeSatellites[0].x, satellites[0].x, 0.001));
assert(closeTo(localTimeSatellites[0].y, satellites[0].y, 0.001));
assert(closeTo(localTimeSatellites[0].z, satellites[0].z, 0.001));
} }
const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX"); const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX");
+70
View File
@@ -0,0 +1,70 @@
#include "../src/UartReplay.h"
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QEventLoop>
#include <QTemporaryFile>
#include <QTimer>
#include <cassert>
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
QTemporaryFile log;
assert(log.open());
log.write(
"[2026-08-12 10:00:00.000] [SESSION START] port=/dev/test baud=460800\n"
"[2026-08-12 10:00:00.001] [LOCATION] source=gps lat=40.0 lon=29.0\n"
"diagnostic line without timestamp\n"
"[LOCATION] source=imu_corrected lat=40.1 lon=29.1\n"
"[FUSION] lat=40.2 lon=29.2 height=10\n"
"[LOCATION] source=gps lat=40.3 lon=29.3\n"
"[2026-08-12 10:00:00.999] [SESSION END]\n");
log.flush();
UartReplay replay;
QStringList received;
QVector<qint64> receivedAt;
QElapsedTimer elapsed;
QEventLoop loop;
QObject::connect(&replay, &UartReplay::lineReady, &loop, [&](const QString &line) {
received.push_back(line);
receivedAt.push_back(elapsed.elapsed());
});
QObject::connect(&replay, &UartReplay::replayFinished, &loop, &QEventLoop::quit);
QTimer::singleShot(2000, &loop, &QEventLoop::quit);
elapsed.start();
assert(replay.start(log.fileName(), 20, 10));
assert(replay.locationCount() == 4);
loop.exec();
assert(!replay.isRunning());
assert(received.size() == 5);
assert(received.first().startsWith(QStringLiteral("[LOCATION] source=gps")));
assert(received.at(1) == QStringLiteral("diagnostic line without timestamp"));
assert(received.at(3).startsWith(QStringLiteral("[FUSION]")));
assert(receivedAt.last() >= 15);
QTemporaryFile splitLog;
assert(splitLog.open());
splitLog.write("latitude: 40.25\nlongitude: 29.50\n");
splitLog.flush();
received.clear();
receivedAt.clear();
elapsed.restart();
assert(replay.start(splitLog.fileName(), 1, 1));
assert(replay.locationCount() == 1);
loop.exec();
assert(received.size() == 2);
QTemporaryFile invalidLog;
assert(invalidLog.open());
invalidLog.write("only diagnostics\n");
invalidLog.flush();
assert(!replay.start(invalidLog.fileName(), 200, 50));
return 0;
}
+140
View File
@@ -0,0 +1,140 @@
cmake_minimum_required(VERSION 3.20)
project(location_plotter VERSION 0.3.0 LANGUAGES CXX)
if(NOT APPLE)
message(FATAL_ERROR "macos/ yalnizca macOS icin yapilandirilabilir")
endif()
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network)
find_package(ZLIB REQUIRED)
add_executable(location_plotter
src/LocationParser.cpp
src/LocationParser.h
src/GlobeWidget.cpp
src/GlobeWidget.h
src/MainWindow.cpp
src/MainWindow.h
src/MapWidget.cpp
src/MapWidget.h
src/SerialReader.cpp
src/SerialReader.h
src/UartReplay.cpp
src/UartReplay.h
src/RinexNavigation.cpp
src/RinexNavigation.h
src/SatelliteData.h
src/TelemetryPoint.h
src/main.cpp
)
target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
target_compile_definitions(location_plotter PRIVATE
LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
)
if(APPLE)
set_target_properties(location_plotter PROPERTIES
MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_BUNDLE_NAME "Location Plotter"
MACOSX_BUNDLE_GUI_IDENTIFIER "com.ankasys.location-plotter"
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
)
endif()
qt_add_resources(location_plotter "app_resources"
PREFIX "/"
FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png
)
enable_testing()
add_executable(location_parser_tests
tests/parser_tests.cpp
src/LocationParser.cpp
src/LocationParser.h
src/RinexNavigation.cpp
src/RinexNavigation.h
src/SatelliteData.h
src/TelemetryPoint.h
)
target_link_libraries(location_parser_tests PRIVATE Qt6::Core Qt6::Network ZLIB::ZLIB)
target_compile_options(location_parser_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
add_test(NAME location_parser_tests COMMAND location_parser_tests)
add_executable(location_map_widget_tests
tests/map_widget_tests.cpp
src/MapWidget.cpp
src/MapWidget.h
src/TelemetryPoint.h
)
target_link_libraries(location_map_widget_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network)
target_compile_options(location_map_widget_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
add_test(NAME location_map_widget_tests COMMAND location_map_widget_tests)
set_tests_properties(location_map_widget_tests PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-map-test-cache")
add_executable(location_main_window_tests
tests/main_window_tests.cpp
src/LocationParser.cpp
src/LocationParser.h
src/MainWindow.cpp
src/MainWindow.h
src/MapWidget.cpp
src/MapWidget.h
src/GlobeWidget.cpp
src/GlobeWidget.h
src/SerialReader.cpp
src/SerialReader.h
src/UartReplay.cpp
src/UartReplay.h
src/RinexNavigation.cpp
src/RinexNavigation.h
src/SatelliteData.h
src/TelemetryPoint.h
)
target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
target_compile_definitions(location_main_window_tests PRIVATE
LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
)
target_compile_options(location_main_window_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
qt_add_resources(location_main_window_tests "test_app_resources"
PREFIX "/"
FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png
)
add_test(NAME location_main_window_tests COMMAND location_main_window_tests)
set_tests_properties(location_main_window_tests PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache")
add_executable(location_uart_replay_tests
tests/uart_replay_tests.cpp
src/UartReplay.cpp
src/UartReplay.h
src/LocationParser.cpp
src/LocationParser.h
src/TelemetryPoint.h
)
target_link_libraries(location_uart_replay_tests PRIVATE Qt6::Core)
target_compile_options(location_uart_replay_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
add_test(NAME location_uart_replay_tests COMMAND location_uart_replay_tests)
install(TARGETS location_plotter
BUNDLE DESTINATION .
RUNTIME DESTINATION bin
)
+242
View File
@@ -0,0 +1,242 @@
# Location Plotter
Qt6/C++ GUI that listens to a USB UART on macOS, parses normal debug lines,
and plots live locations on a map.
The application also includes an interactive 3D Earth/orbit view. It downloads the current
stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15
minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid
file is cached for offline startup.
## Parsed fields
The parser accepts labeled values in the same line or in separate lines:
```text
lat=41.0082 long=28.9784 hheight=123.45 sats=7
latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7
active_sats=8
navigation software running with 5 satellites
GDOP : 3.9576744550
PDOP : 3.2540598660
HDOP : 2.3631161789
VDOP : 2.2370935468
TDOP : 2.2526165852
tracking PRNs: G03, G08, 14, 21
SV=G27
[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5
[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4
lattitude : 40.78925034214206846173
longitude : 29.46277530841727809729
height : -89.05779508221894502640
```
Accepted location labels:
- Latitude: `lat`, `latitude`, `lattitude`
- Longitude: `long`, `longitude`, `lon`, `lng`
- Height: `hheight`, `height`, `alt`, `altitude`
- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`,
`activeSats`, `satellites_active`, `num_sats`, `uydu`
- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP`
- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27`
- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory`
- `tracked` is accepted as the active satellite count; `used` remains visible in
the raw UART line and denotes satellites used by the WLS solution.
If a satellite count arrives without a location, the active satellite display is updated.
If a location arrives without a satellite count, the latest known satellite count is attached
to that point.
If DOP values arrive before a location, the latest known values stay visible in the DOP
section and are attached to the next plotted point.
If latitude, longitude, and height arrive as separate consecutive lines, they are combined
into a single plotted point.
The GPS firmware's level-1 paired protocol is source-aware. Every received
`source=gps` location is added to the blue GPS series and every
`source=imu_corrected` location is added to the red corrected series at the
firmware-configured UART cadence.
Paths are built independently, so alternating UART lines do not create GPS-to-IMU
zigzags. A corrected point newer than one second is also preferred for map
auto-follow, the current-position label and the globe receiver marker. Raw GPS
outliers remain visible in blue for diagnosis without pulling the whole view away.
When corrected points exist, CEP50, average and scatter calculations use only that
corrected series; legacy input without a source keeps the previous behavior.
Synthetic `source=trajectory` points form a third independent green ground-truth
path. The map legend identifies all three series.
## UART log replay
Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded
UART `.log`/`.txt` file back through the same parser used by the live serial port.
Choose the raw GPS update interval (default `200 ms`) and sensor/fused update
interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop.
Recorder timestamps are optional and do not control replay speed. The replayer
removes a leading timestamp when present, identifies `source=gps` as raw GPS and
`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the
two update timelines while retaining the original UART line order. Other UART
diagnostic lines are forwarded with the surrounding location update.
## Sentetik ESKF senaryolarini açma
GPS deposundan 48 kombinasyonun replay loglarini üretin:
```bash
cd /home/onur/Desktop/gps
make -C soc/software/fusion/tests scenario-logs
```
Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin
`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin.
GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da
doğrudan açılabilir:
```bash
cd location_plotter/macos
cmake -S . -B build
cmake --build build -j
./build/location_plotter --replay \
/home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log
```
Tek bir özel kombinasyon üretmek için:
```bash
cd /home/onur/Desktop/gps/soc/software/fusion/tests
make platform_scenario_matrix_test
./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log
```
Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS
periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir.
## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel)
Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan:
```bash
cd location_plotter/macos
chmod +x setup-macos.sh run.sh
./setup-macos.sh
./run.sh
```
Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin
`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine
ekler ve testleri calistirir.
macOS USB UART aygitlarini genellikle iki adla gosterir:
```text
/dev/cu.usbserial-XXXXXXXX
/dev/tty.usbserial-XXXXXXXX
```
veya bir CDC aygitinda:
```text
/dev/cu.usbmodemXXXX
/dev/tty.usbmodemXXXX
```
Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta
disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama
acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik
algilanmayan bir yol port alanina elle de yazilabilir.
Istenen port baslatmadan once ortam degiskeniyle de secilebilir:
```bash
LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh
```
Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup
olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART
donaniminizin macOS surucusunu kontrol edin.
## Elle derleme
Homebrew ile `cmake` ve `qt` kurulduktan sonra derleme ve test:
```bash
cmake -S . -B build -DCMAKE_PREFIX_PATH="$(brew --prefix qt)"
cmake --build build
ctest --test-dir build --output-on-failure
```
Run:
```bash
./run.sh
```
Uygulama Qt tabanli bir macOS masaustu arayuzudur. Harita karolari ve guncel
RINEX verisi icin internet erisimi gerekir; son gecerli RINEX verisi ve harita
onbellegiyle cevrimdisi kullanim da desteklenir. Port secici USB ile ilgili
`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa
`/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak gosterilir.
## Notes
- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection,
points are still drawn over a fallback grid.
- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values
from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF.
PRNs parsed from the current navigation solve are green; other RINEX satellites are red.
Drag to rotate the globe and use the mouse wheel to zoom.
- The globe includes a shaded continent layer and a receiver-centred translucent visibility
cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees
(10 degrees by default, press Enter after typing);
satellites inside the mask get a cyan sight line and halo. Incoming positions update the
receiver marker without resetting a globe orientation chosen by dragging.
- The globe surface uses NASA/GSFC Scientific Visualization Studio's 2048x1024 Blue Marble
mosaic (NASA SVS visualization 2915), projected locally without a runtime network request.
- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the
previous two UTC days if today's stream file is unavailable. Current and next-minute satellite
positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS.
- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status
line shows the system-local time of the last successful download or cache load.
- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the
previous connected-PRN set, so satellites that were lost no longer remain green. Firmware
channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`).
- RINEX text is parsed once per download and retained as compact ephemeris records. The static
globe is cached between camera changes, reducing CPU and allocation load during orbit animation.
- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring,
shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation.
- The main content width and the right-side dashboard/UART height are both adjustable by
dragging their splitter handles. Dashboard cards scroll independently so the UART log can
be enlarged substantially.
- `Max` controls how many recent points stay visible and are included in location
analysis. The default is 10. When the limit is reached, older points are removed
automatically.
- Location analysis shows the average of the plotted samples, the last point's deviation
from that average, mean deviation, RMS scatter, and max deviation in meters. The average
marker can be toggled on the map.
- The map layers panel can hide all telemetry overlays, show only the newest location instead
of the recent path, and independently toggle the average marker.
- GPS points and paths are blue. IMU+ESKF corrected points and their independent
path are red. Synthetic ground-truth trajectory is green; the locations table
shows the parsed source in its `Kaynak` column.
- The application calculates empirical CEP50 as the median horizontal distance of the retained
measurements from their spherical mean, then draws the circle around that measurement center.
This is the radius containing 50 percent of the retained position fixes and is independent of
the optional user reference.
- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates
are transferred to the reference fields with 15 decimal places but do not alter CEP50.
- Latitude and longitude tokens are preserved exactly as received for the table and map
label. Calculations and map projection use IEEE 754 `double` precision.
- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8,
zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates,
but the background tiles cannot add accuracy to the GNSS measurement.
- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP
cache in the application's standard cache directory.
- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not
rebuild the complete interface for every received line. The locations table uses a virtual
model and remains usable with a high `Max` value.
- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir
log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir;
`LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir.
- If no new plotted location arrives for 10 seconds while listening, current location
points and analysis values are cleared.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because one or more lines are too long
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
print_qt_install_hint() {
echo
echo "Qt6 bulunamadi. macOS'ta gerekli paketleri kurmak icin:"
echo " brew install cmake qt"
echo "veya dogrudan: ./setup-macos.sh"
}
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Bu calistirma betigi macOS icindir." >&2
exit 1
fi
project_dir="$(pwd -P)"
if [[ -f ./build/CMakeCache.txt ]]; then
cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)"
if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then
echo "Build klasoru farkli bir proje yoluna ait. 'macos/build' klasorunu silip tekrar deneyin." >&2
exit 1
fi
fi
cmake_args=(-S . -B build)
if command -v brew >/dev/null 2>&1; then
cmake_args+=("-DCMAKE_PREFIX_PATH=$(brew --prefix qt)")
fi
if ! cmake "${cmake_args[@]}"; then
print_qt_install_hint
exit 1
fi
cmake --build build
exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@"
+2 -2
View File
@@ -22,8 +22,8 @@ parallel_jobs="$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4)"
if [[ -f build/CMakeCache.txt ]]; then if [[ -f build/CMakeCache.txt ]]; then
cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' build/CMakeCache.txt)" cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' build/CMakeCache.txt)"
if [[ -n "$cached_system" && "$cached_system" != "Darwin" ]]; then if [[ -n "$cached_system" && "$cached_system" != "Darwin" ]]; then
echo "Baska bir sisteme ait build klasoru yeniden olusturuluyor." echo "Build klasoru macOS disinda bir sisteme ait. 'macos/build' klasorunu silip tekrar deneyin." >&2
rm -rf build exit 1
fi fi
fi fi
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include "SatelliteData.h"
#include <QElapsedTimer>
#include <QImage>
#include <QPoint>
#include <QPixmap>
#include <QSet>
#include <QTimer>
#include <QWidget>
class GlobeWidget : public QWidget {
Q_OBJECT
public:
explicit GlobeWidget(QWidget *parent = nullptr);
void setSatellites(const QVector<SatellitePosition> &satellites);
void setSatelliteMotion(const QVector<SatellitePosition> &current,
const QVector<SatellitePosition> &next,
int durationMs);
void setObservedPrns(const QSet<QString> &prns);
void setReceiverLocation(double latitude, double longitude);
void clearReceiverLocation();
void setRinexStatus(const QString &status, bool error);
[[nodiscard]] QSet<QString> observedPrns() const { return m_observedPrns; }
public slots:
void setElevationMask(double degrees);
protected:
bool event(QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
private:
struct ProjectedPoint {
QPointF point;
double depth = 0.0;
};
ProjectedPoint project(double x, double y, double z, double radius) const;
void drawStars(QPainter &painter) const;
void drawOrbit(QPainter &painter, double inclination, double node) const;
void drawContinents(QPainter &painter) const;
void drawBlueMarble(QPainter &painter) const;
void drawSatelliteCoverage(QPainter &painter) const;
void drawVisibilityCone(QPainter &painter) const;
void drawGraticule(QPainter &painter) const;
void drawReceiver(QPainter &painter) const;
int drawSatellites(QPainter &painter) const;
double satelliteElevation(const SatellitePosition &satellite) const;
void updateSatelliteAnimation();
void rebuildStaticLayer();
QVector<SatellitePosition> m_satellites;
QVector<SatellitePosition> m_satelliteMotionStart;
QVector<SatellitePosition> m_satelliteMotionTarget;
QElapsedTimer m_satelliteMotionClock;
QTimer m_satelliteAnimationTimer;
int m_satelliteMotionDurationMs = 0;
mutable QImage m_landTexture;
mutable double m_landTextureYaw = 0.0;
mutable double m_landTexturePitch = 0.0;
mutable qint64 m_landTextureLightingMinute = -1;
QPixmap m_staticLayer;
QSize m_staticLayerSize;
qreal m_staticLayerDevicePixelRatio = 0.0;
double m_staticLayerYaw = 0.0;
double m_staticLayerPitch = 0.0;
double m_staticLayerZoom = 0.0;
qint64 m_staticLayerLightingMinute = -1;
QSet<QString> m_observedPrns;
QString m_status = QStringLiteral("Konum bekleniyor");
bool m_statusError = false;
bool m_hasReceiver = false;
double m_receiverLatitude = 0.0;
double m_receiverLongitude = 0.0;
double m_yaw = -0.55;
double m_pitch = -0.28;
double m_zoom = 1.0;
double m_elevationMaskDegrees = 10.0;
bool m_dragging = false;
QPoint m_lastMousePosition;
};
+283
View File
@@ -0,0 +1,283 @@
#include "LocationParser.h"
#include <QRegularExpression>
#include <cmath>
namespace {
double toDouble(QString value, bool *ok)
{
value.replace(',', '.');
return value.toDouble(ok);
}
QString normalizedCoordinateText(QString value)
{
value.replace(',', '.');
if (value.startsWith('+')) {
value.remove(0, 1);
}
return value;
}
QString normalizeKey(QString key)
{
key = key.toLower();
key.remove('_');
key.remove('-');
key.remove(' ');
if (key == "lat" || key == "latitude" || key == "lattitude") {
return "latitude";
}
if (key == "long" || key == "longitude" || key == "lon" || key == "lng") {
return "longitude";
}
if (key == "hheight" || key == "height" || key == "alt" || key == "altitude") {
return "hheight";
}
if (key == "sat" || key == "sats" || key == "satellite" || key == "satellites"
|| key == "activesat" || key == "activesats" || key == "activesatellite"
|| key == "activesatellites" || key == "numsat" || key == "numsats"
|| key == "numbersat" || key == "numbersats" || key == "satactive"
|| key == "satsactive" || key == "satelliteactive" || key == "satellitesactive"
|| key == "uydu" || key == "uydular") {
return "satellites";
}
if (key == "tracked") {
return "satellites";
}
if (key == "gdop" || key == "pdop" || key == "hdop" || key == "vdop" || key == "tdop") {
return key;
}
return {};
}
bool isValidLatitude(double latitude)
{
return std::isfinite(latitude) && latitude >= -90.0 && latitude <= 90.0;
}
bool isValidLongitude(double longitude)
{
return std::isfinite(longitude) && longitude >= -180.0 && longitude <= 180.0;
}
} // namespace
std::optional<ParsedTelemetry> LocationParser::parseLine(const QString &line)
{
const QString trimmed = line.trimmed();
if (trimmed.isEmpty()) {
return std::nullopt;
}
static const QRegularExpression labeledValueRegex(
QStringLiteral(R"((^|[^A-Za-z0-9_])(?<key>lat(?:t?itude)?|long(?:itude)?|lon|lng|hheight|height|alt(?:itude)?|active[_ -]?sat(?:ellite)?s?|num(?:ber)?[_ -]?sat(?:ellite)?s?|sat(?:ellite)?s?[_ -]?active|sat(?:ellite)?s?|sats|tracked|uydu(?:lar)?|[gphvt][_ -]?dop)(?:\s*(?:=|:|->)?\s*)(?<value>[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"),
QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression satelliteCountPhraseRegex(
QStringLiteral(R"((^|[^A-Za-z0-9_\.])(?<value>\d+)\s+(?:active\s+)?(?:sat(?:ellite)?s?|uydu(?:lar)?)(?=$|[\s,.;)\]]))"),
QRegularExpression::CaseInsensitiveOption);
ParsedTelemetry telemetry;
telemetry.rawLine = trimmed;
if (trimmed.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)) {
telemetry.source = LocationSource::ImuCorrected;
}
static const QRegularExpression sourceRegex(
QStringLiteral(R"((?:^|\s)source\s*=\s*(?<source>gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"),
QRegularExpression::CaseInsensitiveOption);
const QRegularExpressionMatch sourceMatch = sourceRegex.match(trimmed);
if (sourceMatch.hasMatch()) {
const QString source = sourceMatch.captured(QStringLiteral("source"));
if (source.startsWith(QStringLiteral("imu"), Qt::CaseInsensitive)) {
telemetry.source = LocationSource::ImuCorrected;
} else if (source.startsWith(QStringLiteral("trajectory"), Qt::CaseInsensitive)
|| source.startsWith(QStringLiteral("ground"), Qt::CaseInsensitive)) {
telemetry.source = LocationSource::Trajectory;
} else {
telemetry.source = LocationSource::Gps;
}
}
static const QRegularExpression fusionModeRegex(
QStringLiteral(R"((?:^|\s)mode\s*=\s*(?<mode>gps[_-]?aided|dead[_-]?reckoning)(?=$|\s))"),
QRegularExpression::CaseInsensitiveOption);
const QRegularExpressionMatch fusionModeMatch = fusionModeRegex.match(trimmed);
if (fusionModeMatch.hasMatch()) {
telemetry.fusionMode = fusionModeMatch.captured(QStringLiteral("mode")).startsWith(
QStringLiteral("dead"), Qt::CaseInsensitive)
? FusionMode::DeadReckoning : FusionMode::GpsAided;
}
static const QRegularExpression gpsAgeRegex(
QStringLiteral(R"((?:^|\s)gps[_-]?age[_-]?ms\s*=\s*(?<age>\d+)(?=$|\s))"),
QRegularExpression::CaseInsensitiveOption);
const QRegularExpressionMatch gpsAgeMatch = gpsAgeRegex.match(trimmed);
if (gpsAgeMatch.hasMatch()) {
bool ok = false;
const qint64 ageMs = gpsAgeMatch.captured(QStringLiteral("age")).toLongLong(&ok);
if (ok) {
telemetry.gpsAgeMs = ageMs;
}
}
bool hadCoordinateField = false;
bool hadLatitudeField = false;
bool hadLongitudeField = false;
auto matches = labeledValueRegex.globalMatch(trimmed);
while (matches.hasNext()) {
const QRegularExpressionMatch match = matches.next();
const QString field = normalizeKey(match.captured(QStringLiteral("key")));
if (field.isEmpty()) {
continue;
}
bool ok = false;
const double value = toDouble(match.captured(QStringLiteral("value")), &ok);
if (!ok || !std::isfinite(value)) {
continue;
}
if (field == "latitude") {
hadCoordinateField = true;
hadLatitudeField = true;
telemetry.latitude = value;
telemetry.latitudeText = normalizedCoordinateText(
match.captured(QStringLiteral("value")));
} else if (field == "longitude") {
hadCoordinateField = true;
hadLongitudeField = true;
telemetry.longitude = value;
telemetry.longitudeText = normalizedCoordinateText(
match.captured(QStringLiteral("value")));
} else if (field == "hheight") {
telemetry.hheight = value;
} else if (field == "satellites") {
const int count = static_cast<int>(std::llround(value));
if (count >= 0) {
telemetry.satellites = count;
}
} else if (field == "gdop") {
if (value >= 0.0) {
telemetry.dop.gdop = value;
}
} else if (field == "pdop") {
if (value >= 0.0) {
telemetry.dop.pdop = value;
}
} else if (field == "hdop") {
if (value >= 0.0) {
telemetry.dop.hdop = value;
}
} else if (field == "vdop") {
if (value >= 0.0) {
telemetry.dop.vdop = value;
}
} else if (field == "tdop") {
if (value >= 0.0) {
telemetry.dop.tdop = value;
}
}
}
auto satellitePhraseMatches = satelliteCountPhraseRegex.globalMatch(trimmed);
while (satellitePhraseMatches.hasNext()) {
const QRegularExpressionMatch match = satellitePhraseMatches.next();
bool ok = false;
const int count = match.captured(QStringLiteral("value")).toInt(&ok);
if (ok && count >= 0) {
telemetry.satellites = count;
}
}
if (telemetry.latitude.has_value() && !isValidLatitude(*telemetry.latitude)) {
telemetry.latitude.reset();
telemetry.latitudeText.clear();
}
if (telemetry.longitude.has_value() && !isValidLongitude(*telemetry.longitude)) {
telemetry.longitude.reset();
telemetry.longitudeText.clear();
}
if (hadLatitudeField && hadLongitudeField
&& (!telemetry.hasLocation()
|| !isValidCoordinate(*telemetry.latitude, *telemetry.longitude))) {
telemetry.latitude.reset();
telemetry.longitude.reset();
telemetry.latitudeText.clear();
telemetry.longitudeText.clear();
telemetry.hheight.reset();
} else if (hadCoordinateField && !telemetry.latitude.has_value()
&& !telemetry.longitude.has_value()) {
telemetry.hheight.reset();
}
if (!telemetry.hasAnyValue()) {
return std::nullopt;
}
return telemetry;
}
bool LocationParser::isValidCoordinate(double latitude, double longitude)
{
return std::isfinite(latitude) && std::isfinite(longitude)
&& latitude >= -90.0 && latitude <= 90.0
&& longitude >= -180.0 && longitude <= 180.0;
}
QSet<QString> LocationParser::parseGpsPrns(const QString &line)
{
QSet<QString> result;
const auto addPrn = [&result](QString value, bool zeroBased = false) {
value = value.trimmed().toUpper();
const bool explicitGpsPrn = value.startsWith(QLatin1Char('G'));
if (explicitGpsPrn) value.remove(0, 1);
bool ok = false;
int number = value.toInt(&ok);
if (ok && zeroBased && !explicitGpsPrn) ++number;
if (ok && number >= 1 && number <= 63) {
result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0')));
}
};
static const QRegularExpression gpsToken(
QStringLiteral(R"((?:^|[^A-Za-z0-9])G(?<prn>\d{1,2})(?=$|[^0-9]))"),
QRegularExpression::CaseInsensitiveOption);
auto gpsMatches = gpsToken.globalMatch(line);
while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn")));
// Current firmware versions called their zero-based hardware channel values
// "prns" in complete tracking snapshots. Keep accepting those messages while
// newer firmware uses the unambiguous "prn_indices" label.
static const QRegularExpression legacyTrackingIndices(
QStringLiteral(R"(\btracked\s+satellites\s*=\s*\d+\s+prns?\s*=)"),
QRegularExpression::CaseInsensitiveOption);
const bool legacyZeroBasedPrns = legacyTrackingIndices.match(line).hasMatch();
static const QRegularExpression labeledList(
QStringLiteral(R"((?<label>\bPRN[_ -]?INDICES\b|\bPRNS?\b|\bSVS?\b|\bSVNS?\b|\bSVIDS?\b)\s*(?:=|:|->)?\s*[\[(]?\s*(?<list>(?:G?\d{1,2})(?:\s*[,;/| ]\s*G?\d{1,2})*)\s*[\])]?)"),
QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression number(QStringLiteral(R"(G?\d{1,2})"),
QRegularExpression::CaseInsensitiveOption);
auto listMatches = labeledList.globalMatch(line);
while (listMatches.hasNext()) {
const QRegularExpressionMatch listMatch = listMatches.next();
QString label = listMatch.captured(QStringLiteral("label")).toUpper();
label.remove(QLatin1Char('_'));
label.remove(QLatin1Char('-'));
label.remove(QLatin1Char(' '));
const bool zeroBased = label.startsWith(QStringLiteral("SVN"))
|| label == QStringLiteral("PRNINDICES")
|| (legacyZeroBasedPrns && label.startsWith(QStringLiteral("PRN")));
auto values = number.globalMatch(listMatch.captured(QStringLiteral("list")));
while (values.hasNext()) addPrn(values.next().captured(0), zeroBased);
}
return result;
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "TelemetryPoint.h"
#include <QString>
#include <QSet>
#include <optional>
class LocationParser {
public:
static std::optional<ParsedTelemetry> parseLine(const QString &line);
static QSet<QString> parseGpsPrns(const QString &line);
private:
static bool isValidCoordinate(double latitude, double longitude);
};
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
#pragma once
#include "TelemetryPoint.h"
#include <QMainWindow>
#include <QStringList>
#include <QSet>
#include <QVector>
#include <optional>
class QLabel;
class QCheckBox;
class QComboBox;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
class QSpinBox;
class QTableView;
class QTimer;
class QCloseEvent;
class QFile;
class MapWidget;
class GlobeWidget;
class RinexNavigation;
class SerialReader;
class TelemetryTableModel;
class UartReplay;
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
bool loadTelemetryLog(const QString &filePath);
bool startUartReplay(const QString &filePath, int gpsIntervalMs = 200,
int sensorIntervalMs = 50);
protected:
void closeEvent(QCloseEvent *event) override;
private slots:
void toggleSerial();
void clearPoints();
void handleSerialLine(const QString &line);
void handleSerialStatus(const QString &status);
void handleSerialError(const QString &message);
void handleLocationTimeout();
void trimToMaxPoints();
void updateReferenceLocation();
void flushUiUpdates();
void openTelemetryLog();
void openUartReplay();
void stopUartReplay();
void refreshSerialPorts();
bool startUartLogging(const QString &port, int baudRate);
void stopUartLogging();
private:
void buildUi();
void buildMenus();
void loadSettings();
void saveSettings() const;
void updateConnectionControls();
void addTelemetryPoint(const ParsedTelemetry &telemetry);
void refreshTable();
void updateSummaryLabels();
void updateDopLabels();
void updateAnalysis();
void appendLogLine(const QString &line, bool parsed);
void mergePendingTelemetry(const ParsedTelemetry &telemetry);
void commitPendingTelemetry();
void resetTelemetryState();
void scheduleTelemetryRefresh();
void refreshTelemetryViews();
void flushPendingLogLines();
void appendUartLogLine(const QString &line);
int selectedBaudRate() const;
SerialReader *m_serial = nullptr;
UartReplay *m_replay = nullptr;
MapWidget *m_map = nullptr;
GlobeWidget *m_globe = nullptr;
RinexNavigation *m_rinex = nullptr;
QComboBox *m_portCombo = nullptr;
QComboBox *m_baudCombo = nullptr;
QSpinBox *m_maxPointsSpin = nullptr;
QLineEdit *m_referenceLatEdit = nullptr;
QLineEdit *m_referenceLonEdit = nullptr;
QPushButton *m_connectButton = nullptr;
QPushButton *m_clearButton = nullptr;
QPushButton *m_openLogButton = nullptr;
QPushButton *m_replayButton = nullptr;
QPushButton *m_rinexUpdateButton = nullptr;
QPushButton *m_refreshRinexButton = nullptr;
QCheckBox *m_autoFollowCheck = nullptr;
QCheckBox *m_showTelemetryCheck = nullptr;
QCheckBox *m_showRecentPointsCheck = nullptr;
QLabel *m_statusLabel = nullptr;
QLabel *m_satelliteLabel = nullptr;
QLabel *m_satelliteQualityLabel = nullptr;
QLabel *m_gdopLabel = nullptr;
QLabel *m_hdopLabel = nullptr;
QLabel *m_vdopLabel = nullptr;
QLabel *m_tdopLabel = nullptr;
QLabel *m_countLabel = nullptr;
QLabel *m_meanLocationLabel = nullptr;
QLabel *m_lastDeviationLabel = nullptr;
QLabel *m_meanDeviationLabel = nullptr;
QLabel *m_rmsScatterLabel = nullptr;
QLabel *m_maxScatterLabel = nullptr;
QLabel *m_cepLabel = nullptr;
QCheckBox *m_showAverageCheck = nullptr;
QTableView *m_table = nullptr;
TelemetryTableModel *m_tableModel = nullptr;
QPlainTextEdit *m_log = nullptr;
QTimer *m_locationTimeoutTimer = nullptr;
QTimer *m_uiRefreshTimer = nullptr;
QTimer *m_portRefreshTimer = nullptr;
QFile *m_uartLogFile = nullptr;
QString m_uartLogPath;
QVector<TelemetryPoint> m_points;
QStringList m_pendingLogLines;
std::optional<int> m_lastSatelliteCount;
QSet<QString> m_observedPrns;
std::optional<double> m_referenceLatitude;
std::optional<double> m_referenceLongitude;
DopValues m_lastDop;
ParsedTelemetry m_pendingTelemetry;
bool m_telemetryRefreshPending = false;
bool m_portsInitialized = false;
QStringList m_lastDetectedPorts;
};
+934
View File
@@ -0,0 +1,934 @@
#include "MapWidget.h"
#include <QDateTime>
#include <QDir>
#include <QLabel>
#include <QLineF>
#include <QMouseEvent>
#include <QNativeGestureEvent>
#include <QNetworkAccessManager>
#include <QNetworkDiskCache>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QPainter>
#include <QPainterPath>
#include <QResizeEvent>
#include <QStandardPaths>
#include <QStringList>
#include <QTimer>
#include <QUrl>
#include <QWheelEvent>
#include <algorithm>
#include <cmath>
namespace {
constexpr int TileSize = 256;
constexpr int MinZoom = 2;
constexpr int MaxZoom = 19;
constexpr int MaxMemoryTileCount = 512;
constexpr int InitialTileRetryDelayMs = 250;
constexpr int MaximumTileRetryDelayMs = 8000;
constexpr double Pi = 3.14159265358979323846;
constexpr double WebMercatorRadiusMeters = 6378137.0;
constexpr double MaxMercatorLatitude = 85.05112878;
QString tileKey(int zoom, int tileX, int tileY)
{
return QStringLiteral("%1/%2/%3").arg(zoom).arg(tileX).arg(tileY);
}
QUrl tileUrl(QString urlTemplate, int zoom, int tileX, int tileY)
{
urlTemplate.replace(QStringLiteral("{z}"), QString::number(zoom));
urlTemplate.replace(QStringLiteral("{x}"), QString::number(tileX));
urlTemplate.replace(QStringLiteral("{y}"), QString::number(tileY));
return QUrl(urlTemplate);
}
double clampLatitude(double latitude)
{
return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude);
}
int positiveModulo(int value, int modulo)
{
const int result = value % modulo;
return result < 0 ? result + modulo : result;
}
double metersPerPixel(double latitude, int zoom)
{
const double scale = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
const double latitudeScale = std::cos(latitude * Pi / 180.0);
return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale;
}
} // namespace
MapWidget::MapWidget(QWidget *parent)
: QWidget(parent)
, m_network(new QNetworkAccessManager(this))
, m_tileUrlTemplate(qEnvironmentVariable(
"LOCATION_PLOTTER_TILE_URL_TEMPLATE",
QStringLiteral("https://tile.openstreetmap.org/{z}/{x}/{y}.png")))
{
setMinimumSize(480, 320);
setMouseTracking(true);
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
if (!cacheRoot.isEmpty()) {
auto *diskCache = new QNetworkDiskCache(m_network);
const QString cacheDirectory = cacheRoot + QStringLiteral("/map-tiles");
QDir().mkpath(cacheDirectory);
diskCache->setCacheDirectory(cacheDirectory);
diskCache->setMaximumCacheSize(100 * 1024 * 1024);
m_network->setCache(diskCache);
}
m_attributionLabel = new QLabel(this);
m_attributionLabel->setText(
QStringLiteral("<a style=\"color:#174b7a;text-decoration:none\" "
"href=\"https://www.openstreetmap.org/copyright\">")
+ QString::fromUtf8("\xC2\xA9 OpenStreetMap contributors")
+ QStringLiteral("</a>"));
m_attributionLabel->setOpenExternalLinks(true);
m_attributionLabel->setStyleSheet(QStringLiteral(
"QLabel { background: rgba(255, 255, 255, 220); color: #26313d; "
"border: 1px solid rgba(120, 130, 140, 90); padding: 3px 6px; }"));
m_attributionLabel->adjustSize();
}
void MapWidget::setPoints(const QVector<TelemetryPoint> &points)
{
const bool wasEmpty = m_points.isEmpty();
m_points = points;
if (!m_points.isEmpty() && m_autoFollow) {
const TelemetryPoint &focus = m_points[preferredFocusIndex()];
m_centerLat = focus.latitude;
m_centerLon = focus.longitude;
if (wasEmpty) {
m_zoom = MaxZoom;
}
}
update();
}
void MapWidget::clearPoints()
{
m_points.clear();
m_hasAverageMarker = false;
update();
}
void MapWidget::fitToPoints()
{
if (m_points.isEmpty()) {
return;
}
double minimumLatitude = m_points.first().latitude;
double maximumLatitude = minimumLatitude;
QVector<double> longitudes;
longitudes.reserve(m_points.size());
for (const TelemetryPoint &point : m_points) {
minimumLatitude = std::min(minimumLatitude, point.latitude);
maximumLatitude = std::max(maximumLatitude, point.latitude);
double normalizedLongitude = std::fmod(point.longitude + 360.0, 360.0);
if (normalizedLongitude < 0.0) {
normalizedLongitude += 360.0;
}
longitudes.push_back(normalizedLongitude);
}
std::sort(longitudes.begin(), longitudes.end());
// Fit the shortest circular longitude interval. A plain min/max makes
// points around +180/-180 look almost a full world apart.
double arcStart = longitudes.first();
double longitudeSpan = 0.0;
if (longitudes.size() > 1) {
double largestGap = -1.0;
int largestGapIndex = 0;
for (int i = 0; i < longitudes.size(); ++i) {
const double current = longitudes[i];
const double next = i + 1 < longitudes.size()
? longitudes[i + 1] : longitudes.first() + 360.0;
const double gap = next - current;
if (gap > largestGap) {
largestGap = gap;
largestGapIndex = i;
}
}
arcStart = largestGapIndex + 1 < longitudes.size()
? longitudes[largestGapIndex + 1] : longitudes.first();
longitudeSpan = 360.0 - largestGap;
}
m_centerLat = 0.5 * (minimumLatitude + maximumLatitude);
m_centerLon = std::fmod(arcStart + longitudeSpan * 0.5 + 180.0, 360.0);
if (m_centerLon < 0.0) {
m_centerLon += 360.0;
}
m_centerLon -= 180.0;
const double availableWidth = std::max(100.0, width() * 0.82);
const double availableHeight = std::max(100.0, height() * 0.82);
for (int zoom = MaxZoom; zoom >= MinZoom; --zoom) {
const double worldWidth = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
const double longitudeWidth = longitudeSpan / 360.0 * worldWidth;
const double top = coordinateToWorld(maximumLatitude, m_centerLon, zoom).y();
const double bottom = coordinateToWorld(minimumLatitude, m_centerLon, zoom).y();
if (longitudeWidth <= availableWidth
&& std::fabs(bottom - top) <= availableHeight) {
m_zoom = zoom;
break;
}
}
if (m_autoFollow) {
m_autoFollow = false;
emit autoFollowChanged(false);
}
update();
}
void MapWidget::setAverageMarker(double latitude, double longitude, double radiusMeters)
{
m_hasAverageMarker = true;
m_averageLat = latitude;
m_averageLon = longitude;
m_averageRadiusMeters = std::max(0.0, radiusMeters);
update();
}
void MapWidget::clearAverageMarker()
{
if (!m_hasAverageMarker) {
return;
}
m_hasAverageMarker = false;
update();
}
void MapWidget::setCepMarker(double latitude, double longitude,
const std::optional<double> &radiusMeters)
{
m_hasCepMarker = true;
m_cepLatitude = latitude;
m_cepLongitude = longitude;
if (m_points.isEmpty()) {
m_centerLat = latitude;
m_centerLon = longitude;
m_zoom = MaxZoom;
}
if (radiusMeters.has_value()) {
m_cepRadiusMeters = std::max(0.0, *radiusMeters);
} else {
m_cepRadiusMeters.reset();
}
update();
}
void MapWidget::clearCepMarker()
{
if (!m_hasCepMarker) {
return;
}
m_hasCepMarker = false;
m_cepRadiusMeters.reset();
update();
}
QPointF MapWidget::centerCoordinate() const
{
return QPointF(m_centerLat, m_centerLon);
}
void MapWidget::setAutoFollow(bool enabled)
{
if (m_autoFollow == enabled) {
return;
}
m_autoFollow = enabled;
if (m_autoFollow && !m_points.isEmpty()) {
const TelemetryPoint &focus = m_points[preferredFocusIndex()];
m_centerLat = focus.latitude;
m_centerLon = focus.longitude;
update();
}
emit autoFollowChanged(m_autoFollow);
}
void MapWidget::setAverageOverlayEnabled(bool enabled)
{
if (m_showAverageOverlay == enabled) {
return;
}
m_showAverageOverlay = enabled;
update();
}
void MapWidget::setTelemetryOverlayEnabled(bool enabled)
{
if (m_showTelemetryOverlay == enabled) {
return;
}
m_showTelemetryOverlay = enabled;
update();
}
void MapWidget::setRecentPointsEnabled(bool enabled)
{
if (m_showRecentPoints == enabled) {
return;
}
m_showRecentPoints = enabled;
update();
}
void MapWidget::zoomIn()
{
setZoomKeepingPoint(m_zoom + 1, QPointF(width() / 2.0, height() / 2.0));
}
void MapWidget::zoomOut()
{
setZoomKeepingPoint(m_zoom - 1, QPointF(width() / 2.0, height() / 2.0));
}
void MapWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.fillRect(rect(), QColor(219, 225, 220));
drawTiles(painter);
drawTelemetry(painter);
drawLegend(painter);
if (m_points.isEmpty()) {
drawEmptyState(painter);
}
}
void MapWidget::wheelEvent(QWheelEvent *event)
{
const QPoint pixelDelta = event->pixelDelta();
const QPoint angleDelta = event->angleDelta();
const double delta = !pixelDelta.isNull()
? pixelDelta.y() / 36.0
: angleDelta.y() / 120.0;
if (qFuzzyIsNull(delta)) {
return;
}
disableAutoFollowFromUserAction();
m_wheelZoomAccumulator += delta;
while (std::abs(m_wheelZoomAccumulator) >= 1.0) {
const int direction = m_wheelZoomAccumulator > 0.0 ? 1 : -1;
setZoomKeepingPoint(m_zoom + direction, event->position());
m_wheelZoomAccumulator -= direction;
}
if (event->phase() == Qt::ScrollEnd) {
m_wheelZoomAccumulator = 0.0;
}
event->accept();
}
bool MapWidget::event(QEvent *event)
{
if (event->type() == QEvent::NativeGesture) {
auto *gesture = static_cast<QNativeGestureEvent *>(event);
if (gesture->gestureType() == Qt::ZoomNativeGesture) {
disableAutoFollowFromUserAction();
// macOS reports pinch magnification as small deltas. Accumulate
// them so map-tile zoom remains stable instead of flickering.
m_wheelZoomAccumulator += gesture->value() * 5.0;
while (std::abs(m_wheelZoomAccumulator) >= 1.0) {
const int direction = m_wheelZoomAccumulator > 0.0 ? 1 : -1;
setZoomKeepingPoint(m_zoom + direction, gesture->position());
m_wheelZoomAccumulator -= direction;
}
gesture->accept();
return true;
}
if (gesture->gestureType() == Qt::EndNativeGesture) {
m_wheelZoomAccumulator = 0.0;
}
}
return QWidget::event(event);
}
void MapWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::RightButton) {
const QPointF coordinate = screenToCoordinate(event->position());
double longitude = std::fmod(coordinate.y() + 180.0, 360.0);
if (longitude < 0.0) {
longitude += 360.0;
}
longitude -= 180.0;
emit referenceLocationSelected(coordinate.x(), longitude);
event->accept();
return;
}
if (event->button() == Qt::LeftButton) {
disableAutoFollowFromUserAction();
m_dragging = true;
m_lastMousePos = event->pos();
setCursor(Qt::ClosedHandCursor);
event->accept();
}
}
void MapWidget::mouseMoveEvent(QMouseEvent *event)
{
if (!m_dragging) {
return;
}
const QPoint delta = event->pos() - m_lastMousePos;
m_lastMousePos = event->pos();
const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom);
const QPointF newCenterWorld = centerWorld - QPointF(delta);
const QPointF coordinate = worldToCoordinate(newCenterWorld, m_zoom);
m_centerLat = coordinate.x();
m_centerLon = coordinate.y();
update();
event->accept();
}
void MapWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton && m_dragging) {
m_dragging = false;
unsetCursor();
event->accept();
}
}
void MapWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
if (m_attributionLabel != nullptr) {
m_attributionLabel->adjustSize();
m_attributionLabel->move(width() - m_attributionLabel->width() - 8,
height() - m_attributionLabel->height() - 8);
m_attributionLabel->raise();
}
}
QPointF MapWidget::coordinateToWorld(double latitude, double longitude, int zoom) const
{
latitude = clampLatitude(latitude);
const double sinLat = std::sin(latitude * Pi / 180.0);
const double scale = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
const double x = (longitude + 180.0) / 360.0 * scale;
const double y = (0.5 - std::log((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Pi)) * scale;
return QPointF(x, y);
}
QPointF MapWidget::worldToCoordinate(const QPointF &world, int zoom) const
{
const double scale = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
const double longitude = world.x() / scale * 360.0 - 180.0;
const double n = Pi - 2.0 * Pi * world.y() / scale;
const double latitude = 180.0 / Pi * std::atan(0.5 * (std::exp(n) - std::exp(-n)));
return QPointF(clampLatitude(latitude), longitude);
}
QPointF MapWidget::coordinateToScreen(double latitude, double longitude) const
{
const QPointF world = coordinateToWorld(latitude, longitude, m_zoom);
const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom);
const double worldWidth = static_cast<double>(TileSize) * static_cast<double>(1 << m_zoom);
double deltaX = world.x() - centerWorld.x();
if (deltaX > worldWidth / 2.0) {
deltaX -= worldWidth;
} else if (deltaX < -worldWidth / 2.0) {
deltaX += worldWidth;
}
return QPointF(width() / 2.0 + deltaX, height() / 2.0 + world.y() - centerWorld.y());
}
QPointF MapWidget::screenToCoordinate(const QPointF &screen) const
{
return worldToCoordinate(topLeftWorld() + screen, m_zoom);
}
QPointF MapWidget::topLeftWorld() const
{
return coordinateToWorld(m_centerLat, m_centerLon, m_zoom)
- QPointF(width() / 2.0, height() / 2.0);
}
void MapWidget::drawTiles(QPainter &painter)
{
const int tileCount = 1 << m_zoom;
const QPointF topLeft = topLeftWorld();
const int firstTileX = static_cast<int>(std::floor(topLeft.x() / TileSize));
const int lastTileX = static_cast<int>(std::floor((topLeft.x() + width()) / TileSize));
const int firstTileY = static_cast<int>(std::floor(topLeft.y() / TileSize));
const int lastTileY = static_cast<int>(std::floor((topLeft.y() + height()) / TileSize));
painter.setRenderHint(QPainter::SmoothPixmapTransform, false);
struct MissingTile {
int x;
int y;
double distanceFromCenter;
};
QVector<MissingTile> missingTiles;
QSet<QString> visibleTileKeys;
const QPointF viewportCenter = topLeft + QPointF(width() / 2.0, height() / 2.0);
for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) {
if (tileY < 0 || tileY >= tileCount) {
continue;
}
for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) {
const int wrappedTileX = positiveModulo(tileX, tileCount);
const QString key = tileKey(m_zoom, wrappedTileX, tileY);
visibleTileKeys.insert(key);
const QRectF target(
tileX * TileSize - topLeft.x(),
tileY * TileSize - topLeft.y(),
TileSize,
TileSize);
const auto tileIt = m_tileCache.constFind(key);
if (tileIt != m_tileCache.constEnd()) {
painter.drawPixmap(target, *tileIt, QRectF(tileIt->rect()));
} else {
if (!drawCachedTileOrParent(painter, target, wrappedTileX, tileY, m_zoom)) {
painter.fillRect(target, QColor(230, 234, 230));
painter.setPen(QColor(198, 205, 200));
painter.drawRect(target);
}
const QPointF tileCenter((tileX + 0.5) * TileSize,
(tileY + 0.5) * TileSize);
const QPointF offset = tileCenter - viewportCenter;
missingTiles.push_back({wrappedTileX, tileY,
offset.x() * offset.x() + offset.y() * offset.y()});
}
}
}
// A fast pan or auto-follow jump can otherwise leave old requests ahead of
// the tiles that are visible now. Cancel those requests and enqueue the
// centre tiles first so the viewport fills in promptly.
cancelInvisibleTileRequests(visibleTileKeys);
std::sort(missingTiles.begin(), missingTiles.end(),
[](const MissingTile &left, const MissingTile &right) {
return left.distanceFromCenter < right.distanceFromCenter;
});
for (const MissingTile &tile : missingTiles) {
requestTile(tile.x, tile.y, m_zoom);
}
}
bool MapWidget::drawCachedTileOrParent(QPainter &painter, const QRectF &target,
int tileX, int tileY, int zoom) const
{
for (int parentZoom = zoom - 1; parentZoom >= MinZoom; --parentZoom) {
const int scale = 1 << (zoom - parentZoom);
const int parentX = tileX / scale;
const int parentY = tileY / scale;
const auto parentIt = m_tileCache.constFind(tileKey(parentZoom, parentX, parentY));
if (parentIt == m_tileCache.constEnd()) {
continue;
}
const QPixmap &parent = *parentIt;
const double sourceWidth = static_cast<double>(parent.width()) / scale;
const double sourceHeight = static_cast<double>(parent.height()) / scale;
const QRectF source((tileX % scale) * sourceWidth,
(tileY % scale) * sourceHeight,
sourceWidth, sourceHeight);
painter.drawPixmap(target, parent, source);
return true;
}
return false;
}
void MapWidget::drawTelemetry(QPainter &painter)
{
if (m_points.isEmpty() || !m_showTelemetryOverlay) {
return;
}
painter.setRenderHint(QPainter::Antialiasing, true);
drawCepOverlay(painter);
const auto drawSourcePath = [this, &painter](LocationSource source, const QColor &color) {
QPainterPath path;
bool havePoint = false;
int pointCount = 0;
for (const TelemetryPoint &point : m_points) {
if (point.source != source) {
continue;
}
const QPointF screen = coordinateToScreen(point.latitude, point.longitude);
havePoint ? path.lineTo(screen) : path.moveTo(screen);
havePoint = true;
++pointCount;
}
if (m_showRecentPoints && pointCount > 1) {
QPen pathPen(color, 3.0);
pathPen.setCapStyle(Qt::RoundCap);
pathPen.setJoinStyle(Qt::RoundJoin);
painter.setPen(pathPen);
painter.drawPath(path);
}
};
drawSourcePath(LocationSource::Trajectory, QColor(22, 163, 74));
drawSourcePath(LocationSource::Generic, QColor(210, 105, 30));
drawSourcePath(LocationSource::Gps, QColor(37, 99, 235));
drawSourcePath(LocationSource::ImuCorrected, QColor(220, 38, 38));
drawAverageOverlay(painter);
drawCepMarker(painter);
const bool drawNumbers = m_points.size() <= 150;
const int focusIndex = preferredFocusIndex();
for (int i = 0; i < m_points.size(); ++i) {
if (!m_showRecentPoints && i != focusIndex) continue;
const bool isLast = i == focusIndex;
const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude);
const double radius = isLast ? 8.0 : 5.0;
const QColor pointColor = m_points[i].source == LocationSource::ImuCorrected
? QColor(220, 38, 38)
: (m_points[i].source == LocationSource::Gps
? QColor(37, 99, 235)
: (m_points[i].source == LocationSource::Trajectory
? QColor(22, 163, 74)
: (isLast ? QColor(210, 105, 30) : QColor(234, 88, 12))));
painter.setPen(QPen(Qt::white, isLast ? 3.0 : 2.0));
painter.setBrush(pointColor);
painter.drawEllipse(screen, radius, radius);
if (drawNumbers) {
painter.setPen(Qt::white);
painter.setFont(QFont(painter.font().family(), 8, QFont::Bold));
painter.drawText(QRectF(screen.x() - 13, screen.y() - 9, 26, 18), Qt::AlignCenter,
QString::number(i + 1));
}
}
const TelemetryPoint &last = m_points[focusIndex];
const QPointF lastScreen = coordinateToScreen(last.latitude, last.longitude);
QStringList lines;
const QString latitudeText = last.latitudeText.isEmpty()
? QString::number(last.latitude, 'f', 15) : last.latitudeText;
const QString longitudeText = last.longitudeText.isEmpty()
? QString::number(last.longitude, 'f', 15) : last.longitudeText;
lines << QStringLiteral("Lat: %1").arg(latitudeText);
lines << QStringLiteral("Long: %1").arg(longitudeText);
lines << QStringLiteral("Kaynak: %1").arg(locationSourceText(last.source));
if (last.hheight.has_value()) {
lines << QStringLiteral("HHeight: %1").arg(*last.hheight, 0, 'f', 2);
}
if (last.satellites.has_value()) {
lines << QStringLiteral("Aktif uydu: %1").arg(*last.satellites);
}
if (m_cepRadiusMeters.has_value()) {
lines << QStringLiteral("CEP50: %1 m").arg(*m_cepRadiusMeters, 0, 'f', 2);
}
painter.setFont(QFont(painter.font().family(), 10));
const QString label = lines.join('\n');
const QRect textRect = painter.fontMetrics().boundingRect(QRect(0, 0, 300, 150),
Qt::TextWordWrap, label);
QRectF bubble(lastScreen + QPointF(14, -textRect.height() - 12),
QSizeF(textRect.width() + 18, textRect.height() + 14));
if (bubble.right() > width() - 8) {
bubble.moveRight(width() - 8);
}
if (bubble.top() < 8) {
bubble.moveTop(8);
}
painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0));
painter.setBrush(QColor(255, 255, 255, 232));
painter.drawRoundedRect(bubble, 6, 6);
painter.setPen(QColor(25, 32, 38));
painter.drawText(bubble.adjusted(9, 7, -9, -7), Qt::AlignLeft | Qt::AlignVCenter, label);
}
void MapWidget::drawLegend(QPainter &painter)
{
if (m_points.isEmpty() || !m_showTelemetryOverlay) {
return;
}
const struct {
const char *text;
QColor color;
} entries[] = {
{"Trajectory (truth)", QColor(22, 163, 74)},
{"GPS (4 satellite)", QColor(37, 99, 235)},
{"IMU + ESKF corrected", QColor(220, 38, 38)}
};
const QRectF box(12.0, 12.0, 190.0, 76.0);
painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0));
painter.setBrush(QColor(255, 255, 255, 232));
painter.drawRoundedRect(box, 6.0, 6.0);
painter.setFont(QFont(painter.font().family(), 9, QFont::DemiBold));
for (int index = 0; index < 3; ++index) {
const double y = 27.0 + index * 22.0;
painter.setPen(QPen(entries[index].color, 3.0));
painter.drawLine(QPointF(23.0, y), QPointF(43.0, y));
painter.setBrush(entries[index].color);
painter.drawEllipse(QPointF(33.0, y), 4.0, 4.0);
painter.setPen(QColor(25, 32, 38));
painter.drawText(QRectF(50.0, y - 9.0, 142.0, 18.0),
Qt::AlignLeft | Qt::AlignVCenter,
QString::fromLatin1(entries[index].text));
}
}
void MapWidget::drawCepOverlay(QPainter &painter)
{
if (!m_hasCepMarker || !m_cepRadiusMeters.has_value() || *m_cepRadiusMeters <= 0.0) {
return;
}
const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude);
const double resolution = metersPerPixel(m_cepLatitude, m_zoom);
if (!std::isfinite(resolution) || resolution <= 0.0) {
return;
}
const double radiusPixels = std::min(*m_cepRadiusMeters / resolution, 2500.0);
if (radiusPixels < 1.0) {
return;
}
painter.setPen(QPen(QColor(8, 111, 104, 210), 2.0));
painter.setBrush(QColor(13, 148, 136, 42));
painter.drawEllipse(screen, radiusPixels, radiusPixels);
}
void MapWidget::drawCepMarker(QPainter &painter)
{
if (!m_hasCepMarker) {
return;
}
const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude);
painter.setPen(QPen(QColor(8, 111, 104), 2.5));
painter.setBrush(QColor(255, 255, 255, 230));
painter.drawEllipse(screen, 7.0, 7.0);
painter.drawLine(QPointF(screen.x() - 11.0, screen.y()),
QPointF(screen.x() + 11.0, screen.y()));
painter.drawLine(QPointF(screen.x(), screen.y() - 11.0),
QPointF(screen.x(), screen.y() + 11.0));
const int focusIndex = preferredFocusIndex();
const bool overlapsLast = focusIndex >= 0
&& QLineF(screen, coordinateToScreen(m_points[focusIndex].latitude,
m_points[focusIndex].longitude)).length() < 30.0;
if (!overlapsLast) {
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
painter.setPen(QColor(20, 83, 78));
painter.drawText(QRectF(screen + QPointF(12.0, -10.0), QSizeF(110.0, 20.0)),
Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Bilinen konum"));
}
}
void MapWidget::drawAverageOverlay(QPainter &painter)
{
if (!m_showAverageOverlay || !m_hasAverageMarker) {
return;
}
const QPointF screen = coordinateToScreen(m_averageLat, m_averageLon);
const double resolution = metersPerPixel(m_averageLat, m_zoom);
if (std::isfinite(resolution) && resolution > 0.0 && m_averageRadiusMeters > 0.0) {
const double radiusPixels = std::min(m_averageRadiusMeters / resolution, 2500.0);
if (radiusPixels >= 2.0) {
painter.setPen(QPen(QColor(124, 58, 237, 150), 2.0, Qt::DashLine));
painter.setBrush(QColor(124, 58, 237, 30));
painter.drawEllipse(screen, radiusPixels, radiusPixels);
}
}
painter.setPen(QPen(Qt::white, 3.0));
painter.setBrush(QColor(124, 58, 237));
painter.drawEllipse(screen, 9.0, 9.0);
painter.setPen(QPen(QColor(25, 32, 38), 2.0));
painter.drawLine(QPointF(screen.x() - 12.0, screen.y()), QPointF(screen.x() + 12.0, screen.y()));
painter.drawLine(QPointF(screen.x(), screen.y() - 12.0), QPointF(screen.x(), screen.y() + 12.0));
const int focusIndex = preferredFocusIndex();
const bool overlapsLast = focusIndex >= 0
&& QLineF(screen, coordinateToScreen(m_points[focusIndex].latitude,
m_points[focusIndex].longitude)).length() < 30.0;
if (!overlapsLast) {
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
const QRectF labelRect(screen + QPointF(12.0, 8.0), QSizeF(72.0, 20.0));
painter.setPen(QColor(25, 32, 38));
painter.drawText(labelRect, Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Ortalama"));
}
}
void MapWidget::drawEmptyState(QPainter &painter)
{
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setPen(QColor(83, 96, 110));
painter.setFont(QFont(painter.font().family(), 13));
painter.drawText(rect(), Qt::AlignCenter,
QStringLiteral("Seri porttan lat / long / hheight bekleniyor"));
}
void MapWidget::requestTile(int tileX, int tileY, int zoom)
{
const QString key = tileKey(zoom, tileX, tileY);
if (m_tileCache.contains(key) || m_pendingTiles.contains(key)) {
return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (m_tileRetryAfterMs.value(key, 0) > now) {
return;
}
m_pendingTiles.insert(key);
QNetworkRequest request(tileUrl(m_tileUrlTemplate, zoom, tileX, tileY));
request.setRawHeader("User-Agent", "RocketLocationPlotter/0.3 (Qt desktop application)");
request.setTransferTimeout(15000);
QNetworkReply *reply = m_network->get(request);
reply->setProperty("tileKey", key);
m_tileReplies.insert(key, reply);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QString key = reply->property("tileKey").toString();
m_pendingTiles.remove(key);
if (m_tileReplies.value(key) == reply) {
m_tileReplies.remove(key);
}
bool loaded = false;
if (reply->error() == QNetworkReply::NoError) {
QPixmap pixmap;
if (pixmap.loadFromData(reply->readAll())) {
if (!m_tileCache.contains(key) && m_tileCache.size() >= MaxMemoryTileCount) {
m_tileCache.erase(m_tileCache.begin());
}
m_tileCache.insert(key, pixmap);
m_tileFailureCounts.remove(key);
m_tileRetryAfterMs.remove(key);
loaded = true;
}
}
const bool cancelled = reply->property("tileCancelled").toBool();
if (!loaded && !cancelled) {
const int failures = m_tileFailureCounts.value(key, 0) + 1;
m_tileFailureCounts.insert(key, failures);
const int shift = std::min(failures - 1, 5);
const int retryDelay = std::min(MaximumTileRetryDelayMs,
InitialTileRetryDelayMs * (1 << shift));
m_tileRetryAfterMs.insert(key,
QDateTime::currentMSecsSinceEpoch() + retryDelay);
QTimer::singleShot(retryDelay, this, [this, key]() {
repaintWhenTileRetryIsReady(key);
});
}
if (loaded) {
update();
}
reply->deleteLater();
});
}
void MapWidget::cancelInvisibleTileRequests(const QSet<QString> &visibleTileKeys)
{
QVector<QNetworkReply *> obsoleteReplies;
for (auto it = m_tileReplies.cbegin(); it != m_tileReplies.cend(); ++it) {
if (!visibleTileKeys.contains(it.key())) {
obsoleteReplies.push_back(it.value());
}
}
for (QNetworkReply *reply : obsoleteReplies) {
if (reply == nullptr || reply->isFinished()) {
continue;
}
reply->setProperty("tileCancelled", true);
reply->abort();
}
for (auto it = m_tileRetryAfterMs.begin(); it != m_tileRetryAfterMs.end();) {
if (visibleTileKeys.contains(it.key())) {
++it;
continue;
}
m_tileFailureCounts.remove(it.key());
it = m_tileRetryAfterMs.erase(it);
}
}
void MapWidget::repaintWhenTileRetryIsReady(const QString &key)
{
if (!m_tileRetryAfterMs.contains(key)) {
return;
}
const qint64 remaining = m_tileRetryAfterMs.value(key, 0)
- QDateTime::currentMSecsSinceEpoch();
if (remaining > 0) {
QTimer::singleShot(static_cast<int>(remaining), this, [this, key]() {
repaintWhenTileRetryIsReady(key);
});
return;
}
update();
}
void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor)
{
newZoom = std::clamp(newZoom, MinZoom, MaxZoom);
if (newZoom == m_zoom) {
return;
}
const QPointF coordinateAtAnchor = screenToCoordinate(screenAnchor);
m_zoom = newZoom;
const QPointF anchorWorld = coordinateToWorld(coordinateAtAnchor.x(), coordinateAtAnchor.y(), m_zoom);
const QPointF newCenterWorld = anchorWorld - (screenAnchor - QPointF(width() / 2.0, height() / 2.0));
const QPointF newCenter = worldToCoordinate(newCenterWorld, m_zoom);
m_centerLat = newCenter.x();
m_centerLon = newCenter.y();
update();
}
void MapWidget::disableAutoFollowFromUserAction()
{
if (!m_autoFollow) {
return;
}
m_autoFollow = false;
emit autoFollowChanged(false);
}
int MapWidget::preferredFocusIndex() const
{
constexpr qint64 CorrectedPreferenceWindowMs = 1000;
const int newestIndex = m_points.size() - 1;
for (int index = m_points.size() - 1; index >= 0; --index) {
if (m_points[index].source == LocationSource::ImuCorrected) {
const qint64 ageMs = m_points[index].receivedAt.msecsTo(
m_points[newestIndex].receivedAt);
return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex;
}
}
return newestIndex;
}
+105
View File
@@ -0,0 +1,105 @@
#pragma once
#include "TelemetryPoint.h"
#include <QHash>
#include <QPixmap>
#include <QPoint>
#include <QSet>
#include <QWidget>
#include <optional>
class QNetworkAccessManager;
class QNetworkReply;
class QLabel;
class MapWidget : public QWidget {
Q_OBJECT
public:
explicit MapWidget(QWidget *parent = nullptr);
void setPoints(const QVector<TelemetryPoint> &points);
void fitToPoints();
void clearPoints();
void setAverageMarker(double latitude, double longitude, double radiusMeters);
void clearAverageMarker();
void setCepMarker(double latitude, double longitude,
const std::optional<double> &radiusMeters);
void clearCepMarker();
[[nodiscard]] QPointF centerCoordinate() const;
public slots:
void setAutoFollow(bool enabled);
void setTelemetryOverlayEnabled(bool enabled);
void setAverageOverlayEnabled(bool enabled);
void setRecentPointsEnabled(bool enabled);
void zoomIn();
void zoomOut();
signals:
void autoFollowChanged(bool enabled);
void referenceLocationSelected(double latitude, double longitude);
protected:
bool event(QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private:
QPointF coordinateToWorld(double latitude, double longitude, int zoom) const;
QPointF worldToCoordinate(const QPointF &world, int zoom) const;
QPointF coordinateToScreen(double latitude, double longitude) const;
QPointF screenToCoordinate(const QPointF &screen) const;
QPointF topLeftWorld() const;
void drawTiles(QPainter &painter);
void drawTelemetry(QPainter &painter);
void drawLegend(QPainter &painter);
void drawAverageOverlay(QPainter &painter);
void drawCepOverlay(QPainter &painter);
void drawCepMarker(QPainter &painter);
void drawEmptyState(QPainter &painter);
bool drawCachedTileOrParent(QPainter &painter, const QRectF &target,
int tileX, int tileY, int zoom) const;
void requestTile(int tileX, int tileY, int zoom);
void cancelInvisibleTileRequests(const QSet<QString> &visibleTileKeys);
void repaintWhenTileRetryIsReady(const QString &key);
void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor);
void disableAutoFollowFromUserAction();
int preferredFocusIndex() const;
QVector<TelemetryPoint> m_points;
QNetworkAccessManager *m_network = nullptr;
QLabel *m_attributionLabel = nullptr;
QHash<QString, QPixmap> m_tileCache;
QSet<QString> m_pendingTiles;
QHash<QString, QNetworkReply *> m_tileReplies;
QHash<QString, int> m_tileFailureCounts;
QHash<QString, qint64> m_tileRetryAfterMs;
QString m_tileUrlTemplate;
double m_centerLat = 39.0;
double m_centerLon = 35.0;
int m_zoom = 6;
bool m_autoFollow = true;
bool m_showTelemetryOverlay = true;
bool m_showAverageOverlay = true;
bool m_showRecentPoints = true;
bool m_hasAverageMarker = false;
double m_averageLat = 0.0;
double m_averageLon = 0.0;
double m_averageRadiusMeters = 0.0;
bool m_hasCepMarker = false;
double m_cepLatitude = 0.0;
double m_cepLongitude = 0.0;
std::optional<double> m_cepRadiusMeters;
bool m_dragging = false;
QPoint m_lastMousePos;
double m_wheelZoomAccumulator = 0.0;
};
+377
View File
@@ -0,0 +1,377 @@
#include "RinexNavigation.h"
#include <QDir>
#include <QFile>
#include <QHash>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStandardPaths>
#include <QTimeZone>
#include <QUrl>
#include <zlib.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
namespace {
constexpr double GpsMu = 3.986005e14;
constexpr double EarthRotation = 7.2921151467e-5;
constexpr double GpsWeekSeconds = 604800.0;
constexpr double HalfGpsWeek = GpsWeekSeconds / 2.0;
constexpr double CurrentGpsUtcOffsetSeconds = 18.0;
constexpr int PositionUpdateIntervalMs = 60000;
double parseField(const QString &line, int field)
{
QString text = line.mid(4 + field * 19, 19).trimmed();
text.replace(QChar('D'), QChar('E'), Qt::CaseInsensitive);
bool ok = false;
const double value = text.toDouble(&ok);
return ok ? value : std::numeric_limits<double>::quiet_NaN();
}
bool finiteFields(std::initializer_list<double> values)
{
return std::all_of(values.begin(), values.end(), [](double value) {
return std::isfinite(value);
});
}
QByteArray gunzip(const QByteArray &input)
{
if (input.size() < 2 || static_cast<unsigned char>(input[0]) != 0x1f
|| static_cast<unsigned char>(input[1]) != 0x8b) {
return input;
}
z_stream stream{};
stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(input.constData()));
stream.avail_in = static_cast<uInt>(input.size());
if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) {
return {};
}
QByteArray output;
char buffer[64 * 1024];
int result = Z_OK;
while (result == Z_OK) {
stream.next_out = reinterpret_cast<Bytef *>(buffer);
stream.avail_out = sizeof(buffer);
result = inflate(&stream, Z_NO_FLUSH);
output.append(buffer, static_cast<int>(sizeof(buffer) - stream.avail_out));
}
inflateEnd(&stream);
return result == Z_STREAM_END ? output : QByteArray{};
}
QDateTime gpsEpoch()
{
return QDateTime(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC);
}
} // namespace
RinexNavigation::RinexNavigation(QObject *parent)
: QObject(parent)
{
m_positionTimer.setInterval(PositionUpdateIntervalMs);
connect(&m_positionTimer, &QTimer::timeout,
this, &RinexNavigation::updateCurrentPositions);
m_positionTimer.start();
}
void RinexNavigation::refresh()
{
if (m_loading) return;
if (m_ephemerides.isEmpty()) {
loadCache();
}
setLoading(true);
emit statusChanged(QStringLiteral("Guncel RINEX efemerisi indiriliyor..."), false);
requestDay(QDateTime::currentDateTimeUtc().date(), 0);
}
void RinexNavigation::requestDay(const QDate &date, int fallbackIndex)
{
const QString year = QString::number(date.year());
const QString day = QStringLiteral("%1").arg(date.dayOfYear(), 3, 10, QLatin1Char('0'));
// BKG's stream-derived file is updated during the UTC day; the final IGS
// combination is normally published later and is therefore unsuitable here.
const QString file = QStringLiteral("BRDC00WRD_S_") + year + day
+ QStringLiteral("0000_01D_MN.rnx.gz");
const QUrl url(QStringLiteral("https://igs.bkg.bund.de/root_ftp/IGS/BRDC/%1/%2/%3")
.arg(year, day, file));
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::UserAgentHeader,
QStringLiteral("ANKASYS-LocationPlotter/1.0 (RINEX client)"));
request.setTransferTimeout(15000);
QNetworkReply *reply = m_network.get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply, date, fallbackIndex] {
const bool ok = reply->error() == QNetworkReply::NoError;
const QByteArray data = ok ? reply->readAll() : QByteArray{};
reply->deleteLater();
if (!ok || data.isEmpty()) {
if (fallbackIndex < 2) {
requestDay(date.addDays(-1), fallbackIndex + 1);
} else {
setLoading(false);
if (!m_ephemerides.isEmpty()) {
publishCurrentMotion();
emit statusChanged(
QStringLiteral("RINEX indirilemedi; mevcut efemeris kullaniliyor"),
true);
} else if (!loadCache()) {
emit statusChanged(QStringLiteral("RINEX indirilemedi; internet baglantisini kontrol edin"), true);
}
}
return;
}
handleDownload(data, date, fallbackIndex);
});
}
void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &date,
int fallbackIndex)
{
const QByteArray rinex = gunzip(compressed);
QString error;
QVector<Ephemeris> ephemerides = parseEphemerides(rinex, &error);
const QDateTime now = QDateTime::currentDateTimeUtc();
const QVector<SatellitePosition> positions = propagateEphemerides(
selectEphemerides(ephemerides, now), now);
if (positions.isEmpty()) {
if (fallbackIndex < 2) {
requestDay(date.addDays(-1), fallbackIndex + 1);
return;
}
setLoading(false);
if (!loadCache()) {
emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true);
}
return;
}
QFile cache(cachePath());
QDir().mkpath(QFileInfo(cache).absolutePath());
if (cache.open(QIODevice::WriteOnly)) cache.write(rinex);
m_ephemerides = std::move(ephemerides);
setLoading(false);
publishCurrentMotion();
emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3")
.arg(positions.size())
.arg(date.toString(QStringLiteral("dd.MM.yyyy")))
.arg(now.toLocalTime().toString(
QStringLiteral("HH:mm:ss t"))), false);
}
bool RinexNavigation::loadCache()
{
QFile cache(cachePath());
if (!cache.open(QIODevice::ReadOnly)) return false;
QString error;
QVector<Ephemeris> ephemerides = parseEphemerides(cache.readAll(), &error);
const QDateTime now = QDateTime::currentDateTimeUtc();
const QVector<SatellitePosition> positions = propagateEphemerides(
selectEphemerides(ephemerides, now), now);
if (positions.isEmpty()) return false;
m_ephemerides = std::move(ephemerides);
publishCurrentMotion();
emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2")
.arg(positions.size())
.arg(now.toLocalTime().toString(
QStringLiteral("HH:mm:ss t"))), false);
return true;
}
QString RinexNavigation::cachePath() const
{
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
+ QStringLiteral("/latest-navigation.rnx");
}
void RinexNavigation::updateCurrentPositions()
{
if (m_ephemerides.isEmpty()) return;
publishCurrentMotion();
}
void RinexNavigation::publishCurrentMotion()
{
if (m_ephemerides.isEmpty()) return;
const QDateTime now = QDateTime::currentDateTimeUtc();
const QVector<Ephemeris> selected = selectEphemerides(m_ephemerides, now);
const QVector<SatellitePosition> current = propagateEphemerides(selected, now);
if (current.isEmpty()) return;
const QVector<SatellitePosition> next = propagateEphemerides(
selected, now.addMSecs(PositionUpdateIntervalMs));
emit satellitesUpdated(current);
if (!next.isEmpty()) {
emit satelliteMotionUpdated(current, next, PositionUpdateIntervalMs);
}
}
void RinexNavigation::setLoading(bool loading)
{
if (m_loading == loading) return;
m_loading = loading;
emit loadingChanged(loading);
}
QVector<RinexNavigation::Ephemeris> RinexNavigation::parseEphemerides(
const QByteArray &rinex, QString *error)
{
const QString text = QString::fromLatin1(rinex);
const QStringList lines = text.split('\n');
int index = 0;
while (index < lines.size() && !lines[index].contains(QStringLiteral("END OF HEADER"))) ++index;
if (index >= lines.size()) {
if (error) *error = QStringLiteral("END OF HEADER bulunamadi");
return {};
}
++index;
QVector<Ephemeris> result;
result.reserve(512);
while (index < lines.size()) {
const QString first = lines[index];
if (first.size() < 3) { ++index; continue; }
const QChar system = first[0];
if (!system.isLetter()) { ++index; continue; }
const int blockLines = (system == QLatin1Char('R') || system == QLatin1Char('S')) ? 4 : 8;
if (index + blockLines > lines.size()) break;
if (system != QLatin1Char('G')) { index += blockLines; continue; }
const QStringList epoch = first.mid(3, 20).simplified().split(' ');
if (epoch.size() < 6) { index += 8; continue; }
bool dateOk = false;
const int year = epoch[0].toInt(&dateOk);
const QDateTime toc(QDate(year, epoch[1].toInt(), epoch[2].toInt()),
QTime(epoch[3].toInt(), epoch[4].toInt(),
static_cast<int>(epoch[5].toDouble())), Qt::UTC);
if (!dateOk || !toc.isValid()) { index += 8; continue; }
Ephemeris eph;
eph.prn = first.left(3).trimmed().toUpper();
eph.toc = toc;
eph.crs = parseField(lines[index + 1], 1);
eph.deltaN = parseField(lines[index + 1], 2);
eph.m0 = parseField(lines[index + 1], 3);
eph.cuc = parseField(lines[index + 2], 0);
eph.eccentricity = parseField(lines[index + 2], 1);
eph.cus = parseField(lines[index + 2], 2);
eph.sqrtA = parseField(lines[index + 2], 3);
eph.toe = parseField(lines[index + 3], 0);
eph.cic = parseField(lines[index + 3], 1);
eph.omega0 = parseField(lines[index + 3], 2);
eph.cis = parseField(lines[index + 3], 3);
eph.i0 = parseField(lines[index + 4], 0);
eph.crc = parseField(lines[index + 4], 1);
eph.omega = parseField(lines[index + 4], 2);
eph.omegaDot = parseField(lines[index + 4], 3);
eph.idot = parseField(lines[index + 5], 0);
eph.gpsWeek = static_cast<int>(std::llround(parseField(lines[index + 5], 2)));
const double health = parseField(lines[index + 6], 1);
eph.healthy = !std::isfinite(health) || std::llround(health) == 0;
if (finiteFields({eph.crs, eph.deltaN, eph.m0, eph.cuc, eph.eccentricity,
eph.cus, eph.sqrtA, eph.toe, eph.cic, eph.omega0, eph.cis,
eph.i0, eph.crc, eph.omega, eph.omegaDot, eph.idot})
&& eph.sqrtA > 4000.0 && eph.gpsWeek > 0) {
result.push_back(eph);
}
index += 8;
}
if (result.isEmpty() && error) *error = QStringLiteral("GPS efemerisi bulunamadi");
return result;
}
QVector<RinexNavigation::Ephemeris> RinexNavigation::selectEphemerides(
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc)
{
QHash<QString, Ephemeris> closest;
QHash<QString, qint64> closestAge;
// GPS navigation epochs are labelled in GPS system time, not UTC.
const QDateTime whenGps = whenUtc.addSecs(
static_cast<qint64>(CurrentGpsUtcOffsetSeconds));
for (const Ephemeris &eph : ephemerides) {
const qint64 age = std::abs(eph.toc.msecsTo(whenGps));
if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) {
closest[eph.prn] = eph;
closestAge[eph.prn] = age;
}
}
QVector<Ephemeris> result;
result.reserve(closest.size());
for (const Ephemeris &eph : closest) result.push_back(eph);
std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) {
return a.prn < b.prn;
});
return result;
}
QVector<SatellitePosition> RinexNavigation::propagateEphemerides(
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc)
{
QVector<SatellitePosition> result;
result.reserve(ephemerides.size());
for (const Ephemeris &eph : ephemerides) result.push_back(propagate(eph, whenUtc));
std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; });
return result;
}
SatellitePosition RinexNavigation::propagate(const Ephemeris &eph, const QDateTime &whenUtc)
{
const double gpsSeconds = gpsEpoch().msecsTo(whenUtc) / 1000.0
+ CurrentGpsUtcOffsetSeconds;
const double absoluteToe = eph.gpsWeek * GpsWeekSeconds + eph.toe;
double tk = gpsSeconds - absoluteToe;
while (tk > HalfGpsWeek) tk -= GpsWeekSeconds;
while (tk < -HalfGpsWeek) tk += GpsWeekSeconds;
const double a = eph.sqrtA * eph.sqrtA;
const double meanMotion = std::sqrt(GpsMu / (a * a * a)) + eph.deltaN;
const double meanAnomaly = eph.m0 + meanMotion * tk;
double eccentricAnomaly = meanAnomaly;
for (int iteration = 0; iteration < 12; ++iteration) {
eccentricAnomaly = meanAnomaly + eph.eccentricity * std::sin(eccentricAnomaly);
}
const double trueAnomaly = std::atan2(
std::sqrt(1.0 - eph.eccentricity * eph.eccentricity) * std::sin(eccentricAnomaly),
std::cos(eccentricAnomaly) - eph.eccentricity);
const double phi = trueAnomaly + eph.omega;
const double twoPhi = 2.0 * phi;
const double u = phi + eph.cus * std::sin(twoPhi) + eph.cuc * std::cos(twoPhi);
const double radius = a * (1.0 - eph.eccentricity * std::cos(eccentricAnomaly))
+ eph.crs * std::sin(twoPhi) + eph.crc * std::cos(twoPhi);
const double inclination = eph.i0 + eph.idot * tk
+ eph.cis * std::sin(twoPhi) + eph.cic * std::cos(twoPhi);
const double orbitalX = radius * std::cos(u);
const double orbitalY = radius * std::sin(u);
const double ascendingNode = eph.omega0 + (eph.omegaDot - EarthRotation) * tk
- EarthRotation * eph.toe;
SatellitePosition result;
result.prn = eph.prn;
result.x = orbitalX * std::cos(ascendingNode)
- orbitalY * std::cos(inclination) * std::sin(ascendingNode);
result.y = orbitalX * std::sin(ascendingNode)
+ orbitalY * std::cos(inclination) * std::cos(ascendingNode);
result.z = orbitalY * std::sin(inclination);
result.healthy = eph.healthy;
return result;
}
QVector<SatellitePosition> RinexNavigation::parseGpsNavigation(
const QByteArray &rinex, const QDateTime &whenUtc, QString *error)
{
const QVector<Ephemeris> ephemerides = parseEphemerides(rinex, error);
return propagateEphemerides(selectEphemerides(ephemerides, whenUtc), whenUtc);
}
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include "SatelliteData.h"
#include <QDateTime>
#include <QNetworkAccessManager>
#include <QObject>
#include <QVector>
#include <QTimer>
class RinexNavigation : public QObject {
Q_OBJECT
public:
explicit RinexNavigation(QObject *parent = nullptr);
void refresh();
[[nodiscard]] bool isLoading() const { return m_loading; }
static QVector<SatellitePosition> parseGpsNavigation(
const QByteArray &rinex, const QDateTime &whenUtc, QString *error = nullptr);
signals:
void satellitesUpdated(const QVector<SatellitePosition> &satellites);
void satelliteMotionUpdated(const QVector<SatellitePosition> &current,
const QVector<SatellitePosition> &next,
int durationMs);
void statusChanged(const QString &status, bool error);
void loadingChanged(bool loading);
private:
struct Ephemeris {
QString prn;
QDateTime toc;
double crs = 0.0;
double deltaN = 0.0;
double m0 = 0.0;
double cuc = 0.0;
double eccentricity = 0.0;
double cus = 0.0;
double sqrtA = 0.0;
double toe = 0.0;
double cic = 0.0;
double omega0 = 0.0;
double cis = 0.0;
double i0 = 0.0;
double crc = 0.0;
double omega = 0.0;
double omegaDot = 0.0;
double idot = 0.0;
int gpsWeek = 0;
bool healthy = true;
};
void requestDay(const QDate &date, int fallbackIndex);
void handleDownload(const QByteArray &compressed, const QDate &date, int fallbackIndex);
bool loadCache();
QString cachePath() const;
void updateCurrentPositions();
void publishCurrentMotion();
void setLoading(bool loading);
static QVector<Ephemeris> parseEphemerides(
const QByteArray &rinex, QString *error = nullptr);
static QVector<Ephemeris> selectEphemerides(
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc);
static QVector<SatellitePosition> propagateEphemerides(
const QVector<Ephemeris> &ephemerides, const QDateTime &whenUtc);
static SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc);
QNetworkAccessManager m_network;
QTimer m_positionTimer;
QVector<Ephemeris> m_ephemerides;
bool m_loading = false;
};
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <QString>
struct SatellitePosition {
QString prn;
double x = 0.0; // Earth-centred, Earth-fixed metres
double y = 0.0;
double z = 0.0;
bool healthy = true;
};
+302
View File
@@ -0,0 +1,302 @@
#include "SerialReader.h"
#include <QSocketNotifier>
#include <QTimer>
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <termios.h>
#include <unistd.h>
#ifdef Q_OS_MACOS
#include <IOKit/serial/ioss.h>
#endif
namespace {
constexpr qsizetype MaxBytesPerReadCycle = 64 * 1024;
bool applyBaudRate(termios &options, int baudRate)
{
speed_t speed = 0;
switch (baudRate) {
case 1200:
speed = B1200;
break;
case 2400:
speed = B2400;
break;
case 4800:
speed = B4800;
break;
case 9600:
speed = B9600;
break;
case 19200:
speed = B19200;
break;
case 38400:
speed = B38400;
break;
case 57600:
speed = B57600;
break;
case 115200:
speed = B115200;
break;
#ifdef B230400
case 230400:
speed = B230400;
break;
#endif
#ifdef B460800
case 460800:
speed = B460800;
break;
#endif
#ifdef B921600
case 921600:
speed = B921600;
break;
#endif
default:
#ifdef Q_OS_MACOS
// IOSSIOSPEED below applies the exact requested rate. A valid termios
// placeholder is still required before that ioctl can be issued.
speed = B9600;
break;
#else
return false;
#endif
}
return ::cfsetispeed(&options, speed) == 0
&& ::cfsetospeed(&options, speed) == 0;
}
} // namespace
SerialReader::SerialReader(QObject *parent)
: QObject(parent)
{
m_pollTimer = new QTimer(this);
m_pollTimer->setInterval(20);
connect(m_pollTimer, &QTimer::timeout, this, &SerialReader::handleReadyRead);
m_partialLineTimer = new QTimer(this);
m_partialLineTimer->setSingleShot(true);
m_partialLineTimer->setInterval(250);
connect(m_partialLineTimer, &QTimer::timeout, this, &SerialReader::flushPartialLine);
}
SerialReader::~SerialReader()
{
closePort();
}
bool SerialReader::isOpen() const
{
return m_fd >= 0;
}
QString SerialReader::portName() const
{
return m_portName;
}
bool SerialReader::openPort(const QString &path, int baudRate)
{
closePort();
const QByteArray localPath = path.toLocal8Bit();
m_fd = ::open(localPath.constData(), O_RDWR | O_NOCTTY | O_NONBLOCK);
if (m_fd < 0) {
emitSystemError(QStringLiteral("Port acilamadi"));
return false;
}
m_portName = path;
if (!configurePort(baudRate)) {
closePort();
return false;
}
setModemControlLines();
m_notifier = new QSocketNotifier(m_fd, QSocketNotifier::Read, this);
connect(m_notifier, &QSocketNotifier::activated, this, &SerialReader::handleReadyRead);
m_pollTimer->start();
emit statusChanged(QStringLiteral("%1 dinleniyor (%2 baud)").arg(path).arg(baudRate));
return true;
}
void SerialReader::closePort()
{
if (m_notifier != nullptr) {
m_notifier->setEnabled(false);
m_notifier->deleteLater();
m_notifier = nullptr;
}
m_pollTimer->stop();
m_partialLineTimer->stop();
if (m_fd >= 0) {
::close(m_fd);
m_fd = -1;
}
m_buffer.clear();
if (!m_portName.isEmpty()) {
emit statusChanged(QStringLiteral("%1 kapatildi").arg(m_portName));
}
m_portName.clear();
}
void SerialReader::handleReadyRead()
{
if (m_fd < 0) {
return;
}
char data[4096];
qsizetype bytesProcessed = 0;
while (true) {
const ssize_t bytesRead = ::read(m_fd, data, sizeof(data));
if (bytesRead > 0) {
bytesProcessed += static_cast<qsizetype>(bytesRead);
m_buffer.append(data, static_cast<qsizetype>(bytesRead));
processBuffer();
if (m_buffer.size() > 64 * 1024) {
m_buffer.clear();
emit errorOccurred(QStringLiteral("Satir sonu gelmeyen seri veri tamponu temizlendi"));
}
if (bytesProcessed >= MaxBytesPerReadCycle) {
QTimer::singleShot(0, this, &SerialReader::handleReadyRead);
return;
}
continue;
}
if (bytesRead == 0 || errno == EAGAIN || errno == EWOULDBLOCK) {
return;
}
emitSystemError(QStringLiteral("Seri port okuma hatasi"));
closePort();
return;
}
}
void SerialReader::flushPartialLine()
{
if (m_buffer.isEmpty()) {
return;
}
QByteArray line = m_buffer;
m_buffer.clear();
while (line.endsWith('\r') || line.endsWith('\n')) {
line.chop(1);
}
if (!line.isEmpty()) {
emit lineReceived(QString::fromUtf8(line));
}
}
bool SerialReader::configurePort(int baudRate)
{
termios options {};
if (::tcgetattr(m_fd, &options) != 0) {
emitSystemError(QStringLiteral("Port ayarlari okunamadi"));
return false;
}
::cfmakeraw(&options);
if (!applyBaudRate(options, baudRate)) {
emit errorOccurred(QStringLiteral("Bu sistemde desteklenmeyen baud rate: %1").arg(baudRate));
return false;
}
options.c_cflag |= CLOCAL | CREAD;
options.c_cflag &= ~HUPCL;
options.c_cflag &= ~PARENB;
options.c_cflag &= ~CSTOPB;
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8;
options.c_cc[VMIN] = 0;
options.c_cc[VTIME] = 0;
if (::tcsetattr(m_fd, TCSANOW, &options) != 0) {
emitSystemError(QStringLiteral("Port ayarlari uygulanamadi"));
return false;
}
#ifdef Q_OS_MACOS
speed_t requestedSpeed = static_cast<speed_t>(baudRate);
if (::ioctl(m_fd, IOSSIOSPEED, &requestedSpeed) != 0) {
emitSystemError(QStringLiteral("macOS baud rate uygulanamadi"));
return false;
}
#endif
::tcflush(m_fd, TCIFLUSH);
return true;
}
void SerialReader::processBuffer()
{
bool emittedLine = false;
while (true) {
qsizetype delimiterIndex = -1;
for (qsizetype i = 0; i < m_buffer.size(); ++i) {
if (m_buffer.at(i) == '\n' || m_buffer.at(i) == '\r') {
delimiterIndex = i;
break;
}
}
if (delimiterIndex < 0) {
break;
}
QByteArray line = m_buffer.left(delimiterIndex);
qsizetype removeCount = delimiterIndex + 1;
while (removeCount < m_buffer.size()
&& (m_buffer.at(removeCount) == '\n' || m_buffer.at(removeCount) == '\r')) {
++removeCount;
}
m_buffer.remove(0, removeCount);
if (!line.isEmpty()) {
emittedLine = true;
emit lineReceived(QString::fromUtf8(line));
}
}
if (!m_buffer.isEmpty()) {
m_partialLineTimer->start();
} else if (emittedLine) {
m_partialLineTimer->stop();
}
}
void SerialReader::setModemControlLines()
{
int status = 0;
if (::ioctl(m_fd, TIOCMGET, &status) != 0) {
return;
}
status |= TIOCM_DTR;
status |= TIOCM_RTS;
::ioctl(m_fd, TIOCMSET, &status);
}
void SerialReader::emitSystemError(const QString &prefix)
{
emit errorOccurred(QStringLiteral("%1: %2").arg(prefix, QString::fromLocal8Bit(std::strerror(errno))));
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <QObject>
#include <QString>
class QSocketNotifier;
class QTimer;
class SerialReader : public QObject {
Q_OBJECT
public:
explicit SerialReader(QObject *parent = nullptr);
~SerialReader() override;
[[nodiscard]] bool isOpen() const;
[[nodiscard]] QString portName() const;
public slots:
bool openPort(const QString &path, int baudRate);
void closePort();
signals:
void lineReceived(const QString &line);
void statusChanged(const QString &status);
void errorOccurred(const QString &message);
private slots:
void handleReadyRead();
void flushPartialLine();
private:
bool configurePort(int baudRate);
void processBuffer();
void setModemControlLines();
void emitSystemError(const QString &prefix);
int m_fd = -1;
QSocketNotifier *m_notifier = nullptr;
QTimer *m_pollTimer = nullptr;
QTimer *m_partialLineTimer = nullptr;
QByteArray m_buffer;
QString m_portName;
};
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include <QDateTime>
#include <QString>
#include <optional>
enum class LocationSource {
Generic,
Gps,
ImuCorrected,
Trajectory
};
enum class FusionMode {
Unknown,
GpsAided,
DeadReckoning
};
inline QString locationSourceText(LocationSource source)
{
switch (source) {
case LocationSource::Gps:
return QStringLiteral("GPS");
case LocationSource::ImuCorrected:
return QStringLiteral("IMU corrected");
case LocationSource::Trajectory:
return QStringLiteral("Trajectory (truth)");
case LocationSource::Generic:
default:
return QStringLiteral("Generic");
}
}
struct DopValues {
std::optional<double> gdop;
std::optional<double> pdop;
std::optional<double> hdop;
std::optional<double> vdop;
std::optional<double> tdop;
[[nodiscard]] bool hasAnyValue() const
{
return gdop.has_value() || pdop.has_value() || hdop.has_value()
|| vdop.has_value() || tdop.has_value();
}
};
struct TelemetryPoint {
LocationSource source = LocationSource::Generic;
FusionMode fusionMode = FusionMode::Unknown;
std::optional<qint64> gpsAgeMs;
double latitude = 0.0;
double longitude = 0.0;
QString latitudeText;
QString longitudeText;
std::optional<double> hheight;
std::optional<int> satellites;
DopValues dop;
QDateTime receivedAt = QDateTime::currentDateTimeUtc();
QString rawLine;
};
struct ParsedTelemetry {
LocationSource source = LocationSource::Generic;
FusionMode fusionMode = FusionMode::Unknown;
std::optional<qint64> gpsAgeMs;
std::optional<double> latitude;
std::optional<double> longitude;
QString latitudeText;
QString longitudeText;
std::optional<double> hheight;
std::optional<int> satellites;
DopValues dop;
QString rawLine;
[[nodiscard]] bool hasLocation() const
{
return latitude.has_value() && longitude.has_value();
}
[[nodiscard]] bool hasAnyValue() const
{
return latitude.has_value() || longitude.has_value() || hheight.has_value()
|| satellites.has_value() || dop.hasAnyValue();
}
};
+207
View File
@@ -0,0 +1,207 @@
#include "UartReplay.h"
#include "LocationParser.h"
#include <QFile>
#include <QRegularExpression>
#include <QTimer>
#include <algorithm>
#include <limits>
UartReplay::UartReplay(QObject *parent)
: QObject(parent)
, m_timer(new QTimer(this))
{
m_timer->setSingleShot(true);
connect(m_timer, &QTimer::timeout, this, &UartReplay::emitNext);
}
bool UartReplay::isRunning() const
{
return m_running;
}
int UartReplay::locationCount() const
{
return m_locationCount;
}
bool UartReplay::start(const QString &filePath, int gpsIntervalMs, int sensorIntervalMs)
{
stop();
if (gpsIntervalMs < 1 || sensorIntervalMs < 1) {
emit errorOccurred(QStringLiteral("Replay araliklari en az 1 ms olmalidir"));
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
emit errorOccurred(QStringLiteral("UART replay logu acilamadi: %1")
.arg(file.errorString()));
return false;
}
qint64 nextGpsMs = 0;
qint64 nextSensorMs = 0;
qint64 lastDueMs = 0;
bool pendingLatitude = false;
bool pendingLongitude = false;
LocationSource pendingSource = LocationSource::Generic;
const QList<QByteArray> rawLines = file.readAll().split('\n');
m_lines.reserve(rawLines.size());
for (const QByteArray &rawLine : rawLines) {
const QString line = removeRecorderTimestamp(QString::fromUtf8(rawLine));
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))
|| line.startsWith(QStringLiteral("[SESSION START]"))
|| line.startsWith(QStringLiteral("[SESSION END]"))) {
continue;
}
Stream stream = classifyLine(line);
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
if (telemetry.has_value() && telemetry->hasLocation()) {
pendingLatitude = false;
pendingLongitude = false;
pendingSource = LocationSource::Generic;
} else if (telemetry.has_value()) {
if (telemetry->latitude.has_value()) {
if (pendingLatitude) {
pendingLongitude = false;
}
pendingLatitude = true;
pendingSource = telemetry->source;
}
if (telemetry->longitude.has_value()) {
pendingLongitude = true;
if (telemetry->source != LocationSource::Generic) {
pendingSource = telemetry->source;
}
}
if (pendingLatitude && pendingLongitude) {
stream = pendingSource == LocationSource::ImuCorrected
|| pendingSource == LocationSource::Trajectory
? Stream::Sensor : Stream::Gps;
pendingLatitude = false;
pendingLongitude = false;
pendingSource = LocationSource::Generic;
}
}
qint64 dueMs = lastDueMs;
if (stream == Stream::Gps) {
dueMs = std::max(lastDueMs, nextGpsMs);
nextGpsMs = dueMs + gpsIntervalMs;
++m_locationCount;
} else if (stream == Stream::Sensor) {
dueMs = std::max(lastDueMs, nextSensorMs);
nextSensorMs = dueMs + sensorIntervalMs;
++m_locationCount;
}
ReplayLine replayLine;
replayLine.text = line;
replayLine.delayBeforeMs = static_cast<int>(std::min<qint64>(
dueMs - lastDueMs, std::numeric_limits<int>::max()));
m_lines.push_back(std::move(replayLine));
lastDueMs = dueMs;
}
if (m_locationCount == 0) {
m_lines.clear();
emit errorOccurred(QStringLiteral(
"UART replay logunda GPS veya fused konum kaydi bulunamadi"));
return false;
}
m_running = true;
emit replayStarted(m_lines.size(), m_locationCount);
m_timer->start(0);
return true;
}
void UartReplay::stop()
{
if (!m_running && m_lines.isEmpty()) {
return;
}
m_timer->stop();
m_running = false;
m_lines.clear();
m_index = 0;
m_locationCount = 0;
}
void UartReplay::emitNext()
{
if (!m_running) {
return;
}
constexpr int MaxImmediateLines = 250;
int emitted = 0;
while (m_index < m_lines.size() && emitted < MaxImmediateLines) {
const ReplayLine &line = m_lines.at(m_index);
if (line.delayBeforeMs > 0) {
if (emitted > 0) {
emit progressChanged(m_index, m_lines.size());
}
const int delay = line.delayBeforeMs;
m_lines[m_index].delayBeforeMs = 0;
m_timer->start(delay);
return;
}
emit lineReady(line.text);
++m_index;
++emitted;
}
emit progressChanged(m_index, m_lines.size());
if (m_index >= m_lines.size()) {
finish();
} else {
m_timer->start(0);
}
}
QString UartReplay::removeRecorderTimestamp(const QString &line)
{
QString result = line.trimmed();
static const QRegularExpression timestampPrefix(
QStringLiteral(R"(^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[\.,]\d+)?\]\s*)"));
result.remove(timestampPrefix);
return result.trimmed();
}
UartReplay::Stream UartReplay::classifyLine(const QString &line)
{
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
if (!telemetry.has_value() || !telemetry->hasLocation()) {
return Stream::Diagnostic;
}
switch (telemetry->source) {
case LocationSource::Gps:
return Stream::Gps;
case LocationSource::ImuCorrected:
case LocationSource::Trajectory:
return Stream::Sensor;
case LocationSource::Generic:
default:
// Older firmware used a [FUSION] record without an explicit source.
return line.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)
? Stream::Sensor : Stream::Gps;
}
}
void UartReplay::finish()
{
m_timer->stop();
m_running = false;
m_lines.clear();
m_index = 0;
m_locationCount = 0;
emit replayFinished();
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVector>
class QTimer;
class UartReplay final : public QObject {
Q_OBJECT
public:
explicit UartReplay(QObject *parent = nullptr);
[[nodiscard]] bool isRunning() const;
[[nodiscard]] int locationCount() const;
public slots:
bool start(const QString &filePath, int gpsIntervalMs = 200,
int sensorIntervalMs = 50);
void stop();
signals:
void lineReady(const QString &line);
void replayStarted(int lineCount, int locationCount);
void progressChanged(int linesProcessed, int lineCount);
void replayFinished();
void errorOccurred(const QString &message);
private slots:
void emitNext();
private:
enum class Stream {
Diagnostic,
Gps,
Sensor
};
struct ReplayLine {
QString text;
int delayBeforeMs = 0;
};
static QString removeRecorderTimestamp(const QString &line);
static Stream classifyLine(const QString &line);
void finish();
QTimer *m_timer = nullptr;
QVector<ReplayLine> m_lines;
int m_index = 0;
int m_locationCount = 0;
bool m_running = false;
};
+19
View File
@@ -0,0 +1,19 @@
#include "MainWindow.h"
#include <QApplication>
#include <QFileInfo>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
QApplication::setApplicationName(QStringLiteral("location_plotter"));
QApplication::setOrganizationName(QStringLiteral("rocket"));
MainWindow window;
if (argc == 3 && QString::fromLocal8Bit(argv[1]) == QStringLiteral("--replay")) {
window.loadTelemetryLog(QFileInfo(QString::fromLocal8Bit(argv[2])).absoluteFilePath());
}
window.show();
return QApplication::exec();
}
+322
View File
@@ -0,0 +1,322 @@
#include "../src/MainWindow.h"
#include "../src/GlobeWidget.h"
#include "../src/MapWidget.h"
#include <QApplication>
#include <QAction>
#include <QCheckBox>
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QDir>
#include <QFile>
#include <QLabel>
#include <QLineEdit>
#include <QMetaObject>
#include <QSpinBox>
#include <QPushButton>
#include <QRegularExpression>
#include <QTableView>
#include <QTabWidget>
#include <QSplitter>
#include <QTemporaryFile>
#include <QTemporaryDir>
#include <cassert>
#include <cmath>
namespace {
void sendLine(MainWindow &window, const QString &line)
{
const bool invoked = QMetaObject::invokeMethod(
&window, "handleSerialLine", Qt::DirectConnection, Q_ARG(QString, line));
assert(invoked);
}
void flushUpdates(MainWindow &window)
{
const bool invoked = QMetaObject::invokeMethod(
&window, "flushUiUpdates", Qt::DirectConnection);
assert(invoked);
}
} // namespace
int main(int argc, char **argv)
{
QApplication app(argc, argv);
qputenv("LOCATION_PLOTTER_PORT", "/dev/test-usb-uart");
MainWindow window;
qunsetenv("LOCATION_PLOTTER_PORT");
{
QTemporaryDir uartLogDirectory;
assert(uartLogDirectory.isValid());
qputenv("LOCATION_PLOTTER_UART_LOG_DIR", uartLogDirectory.path().toUtf8());
bool loggingStarted = false;
assert(QMetaObject::invokeMethod(
&window, "startUartLogging", Qt::DirectConnection,
Q_RETURN_ARG(bool, loggingStarted),
Q_ARG(QString, QStringLiteral("/dev/test-uart")), Q_ARG(int, 460800)));
assert(loggingStarted);
sendLine(window, QStringLiteral("raw uart test line"));
assert(QMetaObject::invokeMethod(&window, "stopUartLogging", Qt::DirectConnection));
qunsetenv("LOCATION_PLOTTER_UART_LOG_DIR");
const QStringList logFiles = QDir(uartLogDirectory.path()).entryList(
{QStringLiteral("uart_*.log")}, QDir::Files);
assert(logFiles.size() == 1);
QFile logFile(QDir(uartLogDirectory.path()).filePath(logFiles.constFirst()));
assert(logFile.open(QIODevice::ReadOnly | QIODevice::Text));
const QString contents = QString::fromUtf8(logFile.readAll());
assert(contents.contains(QStringLiteral("[SESSION START] port=/dev/test-uart baud=460800")));
assert(contents.contains(QRegularExpression(
QStringLiteral(R"(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] raw uart test line)"))));
assert(contents.contains(QStringLiteral("[SESSION END]")));
}
auto *table = window.findChild<QTableView *>();
auto *referenceLatInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLatInput"));
auto *referenceLonInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLonInput"));
auto *cepValue = window.findChild<QLabel *>(QStringLiteral("cepValue"));
auto *maxPointsInput = window.findChild<QSpinBox *>(QStringLiteral("maxPointsInput"));
auto *elevationMaskSpin = window.findChild<QDoubleSpinBox *>(QStringLiteral("elevationMaskSpin"));
auto *showTelemetry = window.findChild<QCheckBox *>(QStringLiteral("showTelemetryCheck"));
auto *showRecentPoints = window.findChild<QCheckBox *>(QStringLiteral("showRecentPointsCheck"));
auto *showAverage = window.findChild<QCheckBox *>(QStringLiteral("showAverageCheck"));
auto *portSelector = window.findChild<QComboBox *>(QStringLiteral("portSelector"));
auto *refreshRinexButton =
window.findChild<QPushButton *>(QStringLiteral("refreshRinexButton"));
auto *map = window.findChild<MapWidget *>();
auto *mainViewTabs = window.findChild<QTabWidget *>(QStringLiteral("mainViewTabs"));
auto *sideSplitter = window.findChild<QSplitter *>(QStringLiteral("sideSplitter"));
auto *rinexUpdateButton = window.findChild<QPushButton *>(QStringLiteral("rinexUpdateButton"));
auto *openLogAction = window.findChild<QAction *>(QStringLiteral("openLogAction"));
auto *toggleSerialAction = window.findChild<QAction *>(QStringLiteral("toggleSerialAction"));
auto *rinexUpdateAction = window.findChild<QAction *>(QStringLiteral("rinexUpdateAction"));
assert(table != nullptr);
assert(referenceLatInput != nullptr);
assert(referenceLonInput != nullptr);
assert(cepValue != nullptr);
assert(maxPointsInput != nullptr);
assert(elevationMaskSpin != nullptr);
assert(elevationMaskSpin->value() == 10);
assert(elevationMaskSpin->minimum() == 0);
assert(elevationMaskSpin->maximum() == 90.0);
elevationMaskSpin->setValue(12.5);
assert(elevationMaskSpin->value() == 12.5);
assert(showTelemetry != nullptr);
assert(showRecentPoints != nullptr);
assert(showAverage != nullptr);
assert(portSelector != nullptr);
assert(refreshRinexButton != nullptr);
assert(refreshRinexButton->text() == QStringLiteral("RINEX Guncelle")
|| refreshRinexButton->text() == QStringLiteral("RINEX indiriliyor..."));
assert(portSelector->isEditable());
assert(portSelector->count() >= 1);
assert(portSelector->currentText() == QStringLiteral("/dev/test-usb-uart"));
for (int i = 0; i < portSelector->count(); ++i) {
assert(portSelector->itemText(i).startsWith(QStringLiteral("/dev/")));
assert(!portSelector->itemText(i).trimmed().isEmpty());
}
assert(map != nullptr);
assert(mainViewTabs != nullptr);
assert(mainViewTabs->count() == 2);
assert(sideSplitter != nullptr);
assert(sideSplitter->orientation() == Qt::Vertical);
assert(rinexUpdateButton != nullptr);
assert(!rinexUpdateButton->toolTip().isEmpty());
assert(openLogAction != nullptr && !openLogAction->shortcut().isEmpty());
assert(toggleSerialAction != nullptr && !toggleSerialAction->shortcut().isEmpty());
assert(rinexUpdateAction != nullptr && !rinexUpdateAction->shortcut().isEmpty());
QVector<TelemetryPoint> followPoints;
TelemetryPoint gpsPoint;
gpsPoint.source = LocationSource::Gps;
gpsPoint.latitude = 40.0;
gpsPoint.longitude = 29.0;
followPoints.push_back(gpsPoint);
TelemetryPoint correctedPoint = gpsPoint;
correctedPoint.source = LocationSource::ImuCorrected;
correctedPoint.latitude = 40.25;
correctedPoint.longitude = 29.5;
followPoints.push_back(correctedPoint);
TelemetryPoint trajectoryPoint = gpsPoint;
trajectoryPoint.source = LocationSource::Trajectory;
trajectoryPoint.latitude = 41.0;
trajectoryPoint.longitude = 30.0;
followPoints.push_back(trajectoryPoint);
map->setPoints(followPoints);
assert(std::abs(map->centerCoordinate().x() - 40.25) < 0.000001);
assert(std::abs(map->centerCoordinate().y() - 29.5) < 0.000001);
map->clearPoints();
sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]"));
assert(map != nullptr);
auto *globe = window.findChild<GlobeWidget *>();
assert(globe != nullptr);
assert(globe->observedPrns()
== QSet<QString>({QStringLiteral("G03"), QStringLiteral("G08")}));
sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prn_indices=[13]"));
assert(globe->observedPrns() == QSet<QString>({QStringLiteral("G14")}));
assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection,
Q_ARG(double, 40.25), Q_ARG(double, 29.5)));
assert(referenceLatInput->text() == QStringLiteral("40.250000000000000"));
assert(referenceLonInput->text() == QStringLiteral("29.500000000000000"));
referenceLatInput->clear();
referenceLonInput->clear();
assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection));
showTelemetry->setChecked(false);
assert(!showRecentPoints->isEnabled());
assert(!showAverage->isEnabled());
showTelemetry->setChecked(true);
assert(showRecentPoints->isEnabled());
assert(showAverage->isEnabled());
sendLine(window, QStringLiteral("lattitude : 40.78925034214206846173"));
sendLine(window, QStringLiteral("longitude : 29.46277530841727809729"));
assert(table->model()->rowCount() == 0);
sendLine(window, QStringLiteral("NAV solve done"));
flushUpdates(window);
assert(table->model()->rowCount() == 1);
assert(table->model()->data(table->model()->index(0, 2)).toString()
== QStringLiteral("40.78925034214206846173"));
assert(table->model()->data(table->model()->index(0, 3)).toString()
== QStringLiteral("29.46277530841727809729"));
sendLine(window, QStringLiteral(
"[LOCATION] source=gps lat=40.1 lon=29.1 height=100 gdop=2 pdop=1.5 "
"hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5"));
sendLine(window, QStringLiteral(
"[LOCATION] source=imu_corrected lat=40.10001 lon=29.10001 height=100.2 "
"gdop=2 pdop=1.5 hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5"));
flushUpdates(window);
assert(table->model()->rowCount() == 3);
assert(table->model()->data(table->model()->index(1, 1)).toString()
== QStringLiteral("GPS"));
assert(table->model()->data(table->model()->index(2, 1)).toString()
== QStringLiteral("IMU corrected"));
sendLine(window, QStringLiteral(
"[LOCATION] source=trajectory lat=40.10002 lon=29.10002 height=100 tracked=4 used=4"));
flushUpdates(window);
assert(table->model()->rowCount() == 4);
assert(table->model()->data(table->model()->index(3, 1)).toString()
== QStringLiteral("Trajectory (truth)"));
QTemporaryFile replayLog;
assert(replayLog.open());
replayLog.write(
"[LOCATION] source=trajectory lat=40.0 lon=29.0 height=100 tracked=4 used=4\n"
"[LOCATION] source=gps lat=40.0001 lon=29.0001 height=104 tracked=4 used=4\n"
"[LOCATION] source=imu_corrected lat=40.00002 lon=29.00002 height=101 tracked=4 used=4\n");
replayLog.flush();
assert(window.loadTelemetryLog(replayLog.fileName()));
assert(table->model()->rowCount() == 3);
assert(table->model()->data(table->model()->index(0, 1)).toString()
== QStringLiteral("Trajectory (truth)"));
QTemporaryFile splitReplayLog;
assert(splitReplayLog.open());
splitReplayLog.write("latitude: 40.25\nlongitude: 29.50\n");
splitReplayLog.flush();
assert(window.loadTelemetryLog(splitReplayLog.fileName()));
assert(table->model()->rowCount() == 1);
// A partial fix from an earlier solve must not be completed using a field
// from the following solve.
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
sendLine(window, QStringLiteral("NAV solve start"));
sendLine(window, QStringLiteral("latitude: 40.0"));
sendLine(window, QStringLiteral("NAV solve start"));
sendLine(window, QStringLiteral("longitude: 29.0"));
sendLine(window, QStringLiteral("NAV solve done"));
flushUpdates(window);
assert(table->model()->rowCount() == 0);
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
sendLine(window, QStringLiteral(
"lat=40.78925034214206846173 long=29.46277530841727809729"));
flushUpdates(window);
assert(table->model()->rowCount() == 1);
referenceLatInput->setText(QStringLiteral("40.78925034214206846173"));
referenceLonInput->setText(QStringLiteral("29.46277530841727809729"));
const bool referenceUpdated = QMetaObject::invokeMethod(
&window, "updateReferenceLocation", Qt::DirectConnection);
assert(referenceUpdated);
assert(cepValue->text().startsWith(QStringLiteral("0.00 m")));
assert(cepValue->text().contains(QStringLiteral("N=1")));
const bool cleared = QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection);
assert(cleared);
assert(table->model()->rowCount() == 0);
assert(referenceLatInput->text() == QStringLiteral("40.78925034214206846173"));
assert(referenceLonInput->text() == QStringLiteral("29.46277530841727809729"));
assert(cepValue->text() == QStringLiteral("-"));
referenceLatInput->setText(QStringLiteral("40.0"));
referenceLonInput->setText(QStringLiteral("29.0"));
assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection));
sendLine(window, QStringLiteral("lat=40.000 long=29.000"));
sendLine(window, QStringLiteral("lat=40.001 long=29.000"));
sendLine(window, QStringLiteral("lat=40.002 long=29.000"));
flushUpdates(window);
assert(cepValue->text().startsWith(QStringLiteral("111 m")));
assert(cepValue->text().contains(QStringLiteral("N=3")));
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
referenceLatInput->setText(QStringLiteral("40.78925034214206846173"));
referenceLonInput->setText(QStringLiteral("29.46277530841727809729"));
assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection));
maxPointsInput->setValue(10000);
for (int i = 0; i < 10000; ++i) {
sendLine(window, QStringLiteral("lat=40.78925 long=29.46277 sats=7"));
}
flushUpdates(window);
assert(table->model()->rowCount() == 10000);
assert(cepValue->text().contains(QStringLiteral("N=10000")));
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
maxPointsInput->setValue(10);
{
MapWidget followMap;
TelemetryPoint corrected;
corrected.source = LocationSource::ImuCorrected;
corrected.latitude = 40.1;
corrected.longitude = 29.1;
corrected.receivedAt = QDateTime::currentDateTimeUtc();
TelemetryPoint rawGps;
rawGps.source = LocationSource::Gps;
rawGps.latitude = 41.0;
rawGps.longitude = 30.0;
rawGps.receivedAt = corrected.receivedAt.addMSecs(100);
followMap.setPoints({corrected, rawGps});
assert(followMap.centerCoordinate() == QPointF(40.1, 29.1));
rawGps.receivedAt = corrected.receivedAt.addMSecs(1100);
followMap.setPoints({corrected, rawGps});
assert(followMap.centerCoordinate() == QPointF(41.0, 30.0));
}
const QByteArray screenshotPath = qgetenv("LOCATION_PLOTTER_TEST_SCREENSHOT");
if (!screenshotPath.isEmpty()) {
sendLine(window, QStringLiteral("NAV solve start sats:5"));
sendLine(window, QStringLiteral("tracking PRNs: G03, G08, G14, G21, G27"));
sendLine(window, QStringLiteral(
"lat=40.78925034214206846173 long=29.46277530841727809729 "
"hheight=-89.05779508221894502640 sats=7 PDOP=1.235"));
flushUpdates(window);
mainViewTabs->setCurrentIndex(1);
window.show();
app.processEvents();
window.repaint();
app.processEvents();
assert(window.grab().save(QString::fromLocal8Bit(screenshotPath), "PNG"));
}
return 0;
}
+73
View File
@@ -0,0 +1,73 @@
#include "../src/MapWidget.h"
#include <QApplication>
#include <QElapsedTimer>
#include <QHostAddress>
#include <QSet>
#include <QTcpServer>
#include <QTcpSocket>
#include <QThread>
#include <cassert>
int main(int argc, char **argv)
{
QApplication app(argc, argv);
QTcpServer tileServer;
assert(tileServer.listen(QHostAddress::LocalHost, 0));
int requestCount = 0;
QSet<QByteArray> requestedPaths;
QObject::connect(&tileServer, &QTcpServer::newConnection, [&]() {
while (tileServer.hasPendingConnections()) {
QTcpSocket *socket = tileServer.nextPendingConnection();
const auto respond = [&, socket]() {
if (!socket->canReadLine()) {
return;
}
const QByteArray requestLine = socket->readLine().trimmed();
const QList<QByteArray> fields = requestLine.split(' ');
if (fields.size() >= 2) {
++requestCount;
requestedPaths.insert(fields[1]);
}
socket->readAll();
socket->write("HTTP/1.1 503 Service Unavailable\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n\r\n");
socket->disconnectFromHost();
};
QObject::connect(socket, &QTcpSocket::readyRead, socket, respond);
QObject::connect(socket, &QTcpSocket::disconnected,
socket, &QObject::deleteLater);
respond();
}
});
const QByteArray tileUrl = QByteArray("http://127.0.0.1:")
+ QByteArray::number(tileServer.serverPort()) + "/{z}/{x}/{y}.png";
qputenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE", tileUrl);
{
MapWidget map;
map.resize(640, 480);
map.show();
QElapsedTimer timer;
timer.start();
while (timer.elapsed() < 1200) {
app.processEvents(QEventLoop::AllEvents, 50);
QThread::msleep(5);
}
// Every URL fails. Seeing any URL more than once proves that a failed
// tile schedules a new paint/request instead of remaining a blank
// placeholder forever.
assert(!requestedPaths.isEmpty());
assert(requestCount > requestedPaths.size());
}
qunsetenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE");
return 0;
}
+317
View File
@@ -0,0 +1,317 @@
#include "../src/LocationParser.h"
#include "../src/RinexNavigation.h"
#include <QCoreApplication>
#include <QFile>
#include <cassert>
#include <cmath>
namespace {
bool closeTo(double left, double right, double tolerance = 0.000001)
{
return std::fabs(left - right) < tolerance;
}
QString rinexField(double value)
{
return QStringLiteral("%1").arg(value, 19, 'E', 12);
}
QString rinexContinuation(std::initializer_list<double> values)
{
QString line(4, QLatin1Char(' '));
for (double value : values) line += rinexField(value);
return line + QLatin1Char('\n');
}
QByteArray syntheticGpsNavigation(const QDateTime &gpsEpochLabel, int gpsWeek, double toe)
{
QString rinex = QStringLiteral(
" 3.05 N: GNSS NAV DATA M: MIXED RINEX VERSION / TYPE\n"
" END OF HEADER\n");
rinex += QStringLiteral("G01 %1").arg(
gpsEpochLabel.toString(QStringLiteral("yyyy MM dd HH mm ss")));
rinex += rinexField(0.0) + rinexField(0.0) + rinexField(0.0) + QLatin1Char('\n');
rinex += rinexContinuation({1.0, 0.0, 0.0, 0.0});
rinex += rinexContinuation({0.0, 0.0, 0.0, 5153.7954775});
rinex += rinexContinuation({toe, 0.0, 0.0, 0.0});
rinex += rinexContinuation({0.0, 0.0, 0.0, 0.0});
rinex += rinexContinuation({0.0, 0.0, static_cast<double>(gpsWeek), 0.0});
rinex += rinexContinuation({2.0, 0.0, 0.0, 1.0});
rinex += rinexContinuation({0.0, 0.0, 0.0, 0.0});
return rinex.toLatin1();
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("DBG lat=41.0082000 long=28.9784000 hheight=123.45 sats=7"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(closeTo(*parsed->latitude, 41.0082));
assert(closeTo(*parsed->longitude, 28.9784));
assert(closeTo(*parsed->hheight, 123.45));
assert(parsed->satellites == 7);
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("latitude: -12,5 longitude: 33,25 height: 99 activeSats: 4"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(closeTo(*parsed->latitude, -12.5));
assert(closeTo(*parsed->longitude, 33.25));
assert(parsed->latitudeText == QStringLiteral("-12.5"));
assert(parsed->longitudeText == QStringLiteral("33.25"));
assert(closeTo(*parsed->hheight, 99.0));
assert(parsed->satellites == 4);
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral("satellites_active=11"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(parsed->satellites == 11);
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("lat=200 long=28 hheight=100 active_sats=8"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(parsed->latitudeText.isEmpty());
assert(parsed->longitudeText.isEmpty());
assert(!parsed->hheight.has_value());
assert(parsed->satellites == 8);
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("lattitude : 40.78925034214206846173"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(closeTo(*parsed->latitude, 40.78925034214206846173));
assert(parsed->latitudeText == QStringLiteral("40.78925034214206846173"));
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("longitude : 29.46277530841727809729"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(closeTo(*parsed->longitude, 29.46277530841727809729));
assert(parsed->longitudeText == QStringLiteral("29.46277530841727809729"));
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("height : -89.05779508221894502640 "));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(closeTo(*parsed->hheight, -89.05779508221894502640));
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("NAV solve start sats:4 mode:periodic abs_span_ms:2.176"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(parsed->satellites == 4);
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("[NAV] navigation software running with 5 satellites"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(parsed->satellites == 5);
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral("GDOP : 3.9576744550"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(closeTo(*parsed->dop.gdop, 3.9576744550));
}
{
const auto parsed = LocationParser::parseLine(
QStringLiteral("PDOP : 3.2540598660 HDOP : 2.3631161789 VDOP : 2.2370935468 TDOP : 2.2526165852"));
assert(parsed.has_value());
assert(!parsed->hasLocation());
assert(closeTo(*parsed->dop.pdop, 3.2540598660));
assert(closeTo(*parsed->dop.hdop, 2.3631161789));
assert(closeTo(*parsed->dop.vdop, 2.2370935468));
assert(closeTo(*parsed->dop.tdop, 2.2526165852));
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral("plain debug noise"));
assert(!parsed.has_value());
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("tracking PRNs: 3, G08, 14; SV=G21 SVID: 27"));
assert(prns.contains(QStringLiteral("G03")));
assert(prns.contains(QStringLiteral("G08")));
assert(prns.contains(QStringLiteral("G14")));
assert(prns.contains(QStringLiteral("G21")));
assert(prns.contains(QStringLiteral("G27")));
assert(prns.size() == 5);
}
{
const QString line = QStringLiteral(
"[OK] [TRK] tracked satellites=5 svn=[2,3,5,8,30]");
const auto parsed = LocationParser::parseLine(line);
assert(parsed.has_value());
assert(parsed->satellites == 5);
const QSet<QString> prns = LocationParser::parseGpsPrns(line);
assert(prns == QSet<QString>({QStringLiteral("G03"), QStringLiteral("G04"),
QStringLiteral("G06"), QStringLiteral("G09"),
QStringLiteral("G31")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=2 svn=[0,31]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G32")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=3 prn_indices=[0,7,31]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08"),
QStringLiteral("G32")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=2 prns=[0,7]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=2 prn_indices=[0,G08]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[OK] [NAV] DOP GDOP=7.0907891622 PDOP=5.6333862175 "
"HDOP=2.9819528958 VDOP=4.7794348204 TDOP=4.3063035967"));
assert(parsed.has_value());
assert(closeTo(*parsed->dop.gdop, 7.0907891622));
assert(closeTo(*parsed->dop.pdop, 5.6333862175));
assert(closeTo(*parsed->dop.hdop, 2.9819528958));
assert(closeTo(*parsed->dop.vdop, 4.7794348204));
assert(closeTo(*parsed->dop.tdop, 4.3063035967));
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[OK] [NAV] position lat=40.77831900791127850425 "
"lon=29.43579105837637044374 height=198.80186179187148809433"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(closeTo(*parsed->latitude, 40.77831900791127850425));
assert(closeTo(*parsed->longitude, 29.43579105837637044374));
assert(closeTo(*parsed->hheight, 198.80186179187148809433));
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[LOCATION] source=imu_corrected mode=dead_reckoning "
"lat=40.7356 lon=31.5985 height=812.7 gps_age_ms=3180 "
"gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(parsed->source == LocationSource::ImuCorrected);
assert(parsed->fusionMode == FusionMode::DeadReckoning);
assert(parsed->gpsAgeMs == 3180);
assert(parsed->satellites == 6);
assert(closeTo(*parsed->dop.hdop, 2.7));
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[LOCATION] source=gps lat=40.7357 lon=31.5986 height=811 tracked=5 used=5"));
assert(parsed.has_value());
assert(parsed->source == LocationSource::Gps);
assert(parsed->satellites == 5);
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[FUSION] state=moving lat=40.7357 lon=31.5986 height=811"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(parsed->source == LocationSource::ImuCorrected);
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=810 tracked=4 used=4"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(parsed->source == LocationSource::Trajectory);
assert(parsed->satellites == 4);
}
{
constexpr int gpsWeek = 2428;
constexpr double toe = 403200.0;
const QDateTime gpsEpoch(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC);
const QDateTime gpsLabel = gpsEpoch.addSecs(
static_cast<qint64>(gpsWeek * 604800.0 + toe));
const QDateTime matchingUtc = gpsLabel.addSecs(-18);
QString error;
const auto satellites = RinexNavigation::parseGpsNavigation(
syntheticGpsNavigation(gpsLabel, gpsWeek, toe), matchingUtc, &error);
assert(error.isEmpty());
assert(satellites.size() == 1);
const double semiMajorAxis = 5153.7954775 * 5153.7954775;
const double node = -7.2921151467e-5 * toe;
assert(closeTo(satellites[0].x, semiMajorAxis * std::cos(node), 0.1));
assert(closeTo(satellites[0].y, semiMajorAxis * std::sin(node), 0.1));
assert(closeTo(satellites[0].z, 0.0, 0.1));
// A local wall-clock representation must not move the satellites.
// 11:48 at UTC+03 and 08:48 UTC represent the same physical instant.
const auto localTimeSatellites = RinexNavigation::parseGpsNavigation(
syntheticGpsNavigation(gpsLabel, gpsWeek, toe),
matchingUtc.toOffsetFromUtc(3 * 60 * 60), &error);
assert(error.isEmpty());
assert(localTimeSatellites.size() == satellites.size());
assert(closeTo(localTimeSatellites[0].x, satellites[0].x, 0.001));
assert(closeTo(localTimeSatellites[0].y, satellites[0].y, 0.001));
assert(closeTo(localTimeSatellites[0].z, satellites[0].z, 0.001));
}
const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX");
if (!rinexPath.isEmpty()) {
QFile file(rinexPath);
assert(file.open(QIODevice::ReadOnly));
QString error;
const auto satellites = RinexNavigation::parseGpsNavigation(
file.readAll(), QDateTime::currentDateTimeUtc(), &error);
assert(satellites.size() >= 20);
for (const auto &satellite : satellites) {
assert(satellite.prn.startsWith(QStringLiteral("G")));
assert(std::isfinite(satellite.x));
assert(std::isfinite(satellite.y));
assert(std::isfinite(satellite.z));
}
}
return 0;
}
+70
View File
@@ -0,0 +1,70 @@
#include "../src/UartReplay.h"
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QEventLoop>
#include <QTemporaryFile>
#include <QTimer>
#include <cassert>
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
QTemporaryFile log;
assert(log.open());
log.write(
"[2026-08-12 10:00:00.000] [SESSION START] port=/dev/test baud=460800\n"
"[2026-08-12 10:00:00.001] [LOCATION] source=gps lat=40.0 lon=29.0\n"
"diagnostic line without timestamp\n"
"[LOCATION] source=imu_corrected lat=40.1 lon=29.1\n"
"[FUSION] lat=40.2 lon=29.2 height=10\n"
"[LOCATION] source=gps lat=40.3 lon=29.3\n"
"[2026-08-12 10:00:00.999] [SESSION END]\n");
log.flush();
UartReplay replay;
QStringList received;
QVector<qint64> receivedAt;
QElapsedTimer elapsed;
QEventLoop loop;
QObject::connect(&replay, &UartReplay::lineReady, &loop, [&](const QString &line) {
received.push_back(line);
receivedAt.push_back(elapsed.elapsed());
});
QObject::connect(&replay, &UartReplay::replayFinished, &loop, &QEventLoop::quit);
QTimer::singleShot(2000, &loop, &QEventLoop::quit);
elapsed.start();
assert(replay.start(log.fileName(), 20, 10));
assert(replay.locationCount() == 4);
loop.exec();
assert(!replay.isRunning());
assert(received.size() == 5);
assert(received.first().startsWith(QStringLiteral("[LOCATION] source=gps")));
assert(received.at(1) == QStringLiteral("diagnostic line without timestamp"));
assert(received.at(3).startsWith(QStringLiteral("[FUSION]")));
assert(receivedAt.last() >= 15);
QTemporaryFile splitLog;
assert(splitLog.open());
splitLog.write("latitude: 40.25\nlongitude: 29.50\n");
splitLog.flush();
received.clear();
receivedAt.clear();
elapsed.restart();
assert(replay.start(splitLog.fileName(), 1, 1));
assert(replay.locationCount() == 1);
loop.exec();
assert(received.size() == 2);
QTemporaryFile invalidLog;
assert(invalidLog.open());
invalidLog.write("only diagnostics\n");
invalidLog.flush();
assert(!replay.start(invalidLog.fileName(), 200, 50));
return 0;
}
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
print_qt_install_hint() {
echo
if [[ "$(uname -s)" == "Darwin" ]]; then
echo "Qt6 bulunamadi. macOS'ta gerekli paketleri kurmak icin:"
echo " brew install cmake qt"
echo "veya doğrudan: ./setup-macos.sh"
else
echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:"
echo " sudo apt update"
echo " sudo apt install build-essential cmake qt6-base-dev"
fi
}
project_dir="$(pwd -P)"
if [[ -f ./build/CMakeCache.txt ]]; then
cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)"
cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' ./build/CMakeCache.txt)"
host_system="$(uname -s)"
if [[ ( -n "$cached_source" && "$cached_source" != "$project_dir" ) \
|| ( -n "$cached_system" && "$cached_system" != "$host_system" ) ]]; then
echo "Build klasoru farkli bir proje yolu veya sisteme ait, yeniden olusturuluyor."
rm -rf ./build
fi
fi
cmake_args=(-S . -B build)
if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then
cmake_args+=("-DCMAKE_PREFIX_PATH=$(brew --prefix qt)")
fi
if ! cmake "${cmake_args[@]}"; then
print_qt_install_hint
exit 1
fi
cmake --build build
if [[ "$(uname -s)" == "Darwin" ]]; then
exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@"
fi
missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)"
if [[ -n "$missing_libs" ]]; then
echo "Eksik paylasimli kutuphaneler:"
echo "$missing_libs" | sed 's/^/ /'
print_qt_install_hint
exit 1
fi
if id -nG | tr ' ' '\n' | grep -qx dialout; then
exec ./build/location_plotter "$@"
fi
if getent group dialout | tr ':' '\n' | tail -n 1 | tr ',' '\n' | grep -qx "$(id -un)"; then
exec sg dialout -c "$(printf '%q ' ./build/location_plotter "$@")"
fi
echo "Bu kullanici dialout grubunda degil. Eklemek icin:"
echo " sudo usermod -a -G dialout $(id -un)"
echo "Sonra oturumu kapatip ac veya bu scripti tekrar calistir."
exit 1