diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a0dc26f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.idea +.vscode +target +**/node_modules +**/.svelte-kit +**/.venv +**/.pytest_cache +**/__pycache__ +**/*.db +**/*.db-shm +**/*.db-wal +deployments/ground-station/db/data +apps/tile-provider/tiles_data diff --git a/.github/workflows/ground-station.yml b/.github/workflows/ground-station.yml new file mode 100644 index 0000000..b23703b --- /dev/null +++ b/.github/workflows/ground-station.yml @@ -0,0 +1,102 @@ +name: Ground Station + +on: + push: + branches: [main] + paths: + - "apps/antenna-tracker/**" + - "apps/command-dispatcher/**" + - "apps/ground-station-web/**" + - "apps/sergw/**" + - "apps/telemetry-ingestor/**" + - "apps/tile-provider/**" + - "deployments/ground-station/**" + - "tools/ground-station/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/ground-station.yml" + pull_request: + paths: + - "apps/antenna-tracker/**" + - "apps/command-dispatcher/**" + - "apps/ground-station-web/**" + - "apps/sergw/**" + - "apps/telemetry-ingestor/**" + - "apps/tile-provider/**" + - "deployments/ground-station/**" + - "tools/ground-station/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/ground-station.yml" + +jobs: + rust-host: + name: Rust host applications + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: clippy, rustfmt + - name: Install native dependencies + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config + - name: Check formatting + run: cargo fmt --all -- --check + - name: Lint ground-station crates + run: cargo clippy --locked -p sergw -p command-dispatcher -p telemetry-ingestor -p gps-ingest -p manual-command-dispatcher -p virtual-serial --all-targets -- -D warnings + - name: Test ground-station crates + run: cargo test --locked -p sergw -p command-dispatcher -p telemetry-ingestor -p gps-ingest -p manual-command-dispatcher -p virtual-serial + + web: + name: Ground-station web + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/ground-station-web + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + - run: bun install --frozen-lockfile + - run: bun run check + - run: bun run test + - run: bun run build + + bun-services: + name: Bun tests (${{ matrix.app }}) + runs-on: ubuntu-latest + strategy: + matrix: + app: + - apps/tile-provider + - deployments/ground-station/db + defaults: + run: + working-directory: ${{ matrix.app }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + - run: bun install --frozen-lockfile + - run: bun test + + antenna-python: + name: Antenna Python + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/antenna-tracker + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: uv run --frozen pytest -q + + compose: + name: Compose configuration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker compose -f deployments/ground-station/compose.yaml config --quiet + - run: docker compose -f deployments/ground-station/compose.yaml --profile radio config --quiet diff --git a/.gitignore b/.gitignore index 1c113fc..713f93d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ /target .DS_Store -.uv-cache \ No newline at end of file +.uv-cache + +# Host application dependencies and build output +node_modules/ +.svelte-kit/ +.venv/ +.pytest_cache/ +__pycache__/ +*.py[cod] + +# Runtime data +*.db +*.db-shm +*.db-wal +deployments/ground-station/db/data/ +apps/tile-provider/tiles_data/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index bc7a0b2..dd83308 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,10 @@ "recommendations": [ "dustypomerleau.rust-syntax", "1yib.rust-bundle", - "polymeilex.rust-targets" + "polymeilex.rust-targets", + "svelte.svelte-vscode", + "oven.bun-vscode", + "ms-python.python", + "charliermarsh.ruff" ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index b059cc1..c303074 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,9 +30,12 @@ "chrono", "defmt", "heapless", + "libsql", "mavlink", + "ratatui", "serde", "sergw", + "SvelteKit", "uorocketry" ] -} \ No newline at end of file +} diff --git a/Cargo.lock b/Cargo.lock index 1873abb..9879c36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -11,7 +22,7 @@ dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.8.27", ] [[package]] @@ -32,6 +43,27 @@ dependencies = [ "as-slice", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -143,12 +175,12 @@ dependencies = [ "num-traits", "panic-probe", "pid", - "prost", + "prost 0.14.1", "serde", "smlang", "static_cell", "stm32-fmc", - "strum", + "strum 0.27.2", "uor-high-level", "uor-peripherals", "uor-utils", @@ -163,6 +195,39 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "atoi" version = "2.0.0" @@ -187,6 +252,51 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "bare-metal" version = "0.2.5" @@ -202,6 +312,44 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.106", + "which", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -244,6 +392,21 @@ dependencies = [ "aligned", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -255,6 +418,52 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] [[package]] name = "cfg-if" @@ -262,6 +471,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -273,7 +488,11 @@ name = "chrono" version = "0.4.38" source = "git+https://github.com/uorocketry/chrono#02c0d2a797d91b724e9f3df19e5493f850934584" dependencies = [ + "android-tzdata", + "iana-time-zone", "num-traits", + "serde", + "windows-targets 0.52.6", ] [[package]] @@ -282,7 +501,33 @@ version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", + "windows-link 0.1.3", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", ] [[package]] @@ -325,12 +570,52 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "command-dispatcher" +version = "1.0.1" +dependencies = [ + "chrono 0.4.38", + "clap", + "hostname", + "libsql", + "mavlink", + "messages-prost", + "prost 0.14.1", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "const-default" version = "1.0.0" @@ -346,6 +631,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.0" @@ -395,18 +690,71 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc-any" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a62ec9ff5f7965e4d7280bd5482acd20aadb50d632cf6c1d74493856b011fa73" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "csv-core" version = "0.1.12" @@ -1064,12 +1412,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1083,45 +1455,142 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "futures-core" +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] [[package]] -name = "futures-sink" +name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] [[package]] -name = "futures-task" +name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] -name = "futures-util" +name = "futures-executor" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", - "pin-project-lite", - "pin-utils", + "futures-util", ] [[package]] -name = "getrandom" -version = "0.3.3" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi", +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", ] [[package]] @@ -1130,6 +1599,23 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gps-ingest" +version = "1.0.1" +dependencies = [ + "anyhow", + "chrono 0.4.38", + "clap", + "mavlink", + "messages-prost", + "prost 0.14.1", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "grounded" version = "0.2.0" @@ -1139,6 +1625,25 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hash32" version = "0.2.1" @@ -1157,6 +1662,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.13.2" @@ -1166,12 +1677,42 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heapless" version = "0.7.17" @@ -1213,12 +1754,160 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070" +dependencies = [ + "futures-util", + "http", + "hyper", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "webpki-roots 0.26.11", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1229,6 +1918,16 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -1254,6 +1953,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1272,12 +1989,29 @@ dependencies = [ "no-panic", ] +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lexical-parse-float" version = "0.8.5" @@ -1314,12 +2048,158 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsql" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30fe980ac5693ed1f3db490559fb578885e913a018df64af8a1a46e1959a78df" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64 0.21.7", + "bincode", + "bitflags 2.10.0", + "bytes", + "chrono 0.4.41", + "crc32fast", + "fallible-iterator 0.3.0", + "futures", + "http", + "hyper", + "hyper-rustls", + "libsql-hrana", + "libsql-sqlite3-parser", + "libsql-sys", + "libsql_replication", + "parking_lot", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-web", + "tower", + "tower-http", + "tracing", + "uuid", + "zerocopy 0.7.35", +] + +[[package]] +name = "libsql-ffi" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be1da6f123ceb2cd23f469883415cab9ee963286a85d61e22afb8b12e15e681" +dependencies = [ + "bindgen", + "cc", + "cmake", + "glob", +] + +[[package]] +name = "libsql-hrana" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3358538b52cfcf9af4fe7aeb57d6843aafed2e8af80807bd636fd1448e94ea7" +dependencies = [ + "base64 0.21.7", + "bytes", + "prost 0.12.6", + "serde", +] + +[[package]] +name = "libsql-rusqlite" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b646f94fc1d266e481c38a2d44d6d9d1be3ad04b56b90457acfb310dc450030e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator 0.2.0", + "fallible-streaming-iterator", + "hashlink", + "libsql-ffi", + "smallvec", +] + +[[package]] +name = "libsql-sqlite3-parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a90128c708356af8f7d767c9ac2946692c9112b4f74f07b99a01a60680e413" +dependencies = [ + "bitflags 2.10.0", + "cc", + "fallible-iterator 0.3.0", + "indexmap 2.13.0", + "log", + "memchr", + "phf", + "phf_codegen", + "phf_shared", + "uncased", +] + +[[package]] +name = "libsql-sys" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90725458cc4461bc82f8f7983e80b002ea4f64b5184e1462f252d0dd74b122f5" +dependencies = [ + "bytes", + "libsql-ffi", + "libsql-rusqlite", + "once_cell", + "tracing", + "zerocopy 0.7.35", +] + +[[package]] +name = "libsql_replication" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bba5c9b3a26aca06d70f6a3646ba341cf574a548355353fe135af524b1b77cc" +dependencies = [ + "aes", + "async-stream", + "async-trait", + "bytes", + "cbc", + "libsql-rusqlite", + "libsql-sys", + "parking_lot", + "prost 0.12.6", + "serde", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tracing", + "uuid", + "zerocopy 0.7.35", +] + [[package]] name = "libudev" version = "0.3.0" @@ -1344,7 +2224,13 @@ dependencies = [ name = "linked_list_allocator" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afa463f5405ee81cdb9cc2baf37e08ec7e4c8209442b5d72c04cfb2cd6e6286" +checksum = "9afa463f5405ee81cdb9cc2baf37e08ec7e4c8209442b5d72c04cfb2cd6e6286" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" @@ -1373,6 +2259,15 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "mach2" version = "0.4.3" @@ -1388,6 +2283,28 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" +[[package]] +name = "manual-command-dispatcher" +version = "1.0.1" +dependencies = [ + "clap", + "crossterm", + "futures", + "mavlink", + "messages-prost", + "prost 0.14.1", + "ratatui", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "mavlink" version = "0.13.1" @@ -1447,12 +2364,62 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "messages-prost" +version = "0.1.0" +source = "git+https://github.com/uorocketry/messages-prost#c16c21cee30d57b993b0a3a61ed91dd11df8a9f6" +dependencies = [ + "bitflags 2.10.0", + "defmt 0.3.100", + "heapless 0.8.0", + "mavlink", + "prost 0.14.1", + "prost-build", + "serde", + "ublox 0.4.5", +] + [[package]] name = "micromath" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + [[package]] name = "multimap" version = "0.10.1" @@ -1500,6 +2467,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -1508,7 +2487,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.10.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -1523,6 +2502,25 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -1595,6 +2593,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "panic-probe" version = "0.3.2" @@ -1605,12 +2609,47 @@ dependencies = [ "defmt 0.3.100", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "petgraph" version = "0.7.1" @@ -1618,7 +2657,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.13.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "uncased", ] [[package]] @@ -1651,6 +2729,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1681,7 +2779,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.27", ] [[package]] @@ -1725,6 +2823,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + [[package]] name = "prost" version = "0.14.1" @@ -1732,7 +2840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.1", ] [[package]] @@ -1742,19 +2850,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "once_cell", "petgraph", "prettyplease", - "prost", + "prost 0.14.1", "prost-types", "regex", "syn 2.0.106", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "prost-derive" version = "0.14.1" @@ -1762,7 +2883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.106", @@ -1774,7 +2895,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ - "prost", + "prost 0.14.1", ] [[package]] @@ -1865,16 +2986,43 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.3", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1890,6 +3038,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1897,7 +3048,36 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.3", +] + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", ] [[package]] @@ -1929,6 +3109,20 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rlsf" version = "0.2.1" @@ -1941,6 +3135,12 @@ dependencies = [ "svgbobdoc", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.2.3" @@ -1959,6 +3159,19 @@ dependencies = [ "semver 1.0.26", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1968,16 +3181,87 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1990,6 +3274,29 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93c025f9cfe4c388c328ece47d11a54a823da3b5ad0370b22d95ad47137f85a" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semihosting" version = "0.1.20" @@ -2091,6 +3398,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sergw" version = "1.0.1" @@ -2150,7 +3470,7 @@ checksum = "21f60a586160667241d7702c420fc223939fb3c0bb8d3fac84f78768e8970dee" dependencies = [ "bitflags 2.10.0", "cfg-if", - "core-foundation", + "core-foundation 0.10.0", "core-foundation-sys", "io-kit-sys", "libudev", @@ -2162,6 +3482,58 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simba" version = "0.9.1" @@ -2174,6 +3546,24 @@ dependencies = [ "paste", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "smlang" version = "0.8.0" @@ -2209,6 +3599,26 @@ dependencies = [ "managed", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -2228,6 +3638,16 @@ dependencies = [ "serde", ] +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.106", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -2280,13 +3700,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.106", ] [[package]] @@ -2301,13 +3743,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgbobdoc" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" dependencies = [ - "base64", + "base64 0.13.1", "proc-macro2", "quote", "syn 1.0.109", @@ -2337,76 +3785,345 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.24.0" +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "telemetry-ingestor" +version = "1.0.1" +dependencies = [ + "chrono 0.4.38", + "clap", + "hostname", + "libsql", + "mavlink", + "messages-prost", + "prost 0.14.1", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850" +dependencies = [ + "thiserror-impl 2.0.15", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio 1.1.1", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.21.7", + "bytes", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost 0.12.6", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-web" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1" +dependencies = [ + "base64 0.21.7", + "bytes", + "http", + "http-body", + "hyper", + "pin-project", + "tokio-stream", + "tonic", + "tower-http", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.7", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "termcolor" -version = "1.4.1" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "termios" -version = "0.2.2" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "libc", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ - "thiserror-impl 1.0.69", + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] -name = "thiserror" -version = "2.0.15" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "thiserror-impl 2.0.15", + "once_cell", + "valuable", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "tracing-log" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "log", + "once_cell", + "tracing-core", ] [[package]] -name = "thiserror-impl" -version = "2.0.15" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ts-rs" version = "11.1.0" @@ -2482,6 +4199,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unescaper" version = "0.1.6" @@ -2503,6 +4229,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -2515,6 +4252,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "uor-drivers" version = "1.0.1" @@ -2547,7 +4290,7 @@ dependencies = [ "mavlink-core 0.16.2", "num-derive 0.4.2", "num-traits", - "rand", + "rand 0.9.2", "serde", "serde_arrays 0.2.0", "ts-rs", @@ -2568,7 +4311,7 @@ dependencies = [ "embedded-io", "embedded-io-async", "heapless 0.9.2", - "prost", + "prost 0.14.1", "uor-utils", ] @@ -2595,7 +4338,7 @@ dependencies = [ "glob", "heapless 0.9.2", "mavlink", - "prost", + "prost 0.14.1", "prost-build", "protoc-bin-vendored", "serde", @@ -2656,6 +4399,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcell" version = "0.1.3" @@ -2668,6 +4429,18 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtual-serial" +version = "1.0.1" +dependencies = [ + "anyhow", + "clap", + "libc", + "nix 0.28.0", + "tracing", + "tracing-subscriber", +] + [[package]] name = "void" version = "1.0.2" @@ -2683,6 +4456,21 @@ dependencies = [ "vcell", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" @@ -2692,6 +4480,97 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2701,6 +4580,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -2713,6 +4633,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2740,6 +4687,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2773,6 +4735,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2785,6 +4753,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2797,6 +4771,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2821,6 +4801,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2833,6 +4819,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2845,6 +4837,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2857,6 +4855,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2878,13 +4882,34 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + [[package]] name = "zerocopy" version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.8.27", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -2897,3 +4922,15 @@ dependencies = [ "quote", "syn 2.0.106", ] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 5c09e27..e1fa4bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,16 @@ [workspace] resolver = "2" -members = ["apps/sergw", "boards/*", "common/*"] +members = [ + "apps/antenna-tracker", + "apps/command-dispatcher", + "apps/sergw", + "apps/telemetry-ingestor", + "boards/*", + "common/*", + "tools/ground-station/manual-command-dispatcher", + "tools/ground-station/virtual-serial", +] # Specify which members to build by default. Some libraries, such as messages, contain dev-dependencies that will give # compile errors if built directly. @@ -20,6 +29,7 @@ license-file = "LICENSE" [workspace.dependencies] bitflags = { version = "2.10.0", default-features = false } chrono = { git = "https://github.com/uorocketry/chrono", default-features = false } +clap = { version = "4.5.16", features = ["derive"] } cortex-m = { version = "0.7.6", features = ["critical-section-single-core"] } cortex-m-rt = "0.7.1" critical-section = "1.1" @@ -41,15 +51,30 @@ embedded-io = { version = "0.6.1" } embedded-io-async = { version = "0.6.1" } enum_dispatch = "0.3.11" heapless = "0.9.1" +hostname = "0.4.0" +libsql = "0.9.20" libm = "0.2.11" +mavlink = { git = "https://github.com/uorocketry/rust-mavlink.git", features = [ + "uorocketry", + "std", + "tcp", + "udp", + "direct-serial", + "serde", +], default-features = false } +messages-prost = { git = "https://github.com/uorocketry/messages-prost", features = ["std"], default-features = false } nb = "1.1.0" num-traits = { version = "0.2.19", default-features = false } panic-probe = { version = "0.3", features = ["print-defmt"] } prost = { version = "0.14.1", default-features = false, features = ["derive"] } serde = { version = "1.0.150", features = ["derive"], default-features = false } serde-csv-core = { version = "0.3.2", features = ["defmt"] } +serde_json = "1" smlang = "0.8.0" static_cell = "2" +tokio = { version = "1", features = ["full"] } +tracing = "0.1.40" +tracing-subscriber = "0.3.18" uor-drivers = { path = "./common/drivers" } uor-high-level = { path = "./common/high-level" } uor-peripherals = { path = "./common/peripherals" } diff --git a/README.md b/README.md index c926dd3..c68d877 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ # uORocketry Avionics Software Monorepo -Welcome to the uORocketry's avionics software monorepo. This is where we house all of our packages and crates from various boards and our ground station software. We're currently in the process of migrating our other repos to this monorepo so bear with us. +Firmware, shared avionics libraries, host applications, and ground-station deployment configuration live in this repository. ## Monorepo Structure -This monorepo is houses three main types of packages. ### Boards -Embedded software developed for each of our boards can be found at `boards/*`. +Embedded software developed for each board lives in `boards/*`. See [boards/](./boards/) for more details. ### Common Logic -Common logic shared between boards and possibly ground station can be found at `common/*`. +Reusable drivers, peripherals, high-level logic, and utilities live in `common/*`. See [common/](./common/). - + +The complete ground-station stack is defined in [`deployments/ground-station/`](./deployments/ground-station/). Development and field utilities live in [`tools/ground-station/`](./tools/ground-station/), with architecture notes in [`docs/ground-station/`](./docs/ground-station/). diff --git a/apps/README.md b/apps/README.md index d4f2c9b..2f0f0bb 100644 --- a/apps/README.md +++ b/apps/README.md @@ -1,3 +1,13 @@ # Apps -This directory houses applications that can run on your machine. It can be anything from ground station code to specific testing apps for interfacing with boards etc. \ No newline at end of file +Host-side applications that can be built, tested, and run independently: + +- `sergw`: serial-to-TCP radio gateway +- `telemetry-ingestor`: MAVLink telemetry persistence +- `command-dispatcher`: database-backed outbound command delivery +- `ground-station-web`: operator interface and backend-for-frontend +- `tile-provider`: offline map tile storage and downloads +- `antenna-tracker`: optional GPS prediction and antenna control pipeline +- `argus`, `phil-cart`, `persistence`, and `grafana`: board and test infrastructure + +Deployment composition belongs under `deployments/`; app directories should not contain copies of other applications. diff --git a/apps/antenna-tracker/.python-version b/apps/antenna-tracker/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/apps/antenna-tracker/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/apps/antenna-tracker/ARCHITECTURE.md b/apps/antenna-tracker/ARCHITECTURE.md new file mode 100644 index 0000000..13020a3 --- /dev/null +++ b/apps/antenna-tracker/ARCHITECTURE.md @@ -0,0 +1,183 @@ +# GPS Ingest Architecture + +## Overview + +The optional antenna-tracker toolset is a focused data-processing pipeline rather than a deployed RGS microservice. Its `gps-ingest` binary streams GPS coordinates and altitude data from MAVLink POSTCARD messages as JSON lines for the Python predictor and antenna-control tools. + +## System Architecture + +This program is designed to fit into the antenna tracker architecture shown in the system diagram: + +``` +MAVLink Source → gps-ingest → JSON Lines → Predictor (Python) → Antenna Tracker API + ↓ ↓ ↓ ↓ ↓ + SerGW/Radio (Rust) stdout pipe example_pipeline.py (External System) +``` + +### Component Roles + +1. **GPS Stream (gps-ingest)** + - **Language**: Rust + - **Input**: MAVLink POSTCARD messages (via TCP/UDP connection) + - **Output**: JSON lines to stdout + - **Purpose**: Extract and format GPS/altitude data from MAVLink telemetry + +2. **Predictor (example_pipeline.py)** + - **Language**: Python + - **Input**: JSON lines from gps-ingest (via pipe) + - **Output**: Prediction commands + - **Purpose**: Process GPS data and predict future positions + +3. **Antenna Tracker API** + - **Language**: Python library + - **Input**: Commands from predictor + - **Output**: Hardware control signals + - **Purpose**: Control antenna positioning hardware + +## Data Flow + +``` +MAVLink POSTCARD → RadioFrame (protobuf) → SbgGpsPos/SbgAir → JSON Lines → Python Script → Predictions → API Commands +``` + +### MAVLink Message Processing + +The program processes the following MAVLink messages: + +- **POSTCARD_MESSAGE**: Contains a protobuf-encoded `RadioFrame` with telemetry data + - Extracts `SbgGpsPos` data for GPS coordinates (latitude, longitude) + - Extracts `SbgAir` data for barometric altitude +- **RADIO_STATUS**: Logged for monitoring but not used for GPS output + +### Data Validation + +Before outputting a sample, the program validates: +- GPS coordinates are not near (0, 0) - invalid GPS fix +- GPS coordinates are within Canada bounds (lat: 41.68 to 83.11, lon: -141.00 to -52.62) +- All required fields are present (lat, lon, altitude_m) + +### JSON Output Format + +The program outputs one JSON line per complete sample: + +```json +{"lat":45.123456,"lon":-75.654321,"altitude_m":100.5,"ts":"2024-01-01T12:00:00Z","source":"gps-ingest"} +``` + +Each line contains: +- `lat`: Latitude in degrees (f64) +- `lon`: Longitude in degrees (f64) +- `altitude_m`: Altitude in meters (f64) - from barometric pressure, not GPS +- `ts`: ISO 8601 timestamp (RFC3339 format) +- `source`: Always "gps-ingest" + +The program only outputs when it has received both GPS position and altitude data, ensuring complete samples. + +## Usage Patterns + +### Basic Usage +```bash +# Stream GPS data (default: tcpout:127.0.0.1:5656) +cargo run -p gps-ingest + +# Use different MAVLink connection +cargo run -p gps-ingest -- --connection udpin:0.0.0.0:14550 + +# Pretty JSON output +cargo run -p gps-ingest -- --pretty +``` + +### Piping to Other Programs +```bash +# Extract just latitude +cargo run -p gps-ingest | jq -r '.lat' + +# Format coordinates +cargo run -p gps-ingest | jq -r '"\(.lat),\(.lon),\(.altitude_m)"' + +# Filter recent samples +cargo run -p gps-ingest | jq -r 'select(.ts | fromdateiso8601 > (now - 3600))' +``` + +### Integration with Python +```python +import subprocess +import json + +# Run gps-ingest and capture output line by line +proc = subprocess.Popen( + ["cargo", "run", "-p", "gps-ingest"], + stdout=subprocess.PIPE, + text=True +) + +for line in proc.stdout: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + print(f"Position: {data['lat']}, {data['lon']}, Altitude: {data['altitude_m']}m") + except json.JSONDecodeError: + continue +``` + +## Design Principles + +1. **Single Responsibility**: Only extracts GPS data from MAVLink, doesn't process it +2. **Unix Philosophy**: Does one thing well, outputs to stdout +3. **JSON Lines**: One complete sample per line for easy streaming and parsing +4. **Configurable**: MAVLink connection string can be specified +5. **Error Handling**: Automatic reconnection on connection loss +6. **Data Validation**: Filters invalid coordinates before output +7. **Real-time**: Streams data as it arrives, not batch processing + +## Connection Management + +- Automatically connects to the specified MAVLink endpoint +- Handles connection failures gracefully with automatic reconnection +- Reconnection delay: 1 second after connection loss +- Logs connection status and errors using structured logging (tracing) + +## Future Enhancements + +- **Connection Retry Backoff**: Exponential backoff for reconnection attempts +- **Data Filtering**: Time range and quality filters +- **Multiple Formats**: CSV, XML, or binary output options +- **WebSocket Output**: Real-time streaming over network +- **Data Validation**: Additional quality checks and outlier detection +- **Rate Limiting**: Configurable output rate limiting + +## Dependencies + +- **mavlink**: MAVLink protocol support for uORocketry message set +- **messages-prost**: Protobuf message definitions for RadioFrame and SBG data +- **serde**: JSON serialization/deserialization +- **tokio**: Async runtime for MAVLink I/O operations +- **clap**: Command-line argument parsing +- **chrono**: Timestamp handling and formatting +- **tracing**: Structured logging +- **prost**: Protobuf decoding +- **anyhow**: Error handling + +## Building and Testing + +```bash +# Build the project +cargo build --release + +# Check compilation +cargo check + +# Run with verbose logging +RUST_LOG=debug cargo run +``` + +## Integration Notes + +- The program is designed to be lightweight and fast +- JSON lines output is optimized for parsing by other tools +- Error conditions are handled gracefully with automatic reconnection +- The program can be easily integrated into CI/CD pipelines +- MAVLink connections are properly managed and automatically recovered +- Designed to work with SerGW (Serial Gateway) which bridges serial devices to TCP diff --git a/apps/antenna-tracker/Cargo.toml b/apps/antenna-tracker/Cargo.toml new file mode 100644 index 0000000..92bdd2c --- /dev/null +++ b/apps/antenna-tracker/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "gps-ingest" +version.workspace = true +authors.workspace = true +publish.workspace = true +license.workspace = true +edition = "2021" + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true, features = ["derive", "std"] } +serde_json = { workspace = true } +chrono = { workspace = true, features = ["clock", "serde", "std"] } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +mavlink = { workspace = true } +messages-prost = { workspace = true } +anyhow = "1.0" +prost = { workspace = true } diff --git a/apps/antenna-tracker/README.md b/apps/antenna-tracker/README.md new file mode 100644 index 0000000..d4cd41c --- /dev/null +++ b/apps/antenna-tracker/README.md @@ -0,0 +1,95 @@ +# Antenna Tracker Tools + +Optional antenna-tracking tools, including the Rust `gps-ingest` stream extractor and Python prediction/control pipeline. This directory is a development and field utility, not a default RGS runtime service. + +## Features + +- Connects to MAVLink and listens for POSTCARD messages +- Extracts GPS position data from `SbgGpsPos` messages +- Extracts altitude data from `SbgAir` messages +- Validates coordinates (filters invalid coordinates near 0,0 and outside Canada bounds) +- Outputs JSON lines to stdout (one complete sample per line) +- Automatic reconnection on connection loss +- Designed to be piped to other programs + +## Usage + +### Basic usage (default MAVLink connection) +```bash +cargo run -p gps-ingest +``` + +### Specify MAVLink connection string +```bash +cargo run -p gps-ingest -- --connection tcpout:127.0.0.1:5656 +``` + +### Use UDP input instead +```bash +cargo run -- --connection udpin:0.0.0.0:14550 +``` + +### Pretty JSON output (multi-line) +```bash +cargo run -p gps-ingest -- --pretty +``` + +### Pipe output to another program +```bash +cargo run | jq '.lat' +``` + +## Command Line Options + +- `--connection`: MAVLink connection string (default: `tcpout:127.0.0.1:5656`) + - Examples: `tcpout:127.0.0.1:5656`, `udpin:0.0.0.0:14550`, `udpout:192.168.1.100:14550` +- `--pretty`: Print pretty JSON (multi-line) instead of compact single-line JSON (default: false) +- `-h, --help`: Show help information + +## Output Format + +The program outputs JSON lines (one per complete sample) with the following structure: + +```json +{"lat":45.123456,"lon":-75.654321,"altitude_m":100.5,"ts":"2024-01-01T12:00:00Z","source":"gps-ingest"} +``` + +Each line contains: +- `lat`: Latitude in degrees (f64) +- `lon`: Longitude in degrees (f64) +- `altitude_m`: Altitude in meters (f64) +- `ts`: ISO 8601 timestamp (RFC3339 format) +- `source`: Always "gps-ingest" + +The program only outputs a line when it has received both GPS position (from `SbgGpsPos`) and altitude (from `SbgAir`) data, ensuring complete samples. + +## How It Works + +1. Connects to the specified MAVLink endpoint +2. Listens for `POSTCARD_MESSAGE` MAVLink messages +3. Decodes the protobuf `RadioFrame` from each POSTCARD message +4. Extracts GPS coordinates from `SbgGpsPos` data +5. Extracts altitude from `SbgAir` data +6. Validates coordinates (must be within Canada bounds and not near 0,0) +7. Outputs a JSON line when all required fields are available +8. Automatically reconnects if the connection is lost + +## Building + +```bash +cargo build --release +``` + +The binary will be available at `target/release/gps-ingest`. + +## Dependencies + +- `mavlink`: MAVLink protocol support +- `messages-prost`: Protobuf message definitions for radio frames +- `serde` + `serde_json`: JSON serialization +- `chrono`: Timestamp handling +- `tokio`: Async runtime +- `clap`: Command line argument parsing +- `tracing`: Structured logging +- `anyhow`: Error handling +- `prost`: Protobuf decoding diff --git a/apps/antenna-tracker/antenna_service.py b/apps/antenna-tracker/antenna_service.py new file mode 100755 index 0000000..15e423b --- /dev/null +++ b/apps/antenna-tracker/antenna_service.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +""" +Antenna service abstraction used by gps-ingest REPL. + +Provides a uniform API over either a real ODrive-backed antenna or a mock +software implementation when hardware is not available. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Dict, Tuple, Optional + + +@dataclass +class AntennaConfig: + # Motion limits (degrees) + limits_yaw_deg: Tuple[float, float] = (-370.0, 370.0) # Multi-turn support to match manual.py + limits_pitch_deg: Tuple[float, float] = (-1.0, 90.0) + # Gear ratios for converting degrees <-> motor turns (optional if using real controller differently) + gear_ratio_yaw: float = 30.0 + gear_ratio_pitch: float = 36.0 + # Hardware toggles + use_real_hardware: bool = False + # Optional debug prints + debug: bool = False + # ODrive motor/controller settings + pole_pairs: int = 7 + torque_constant: float = 8.27 / 150.0 + current_limit: float = 30.0 + regen_current_limit: float = 4.0 + dc_max_negative_current: float = -6.0 + dc_max_positive_current: float = 60.0 + calibration_current: float = 8.0 + calibration_timeout: float = 30.0 + # Controller gains + pos_gain: float = 2.0 + vel_gain: float = 0.10 + vel_integrator_gain: float = 3.0 + # Trap trajectory defaults (in degrees per second) + trap_vel_limit_degps: float = 120.0 + trap_accel_limit_degps2: float = 90.0 + trap_decel_limit_degps2: float = 90.0 + # Direction (+1 normal, -1 if mechanics/wiring are reversed) + dir_yaw: float = +1.0 + dir_pitch: float = +1.0 + # Velocity control (controller mode) + vel_ramp_rate_degps2_yaw: float = 180.0 + vel_ramp_rate_degps2_pitch: float = 180.0 + max_vel_degps_yaw: float = 120.0 + max_vel_degps_pitch: float = 120.0 + + +class AntennaService: + """Facade for antenna control. Falls back to a mock implementation if hardware is not enabled.""" + + def __init__(self, config: AntennaConfig): + self.config = config + self._connected = False + self._configured = False + self._calibrated = False + self._engaged = False + # Internal state (degrees) + self._yaw_deg = 0.0 + self._pitch_deg = 0.0 + # Hardware handles (if any) + self._device = None # ODrive device + self._axes: Dict[str, object] = {} # {'yaw': axis0, 'pitch': axis1} + self._offset_turns: Dict[str, float] = {"yaw": 0.0, "pitch": 0.0} + # Lazily imported enums (only when hardware is enabled) + self._AxisState = None + self._ControlMode = None + self._InputMode = None + + # ---- Lifecycle ---- + def connect(self) -> bool: + if not self.config.use_real_hardware: + self._connected = True + return True + try: + import odrive # type: ignore + from odrive.enums import AxisState, ControlMode, InputMode # type: ignore + self._AxisState = AxisState + self._ControlMode = ControlMode + self._InputMode = InputMode + print("AntennaService: Searching for ODrive...") + self._device = odrive.find_any() + if not self._device: + print("AntennaService: no ODrive found") + self._connected = False + return False + self._axes = {"yaw": self._device.axis0, "pitch": self._device.axis1} + self._device.clear_errors() + self._connected = True + if self.config.debug: + print(f"AntennaService: connected to ODrive serial {self._device.serial_number}") + return True + except Exception as e: # pragma: no cover + print("AntennaService: hardware connect failed:", e) + self._connected = False + return False + + def configure(self) -> bool: + if not self._connected: + return False + if not self.config.use_real_hardware: + self._configured = True + return True + try: + dev = self._device + assert dev is not None + # Global device configuration + dev.config.enable_brake_resistor = True + dev.config.brake_resistance = 2.0 + dev.config.dc_max_negative_current = self.config.dc_max_negative_current + dev.config.dc_max_positive_current = self.config.dc_max_positive_current + dev.config.max_regen_current = self.config.regen_current_limit + # Per-axis configuration + for name, axis in self._axes.items(): + # Mirror manual.py behavior + axis.controller.config.vel_limit = float('inf') + mcfg = axis.motor.config + ccfg = axis.controller.config + tcfg = axis.trap_traj.config + # Motor settings + mcfg.pole_pairs = self.config.pole_pairs + mcfg.torque_constant = self.config.torque_constant + mcfg.current_lim = self.config.current_limit + mcfg.calibration_current = self.config.calibration_current + # Controller settings + ccfg.input_mode = self._InputMode.TRAP_TRAJ + ccfg.control_mode = self._ControlMode.POSITION_CONTROL + ccfg.pos_gain = self.config.pos_gain + ccfg.vel_gain = self.config.vel_gain + ccfg.vel_integrator_gain = self.config.vel_integrator_gain + # Trap trajectory limits (in motor turns per second) + if name == 'yaw': + gear_ratio = self.config.gear_ratio_yaw + else: # pitch + gear_ratio = self.config.gear_ratio_pitch + + tcfg.vel_limit = (self.config.trap_vel_limit_degps / 360.0) * gear_ratio + tcfg.accel_limit = (self.config.trap_accel_limit_degps2 / 360.0) * gear_ratio + tcfg.decel_limit = (self.config.trap_decel_limit_degps2 / 360.0) * gear_ratio + # Keep controller vel_limit in sync with trap traj + ccfg.vel_limit = tcfg.vel_limit + # Ensure non-circular setpoints for both axes + ccfg.circular_setpoints = False + self._configured = True + if self.config.debug: + print("AntennaService: configured motors and controller") + return True + except Exception as e: + print("AntennaService: configure failed:", e) + return False + + def calibrate(self) -> bool: + if not self._configured: + return False + if not self.config.use_real_hardware: + self._calibrated = True + return True + try: + dev = self._device + assert dev is not None + dev.clear_errors() + timeout_s = self.config.calibration_timeout + # Start calibration on both axes simultaneously + for name, ax in self._axes.items(): + if self.config.debug: + print(f"AntennaService: requesting calibration for {name} axis...") + ax.requested_state = self._AxisState.FULL_CALIBRATION_SEQUENCE + + # Wait until both axes are idle or timeout + start = time.time() + while True: + all_idle = all(ax.current_state == self._AxisState.IDLE for ax in self._axes.values()) + if all_idle: + break + if time.time() - start > timeout_s: + print("AntennaService: calibration timed out for one or more axes") + return False + time.sleep(0.2) + + # Set both axes to closed loop control + for name, ax in self._axes.items(): + ax.requested_state = self._AxisState.CLOSED_LOOP_CONTROL + time.sleep(0.5) + + # Check for errors on both axes + for name, ax in self._axes.items(): + if ax.error != 0: + try: + from odrive.utils import dump_errors # type: ignore + dump_errors(dev) + except Exception: + pass + print(f"AntennaService: {name} axis error after calibration") + return False + if self.config.debug: + print(f"AntennaService: {name} axis calibration complete") + self._calibrated = True + self._engaged = True + if self.config.debug: + print("AntennaService: calibration successful") + return True + except Exception as e: + print("AntennaService: calibrate failed:", e) + return False + + def engage(self) -> bool: + if not self._calibrated: + return False + if not self.config.use_real_hardware: + self._engaged = True + return True + try: + for ax in self._axes.values(): + ax.requested_state = self._AxisState.CLOSED_LOOP_CONTROL + time.sleep(0.2) + self._engaged = True + return not self._has_errors() + except Exception: + return False + + def disengage(self) -> bool: + if not self.config.use_real_hardware: + self._engaged = False + return True + try: + for ax in self._axes.values(): + ax.requested_state = self._AxisState.IDLE + time.sleep(0.2) + self._engaged = False + return not self._has_errors() + except Exception: + return False + + def stop(self) -> bool: + if not self.config.use_real_hardware: + return True + try: + for ax in self._axes.values(): + ax.controller.input_vel = 0 + return not self._has_errors() + except Exception: + return False + + # ---- Velocity control (controller mode) ---- + def enter_velocity_mode(self) -> bool: + if not self._connected: + return False + if not self.config.use_real_hardware: + # No-op in mock mode + return True + try: + for name, ax in self._axes.items(): + ccfg = ax.controller.config + ccfg.control_mode = self._ControlMode.VELOCITY_CONTROL + ccfg.input_mode = self._InputMode.VEL_RAMP + # Configure ramp rates per axis + if name == 'yaw': + ramp_deg_s2 = self.config.vel_ramp_rate_degps2_yaw + max_deg_s = self.config.max_vel_degps_yaw + gear = self.config.gear_ratio_yaw + else: + ramp_deg_s2 = self.config.vel_ramp_rate_degps2_pitch + max_deg_s = self.config.max_vel_degps_pitch + gear = self.config.gear_ratio_pitch + ccfg.vel_ramp_rate = (ramp_deg_s2 / 360.0) * gear + ccfg.vel_limit = max(1.0, (max_deg_s / 360.0) * gear * 1.5) + return True + except Exception: + return False + + def set_velocity(self, *, pitch_dps: Optional[float] = None, yaw_dps: Optional[float] = None) -> bool: + if pitch_dps is None and yaw_dps is None: + return True + if not self.config.use_real_hardware: + # In mock mode, just accept + return True + try: + if pitch_dps is not None: + turns_per_s = (self.config.dir_pitch * pitch_dps / 360.0) * self.config.gear_ratio_pitch + self._axes['pitch'].controller.input_vel = turns_per_s + if yaw_dps is not None: + turns_per_s = (self.config.dir_yaw * yaw_dps / 360.0) * self.config.gear_ratio_yaw + self._axes['yaw'].controller.input_vel = turns_per_s + return not self._has_errors() + except Exception: + return False + + def calculate_max_permissible_velocity(self, axis_name: str, requested_velocity_degps: float) -> float: + """Limit velocity so motion can stop before hitting position limits.""" + if requested_velocity_degps == 0.0: + return 0.0 + pos = self.get_position() + current_pos = pos[axis_name] + lo, hi = (self.config.limits_pitch_deg if axis_name == 'pitch' else self.config.limits_yaw_deg) + max_accel = self.config.vel_ramp_rate_degps2_pitch if axis_name == 'pitch' else self.config.vel_ramp_rate_degps2_yaw + if requested_velocity_degps > 0: + available = hi - current_pos + direction = 1.0 + else: + available = current_pos - lo + direction = -1.0 + if available <= 0: + return 0.0 + max_stop_vel = (2.0 * max_accel * max(available, 0.0)) ** 0.5 + limited = max_stop_vel if abs(requested_velocity_degps) > max_stop_vel else abs(requested_velocity_degps) + return direction * limited + + def zero(self) -> None: + if not self.config.use_real_hardware: + self._yaw_deg = 0.0 + self._pitch_deg = 0.0 + return + for name, ax in self._axes.items(): + self._offset_turns[name] = float(ax.encoder.pos_estimate) + + # ---- Status ---- + def get_position(self) -> Dict[str, float]: + if not self.config.use_real_hardware: + return {"yaw": self._yaw_deg, "pitch": self._pitch_deg} + pos: Dict[str, float] = {} + for name, ax in self._axes.items(): + turns = float(ax.encoder.pos_estimate) - self._offset_turns[name] + if name == 'yaw': + deg = (turns / self.config.gear_ratio_yaw) * 360.0 + pos[name] = self.config.dir_yaw * deg + else: + deg = (turns / self.config.gear_ratio_pitch) * 360.0 + pos[name] = self.config.dir_pitch * deg + return pos + + def status(self) -> Dict[str, bool]: + return { + "connected": self._connected, + "configured": self._configured, + "calibrated": self._calibrated, + "engaged": self._engaged, + } + + # ---- Command ---- + def set_position(self, *, pitch: Optional[float] = None, yaw: Optional[float] = None, bypass_limits: bool = False) -> bool: + # Always print when called, with arguments + if self.config.debug: + print(f"AntennaService: set_position called with pitch={pitch} yaw={yaw} bypass_limits={bypass_limits}") + + if pitch is None and yaw is None: + return True + + # Clamp in logical space (positive-up convention) with optional debug warnings + orig_pitch, orig_yaw = pitch, yaw + if pitch is not None and not bypass_limits: + lo, hi = self.config.limits_pitch_deg + new_pitch = max(lo, min(hi, pitch)) + if new_pitch != pitch and self.config.debug: + print(f"WARNING: Pitch clamped {pitch:.2f}° -> {new_pitch:.2f}°") + pitch = new_pitch + if yaw is not None and not bypass_limits: + lo, hi = self.config.limits_yaw_deg + new_yaw = max(lo, min(hi, yaw)) + if new_yaw != yaw and self.config.debug: + print(f"WARNING: Yaw clamped {yaw:.2f}° -> {new_yaw:.2f}°") + yaw = new_yaw + + if not self.config.use_real_hardware: + if self.config.debug: + print(f"AntennaService: (TEST) set_position to pitch={pitch} yaw={yaw}") + if pitch is not None: + self._pitch_deg = pitch + if yaw is not None: + self._yaw_deg = yaw + if self.config.debug: + print(f"AntennaService: (TEST) internal state now pitch={self._pitch_deg:.2f} yaw={self._yaw_deg:.2f}") + return True + + try: + # Map to hardware direction and send + if self.config.debug: + print(f"AntennaService: (HW) set_position to pitch={pitch} yaw={yaw}") + if pitch is not None: + pitch_hw = self.config.dir_pitch * pitch + turns = (pitch_hw / 360.0) * self.config.gear_ratio_pitch + self._offset_turns["pitch"] + if self.config.debug: + print(f"AntennaService: (HW) pitch command: pitch_hw={pitch_hw:.2f}, turns={turns:.4f}") + self._axes["pitch"].controller.input_pos = turns + if yaw is not None: + yaw_hw = self.config.dir_yaw * yaw + turns = (yaw_hw / 360.0) * self.config.gear_ratio_yaw + self._offset_turns["yaw"] + if self.config.debug: + print(f"AntennaService: (HW) yaw command: yaw_hw={yaw_hw:.2f}, turns={turns:.4f}") + self._axes["yaw"].controller.input_pos = turns + return not self._has_errors() + except Exception as e: + print("AntennaService: set_position failed:", e) + return False + + # ---- Internal ---- + def _has_errors(self) -> bool: + if not self.config.use_real_hardware: + return False + try: + if any(getattr(ax, 'error', 0) for ax in self._axes.values()): + try: + from odrive.utils import dump_errors # type: ignore + dump_errors(self._device) + except Exception: + pass + return True + return False + except Exception: + return True diff --git a/apps/antenna-tracker/example_pipeline.py b/apps/antenna-tracker/example_pipeline.py new file mode 100755 index 0000000..c872e9c --- /dev/null +++ b/apps/antenna-tracker/example_pipeline.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Example Python script that demonstrates how gps-ingest can be used in a pipeline. +This simulates the "predictor" component from the antenna tracker architecture. +""" + +import json +import subprocess +import sys +import time +import shlex +import select +from typing import Dict, List, Optional +from dataclasses import dataclass +from datetime import datetime, timezone +import math +import numpy as np + +# Antenna tracker location (observer) +ANTENNA_LAT_DEG = 48.48058344683317 +ANTENNA_LON_DEG = -81.33124126482966 +ANTENNA_ALT_M = 300.0 + +# Toggle between real gps-ingest and test mode (easily tweakable) +USE_REAL_GPS_INGEST = False +# Test coordinates for the target (rocket) when not using real gps-ingest +TEST_TARGET_LAT_DEG = 48.48158344683317 +TEST_TARGET_LON_DEG = -81.33124126482966 +TEST_TARGET_ALT_M = 301.0 + +# Antenna yaw-zero reference and normalization (easily tweakable) +# Yaw-zero is the mechanical zero direction; with South as zero we set 180° from North +ANTENNA_YAW_ZERO_DEG = 180.0 # due South +ROTATION_NORMALIZATION = "pm180" # "pm180" for [-180,180], or "0to360" for [0,360) + +# History configuration +HISTORY_MAX_SAMPLES = 200 +STREAM_TIMEOUT_S = 15.0 +TARGET_STREAM_SAMPLES = 10 # try to collect up to this many per run if available + +# WGS84 ellipsoid constants +_WGS84_A = 6378137.0 # semi-major axis [m] +_WGS84_F = 1.0 / 298.257223563 # flattening +_WGS84_E2 = _WGS84_F * (2.0 - _WGS84_F) # eccentricity squared + +@dataclass +class GpsPoint: + latitude: float + longitude: float + altitude: float + timestamp: str + source: str + +@dataclass +class Prediction: + target_lat: float + target_lon: float + target_alt: float + confidence: float + prediction_time: str + +class GpsPredictor: + """Simple GPS predictor that demonstrates pipeline usage""" + + def __init__(self, gps_ingest_path: str = "cargo run -p gps-ingest"): + self.gps_ingest_path = gps_ingest_path + self.last_position: Optional[GpsPoint] = None + # Each entry: (t_epoch_s, lat, lon, alt) + self.history: List[tuple[float, float, float, float]] = [] + + def _add_sample(self, pos: GpsPoint) -> None: + t = time.time() + self.history.append((t, pos.latitude, pos.longitude, pos.altitude)) + if len(self.history) > HISTORY_MAX_SAMPLES: + self.history = self.history[-HISTORY_MAX_SAMPLES:] + self.last_position = pos + + def _add_sample_from_dict(self, d: Dict) -> None: + pos = GpsPoint( + latitude=d['lat'], + longitude=d['lon'], + altitude=d['altitude_m'], + timestamp=d['ts'], + source=d.get('source', 'gps-ingest') + ) + self._add_sample(pos) + + def get_latest_gps_data(self) -> Optional[Dict]: + """Get GPS data by streaming stdout; accumulate multiple samples for regression when possible. + If USE_REAL_GPS_INGEST is False, return test coordinates and add them to history. + """ + try: + if not USE_REAL_GPS_INGEST: + # Test mode: return synthetic sample and add to history + now = datetime.now(timezone.utc).isoformat() + print( + f"[TEST MODE] Using fixed coordinates lat={TEST_TARGET_LAT_DEG}, lon={TEST_TARGET_LON_DEG}, alt={TEST_TARGET_ALT_M}m" + ) + d = { + "lat": TEST_TARGET_LAT_DEG, + "lon": TEST_TARGET_LON_DEG, + "altitude_m": TEST_TARGET_ALT_M, + "ts": now, + "source": "test" + } + self._add_sample_from_dict(d) + return d + + cmd = shlex.split(self.gps_ingest_path) + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, # line-buffered + cwd=".." + ) + + start = time.monotonic() + timeout_s = STREAM_TIMEOUT_S + samples_collected = 0 + last_valid: Optional[Dict] = None + + assert proc.stdout is not None + while True: + # Break if timed out + if time.monotonic() - start > timeout_s: + break + + rlist, _, _ = select.select([proc.stdout], [], [], 0.5) + if not rlist: + # Also break if process has exited and there's nothing to read + if proc.poll() is not None: + break + continue + + line = proc.stdout.readline() + if not line: + # EOF + if proc.poll() is not None: + break + continue + + line = line.strip() + if not line: + continue + + # Expect JSON lines from gps-ingest; skip logs + if not (line.startswith("{") and line.endswith("}")): + continue + + try: + candidate = json.loads(line) + except json.JSONDecodeError: + continue + + if ( + candidate.get("lat") is not None + and candidate.get("lon") is not None + and candidate.get("altitude_m") is not None + and candidate.get("ts") is not None + ): + # Accept both gps-ingest and test sources for accumulation + self._add_sample_from_dict(candidate) + last_valid = candidate + samples_collected += 1 + if samples_collected >= TARGET_STREAM_SAMPLES: + break + + # Ensure process is stopped + if proc.poll() is None: + try: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + except Exception: + pass + + return last_valid + + except FileNotFoundError as e: + print(f"gps-ingest not found: {e}", file=sys.stderr) + return None + except Exception as e: + print(f"Error getting GPS data: {e}", file=sys.stderr) + return None + + def predict_next_position(self, current_pos: GpsPoint) -> Prediction: + """Predict next position (+1s) using linear regression over accumulated history.""" + # Ensure the current point is included + self._add_sample(current_pos) + + if len(self.history) < 2: + # Not enough data; return current as predicted + t_iso = datetime.now(timezone.utc).isoformat() + return Prediction( + target_lat=current_pos.latitude, + target_lon=current_pos.longitude, + target_alt=current_pos.altitude, + confidence=0.5, + prediction_time=t_iso, + ) + + # Prepare data for regression + times = np.array([t for (t, _, _, _) in self.history], dtype=float) + lats = np.array([lat for (_, lat, _, _) in self.history], dtype=float) + lons = np.array([lon for (_, _, lon, _) in self.history], dtype=float) + alts = np.array([alt for (_, _, _, alt) in self.history], dtype=float) + + # Normalize time to improve conditioning + t0 = times[0] + t_rel = times - t0 + t_future = (times[-1] + 1.0) - t0 # predict 1 second ahead + + try: + lat_slope, lat_intercept = np.polyfit(t_rel, lats, 1) + lon_slope, lon_intercept = np.polyfit(t_rel, lons, 1) + alt_slope, alt_intercept = np.polyfit(t_rel, alts, 1) + + pred_lat = lat_slope * t_future + lat_intercept + pred_lon = lon_slope * t_future + lon_intercept + pred_alt = alt_slope * t_future + alt_intercept + confidence = 0.9 if len(self.history) >= 5 else 0.7 + except Exception: + # Fallback if polyfit fails (e.g., numerical issues) + pred_lat = current_pos.latitude + pred_lon = current_pos.longitude + pred_alt = current_pos.altitude + confidence = 0.5 + + return Prediction( + target_lat=pred_lat, + target_lon=pred_lon, + target_alt=pred_alt, + confidence=confidence, + prediction_time=datetime.now(timezone.utc).isoformat() + ) + + @staticmethod + def _geodetic_to_ecef(lat_deg: float, lon_deg: float, h_m: float) -> tuple[float, float, float]: + lat = math.radians(lat_deg) + lon = math.radians(lon_deg) + sin_lat = math.sin(lat) + cos_lat = math.cos(lat) + sin_lon = math.sin(lon) + cos_lon = math.cos(lon) + # Prime vertical radius of curvature + N = _WGS84_A / math.sqrt(1.0 - _WGS84_E2 * sin_lat * sin_lat) + x = (N + h_m) * cos_lat * cos_lon + y = (N + h_m) * cos_lat * sin_lon + z = (N * (1.0 - _WGS84_E2) + h_m) * sin_lat + return x, y, z + + @staticmethod + def _ecef_to_enu(dx: tuple[float, float, float], lat0_deg: float, lon0_deg: float) -> tuple[float, float, float]: + lat0 = math.radians(lat0_deg) + lon0 = math.radians(lon0_deg) + sin_lat = math.sin(lat0) + cos_lat = math.cos(lat0) + sin_lon = math.sin(lon0) + cos_lon = math.cos(lon0) + dx_x, dx_y, dx_z = dx + # Rotation from ECEF delta to ENU at observer + e = -sin_lon * dx_x + cos_lon * dx_y + n = -sin_lat * cos_lon * dx_x - sin_lat * sin_lon * dx_y + cos_lat * dx_z + u = cos_lat * cos_lon * dx_x + cos_lat * sin_lon * dx_y + sin_lat * dx_z + return e, n, u + + @staticmethod + def normalize_angle_0_360(deg: float) -> float: + return (deg % 360.0 + 360.0) % 360.0 + + @staticmethod + def normalize_angle_pm180(deg: float) -> float: + d = GpsPredictor.normalize_angle_0_360(deg) + return d - 360.0 if d > 180.0 else d + + @staticmethod + def initial_bearing_deg(lat1_deg: float, lon1_deg: float, lat2_deg: float, lon2_deg: float) -> float: + """Great-circle initial bearing from point 1 to point 2, degrees cw from North.""" + lat1 = math.radians(lat1_deg) + lat2 = math.radians(lat2_deg) + dlon = math.radians(lon2_deg - lon1_deg) + x = math.sin(dlon) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) + brng = math.degrees(math.atan2(x, y)) + return GpsPredictor.normalize_angle_0_360(brng) + + def compute_az_el(self, obs_lat: float, obs_lon: float, obs_alt: float, + tgt_lat: float, tgt_lon: float, tgt_alt: float) -> tuple[float, float]: + """Compute azimuth (yaw, degrees cw from North) and elevation (pitch, degrees) from observer to target.""" + x_o, y_o, z_o = self._geodetic_to_ecef(obs_lat, obs_lon, obs_alt) + x_t, y_t, z_t = self._geodetic_to_ecef(tgt_lat, tgt_lon, tgt_alt) + dx = (x_t - x_o, y_t - y_o, z_t - z_o) + e, n, u = self._ecef_to_enu(dx, obs_lat, obs_lon) + az_rad = math.atan2(e, n) # 0 at North, +East + el_rad = math.atan2(u, math.hypot(e, n)) + az_deg = math.degrees(az_rad) + if az_deg < 0: + az_deg += 360.0 + el_deg = math.degrees(el_rad) + return az_deg, el_deg + + def rotation_from_yaw_zero(self, bearing_deg: float, yaw_zero_deg: float, mode: str = "pm180") -> float: + raw = bearing_deg - yaw_zero_deg + if mode == "0to360": + return GpsPredictor.normalize_angle_0_360(raw) + return GpsPredictor.normalize_angle_pm180(raw) + + def run_prediction_cycle(self): + """Main prediction cycle - simulates continuous operation""" + print("=== GPS Predictor Starting ===") + print("Getting latest GPS data...") + + data = self.get_latest_gps_data() + + if not data: + print("No valid GPS data available") + return + + # Create GPS point from the new/last data + current_pos = GpsPoint( + latitude=data['lat'], + longitude=data['lon'], + altitude=data['altitude_m'], + timestamp=data['ts'], + source=data.get('source', 'gps-ingest') + ) + + print(f"Current Position: {current_pos.latitude:.6f}, {current_pos.longitude:.6f}") + print(f"Altitude: {current_pos.altitude:.1f}m") + print(f"Source: {current_pos.source}") + print(f"Timestamp: {current_pos.timestamp}") + + # Compute az/el for current position + az_deg, el_deg = self.compute_az_el( + ANTENNA_LAT_DEG, ANTENNA_LON_DEG, ANTENNA_ALT_M, + current_pos.latitude, current_pos.longitude, current_pos.altitude + ) + # Compute great-circle initial bearing (from North, cw) and rotation from yaw-zero=South + bearing_deg = self.initial_bearing_deg( + ANTENNA_LAT_DEG, ANTENNA_LON_DEG, + current_pos.latitude, current_pos.longitude + ) + rotation_deg = self.rotation_from_yaw_zero( + bearing_deg, ANTENNA_YAW_ZERO_DEG, ROTATION_NORMALIZATION + ) + print(f"\nPointing (current):") + print(f" Yaw (azimuth): {az_deg:.2f}° | Pitch (elevation): {el_deg:.2f}°") + print(f" Great-circle bearing: {bearing_deg:.2f}° from North") + print(f" Antenna rotation (yaw-zero=South): {rotation_deg:.2f}° [{ROTATION_NORMALIZATION}]") + + # Predict next position (+1s) using regression over accumulated history + prediction = self.predict_next_position(current_pos) + + print(f"\nPrediction (1 second ahead) [linear regression]:") + print(f" Target: {prediction.target_lat:.6f}, {prediction.target_lon:.6f}") + print(f" Altitude: {prediction.target_alt:.1f}m") + print(f" Confidence: {prediction.confidence:.2f}") + + # Compute az/el, bearing and rotation for predicted position + az_pred_deg, el_pred_deg = self.compute_az_el( + ANTENNA_LAT_DEG, ANTENNA_LON_DEG, ANTENNA_ALT_M, + prediction.target_lat, prediction.target_lon, prediction.target_alt + ) + bearing_pred_deg = self.initial_bearing_deg( + ANTENNA_LAT_DEG, ANTENNA_LON_DEG, + prediction.target_lat, prediction.target_lon + ) + rotation_pred_deg = self.rotation_from_yaw_zero( + bearing_pred_deg, ANTENNA_YAW_ZERO_DEG, ROTATION_NORMALIZATION + ) + print(f"\nPointing (predicted +1s):") + print(f" Yaw (azimuth): {az_pred_deg:.2f}° | Pitch (elevation): {el_pred_deg:.2f}°") + print(f" Great-circle bearing: {bearing_pred_deg:.2f}° from North") + print(f" Antenna rotation (yaw-zero=South): {rotation_pred_deg:.2f}° [{ROTATION_NORMALIZATION}]") + + # In a real system, this would be sent to the antenna tracker API + print(f"\nSending to antenna tracker API...") + print( + f" COMMAND: TRACK yaw={az_pred_deg:.2f} pitch={el_pred_deg:.2f} rotation={rotation_pred_deg:.2f} (at +1s)" + ) + + self.last_position = current_pos + +def main(): + """Main function demonstrating the pipeline""" + predictor = GpsPredictor() + + try: + predictor.run_prediction_cycle() + except KeyboardInterrupt: + print("\nPrediction cycle interrupted") + except Exception as e: + print(f"Error in prediction cycle: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/apps/antenna-tracker/fake_gps_ingest.py b/apps/antenna-tracker/fake_gps_ingest.py new file mode 100644 index 0000000..62ffbc9 --- /dev/null +++ b/apps/antenna-tracker/fake_gps_ingest.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import sys +import time +import json +import argparse +from datetime import datetime + + +def parse_args(): + p = argparse.ArgumentParser(description="Replay JSONL GPS as timed stdout") + p.add_argument("jsonl", help="Input JSONL with fields: ts, lat, lon, altitude_m, source") + p.add_argument("--speed", type=float, default=1.0, help="Time scale (2.0 = 2x faster)") + p.add_argument("--loop", action="store_true", help="Loop forever") + p.add_argument("--delay", type=float, default=0.0, help="Seconds to wait before first sample") + return p.parse_args() + + +def iso_to_epoch(s: str) -> float: + if s.endswith('Z'): + s = s[:-1] + "+00:00" + return datetime.fromisoformat(s).timestamp() + + +def replay(path: str, speed: float, loop: bool, delay: float) -> None: + while True: + prev_t = None + first = True + with open(path, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + t = iso_to_epoch(str(obj.get("ts"))) + if first: + if delay > 0: + time.sleep(delay) + first = False + if prev_t is not None: + dt = max(0.0, (t - prev_t) / max(1e-6, speed)) + time.sleep(dt) + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + prev_t = t + if not loop: + break + + +def main() -> None: + a = parse_args() + replay(a.jsonl, a.speed, a.loop, a.delay) + + +if __name__ == "__main__": + main() diff --git a/apps/antenna-tracker/main.py b/apps/antenna-tracker/main.py new file mode 100755 index 0000000..7d7c9d5 --- /dev/null +++ b/apps/antenna-tracker/main.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +gps-ingest CLI REPL for antenna integration. + +Commands: + - gps: stream-print latest GPS samples; Ctrl-C to stop + - prediction: print current prediction, pointing angles and antenna rotation + - rotate: print the command that would rotate antenna to predicted angles + - yolo: continuously (auto) print predicted rotation; Ctrl-C to stop + +This CLI uses PredictorService as the API layer managing background ingestion +and prediction. +""" + +from __future__ import annotations + +import sys +import time +from dataclasses import asdict +import os +import json + +from predictor_service import PredictorConfig, PredictorService +from antenna_service import AntennaConfig, AntennaService +from remote import XboxController + + +def _print_gps(sample) -> None: + print( + f"[{sample.ts_iso}] lat={sample.lat:.8f} lon={sample.lon:.8f} alt={sample.alt_m:.1f}m src={sample.source}" + ) + + +def cmd_gps(svc: PredictorService) -> None: + print("Streaming GPS (Ctrl-C to stop)...") + try: + # Print existing history first + hist = svc.get_history() + last_ts = None + for s in hist: + _print_gps(s) + last_ts = s.ts_iso + while True: + s = svc.get_latest() + if s and s.ts_iso != last_ts: + _print_gps(s) + last_ts = s.ts_iso + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopped GPS stream.") + + +def cmd_prediction(svc: PredictorService) -> None: + latest = svc.get_latest() + pred = svc.get_prediction() + if not latest: + print("No GPS yet.") + return + print("Antenna:") + print( + f" lat={svc.config.antenna_lat_deg:.8f} lon={svc.config.antenna_lon_deg:.8f} alt={svc.config.antenna_alt_m:.1f}m" + ) + print("Latest GPS:") + _print_gps(latest) + if not pred: + print("No prediction yet.") + return + az, el = svc.compute_az_el(pred.lat, pred.lon, pred.alt_m) + bearing = svc.initial_bearing_deg( + svc.config.antenna_lat_deg, svc.config.antenna_lon_deg, pred.lat, pred.lon + ) + + # Preview the rotation we would send (without updating the accumulator) + if svc.config.yaw_normalization == "continuous" and svc._last_yaw_cmd is not None: + rotation_preview = svc.rotation_from_yaw_zero_continuous(az, svc._last_yaw_cmd) + else: + rotation_preview = svc._rotation_from_yaw_zero_basic(az) + + print("Prediction (+1s):") + print( + f" lat={pred.lat:.8f} lon={pred.lon:.8f} alt={pred.alt_m:.1f}m conf={pred.confidence:.2f} ts={pred.ts_iso}" + ) + print(f" yaw(az from North)={az:.2f}° pitch(el)={el:.2f}°") + print(f" bearing={bearing:.2f}° from North; rotation(yaw-zero={svc.config.antenna_yaw_zero_deg:.1f}, mode={svc.config.yaw_normalization})={rotation_preview:.2f}°") + + # Show what would be sent to the antenna + yaw_cmd, pit_cmd = svc.yaw_pitch_from_target(pred.lat, pred.lon, pred.alt_m) + print(f"ROTATE pitch={pit_cmd:.2f}° yaw={yaw_cmd:.2f}°") + + +def cmd_rotate(svc: PredictorService, ant: AntennaService) -> None: + pred = svc.get_prediction() + if not pred: + print("No prediction available.") + return + + yaw_cmd, pit_cmd = svc.yaw_pitch_from_target(pred.lat, pred.lon, pred.alt_m) + ok = ant.set_position(pitch=pit_cmd, yaw=yaw_cmd, bypass_limits=False) + if ok: + print(f"ROTATE pitch={pit_cmd:.2f}° yaw={yaw_cmd:.2f}° (sent)") + else: + print("Failed to send rotation command") + + +# def cmd_yolo(svc: PredictorService, ant: AntennaService) -> None: +def cmd_yolo(svc: PredictorService, ant: AntennaService | None = None, *, do_move: bool = False) -> None: + print("Auto-tracking predictions (Ctrl-C to stop)...") + try: + while True: + pred = svc.get_prediction() + if pred: + yaw_cmd, pit_cmd = svc.yaw_pitch_from_target(pred.lat, pred.lon, pred.alt_m) + if do_move and ant is not None: + ant.set_position(pitch=pit_cmd, yaw=yaw_cmd, bypass_limits=False) + print(f"\rpitch={pit_cmd:+06.2f}° yaw={yaw_cmd:+07.2f}°", end="") + time.sleep(0.3) + except KeyboardInterrupt: + print("\nStopped auto-tracking.") + + +def cmd_demo(svc: PredictorService, ant: AntennaService, jsonl_path: str, *, speed: float = 10.0, delay: float = 5.0, do_move: bool = False) -> None: + """Preview first 5 lines of the JSONL, then stream using the replay script with delay.""" + abs_jsonl = os.path.abspath(jsonl_path) + if not os.path.exists(abs_jsonl): + print(f"JSONL not found: {abs_jsonl}") + return + print(f"Demo preview (first 5 lines) from {abs_jsonl}:") + try: + with open(abs_jsonl, 'r') as f: + shown = 0 + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except Exception: + continue + ts = obj.get('ts') + lat = obj.get('lat') + lon = obj.get('lon') + alt = obj.get('altitude_m') + src = obj.get('source', 'jsonl') + print(f" [{ts}] lat={lat:.8f} lon={lon:.8f} alt={alt:.1f}m src={src}") + shown += 1 + if shown >= 5: + break + except Exception as e: + print("Failed to preview JSONL:", e) + return + + # Reconfigure predictor to use external feed via replay script + svc.stop() + script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'fake_gps_ingest.py')) + svc.config.use_real_gps_ingest = True + svc.config.num_samples = 8 + svc.config.gps_ingest_cmd = f"python3 {script_path} {abs_jsonl} --speed {speed} --delay {delay}" + print(f"Starting demo stream with delay={delay}s, speed={speed}x...{' (MOVING)' if do_move else ''}") + svc.start() + # Run like yolo (auto-tracking printout); Ctrl-C to stop + cmd_yolo(svc, ant if do_move else None, do_move=do_move) +def cmd_calibrate(ant: AntennaService) -> None: + """Calibrate the antenna motors using the same logic as manual.py""" + print("Clearing errors and calibrating axes...") + if not ant._connected: + print("ERROR: Antenna not connected. Run connect first.") + return + + # Clear any existing errors before calibration + if hasattr(ant, '_device') and ant._device: + ant._device.clear_errors() + + print(f"Calibrating both axes simultaneously (timeout: {ant.config.calibration_timeout}s)...") + + # Start calibration on both axes + for name, ax in ant._axes.items(): + print(f"Requesting calibration for {name} axis...") + ax.requested_state = ant._AxisState.FULL_CALIBRATION_SEQUENCE + + start = time.time() + # Wait for both axes to finish calibration or timeout + while True: + all_idle = all(ax.current_state == ant._AxisState.IDLE for ax in ant._axes.values()) + if all_idle: + break + if time.time() - start > ant.config.calibration_timeout: + print("ERROR: Calibration timed out for one or more axes") + return + time.sleep(0.2) + + # Set both axes to closed loop control + for name, ax in ant._axes.items(): + ax.requested_state = ant._AxisState.CLOSED_LOOP_CONTROL + time.sleep(0.5) + + # Check for errors on both axes + for name, ax in ant._axes.items(): + if ax.error != 0: + print(f"ERROR: {name} axis has error after calibration") + # Use the existing _has_errors method which will dump errors + ant._has_errors() + return + print(f"{name.capitalize()} axis calibration complete.") + + print("All axes calibrated successfully.") + ant._calibrated = True + ant._engaged = True + + +def cmd_engage(ant: AntennaService) -> None: + """Engage the antenna motors for closed loop control""" + if not ant._connected: + print("ERROR: Antenna not connected. Run connect first.") + return + if not ant._calibrated: + print("ERROR: Antenna not calibrated. Run calibrate first.") + return + if ant._engaged: + print("Antenna is already engaged.") + return + + print("Engaging antenna motors...") + if ant.engage(): + print("Antenna motors engaged successfully.") + else: + print("ERROR: Failed to engage antenna motors.") + + +def cmd_disengage(ant: AntennaService) -> None: + """Disengage the antenna motors and set to idle""" + if not ant._connected: + print("ERROR: Antenna not connected. Run connect first.") + return + if not ant._engaged: + print("Antenna is already disengaged.") + return + + print("Disengaging antenna motors...") + if ant.disengage(): + print("Antenna motors disengaged successfully.") + else: + print("ERROR: Failed to disengage antenna motors.") + + +def cmd_help() -> None: + print("Available commands:") + print(" gps - Stream GPS history and live updates (Ctrl-C to stop)") + print(" prediction - Show current prediction, pointing angles and antenna rotation") + print(" rotate - Rotate antenna to current prediction (respects limits)") + print(" yolo - Auto-track predictions continuously (no motion)") + print(" yolo-move - Auto-track and MOVE antenna continuously (Ctrl-C to stop)") + print(" demo [path] - Replay JSONL with 5s delay (like yolo; no motion); default is apps/antenna-tracker/rocket_gps.jsonl") + print(" demo-move [path] - Replay JSONL with 5s delay and MOVE antenna continuously") + print(" pos [bypass] - Set absolute position in degrees; add 'bypass' to ignore limits") + print(" zero - Set current encoder offsets as zero for both axes") + print(" set-yaw-zero - Set predictor yaw-zero at runtime (no restart)") + print(" controller - Enter controller mode (Xbox): LX=yaw, RY=pitch; X=zero, Y=go to zero, B=idle") + print(" calibrate - Calibrate antenna motors (clears errors, runs full calibration)") + print(" engage - Engage antenna motors for closed loop control") + print(" disengage - Disengage antenna motors and set to idle") + print(" help - Show this help message") + print(" quit/exit - Exit the program") + + +def _run_controller_mode(ant: AntennaService) -> None: + """Controller mode similar to manual.py: LX=yaw, RY=pitch; X=zero, Y=go to zero, B=idle.""" + ctl = XboxController(deadzone=0.1) + if not ctl.start(): + print("ERROR: failed to start controller") + return + print("\nController mode (VELOCITY). Ctrl-C to exit.") + print("X=set zero here, Y=go to zero, B=idle") + try: + # Switch to velocity mode + if not ant.enter_velocity_mode(): + print("ERROR: couldn't enter velocity mode") + return + last_x = last_y = last_b = False + while True: + if not ctl.is_connected(): + print("Controller disconnected.") + break + xb = ctl.get_button('X') + yb = ctl.get_button('Y') + bb = ctl.get_button('B') + + if xb and not last_x: + ant.zero() + print("Zero position set at current location") + + if yb and not last_y: + # Temporarily move to zero using position control + ant.set_position(pitch=0.0, yaw=0.0, bypass_limits=False) + time.sleep(0.5) + + if bb and not last_b: + print("EMERGENCY STOP: setting IDLE") + ant.disengage() + break + + last_x, last_y, last_b = xb, yb, bb + + yaw_in = ctl.get_axis('LX') + pitch_in = -ctl.get_axis('RY') + yaw_dps = yaw_in * ant.config.max_vel_degps_yaw + pitch_dps = pitch_in * ant.config.max_vel_degps_pitch + # Apply kinematic limits based on remaining travel + yaw_dps_limited = ant.calculate_max_permissible_velocity('yaw', yaw_dps) + pitch_dps_limited = ant.calculate_max_permissible_velocity('pitch', pitch_dps) + ant.set_velocity(pitch_dps=pitch_dps_limited, yaw_dps=yaw_dps_limited) + + # Optional: show position when moving + if abs(yaw_in) > 0.05 or abs(pitch_in) > 0.05: + pos_deg = ant.get_position() + print(f"\rpitch={pos_deg['pitch']:+06.2f}° yaw={pos_deg['yaw']:+07.2f}°", end="") + time.sleep(0.02) + except KeyboardInterrupt: + print("\nExiting controller mode...") + finally: + ctl.stop() + + +def cmd_pos(ant: AntennaService, args: str) -> None: + """Set absolute pitch/yaw in degrees, like manual.py set_pos. + + Usage: pos [bypass] + """ + parts = args.split() + if len(parts) < 2: + print("Usage: pos [bypass]") + return + try: + pitch = float(parts[0]) + yaw = float(parts[1]) + except ValueError: + print("Usage: pos [bypass]") + return + bypass = False + if len(parts) >= 3 and parts[2].lower() in ("bypass", "true", "yes", "on"): + bypass = True + if bypass: + # Mirror manual.py: announce limits bypass explicitly + print(f"LIMITS BYPASSED: Pitch set to {pitch:.2f}°; Yaw set to {yaw:.2f}°") + ok = ant.set_position(pitch=pitch, yaw=yaw, bypass_limits=bypass) + # If not bypassing, AntennaService will WARN on clamps when debug=True + if ok: + print(f"Pitch Degrees: {pitch:.2f}, Yaw Degrees: {yaw:.2f}") + else: + print("ERROR: Failed to send position command") + + +def main() -> None: + cfg = PredictorConfig() + svc = PredictorService(cfg) + ant = AntennaService(AntennaConfig()) + ant.connect(); ant.configure(); ant.calibrate(); ant.engage() + svc.start() + print("gps-ingest REPL. Type 'help' for commands.") + try: + while True: + try: + cmd = input("> ").strip().lower() + except EOFError: + break + if cmd in ("quit", "exit"): + break + if cmd == "help": + cmd_help() + elif cmd == "gps": + cmd_gps(svc) + elif cmd == "prediction": + cmd_prediction(svc) + elif cmd == "rotate": + cmd_rotate(svc, ant) + elif cmd == "yolo": + cmd_yolo(svc, None, do_move=False) + elif cmd == "yolo-move": + cmd_yolo(svc, ant, do_move=True) + elif cmd.startswith("demo"): + parts = cmd.split(maxsplit=1) + if len(parts) == 2 and parts[1]: + path = parts[1].strip() + else: + path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'rocket_gps.jsonl')) + if cmd.startswith("demo-move"): + cmd_demo(svc, ant, path, do_move=True) + else: + cmd_demo(svc, ant, path, do_move=False) + elif cmd.startswith("pos"): + # usage: pos [bypass] + args = cmd[len("pos"):].strip() + cmd_pos(ant, args) + elif cmd == "zero": + ant.zero() + print("Zero position set from encoder.") + elif cmd.startswith("set-yaw-zero"): + parts = cmd.split() + if len(parts) != 2: + print("Usage: set-yaw-zero ") + else: + try: + new_zero = float(parts[1]) + svc.config.antenna_yaw_zero_deg = new_zero + svc.reset_yaw_continuity() + print(f"Set yaw-zero to {new_zero:.2f}° and reset continuity.") + except ValueError: + print("Invalid number. Usage: set-yaw-zero ") + elif cmd == "controller": + _run_controller_mode(ant) + elif cmd == "calibrate": + cmd_calibrate(ant) + elif cmd == "engage": + cmd_engage(ant) + elif cmd == "disengage": + cmd_disengage(ant) + elif cmd == "": + continue + else: + print("Unknown command. Type 'help' for available commands.") + finally: + print("Shutting down...") + svc.stop() + # Ensure antenna is disengaged and set to idle before exit + if ant._engaged: + print("Disengaging antenna motors...") + ant.disengage() + print("Cleanup complete.") + + +if __name__ == "__main__": + main() diff --git a/apps/antenna-tracker/predictor_service.py b/apps/antenna-tracker/predictor_service.py new file mode 100755 index 0000000..7905d72 --- /dev/null +++ b/apps/antenna-tracker/predictor_service.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Prediction service for gps-ingest. + +Self-managed background process that ingests GPS samples (from the Rust gps-ingest +binary or from test coordinates), maintains a short history, and exposes the +latest GPS and a next-position prediction. Also provides geodesy helpers for +antenna pointing. +""" + +from __future__ import annotations + +import json +import math +import select +import shlex +import subprocess +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List, Optional, Tuple + +try: + import numpy as np +except Exception: # pragma: no cover - optional dependency + np = None # type: ignore + + +# ----------------------- +# Timestamp helper +# ----------------------- +def _parse_iso_to_epoch(ts_iso: str) -> float: + """Parse ISO-8601 string to UNIX epoch seconds. Handles 'Z' and '+00:00'.""" + return datetime.fromisoformat(ts_iso.replace('Z', '+00:00')).timestamp() + + +# ----------------------- +# Configuration and Models +# ----------------------- + +@dataclass +class PredictorConfig: + # Antenna site + antenna_lat_deg: float = 48.480495 + antenna_lon_deg: float = -81.331258 + antenna_alt_m: float = 294.04 + antenna_yaw_zero_deg: float = 270.0 # yaw-zero = West + yaw_normalization: str = "continuous" # options: "continuous", "pm180", "0to360" + # Debug flag to silence prints in tests unless enabled + debug: bool = False + + # Data source + use_real_gps_ingest: bool = False + gps_ingest_cmd: str = "cargo run -p gps-ingest" + test_target_lat_deg: float = 48.480345 + test_target_lon_deg: float = -81.331308 + test_target_alt_m: float = 299 + + # Prediction/history + num_samples: int = 1 # how many samples to keep (and fit). 1 means latest only + + +@dataclass +class GpsSample: + lat: float + lon: float + alt_m: float + ts_iso: str + source: str + t_epoch: float + + +@dataclass +class Prediction: + lat: float + lon: float + alt_m: float + confidence: float + ts_iso: str + + +# ----------------------- +# Prediction Service +# ----------------------- + +class PredictorService: + def __init__(self, config: PredictorConfig): + self.config = config + self._history: List[GpsSample] = [] + self._history_lock = threading.Lock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._last_prediction: Optional[Prediction] = None + self._last_yaw_cmd: Optional[float] = None + + # ---- Public API ---- + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._run_loop, name="PredictorService", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=2) + + def get_history(self) -> List[GpsSample]: + with self._history_lock: + return list(self._history) + + def get_latest(self) -> Optional[GpsSample]: + with self._history_lock: + return self._history[-1] if self._history else None + + def get_prediction(self) -> Optional[Prediction]: + return self._last_prediction + + # ---- Yaw normalization helpers ---- + @staticmethod + def _normalize_0_360(deg: float) -> float: + return (deg % 360.0 + 360.0) % 360.0 + + @staticmethod + def _normalize_pm180(deg: float) -> float: + d = PredictorService._normalize_0_360(deg) + return d - 360.0 if d > 180.0 else d + + def _rotation_from_yaw_zero_basic(self, bearing_deg: float) -> float: + """Yaw relative to yaw-zero (single-turn), honoring config.yaw_normalization (0to360 or pm180).""" + raw = bearing_deg - self.config.antenna_yaw_zero_deg + if self.config.yaw_normalization == "0to360": + return PredictorService._normalize_0_360(raw) + # default for 'pm180' or anything else non-continuous + return PredictorService._normalize_pm180(raw) + + def rotation_from_yaw_zero_continuous(self, bearing_deg: float, ref_yaw_deg: float) -> float: + """ + Map desired yaw (bearing - yaw_zero) to nearest 360°-equivalent near ref_yaw_deg. + Enables multi-turn continuity (no wrap jumps). + """ + desired_mod = PredictorService._normalize_0_360(bearing_deg - self.config.antenna_yaw_zero_deg) + ref_mod = PredictorService._normalize_0_360(ref_yaw_deg) + delta = PredictorService._normalize_pm180(desired_mod - ref_mod) # shortest delta in [-180, 180] + return ref_yaw_deg + delta + + # ---- Internal loop ---- + def _run_loop(self) -> None: + if self.config.use_real_gps_ingest: + self._loop_real() + else: + self._loop_test() + + def _loop_test(self) -> None: + # Emit the same test coordinate repeatedly, no timeout + while not self._stop.is_set(): + now = datetime.now(timezone.utc).isoformat() + sample = GpsSample( + lat=self.config.test_target_lat_deg, + lon=self.config.test_target_lon_deg, + alt_m=self.config.test_target_alt_m, + ts_iso=now, + source="test", + t_epoch=time.time(), + ) + self._push_sample(sample) + self._compute_prediction() + self._stop.wait(0.5) + + def _loop_real(self) -> None: + # Spawn gps-ingest and stream lines indefinitely + while not self._stop.is_set(): + try: + cmd = shlex.split(self.config.gps_ingest_cmd) + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except Exception: + # Backoff if launching fails + self._stop.wait(1.0) + continue + + try: + assert proc.stdout is not None + while not self._stop.is_set(): + rlist, _, _ = select.select([proc.stdout], [], [], 0.5) + if not rlist: + if proc.poll() is not None: + break + continue + line = proc.stdout.readline() + if not line: + if proc.poll() is not None: + break + continue + line = line.strip() + if not (line.startswith("{") and line.endswith("}")): + continue + try: + data = json.loads(line) + except Exception: + continue + if ( + data.get("lat") is None + or data.get("lon") is None + or data.get("altitude_m") is None + or data.get("ts") is None + ): + continue + # Prefer GPS-provided timestamp to improve model robustness + try: + t_epoch_parsed = _parse_iso_to_epoch(str(data["ts"])) + except Exception: + t_epoch_parsed = time.time() + sample = GpsSample( + lat=float(data["lat"]), + lon=float(data["lon"]), + alt_m=float(data["altitude_m"]), + ts_iso=str(data["ts"]), + source=str(data.get("source", "gps-ingest")), + t_epoch=t_epoch_parsed, + ) + self._push_sample(sample) + self._compute_prediction() + finally: + try: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1) + except subprocess.TimeoutExpired: + proc.kill() + except Exception: + pass + + def _push_sample(self, sample: GpsSample) -> None: + with self._history_lock: + self._history.append(sample) + if self.config.num_samples > 0 and len(self._history) > self.config.num_samples: + self._history = self._history[-self.config.num_samples :] + + # ----------------------- + # Prediction and Geodesy + # ----------------------- + def _compute_prediction(self) -> None: + hist = self.get_history() + if not hist: + self._last_prediction = None + return + if len(hist) == 1 or np is None: + s = hist[-1] + self._last_prediction = Prediction( + lat=s.lat, + lon=s.lon, + alt_m=s.alt_m, + confidence=0.5, + ts_iso=datetime.now(timezone.utc).isoformat(), + ) + return + # Fit linear model per component + times = [s.t_epoch for s in hist] + lats = [s.lat for s in hist] + lons = [s.lon for s in hist] + alts = [s.alt_m for s in hist] + t0 = times[0] + t_rel = [t - t0 for t in times] + t_future = (times[-1] + 1.0) - t0 + try: + lat_slope, lat_intercept = np.polyfit(t_rel, lats, 1) # type: ignore[attr-defined] + lon_slope, lon_intercept = np.polyfit(t_rel, lons, 1) # type: ignore[attr-defined] + alt_slope, alt_intercept = np.polyfit(t_rel, alts, 1) # type: ignore[attr-defined] + pred_lat = float(lat_slope * t_future + lat_intercept) + pred_lon = float(lon_slope * t_future + lon_intercept) + pred_alt = float(alt_slope * t_future + alt_intercept) + confidence = 0.9 if len(hist) >= 5 else 0.7 + except Exception: + s = hist[-1] + pred_lat, pred_lon, pred_alt, confidence = s.lat, s.lon, s.alt_m, 0.5 + self._last_prediction = Prediction( + lat=pred_lat, + lon=pred_lon, + alt_m=pred_alt, + confidence=confidence, + ts_iso=datetime.now(timezone.utc).isoformat(), + ) + + # WGS84 constants + _WGS84_A = 6378137.0 + _WGS84_F = 1.0 / 298.257223563 + _WGS84_E2 = _WGS84_F * (2.0 - _WGS84_F) + + # Geodesy helpers + @staticmethod + def _geodetic_to_ecef(lat_deg: float, lon_deg: float, h_m: float) -> Tuple[float, float, float]: + lat = math.radians(lat_deg) + lon = math.radians(lon_deg) + sin_lat = math.sin(lat) + cos_lat = math.cos(lat) + sin_lon = math.sin(lon) + cos_lon = math.cos(lon) + N = PredictorService._WGS84_A / math.sqrt(1.0 - PredictorService._WGS84_E2 * sin_lat * sin_lat) + x = (N + h_m) * cos_lat * cos_lon + y = (N + h_m) * cos_lat * sin_lon + z = (N * (1.0 - PredictorService._WGS84_E2) + h_m) * sin_lat + # Verbose print only when debug enabled + # Note: staticmethod, cannot access self.config; keep quiet by default + # If needed, enable prints in callers + # print("_geodetic_to_ecef", "in:", lat_deg, lon_deg, h_m, "\n", "out:", x, y, z) + return x, y, z + + + @staticmethod + def _ecef_to_enu(dx: Tuple[float, float, float], lat0_deg: float, lon0_deg: float) -> Tuple[float, float, float]: + lat0 = math.radians(lat0_deg) + lon0 = math.radians(lon0_deg) + sin_lat = math.sin(lat0) + cos_lat = math.cos(lat0) + sin_lon = math.sin(lon0) + cos_lon = math.cos(lon0) + dx_x, dx_y, dx_z = dx + e = -sin_lon * dx_x + cos_lon * dx_y + n = -sin_lat * cos_lon * dx_x - sin_lat * sin_lon * dx_y + cos_lat * dx_z + u = cos_lat * cos_lon * dx_x + cos_lat * sin_lon * dx_y + sin_lat * dx_z + return e, n, u + + @staticmethod + def initial_bearing_deg(lat1_deg: float, lon1_deg: float, lat2_deg: float, lon2_deg: float) -> float: + lat1 = math.radians(lat1_deg) + lat2 = math.radians(lat2_deg) + dlon = math.radians(lon2_deg - lon1_deg) + x = math.sin(dlon) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) + brng = math.degrees(math.atan2(x, y)) + return PredictorService._normalize_0_360(brng) + + + + def compute_az_el(self, tgt_lat: float, tgt_lon: float, tgt_alt: float) -> Tuple[float, float]: + if self.config.debug: + print("Calculating antenna ecef") + x_o, y_o, z_o = self._geodetic_to_ecef( + self.config.antenna_lat_deg, self.config.antenna_lon_deg, self.config.antenna_alt_m + ) + if self.config.debug: + print("Calculating target ecef") + x_t, y_t, z_t = self._geodetic_to_ecef(tgt_lat, tgt_lon, tgt_alt) + e, n, u = self._ecef_to_enu((x_t - x_o, y_t - y_o, z_t - z_o), self.config.antenna_lat_deg, self.config.antenna_lon_deg) + az = math.degrees(math.atan2(e, n)) + if az < 0: + az += 360.0 + el = math.degrees(math.atan2(u, math.hypot(e, n))) + if self.config.debug: + print("Calculated AZ:", az) + print("Calculated EL:", el) + return az, el + + def yaw_pitch_from_target(self, tgt_lat: float, tgt_lon: float, tgt_alt: float) -> Tuple[float, float]: + """ + Compute yaw (relative to yaw-zero) and pitch. + Modes: + - continuous: unwrap around last commanded yaw + - pm180 / 0to360: single-turn normalized output + Updates self._last_yaw_cmd for continuity. + """ + if self.config.debug: + print("Calculating Yaw pitch from target") + az, el = self.compute_az_el(tgt_lat, tgt_lon, tgt_alt) # az from North + + if self.config.yaw_normalization == "continuous" and self._last_yaw_cmd is not None: + yaw_rel = self.rotation_from_yaw_zero_continuous(az, self._last_yaw_cmd) + elif self.config.yaw_normalization == "continuous": + # first command in continuous mode: seed using pm180 for a near value + yaw_rel = self._rotation_from_yaw_zero_basic(az) + else: + yaw_rel = self._rotation_from_yaw_zero_basic(az) + + # Remember last commanded yaw for continuity + self._last_yaw_cmd = yaw_rel + return yaw_rel, el + + def reset_yaw_continuity(self) -> None: + """Reset the last yaw command to seed continuous unwrapping fresh.""" + self._last_yaw_cmd = None diff --git a/apps/antenna-tracker/pyproject.toml b/apps/antenna-tracker/pyproject.toml new file mode 100644 index 0000000..5531232 --- /dev/null +++ b/apps/antenna-tracker/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "gps-ingest" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "numpy>=2.3.2", + "odrive>=0.6.10.post0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", +] diff --git a/apps/antenna-tracker/remote.py b/apps/antenna-tracker/remote.py new file mode 100755 index 0000000..3887537 --- /dev/null +++ b/apps/antenna-tracker/remote.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +import errno +import fcntl +import os +import struct +import threading +import time +from typing import Dict, Optional + + +EVENT_FMT = 'IhBB' +EVENT_SIZE = struct.calcsize(EVENT_FMT) + +JS_EVENT_BUTTON = 0x01 +JS_EVENT_AXIS = 0x02 +JS_EVENT_INIT = 0x80 + + +BUTTON_INDEX_TO_NAME: Dict[int, str] = { + 0: 'A', 1: 'B', 2: 'X', 3: 'Y', + 4: 'LB', 5: 'RB', 6: 'Back', 7: 'Start', + 8: 'Guide', 9: 'LThumb', 10: 'RThumb', +} + +AXIS_INDEX_TO_NAME: Dict[int, str] = { + 0: 'LX', 1: 'LY', 2: 'LT', + 3: 'RX', 4: 'RY', 5: 'RT', +} + +BUTTON_NAME_TO_INDEX: Dict[str, int] = {v: k for k, v in BUTTON_INDEX_TO_NAME.items()} +AXIS_NAME_TO_INDEX: Dict[str, int] = {v: k for k, v in AXIS_INDEX_TO_NAME.items()} + + +def _normalize_axis(value: int) -> float: + if value <= -32767: + return -1.0 + if value >= 32767: + return 1.0 + return value / 32767.0 + + +class XboxController: + """Simple Linux joystick reader for Xbox controllers using /dev/input/js*. + + Exposes a minimal API expected by manual.py: + - start() -> bool + - stop() -> None + - is_connected() -> bool + - get_button(name: str) -> bool + - get_axis(name: str) -> float in [-1.0, 1.0] + """ + + def __init__(self, deadzone: float = 0.1, device_path: str = '/dev/input/js0') -> None: + self.device_path: str = device_path + self.deadzone: float = max(0.0, min(0.9, deadzone)) + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._connected: bool = False + self._fd: Optional[int] = None + self._lock = threading.Lock() + self._buttons: Dict[str, bool] = {name: False for name in BUTTON_NAME_TO_INDEX.keys()} + self._axes: Dict[str, float] = {name: 0.0 for name in AXIS_NAME_TO_INDEX.keys()} + + def start(self) -> bool: + if self._thread and self._thread.is_alive(): + return True + self._stop_event.clear() + self._thread = threading.Thread(target=self._reader_loop, name='XboxControllerReader', daemon=True) + self._thread.start() + # Give the thread a short time to attempt initial open + time.sleep(0.1) + return True + + def stop(self) -> None: + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.5) + self._close_device() + + def is_connected(self) -> bool: + return self._connected + + def get_button(self, name: str) -> bool: + with self._lock: + return bool(self._buttons.get(name, False)) + + def get_axis(self, name: str) -> float: + with self._lock: + value = float(self._axes.get(name, 0.0)) + if abs(value) < self.deadzone: + return 0.0 + return value + + # Internal + def _open_device(self) -> None: + try: + fd = os.open(self.device_path, os.O_RDONLY | os.O_NONBLOCK) + # Ensure non-blocking + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + self._fd = fd + self._connected = True + except FileNotFoundError: + self._connected = False + except PermissionError: + self._connected = False + + def _close_device(self) -> None: + if self._fd is not None: + try: + os.close(self._fd) + except OSError: + pass + finally: + self._fd = None + self._connected = False + + def _reader_loop(self) -> None: + last_attempt = 0.0 + while not self._stop_event.is_set(): + # Ensure device is open + if self._fd is None: + now = time.time() + if now - last_attempt < 0.5: + time.sleep(0.05) + continue + last_attempt = now + self._open_device() + if self._fd is None: + time.sleep(0.3) + continue + + # Read any available events + try: + data = os.read(self._fd, EVENT_SIZE) + if not data: + time.sleep(0.01) + continue + if len(data) < EVENT_SIZE: + continue + timestamp, value, ev_type, number = struct.unpack(EVENT_FMT, data) + if ev_type & JS_EVENT_INIT: + continue + if ev_type & JS_EVENT_BUTTON: + name = BUTTON_INDEX_TO_NAME.get(number) + if name is not None: + with self._lock: + self._buttons[name] = (value != 0) + elif ev_type & JS_EVENT_AXIS: + name = AXIS_INDEX_TO_NAME.get(number) + if name is not None: + norm = _normalize_axis(value) + with self._lock: + self._axes[name] = norm + # Gentle yield + time.sleep(0.001) + except BlockingIOError: + time.sleep(0.01) + continue + except OSError as e: + if e.errno == errno.ENODEV: + # Device disconnected + self._close_device() + time.sleep(0.3) + continue + # Other I/O error: reset and retry + self._close_device() + time.sleep(0.2) + continue diff --git a/apps/antenna-tracker/rocket_gps.jsonl b/apps/antenna-tracker/rocket_gps.jsonl new file mode 100644 index 0000000..973fe37 --- /dev/null +++ b/apps/antenna-tracker/rocket_gps.jsonl @@ -0,0 +1,1610 @@ +{"ts": "2025-08-20T02:28:49.973597+00:00", "lat": 48.4807521, "lon": -81.3332565, "altitude_m": 295.04, "source": "sim"} +{"ts": "2025-08-20T02:28:50.123597+00:00", "lat": 48.4807175, "lon": -81.3332573, "altitude_m": 298.27, "source": "sim"} +{"ts": "2025-08-20T02:28:50.273597+00:00", "lat": 48.4807133, "lon": -81.3332919, "altitude_m": 299.12, "source": "sim"} +{"ts": "2025-08-20T02:28:50.373597+00:00", "lat": 48.4807306, "lon": -81.3332501, "altitude_m": 294.56, "source": "sim"} +{"ts": "2025-08-20T02:28:50.473597+00:00", "lat": 48.4807256, "lon": -81.33327, "altitude_m": 289.83, "source": "sim"} +{"ts": "2025-08-20T02:28:50.573597+00:00", "lat": 48.4807186, "lon": -81.3332964, "altitude_m": 299.77, "source": "sim"} +{"ts": "2025-08-20T02:28:50.723597+00:00", "lat": 48.4807281, "lon": -81.3332797, "altitude_m": 297.05, "source": "sim"} +{"ts": "2025-08-20T02:28:50.873597+00:00", "lat": 48.4807118, "lon": -81.3332413, "altitude_m": 300.55, "source": "sim"} +{"ts": "2025-08-20T02:28:51.023597+00:00", "lat": 48.4807359, "lon": -81.3333085, "altitude_m": 301.05, "source": "sim"} +{"ts": "2025-08-20T02:28:51.173597+00:00", "lat": 48.4807435, "lon": -81.3333064, "altitude_m": 302.9, "source": "sim"} +{"ts": "2025-08-20T02:28:51.273597+00:00", "lat": 48.480733, "lon": -81.3332965, "altitude_m": 302.4, "source": "sim"} +{"ts": "2025-08-20T02:28:51.373597+00:00", "lat": 48.4807054, "lon": -81.3332757, "altitude_m": 307.75, "source": "sim"} +{"ts": "2025-08-20T02:28:51.473597+00:00", "lat": 48.4807059, "lon": -81.3332653, "altitude_m": 301.56, "source": "sim"} +{"ts": "2025-08-20T02:28:51.623597+00:00", "lat": 48.4807294, "lon": -81.3332908, "altitude_m": 311.3, "source": "sim"} +{"ts": "2025-08-20T02:28:51.773597+00:00", "lat": 48.4807231, "lon": -81.3333047, "altitude_m": 315.64, "source": "sim"} +{"ts": "2025-08-20T02:28:51.923597+00:00", "lat": 48.4807212, "lon": -81.3333072, "altitude_m": 325.85, "source": "sim"} +{"ts": "2025-08-20T02:28:52.073597+00:00", "lat": 48.4807285, "lon": -81.3333269, "altitude_m": 326.55, "source": "sim"} +{"ts": "2025-08-20T02:28:52.173597+00:00", "lat": 48.4807347, "lon": -81.3333125, "altitude_m": 322.25, "source": "sim"} +{"ts": "2025-08-20T02:28:52.323597+00:00", "lat": 48.480735, "lon": -81.3333306, "altitude_m": 330.55, "source": "sim"} +{"ts": "2025-08-20T02:28:52.473597+00:00", "lat": 48.4807478, "lon": -81.3333123, "altitude_m": 334.76, "source": "sim"} +{"ts": "2025-08-20T02:28:52.623597+00:00", "lat": 48.480743, "lon": -81.3333075, "altitude_m": 334.79, "source": "sim"} +{"ts": "2025-08-20T02:28:52.723597+00:00", "lat": 48.4807322, "lon": -81.3332821, "altitude_m": 339.94, "source": "sim"} +{"ts": "2025-08-20T02:28:52.873597+00:00", "lat": 48.4807441, "lon": -81.3333675, "altitude_m": 339.1, "source": "sim"} +{"ts": "2025-08-20T02:28:53.023597+00:00", "lat": 48.4807623, "lon": -81.3333182, "altitude_m": 358.09, "source": "sim"} +{"ts": "2025-08-20T02:28:53.223597+00:00", "lat": 48.4807567, "lon": -81.3332952, "altitude_m": 361.54, "source": "sim"} +{"ts": "2025-08-20T02:28:53.373597+00:00", "lat": 48.4807333, "lon": -81.3333407, "altitude_m": 365.59, "source": "sim"} +{"ts": "2025-08-20T02:28:53.473597+00:00", "lat": 48.4807776, "lon": -81.3333462, "altitude_m": 370.52, "source": "sim"} +{"ts": "2025-08-20T02:28:53.623597+00:00", "lat": 48.4807574, "lon": -81.333375, "altitude_m": 376.29, "source": "sim"} +{"ts": "2025-08-20T02:28:53.723597+00:00", "lat": 48.4807623, "lon": -81.3333418, "altitude_m": 379.34, "source": "sim"} +{"ts": "2025-08-20T02:28:53.823597+00:00", "lat": 48.4807518, "lon": -81.3333803, "altitude_m": 386.43, "source": "sim"} +{"ts": "2025-08-20T02:28:53.923597+00:00", "lat": 48.4807686, "lon": -81.3333696, "altitude_m": 392.26, "source": "sim"} +{"ts": "2025-08-20T02:28:54.073597+00:00", "lat": 48.4807228, "lon": -81.3334056, "altitude_m": 403.46, "source": "sim"} +{"ts": "2025-08-20T02:28:54.173597+00:00", "lat": 48.4807539, "lon": -81.333349, "altitude_m": 400.86, "source": "sim"} +{"ts": "2025-08-20T02:28:54.323597+00:00", "lat": 48.4807442, "lon": -81.3333668, "altitude_m": 412.54, "source": "sim"} +{"ts": "2025-08-20T02:28:54.473597+00:00", "lat": 48.4807409, "lon": -81.3333655, "altitude_m": 425.79, "source": "sim"} +{"ts": "2025-08-20T02:28:54.623597+00:00", "lat": 48.4807542, "lon": -81.3334099, "altitude_m": 423.41, "source": "sim"} +{"ts": "2025-08-20T02:28:54.773597+00:00", "lat": 48.4807451, "lon": -81.3334024, "altitude_m": 436.46, "source": "sim"} +{"ts": "2025-08-20T02:28:54.873597+00:00", "lat": 48.4807428, "lon": -81.3333786, "altitude_m": 445.78, "source": "sim"} +{"ts": "2025-08-20T02:28:54.973597+00:00", "lat": 48.4807499, "lon": -81.3333767, "altitude_m": 446.11, "source": "sim"} +{"ts": "2025-08-20T02:28:55.123597+00:00", "lat": 48.4807591, "lon": -81.3334038, "altitude_m": 454.16, "source": "sim"} +{"ts": "2025-08-20T02:28:55.223597+00:00", "lat": 48.4807534, "lon": -81.3334022, "altitude_m": 462.4, "source": "sim"} +{"ts": "2025-08-20T02:28:55.323597+00:00", "lat": 48.4807533, "lon": -81.3334015, "altitude_m": 466.02, "source": "sim"} +{"ts": "2025-08-20T02:28:55.473597+00:00", "lat": 48.4807618, "lon": -81.3333839, "altitude_m": 475.17, "source": "sim"} +{"ts": "2025-08-20T02:28:55.573597+00:00", "lat": 48.4807649, "lon": -81.3334339, "altitude_m": 486.53, "source": "sim"} +{"ts": "2025-08-20T02:28:55.673597+00:00", "lat": 48.4807623, "lon": -81.3334445, "altitude_m": 494.74, "source": "sim"} +{"ts": "2025-08-20T02:28:55.823597+00:00", "lat": 48.4807722, "lon": -81.3334305, "altitude_m": 509.43, "source": "sim"} +{"ts": "2025-08-20T02:28:55.923597+00:00", "lat": 48.4807598, "lon": -81.3334025, "altitude_m": 510.22, "source": "sim"} +{"ts": "2025-08-20T02:28:56.023597+00:00", "lat": 48.4807636, "lon": -81.3334335, "altitude_m": 516.3, "source": "sim"} +{"ts": "2025-08-20T02:28:56.173597+00:00", "lat": 48.4807659, "lon": -81.3334138, "altitude_m": 526.95, "source": "sim"} +{"ts": "2025-08-20T02:28:56.273597+00:00", "lat": 48.480744, "lon": -81.3333719, "altitude_m": 536.12, "source": "sim"} +{"ts": "2025-08-20T02:28:56.373597+00:00", "lat": 48.4807402, "lon": -81.3334158, "altitude_m": 550.27, "source": "sim"} +{"ts": "2025-08-20T02:28:56.473597+00:00", "lat": 48.4807748, "lon": -81.3334393, "altitude_m": 557.64, "source": "sim"} +{"ts": "2025-08-20T02:28:56.573597+00:00", "lat": 48.4807498, "lon": -81.3334359, "altitude_m": 563.04, "source": "sim"} +{"ts": "2025-08-20T02:28:56.773597+00:00", "lat": 48.4807635, "lon": -81.3334563, "altitude_m": 575.66, "source": "sim"} +{"ts": "2025-08-20T02:28:56.873597+00:00", "lat": 48.4807584, "lon": -81.3334415, "altitude_m": 587.1, "source": "sim"} +{"ts": "2025-08-20T02:28:56.973597+00:00", "lat": 48.4807806, "lon": -81.3334339, "altitude_m": 596.49, "source": "sim"} +{"ts": "2025-08-20T02:28:57.073597+00:00", "lat": 48.4807556, "lon": -81.3334475, "altitude_m": 597.86, "source": "sim"} +{"ts": "2025-08-20T02:28:57.223597+00:00", "lat": 48.4807735, "lon": -81.3334422, "altitude_m": 621.18, "source": "sim"} +{"ts": "2025-08-20T02:28:57.323597+00:00", "lat": 48.4807559, "lon": -81.3334814, "altitude_m": 626.79, "source": "sim"} +{"ts": "2025-08-20T02:28:57.473597+00:00", "lat": 48.4807613, "lon": -81.333469, "altitude_m": 642.88, "source": "sim"} +{"ts": "2025-08-20T02:28:57.623597+00:00", "lat": 48.4807489, "lon": -81.3334693, "altitude_m": 655.26, "source": "sim"} +{"ts": "2025-08-20T02:28:57.773597+00:00", "lat": 48.4807712, "lon": -81.3334577, "altitude_m": 668.26, "source": "sim"} +{"ts": "2025-08-20T02:28:57.923597+00:00", "lat": 48.4807519, "lon": -81.3334532, "altitude_m": 681.4, "source": "sim"} +{"ts": "2025-08-20T02:28:58.023597+00:00", "lat": 48.4807622, "lon": -81.3334605, "altitude_m": 689.23, "source": "sim"} +{"ts": "2025-08-20T02:28:58.123597+00:00", "lat": 48.4807513, "lon": -81.3334976, "altitude_m": 700.88, "source": "sim"} +{"ts": "2025-08-20T02:28:58.273597+00:00", "lat": 48.4807685, "lon": -81.3334692, "altitude_m": 717.55, "source": "sim"} +{"ts": "2025-08-20T02:28:58.373597+00:00", "lat": 48.4807847, "lon": -81.333489, "altitude_m": 722.68, "source": "sim"} +{"ts": "2025-08-20T02:28:58.473597+00:00", "lat": 48.4807884, "lon": -81.3335044, "altitude_m": 740.65, "source": "sim"} +{"ts": "2025-08-20T02:28:58.623597+00:00", "lat": 48.4807719, "lon": -81.3334849, "altitude_m": 756.42, "source": "sim"} +{"ts": "2025-08-20T02:28:58.723597+00:00", "lat": 48.4807355, "lon": -81.3334878, "altitude_m": 764.45, "source": "sim"} +{"ts": "2025-08-20T02:28:58.873597+00:00", "lat": 48.4807928, "lon": -81.3334936, "altitude_m": 784.61, "source": "sim"} +{"ts": "2025-08-20T02:28:59.073597+00:00", "lat": 48.4807501, "lon": -81.3335056, "altitude_m": 806.7, "source": "sim"} +{"ts": "2025-08-20T02:28:59.173597+00:00", "lat": 48.4807714, "lon": -81.3335317, "altitude_m": 810.81, "source": "sim"} +{"ts": "2025-08-20T02:28:59.273597+00:00", "lat": 48.4807645, "lon": -81.3335124, "altitude_m": 827.75, "source": "sim"} +{"ts": "2025-08-20T02:28:59.423597+00:00", "lat": 48.4807484, "lon": -81.3334945, "altitude_m": 842.49, "source": "sim"} +{"ts": "2025-08-20T02:28:59.523597+00:00", "lat": 48.4807815, "lon": -81.3335111, "altitude_m": 854.37, "source": "sim"} +{"ts": "2025-08-20T02:28:59.623597+00:00", "lat": 48.480793, "lon": -81.3335151, "altitude_m": 866.52, "source": "sim"} +{"ts": "2025-08-20T02:28:59.723597+00:00", "lat": 48.4807787, "lon": -81.3335224, "altitude_m": 877.55, "source": "sim"} +{"ts": "2025-08-20T02:28:59.823597+00:00", "lat": 48.4807762, "lon": -81.3335081, "altitude_m": 891.7, "source": "sim"} +{"ts": "2025-08-20T02:28:59.923597+00:00", "lat": 48.4807855, "lon": -81.3335131, "altitude_m": 900.99, "source": "sim"} +{"ts": "2025-08-20T02:29:00.023597+00:00", "lat": 48.4807959, "lon": -81.3335439, "altitude_m": 915.71, "source": "sim"} +{"ts": "2025-08-20T02:29:00.173597+00:00", "lat": 48.4807767, "lon": -81.3335358, "altitude_m": 931.05, "source": "sim"} +{"ts": "2025-08-20T02:29:00.323597+00:00", "lat": 48.4807565, "lon": -81.3335522, "altitude_m": 952.83, "source": "sim"} +{"ts": "2025-08-20T02:29:00.473597+00:00", "lat": 48.4807682, "lon": -81.3335421, "altitude_m": 971.8, "source": "sim"} +{"ts": "2025-08-20T02:29:00.573597+00:00", "lat": 48.480776, "lon": -81.3335451, "altitude_m": 984.7, "source": "sim"} +{"ts": "2025-08-20T02:29:00.723597+00:00", "lat": 48.4807568, "lon": -81.3335741, "altitude_m": 1010.67, "source": "sim"} +{"ts": "2025-08-20T02:29:00.823597+00:00", "lat": 48.4807865, "lon": -81.333542, "altitude_m": 1012.09, "source": "sim"} +{"ts": "2025-08-20T02:29:00.973597+00:00", "lat": 48.4807783, "lon": -81.3335646, "altitude_m": 1036.56, "source": "sim"} +{"ts": "2025-08-20T02:29:01.123597+00:00", "lat": 48.4807734, "lon": -81.3335821, "altitude_m": 1060.76, "source": "sim"} +{"ts": "2025-08-20T02:29:01.273597+00:00", "lat": 48.4807699, "lon": -81.3335495, "altitude_m": 1078.54, "source": "sim"} +{"ts": "2025-08-20T02:29:01.373597+00:00", "lat": 48.4807904, "lon": -81.3335724, "altitude_m": 1096.25, "source": "sim"} +{"ts": "2025-08-20T02:29:01.473597+00:00", "lat": 48.4808003, "lon": -81.3335551, "altitude_m": 1109.21, "source": "sim"} +{"ts": "2025-08-20T02:29:01.573597+00:00", "lat": 48.4807673, "lon": -81.3335851, "altitude_m": 1120.95, "source": "sim"} +{"ts": "2025-08-20T02:29:01.673597+00:00", "lat": 48.4808071, "lon": -81.3335649, "altitude_m": 1138.5, "source": "sim"} +{"ts": "2025-08-20T02:29:01.823597+00:00", "lat": 48.480753, "lon": -81.3335787, "altitude_m": 1160.04, "source": "sim"} +{"ts": "2025-08-20T02:29:01.973597+00:00", "lat": 48.4807827, "lon": -81.3335537, "altitude_m": 1174.7, "source": "sim"} +{"ts": "2025-08-20T02:29:02.173597+00:00", "lat": 48.4807571, "lon": -81.3336072, "altitude_m": 1211.85, "source": "sim"} +{"ts": "2025-08-20T02:29:02.323597+00:00", "lat": 48.4808041, "lon": -81.3336092, "altitude_m": 1234.78, "source": "sim"} +{"ts": "2025-08-20T02:29:02.423597+00:00", "lat": 48.4807728, "lon": -81.3336347, "altitude_m": 1248.48, "source": "sim"} +{"ts": "2025-08-20T02:29:02.573597+00:00", "lat": 48.4807821, "lon": -81.3335706, "altitude_m": 1273.78, "source": "sim"} +{"ts": "2025-08-20T02:29:03.023597+00:00", "lat": 48.4808, "lon": -81.3336008, "altitude_m": 1337.56, "source": "sim"} +{"ts": "2025-08-20T02:29:03.623597+00:00", "lat": 48.4807875, "lon": -81.3336253, "altitude_m": 1437.84, "source": "sim"} +{"ts": "2025-08-20T02:29:04.073597+00:00", "lat": 48.4808085, "lon": -81.3336667, "altitude_m": 1521.62, "source": "sim"} +{"ts": "2025-08-20T02:29:04.623597+00:00", "lat": 48.4807731, "lon": -81.3336648, "altitude_m": 1612.3, "source": "sim"} +{"ts": "2025-08-20T02:29:05.123597+00:00", "lat": 48.4807664, "lon": -81.3336927, "altitude_m": 1708.83, "source": "sim"} +{"ts": "2025-08-20T02:29:05.623597+00:00", "lat": 48.4808035, "lon": -81.3336744, "altitude_m": 1802.26, "source": "sim"} +{"ts": "2025-08-20T02:29:06.173597+00:00", "lat": 48.4808028, "lon": -81.3336911, "altitude_m": 1910.13, "source": "sim"} +{"ts": "2025-08-20T02:29:06.723597+00:00", "lat": 48.480781, "lon": -81.3337092, "altitude_m": 2023.6, "source": "sim"} +{"ts": "2025-08-20T02:29:07.173597+00:00", "lat": 48.4808049, "lon": -81.3337111, "altitude_m": 2112.46, "source": "sim"} +{"ts": "2025-08-20T02:29:07.673597+00:00", "lat": 48.4808343, "lon": -81.3337098, "altitude_m": 2222.23, "source": "sim"} +{"ts": "2025-08-20T02:29:08.123597+00:00", "lat": 48.4808201, "lon": -81.3337606, "altitude_m": 2320.94, "source": "sim"} +{"ts": "2025-08-20T02:29:08.523597+00:00", "lat": 48.4808205, "lon": -81.3337746, "altitude_m": 2413.39, "source": "sim"} +{"ts": "2025-08-20T02:29:08.923597+00:00", "lat": 48.4808283, "lon": -81.333752, "altitude_m": 2514.58, "source": "sim"} +{"ts": "2025-08-20T02:29:09.323597+00:00", "lat": 48.4808233, "lon": -81.3338084, "altitude_m": 2598.79, "source": "sim"} +{"ts": "2025-08-20T02:29:23.473597+00:00", "lat": 48.4808396, "lon": -81.3342147, "altitude_m": 7207.34, "source": "sim"} +{"ts": "2025-08-20T02:29:24.323597+00:00", "lat": 48.4808961, "lon": -81.3342511, "altitude_m": 7558.72, "source": "sim"} +{"ts": "2025-08-20T02:29:25.323597+00:00", "lat": 48.4809117, "lon": -81.3341711, "altitude_m": 7979.43, "source": "sim"} +{"ts": "2025-08-20T02:29:26.273597+00:00", "lat": 48.4808862, "lon": -81.3342181, "altitude_m": 8404.58, "source": "sim"} +{"ts": "2025-08-20T02:29:27.073597+00:00", "lat": 48.4809003, "lon": -81.3342149, "altitude_m": 8777.02, "source": "sim"} +{"ts": "2025-08-20T02:29:27.973597+00:00", "lat": 48.4809053, "lon": -81.3342628, "altitude_m": 9191.16, "source": "sim"} +{"ts": "2025-08-20T02:29:28.823597+00:00", "lat": 48.4809086, "lon": -81.3343144, "altitude_m": 9583.03, "source": "sim"} +{"ts": "2025-08-20T02:29:29.723597+00:00", "lat": 48.4809098, "lon": -81.3343142, "altitude_m": 10024.75, "source": "sim"} +{"ts": "2025-08-20T02:29:30.773597+00:00", "lat": 48.4808903, "lon": -81.3343151, "altitude_m": 10555.61, "source": "sim"} +{"ts": "2025-08-20T02:29:31.973597+00:00", "lat": 48.4809077, "lon": -81.3344127, "altitude_m": 11148.0, "source": "sim"} +{"ts": "2025-08-20T02:29:33.073597+00:00", "lat": 48.4809433, "lon": -81.3344128, "altitude_m": 11726.09, "source": "sim"} +{"ts": "2025-08-20T02:29:34.073597+00:00", "lat": 48.4809435, "lon": -81.3345427, "altitude_m": 12278.39, "source": "sim"} +{"ts": "2025-08-20T02:29:35.123597+00:00", "lat": 48.4808456, "lon": -81.3344256, "altitude_m": 12854.59, "source": "sim"} +{"ts": "2025-08-20T02:29:36.223597+00:00", "lat": 48.4809602, "lon": -81.334492, "altitude_m": 13460.1, "source": "sim"} +{"ts": "2025-08-20T02:29:37.073597+00:00", "lat": 48.4808911, "lon": -81.3345532, "altitude_m": 13952.67, "source": "sim"} +{"ts": "2025-08-20T02:29:38.073597+00:00", "lat": 48.4809426, "lon": -81.3346303, "altitude_m": 14543.81, "source": "sim"} +{"ts": "2025-08-20T02:29:38.923597+00:00", "lat": 48.4809915, "lon": -81.334638, "altitude_m": 15043.03, "source": "sim"} +{"ts": "2025-08-20T02:29:39.723597+00:00", "lat": 48.480994, "lon": -81.3346146, "altitude_m": 15529.14, "source": "sim"} +{"ts": "2025-08-20T02:29:40.573597+00:00", "lat": 48.4809496, "lon": -81.3346658, "altitude_m": 16056.15, "source": "sim"} +{"ts": "2025-08-20T02:29:41.623597+00:00", "lat": 48.4809339, "lon": -81.334692, "altitude_m": 16718.49, "source": "sim"} +{"ts": "2025-08-20T02:29:42.623597+00:00", "lat": 48.4809601, "lon": -81.334595, "altitude_m": 17370.36, "source": "sim"} +{"ts": "2025-08-20T02:29:43.773597+00:00", "lat": 48.4810107, "lon": -81.3346498, "altitude_m": 18110.85, "source": "sim"} +{"ts": "2025-08-20T02:29:44.573597+00:00", "lat": 48.4810235, "lon": -81.3348455, "altitude_m": 18656.7, "source": "sim"} +{"ts": "2025-08-20T02:30:03.573597+00:00", "lat": 48.4811341, "lon": -81.3352175, "altitude_m": 31623.38, "source": "sim"} +{"ts": "2025-08-20T02:30:05.273597+00:00", "lat": 48.4810234, "lon": -81.3353448, "altitude_m": 32604.66, "source": "sim"} +{"ts": "2025-08-20T02:30:06.773597+00:00", "lat": 48.4811115, "lon": -81.3353628, "altitude_m": 33486.24, "source": "sim"} +{"ts": "2025-08-20T02:30:08.423597+00:00", "lat": 48.4810073, "lon": -81.3351791, "altitude_m": 34432.93, "source": "sim"} +{"ts": "2025-08-20T02:30:10.173597+00:00", "lat": 48.4810293, "lon": -81.3353665, "altitude_m": 35374.08, "source": "sim"} +{"ts": "2025-08-20T02:30:11.823597+00:00", "lat": 48.4810389, "lon": -81.3354057, "altitude_m": 36287.43, "source": "sim"} +{"ts": "2025-08-20T02:30:13.423597+00:00", "lat": 48.4811301, "lon": -81.3355352, "altitude_m": 37083.22, "source": "sim"} +{"ts": "2025-08-20T02:30:15.523597+00:00", "lat": 48.4810073, "lon": -81.3354908, "altitude_m": 38156.02, "source": "sim"} +{"ts": "2025-08-20T02:30:17.323597+00:00", "lat": 48.4810457, "lon": -81.3356258, "altitude_m": 39001.99, "source": "sim"} +{"ts": "2025-08-20T02:30:19.173597+00:00", "lat": 48.4812058, "lon": -81.3356726, "altitude_m": 39877.05, "source": "sim"} +{"ts": "2025-08-20T02:30:21.073597+00:00", "lat": 48.4810958, "lon": -81.3357738, "altitude_m": 40679.46, "source": "sim"} +{"ts": "2025-08-20T02:30:22.523597+00:00", "lat": 48.4811491, "lon": -81.3358417, "altitude_m": 41325.13, "source": "sim"} +{"ts": "2025-08-20T02:30:24.223597+00:00", "lat": 48.4812229, "lon": -81.3358834, "altitude_m": 42011.81, "source": "sim"} +{"ts": "2025-08-20T02:30:25.773597+00:00", "lat": 48.4811198, "lon": -81.3358197, "altitude_m": 42622.73, "source": "sim"} +{"ts": "2025-08-20T02:30:27.273597+00:00", "lat": 48.481167, "lon": -81.3358095, "altitude_m": 43195.01, "source": "sim"} +{"ts": "2025-08-20T02:30:29.273597+00:00", "lat": 48.481121, "lon": -81.3360102, "altitude_m": 43942.25, "source": "sim"} +{"ts": "2025-08-20T02:30:30.923597+00:00", "lat": 48.481184, "lon": -81.3361667, "altitude_m": 44498.01, "source": "sim"} +{"ts": "2025-08-20T02:30:32.523597+00:00", "lat": 48.4811153, "lon": -81.3359987, "altitude_m": 45030.57, "source": "sim"} +{"ts": "2025-08-20T02:30:34.373597+00:00", "lat": 48.4813449, "lon": -81.3360455, "altitude_m": 45617.54, "source": "sim"} +{"ts": "2025-08-20T02:30:36.023597+00:00", "lat": 48.4812744, "lon": -81.3361198, "altitude_m": 46097.85, "source": "sim"} +{"ts": "2025-08-20T02:30:37.723597+00:00", "lat": 48.4813156, "lon": -81.3362842, "altitude_m": 46578.72, "source": "sim"} +{"ts": "2025-08-20T02:30:39.523597+00:00", "lat": 48.4812243, "lon": -81.3361943, "altitude_m": 47041.61, "source": "sim"} +{"ts": "2025-08-20T02:30:41.173597+00:00", "lat": 48.4812335, "lon": -81.3362188, "altitude_m": 47433.46, "source": "sim"} +{"ts": "2025-08-20T02:30:42.873597+00:00", "lat": 48.4814065, "lon": -81.3363159, "altitude_m": 47815.12, "source": "sim"} +{"ts": "2025-08-20T02:30:44.723597+00:00", "lat": 48.481157, "lon": -81.3363141, "altitude_m": 48230.14, "source": "sim"} +{"ts": "2025-08-20T02:30:46.573597+00:00", "lat": 48.4813194, "lon": -81.3364428, "altitude_m": 48577.92, "source": "sim"} +{"ts": "2025-08-20T02:30:48.123597+00:00", "lat": 48.4811927, "lon": -81.3363444, "altitude_m": 48837.97, "source": "sim"} +{"ts": "2025-08-20T02:30:49.623597+00:00", "lat": 48.4812536, "lon": -81.3365163, "altitude_m": 49084.9, "source": "sim"} +{"ts": "2025-08-20T02:30:51.373597+00:00", "lat": 48.4812676, "lon": -81.3365389, "altitude_m": 49346.08, "source": "sim"} +{"ts": "2025-08-20T02:30:52.873597+00:00", "lat": 48.4811474, "lon": -81.3366457, "altitude_m": 49522.88, "source": "sim"} +{"ts": "2025-08-20T02:30:54.723597+00:00", "lat": 48.4813791, "lon": -81.3365419, "altitude_m": 49758.47, "source": "sim"} +{"ts": "2025-08-20T02:30:56.423597+00:00", "lat": 48.4812522, "lon": -81.3367497, "altitude_m": 49924.59, "source": "sim"} +{"ts": "2025-08-20T02:30:58.123597+00:00", "lat": 48.4813253, "lon": -81.3367453, "altitude_m": 50042.14, "source": "sim"} +{"ts": "2025-08-20T02:30:59.923597+00:00", "lat": 48.4813258, "lon": -81.3370056, "altitude_m": 50133.73, "source": "sim"} +{"ts": "2025-08-20T02:31:01.423597+00:00", "lat": 48.4814203, "lon": -81.3367909, "altitude_m": 50232.31, "source": "sim"} +{"ts": "2025-08-20T02:31:03.073597+00:00", "lat": 48.4811689, "lon": -81.3369093, "altitude_m": 50245.79, "source": "sim"} +{"ts": "2025-08-20T02:31:04.723597+00:00", "lat": 48.4813463, "lon": -81.3368509, "altitude_m": 50335.48, "source": "sim"} +{"ts": "2025-08-20T02:31:06.473597+00:00", "lat": 48.4813563, "lon": -81.3368578, "altitude_m": 50292.29, "source": "sim"} +{"ts": "2025-08-20T02:31:14.473597+00:00", "lat": 48.4813943, "lon": -81.3373733, "altitude_m": 49896.26, "source": "sim"} +{"ts": "2025-08-20T02:31:16.273597+00:00", "lat": 48.4813895, "lon": -81.3371579, "altitude_m": 49720.24, "source": "sim"} +{"ts": "2025-08-20T02:31:17.973597+00:00", "lat": 48.4814655, "lon": -81.3374013, "altitude_m": 49499.89, "source": "sim"} +{"ts": "2025-08-20T02:31:19.823597+00:00", "lat": 48.481402, "lon": -81.3374286, "altitude_m": 49254.66, "source": "sim"} +{"ts": "2025-08-20T02:31:21.373597+00:00", "lat": 48.4815014, "lon": -81.3374512, "altitude_m": 49017.08, "source": "sim"} +{"ts": "2025-08-20T02:31:23.223597+00:00", "lat": 48.4814464, "lon": -81.3372685, "altitude_m": 48712.87, "source": "sim"} +{"ts": "2025-08-20T02:31:24.923597+00:00", "lat": 48.4814735, "lon": -81.3375635, "altitude_m": 48398.24, "source": "sim"} +{"ts": "2025-08-20T02:31:26.623597+00:00", "lat": 48.4814826, "lon": -81.3374489, "altitude_m": 48060.37, "source": "sim"} +{"ts": "2025-08-20T02:31:28.173597+00:00", "lat": 48.4815146, "lon": -81.3374923, "altitude_m": 47750.42, "source": "sim"} +{"ts": "2025-08-20T02:31:30.123597+00:00", "lat": 48.4815243, "lon": -81.3375513, "altitude_m": 47296.23, "source": "sim"} +{"ts": "2025-08-20T02:31:31.623597+00:00", "lat": 48.4815077, "lon": -81.3376229, "altitude_m": 46866.38, "source": "sim"} +{"ts": "2025-08-20T02:31:33.273597+00:00", "lat": 48.4814435, "lon": -81.3376696, "altitude_m": 46461.08, "source": "sim"} +{"ts": "2025-08-20T02:31:35.123597+00:00", "lat": 48.4814191, "lon": -81.3376403, "altitude_m": 45906.73, "source": "sim"} +{"ts": "2025-08-20T02:31:36.823597+00:00", "lat": 48.4815507, "lon": -81.3377555, "altitude_m": 45410.53, "source": "sim"} +{"ts": "2025-08-20T02:31:38.473597+00:00", "lat": 48.4815158, "lon": -81.3380217, "altitude_m": 44900.66, "source": "sim"} +{"ts": "2025-08-20T02:31:40.173597+00:00", "lat": 48.4813375, "lon": -81.3378748, "altitude_m": 44315.33, "source": "sim"} +{"ts": "2025-08-20T02:31:41.773597+00:00", "lat": 48.481435, "lon": -81.3379437, "altitude_m": 43784.32, "source": "sim"} +{"ts": "2025-08-20T02:31:43.423597+00:00", "lat": 48.4815191, "lon": -81.3378326, "altitude_m": 43158.6, "source": "sim"} +{"ts": "2025-08-20T02:31:45.023597+00:00", "lat": 48.4815723, "lon": -81.3381177, "altitude_m": 42563.4, "source": "sim"} +{"ts": "2025-08-20T02:31:46.773597+00:00", "lat": 48.4816022, "lon": -81.3380606, "altitude_m": 41853.0, "source": "sim"} +{"ts": "2025-08-20T02:31:48.223597+00:00", "lat": 48.4815187, "lon": -81.3380624, "altitude_m": 41288.12, "source": "sim"} +{"ts": "2025-08-20T02:31:49.823597+00:00", "lat": 48.4815858, "lon": -81.3381793, "altitude_m": 40594.78, "source": "sim"} +{"ts": "2025-08-20T02:31:51.623597+00:00", "lat": 48.4815675, "lon": -81.3382651, "altitude_m": 39764.12, "source": "sim"} +{"ts": "2025-08-20T02:31:53.223597+00:00", "lat": 48.4816182, "lon": -81.3381532, "altitude_m": 39022.1, "source": "sim"} +{"ts": "2025-08-20T02:31:54.973597+00:00", "lat": 48.4816169, "lon": -81.3383421, "altitude_m": 38190.93, "source": "sim"} +{"ts": "2025-08-20T02:31:56.673597+00:00", "lat": 48.4815898, "lon": -81.3383095, "altitude_m": 37350.5, "source": "sim"} +{"ts": "2025-08-20T02:31:58.523597+00:00", "lat": 48.481516, "lon": -81.3384089, "altitude_m": 36407.21, "source": "sim"} +{"ts": "2025-08-20T02:32:00.373597+00:00", "lat": 48.481616, "lon": -81.3384018, "altitude_m": 35428.66, "source": "sim"} +{"ts": "2025-08-20T02:32:02.073597+00:00", "lat": 48.4816118, "lon": -81.3383601, "altitude_m": 34484.39, "source": "sim"} +{"ts": "2025-08-20T02:32:03.773597+00:00", "lat": 48.4815758, "lon": -81.3384898, "altitude_m": 33515.79, "source": "sim"} +{"ts": "2025-08-20T02:32:05.673597+00:00", "lat": 48.4817142, "lon": -81.3386384, "altitude_m": 32416.76, "source": "sim"} +{"ts": "2025-08-20T02:32:07.473597+00:00", "lat": 48.4817023, "lon": -81.3384892, "altitude_m": 31345.6, "source": "sim"} +{"ts": "2025-08-20T02:32:09.123597+00:00", "lat": 48.4816454, "lon": -81.3387446, "altitude_m": 30324.58, "source": "sim"} +{"ts": "2025-08-20T02:32:10.923597+00:00", "lat": 48.4816357, "lon": -81.3387245, "altitude_m": 29160.24, "source": "sim"} +{"ts": "2025-08-20T02:32:12.523597+00:00", "lat": 48.4815597, "lon": -81.3386721, "altitude_m": 28123.88, "source": "sim"} +{"ts": "2025-08-20T02:32:14.273597+00:00", "lat": 48.4816663, "lon": -81.3387617, "altitude_m": 26940.65, "source": "sim"} +{"ts": "2025-08-20T02:32:16.173597+00:00", "lat": 48.4817035, "lon": -81.3388424, "altitude_m": 25660.51, "source": "sim"} +{"ts": "2025-08-20T02:32:17.873597+00:00", "lat": 48.4816598, "lon": -81.3387995, "altitude_m": 24460.13, "source": "sim"} +{"ts": "2025-08-20T02:32:19.573597+00:00", "lat": 48.4816921, "lon": -81.3388249, "altitude_m": 23228.56, "source": "sim"} +{"ts": "2025-08-20T02:32:21.223597+00:00", "lat": 48.4817091, "lon": -81.338964, "altitude_m": 22027.93, "source": "sim"} +{"ts": "2025-08-20T02:32:22.973597+00:00", "lat": 48.4817317, "lon": -81.3389965, "altitude_m": 20691.37, "source": "sim"} +{"ts": "2025-08-20T02:32:23.873597+00:00", "lat": 48.4817005, "lon": -81.3389619, "altitude_m": 20018.59, "source": "sim"} +{"ts": "2025-08-20T02:32:24.823597+00:00", "lat": 48.4816391, "lon": -81.3390909, "altitude_m": 19265.5, "source": "sim"} +{"ts": "2025-08-20T02:32:25.823597+00:00", "lat": 48.4816816, "lon": -81.3390957, "altitude_m": 18480.11, "source": "sim"} +{"ts": "2025-08-20T02:32:26.773597+00:00", "lat": 48.4817415, "lon": -81.3391807, "altitude_m": 17728.35, "source": "sim"} +{"ts": "2025-08-20T02:32:27.673597+00:00", "lat": 48.4816888, "lon": -81.3391445, "altitude_m": 17007.02, "source": "sim"} +{"ts": "2025-08-20T02:32:28.573597+00:00", "lat": 48.4817171, "lon": -81.3391931, "altitude_m": 16273.26, "source": "sim"} +{"ts": "2025-08-20T02:32:29.523597+00:00", "lat": 48.4817187, "lon": -81.3391784, "altitude_m": 15512.05, "source": "sim"} +{"ts": "2025-08-20T02:32:30.323597+00:00", "lat": 48.4817467, "lon": -81.339168, "altitude_m": 14843.22, "source": "sim"} +{"ts": "2025-08-20T02:32:31.223597+00:00", "lat": 48.4817457, "lon": -81.3392719, "altitude_m": 14070.04, "source": "sim"} +{"ts": "2025-08-20T02:32:32.223597+00:00", "lat": 48.4817497, "lon": -81.3392513, "altitude_m": 13230.81, "source": "sim"} +{"ts": "2025-08-20T02:32:33.173597+00:00", "lat": 48.4817274, "lon": -81.3393304, "altitude_m": 12413.73, "source": "sim"} +{"ts": "2025-08-20T02:32:33.973597+00:00", "lat": 48.48171, "lon": -81.339224, "altitude_m": 11725.7, "source": "sim"} +{"ts": "2025-08-20T02:32:34.723597+00:00", "lat": 48.4817109, "lon": -81.3392949, "altitude_m": 11066.66, "source": "sim"} +{"ts": "2025-08-20T02:32:35.673597+00:00", "lat": 48.4817149, "lon": -81.3394426, "altitude_m": 10228.26, "source": "sim"} +{"ts": "2025-08-20T02:32:36.573597+00:00", "lat": 48.4817359, "lon": -81.3394229, "altitude_m": 9436.32, "source": "sim"} +{"ts": "2025-08-20T02:32:37.373597+00:00", "lat": 48.4817363, "lon": -81.3393859, "altitude_m": 8716.86, "source": "sim"} +{"ts": "2025-08-20T02:32:38.223597+00:00", "lat": 48.481743, "lon": -81.3394484, "altitude_m": 7940.06, "source": "sim"} +{"ts": "2025-08-20T02:32:39.223597+00:00", "lat": 48.4817538, "lon": -81.3394325, "altitude_m": 7029.39, "source": "sim"} +{"ts": "2025-08-20T02:32:40.223597+00:00", "lat": 48.4818053, "lon": -81.3395106, "altitude_m": 6102.46, "source": "sim"} +{"ts": "2025-08-20T02:32:41.123597+00:00", "lat": 48.481759, "lon": -81.3395301, "altitude_m": 5258.59, "source": "sim"} +{"ts": "2025-08-20T02:32:41.523597+00:00", "lat": 48.4817501, "lon": -81.3395045, "altitude_m": 4876.14, "source": "sim"} +{"ts": "2025-08-20T02:32:42.023597+00:00", "lat": 48.4817487, "lon": -81.3395223, "altitude_m": 4408.37, "source": "sim"} +{"ts": "2025-08-20T02:32:42.573597+00:00", "lat": 48.481738, "lon": -81.3395421, "altitude_m": 3886.57, "source": "sim"} +{"ts": "2025-08-20T02:32:43.173597+00:00", "lat": 48.4817705, "lon": -81.339531, "altitude_m": 3313.47, "source": "sim"} +{"ts": "2025-08-20T02:32:43.573597+00:00", "lat": 48.4817901, "lon": -81.3395829, "altitude_m": 3288.07, "source": "sim"} +{"ts": "2025-08-20T02:32:44.023597+00:00", "lat": 48.4817598, "lon": -81.3395174, "altitude_m": 3286.22, "source": "sim"} +{"ts": "2025-08-20T02:32:44.623597+00:00", "lat": 48.4817282, "lon": -81.3395625, "altitude_m": 3280.92, "source": "sim"} +{"ts": "2025-08-20T02:32:45.023597+00:00", "lat": 48.4817664, "lon": -81.3395644, "altitude_m": 3272.55, "source": "sim"} +{"ts": "2025-08-20T02:32:45.723597+00:00", "lat": 48.4817734, "lon": -81.3395305, "altitude_m": 3270.96, "source": "sim"} +{"ts": "2025-08-20T02:32:46.323597+00:00", "lat": 48.4817311, "lon": -81.3395406, "altitude_m": 3269.25, "source": "sim"} +{"ts": "2025-08-20T02:32:46.873597+00:00", "lat": 48.48173, "lon": -81.3395657, "altitude_m": 3267.64, "source": "sim"} +{"ts": "2025-08-20T02:32:47.323597+00:00", "lat": 48.4817333, "lon": -81.3395055, "altitude_m": 3270.67, "source": "sim"} +{"ts": "2025-08-20T02:32:47.773597+00:00", "lat": 48.4816841, "lon": -81.3395014, "altitude_m": 3259.77, "source": "sim"} +{"ts": "2025-08-20T02:32:48.323597+00:00", "lat": 48.4817591, "lon": -81.3395252, "altitude_m": 3249.52, "source": "sim"} +{"ts": "2025-08-20T02:32:48.773597+00:00", "lat": 48.4817332, "lon": -81.3395974, "altitude_m": 3246.76, "source": "sim"} +{"ts": "2025-08-20T02:32:49.273597+00:00", "lat": 48.4817029, "lon": -81.3395285, "altitude_m": 3244.34, "source": "sim"} +{"ts": "2025-08-20T02:32:49.873597+00:00", "lat": 48.4817126, "lon": -81.3394879, "altitude_m": 3245.53, "source": "sim"} +{"ts": "2025-08-20T02:32:50.473597+00:00", "lat": 48.4816839, "lon": -81.3395129, "altitude_m": 3236.3, "source": "sim"} +{"ts": "2025-08-20T02:32:50.873597+00:00", "lat": 48.4817036, "lon": -81.339494, "altitude_m": 3228.25, "source": "sim"} +{"ts": "2025-08-20T02:32:51.273597+00:00", "lat": 48.4817231, "lon": -81.3394676, "altitude_m": 3231.78, "source": "sim"} +{"ts": "2025-08-20T02:32:51.673597+00:00", "lat": 48.4817089, "lon": -81.3395052, "altitude_m": 3225.4, "source": "sim"} +{"ts": "2025-08-20T02:32:52.173597+00:00", "lat": 48.4817056, "lon": -81.3394514, "altitude_m": 3219.61, "source": "sim"} +{"ts": "2025-08-20T02:32:52.673597+00:00", "lat": 48.4817115, "lon": -81.3394769, "altitude_m": 3217.68, "source": "sim"} +{"ts": "2025-08-20T02:32:53.073597+00:00", "lat": 48.4816605, "lon": -81.3394903, "altitude_m": 3219.39, "source": "sim"} +{"ts": "2025-08-20T02:32:53.623597+00:00", "lat": 48.4816848, "lon": -81.3394892, "altitude_m": 3211.19, "source": "sim"} +{"ts": "2025-08-20T02:32:54.173597+00:00", "lat": 48.4816492, "lon": -81.3394452, "altitude_m": 3204.9, "source": "sim"} +{"ts": "2025-08-20T02:32:54.673597+00:00", "lat": 48.4816454, "lon": -81.3395228, "altitude_m": 3204.89, "source": "sim"} +{"ts": "2025-08-20T02:32:55.123597+00:00", "lat": 48.4816463, "lon": -81.3394466, "altitude_m": 3200.95, "source": "sim"} +{"ts": "2025-08-20T02:32:55.623597+00:00", "lat": 48.4816161, "lon": -81.3394528, "altitude_m": 3196.05, "source": "sim"} +{"ts": "2025-08-20T02:32:56.073597+00:00", "lat": 48.4816482, "lon": -81.3394319, "altitude_m": 3190.5, "source": "sim"} +{"ts": "2025-08-20T02:32:56.573597+00:00", "lat": 48.4815888, "lon": -81.3394661, "altitude_m": 3186.43, "source": "sim"} +{"ts": "2025-08-20T02:32:57.073597+00:00", "lat": 48.4816575, "lon": -81.3394288, "altitude_m": 3187.01, "source": "sim"} +{"ts": "2025-08-20T02:32:57.473597+00:00", "lat": 48.4816447, "lon": -81.3394497, "altitude_m": 3180.91, "source": "sim"} +{"ts": "2025-08-20T02:32:58.023597+00:00", "lat": 48.4816219, "lon": -81.3394371, "altitude_m": 3177.09, "source": "sim"} +{"ts": "2025-08-20T02:32:58.523597+00:00", "lat": 48.4816296, "lon": -81.3394452, "altitude_m": 3166.96, "source": "sim"} +{"ts": "2025-08-20T02:32:58.973597+00:00", "lat": 48.4816014, "lon": -81.3394598, "altitude_m": 3169.43, "source": "sim"} +{"ts": "2025-08-20T02:32:59.423597+00:00", "lat": 48.4816097, "lon": -81.3394313, "altitude_m": 3163.22, "source": "sim"} +{"ts": "2025-08-20T02:32:59.973597+00:00", "lat": 48.4815986, "lon": -81.3394623, "altitude_m": 3156.12, "source": "sim"} +{"ts": "2025-08-20T02:33:00.423597+00:00", "lat": 48.481584, "lon": -81.3394507, "altitude_m": 3153.31, "source": "sim"} +{"ts": "2025-08-20T02:33:00.973597+00:00", "lat": 48.4816036, "lon": -81.339433, "altitude_m": 3148.85, "source": "sim"} +{"ts": "2025-08-20T02:33:01.423597+00:00", "lat": 48.4815643, "lon": -81.3394167, "altitude_m": 3146.25, "source": "sim"} +{"ts": "2025-08-20T02:33:01.823597+00:00", "lat": 48.4815559, "lon": -81.3393944, "altitude_m": 3140.26, "source": "sim"} +{"ts": "2025-08-20T02:33:02.323597+00:00", "lat": 48.4815646, "lon": -81.3394367, "altitude_m": 3139.51, "source": "sim"} +{"ts": "2025-08-20T02:33:02.773597+00:00", "lat": 48.4815648, "lon": -81.3394469, "altitude_m": 3133.94, "source": "sim"} +{"ts": "2025-08-20T02:33:03.323597+00:00", "lat": 48.4815529, "lon": -81.3394056, "altitude_m": 3131.48, "source": "sim"} +{"ts": "2025-08-20T02:33:03.923597+00:00", "lat": 48.4815579, "lon": -81.3393812, "altitude_m": 3131.22, "source": "sim"} +{"ts": "2025-08-20T02:33:04.373597+00:00", "lat": 48.4815757, "lon": -81.3393975, "altitude_m": 3123.75, "source": "sim"} +{"ts": "2025-08-20T02:33:04.823597+00:00", "lat": 48.4815598, "lon": -81.339397, "altitude_m": 3119.86, "source": "sim"} +{"ts": "2025-08-20T02:33:05.423597+00:00", "lat": 48.4815243, "lon": -81.3394025, "altitude_m": 3117.29, "source": "sim"} +{"ts": "2025-08-20T02:33:05.973597+00:00", "lat": 48.4815267, "lon": -81.3394131, "altitude_m": 3114.74, "source": "sim"} +{"ts": "2025-08-20T02:33:06.373597+00:00", "lat": 48.4815333, "lon": -81.3394267, "altitude_m": 3109.9, "source": "sim"} +{"ts": "2025-08-20T02:33:06.823597+00:00", "lat": 48.4815266, "lon": -81.3393886, "altitude_m": 3107.61, "source": "sim"} +{"ts": "2025-08-20T02:33:07.273597+00:00", "lat": 48.481565, "lon": -81.3394371, "altitude_m": 3099.13, "source": "sim"} +{"ts": "2025-08-20T02:33:07.673597+00:00", "lat": 48.4815326, "lon": -81.339374, "altitude_m": 3093.93, "source": "sim"} +{"ts": "2025-08-20T02:33:08.123597+00:00", "lat": 48.4814953, "lon": -81.3394101, "altitude_m": 3100.37, "source": "sim"} +{"ts": "2025-08-20T02:33:08.723597+00:00", "lat": 48.481488, "lon": -81.339426, "altitude_m": 3094.44, "source": "sim"} +{"ts": "2025-08-20T02:33:09.173597+00:00", "lat": 48.4815221, "lon": -81.3393169, "altitude_m": 3090.91, "source": "sim"} +{"ts": "2025-08-20T02:33:09.623597+00:00", "lat": 48.4815038, "lon": -81.3393396, "altitude_m": 3083.02, "source": "sim"} +{"ts": "2025-08-20T02:33:10.073597+00:00", "lat": 48.4814848, "lon": -81.3393322, "altitude_m": 3077.0, "source": "sim"} +{"ts": "2025-08-20T02:33:10.623597+00:00", "lat": 48.48148, "lon": -81.3393051, "altitude_m": 3082.62, "source": "sim"} +{"ts": "2025-08-20T02:33:11.123597+00:00", "lat": 48.4814957, "lon": -81.3393466, "altitude_m": 3067.9, "source": "sim"} +{"ts": "2025-08-20T02:33:11.723597+00:00", "lat": 48.4814796, "lon": -81.3393472, "altitude_m": 3066.42, "source": "sim"} +{"ts": "2025-08-20T02:33:12.273597+00:00", "lat": 48.4814448, "lon": -81.3393655, "altitude_m": 3069.76, "source": "sim"} +{"ts": "2025-08-20T02:33:12.823597+00:00", "lat": 48.4814728, "lon": -81.3393535, "altitude_m": 3061.1, "source": "sim"} +{"ts": "2025-08-20T02:33:13.373597+00:00", "lat": 48.4814447, "lon": -81.339282, "altitude_m": 3053.13, "source": "sim"} +{"ts": "2025-08-20T02:33:13.923597+00:00", "lat": 48.4814605, "lon": -81.3392821, "altitude_m": 3053.62, "source": "sim"} +{"ts": "2025-08-20T02:33:14.423597+00:00", "lat": 48.4814495, "lon": -81.3393133, "altitude_m": 3045.1, "source": "sim"} +{"ts": "2025-08-20T02:33:14.823597+00:00", "lat": 48.4814553, "lon": -81.3393353, "altitude_m": 3043.84, "source": "sim"} +{"ts": "2025-08-20T02:33:15.273597+00:00", "lat": 48.4814105, "lon": -81.3393146, "altitude_m": 3042.25, "source": "sim"} +{"ts": "2025-08-20T02:33:15.673597+00:00", "lat": 48.4814331, "lon": -81.3392664, "altitude_m": 3034.64, "source": "sim"} +{"ts": "2025-08-20T02:33:16.173597+00:00", "lat": 48.4814052, "lon": -81.339284, "altitude_m": 3029.41, "source": "sim"} +{"ts": "2025-08-20T02:33:16.623597+00:00", "lat": 48.4814172, "lon": -81.3393074, "altitude_m": 3026.52, "source": "sim"} +{"ts": "2025-08-20T02:33:17.073597+00:00", "lat": 48.4814221, "lon": -81.3392982, "altitude_m": 3024.05, "source": "sim"} +{"ts": "2025-08-20T02:33:17.573597+00:00", "lat": 48.4814334, "lon": -81.3392885, "altitude_m": 3013.64, "source": "sim"} +{"ts": "2025-08-20T02:33:18.023597+00:00", "lat": 48.481402, "lon": -81.3393144, "altitude_m": 3015.42, "source": "sim"} +{"ts": "2025-08-20T02:33:18.623597+00:00", "lat": 48.4814115, "lon": -81.3392759, "altitude_m": 3012.32, "source": "sim"} +{"ts": "2025-08-20T02:33:19.123597+00:00", "lat": 48.4813748, "lon": -81.3393145, "altitude_m": 3009.81, "source": "sim"} +{"ts": "2025-08-20T02:33:19.573597+00:00", "lat": 48.4814102, "lon": -81.3392741, "altitude_m": 3002.97, "source": "sim"} +{"ts": "2025-08-20T02:33:20.073597+00:00", "lat": 48.4813623, "lon": -81.3392689, "altitude_m": 2995.34, "source": "sim"} +{"ts": "2025-08-20T02:33:20.473597+00:00", "lat": 48.4813653, "lon": -81.339258, "altitude_m": 2998.3, "source": "sim"} +{"ts": "2025-08-20T02:33:21.023597+00:00", "lat": 48.4813668, "lon": -81.3392144, "altitude_m": 2988.32, "source": "sim"} +{"ts": "2025-08-20T02:33:21.473597+00:00", "lat": 48.481366, "lon": -81.3392422, "altitude_m": 2981.78, "source": "sim"} +{"ts": "2025-08-20T02:33:22.073597+00:00", "lat": 48.4813447, "lon": -81.3392645, "altitude_m": 2979.33, "source": "sim"} +{"ts": "2025-08-20T02:33:22.573597+00:00", "lat": 48.4813296, "lon": -81.3392447, "altitude_m": 2978.97, "source": "sim"} +{"ts": "2025-08-20T02:33:23.073597+00:00", "lat": 48.4813763, "lon": -81.339248, "altitude_m": 2972.88, "source": "sim"} +{"ts": "2025-08-20T02:33:23.523597+00:00", "lat": 48.4813361, "lon": -81.339238, "altitude_m": 2969.06, "source": "sim"} +{"ts": "2025-08-20T02:33:24.123597+00:00", "lat": 48.4813547, "lon": -81.3392663, "altitude_m": 2965.6, "source": "sim"} +{"ts": "2025-08-20T02:33:24.573597+00:00", "lat": 48.4813563, "lon": -81.3392302, "altitude_m": 2963.35, "source": "sim"} +{"ts": "2025-08-20T02:33:25.023597+00:00", "lat": 48.4813266, "lon": -81.3392512, "altitude_m": 2963.64, "source": "sim"} +{"ts": "2025-08-20T02:33:25.523597+00:00", "lat": 48.4813283, "lon": -81.3392714, "altitude_m": 2950.07, "source": "sim"} +{"ts": "2025-08-20T02:33:25.923597+00:00", "lat": 48.481336, "lon": -81.3392411, "altitude_m": 2954.76, "source": "sim"} +{"ts": "2025-08-20T02:33:26.523597+00:00", "lat": 48.4813, "lon": -81.3392286, "altitude_m": 2948.93, "source": "sim"} +{"ts": "2025-08-20T02:33:26.973597+00:00", "lat": 48.481317, "lon": -81.3392544, "altitude_m": 2946.77, "source": "sim"} +{"ts": "2025-08-20T02:33:27.473597+00:00", "lat": 48.4813031, "lon": -81.339242, "altitude_m": 2940.35, "source": "sim"} +{"ts": "2025-08-20T02:33:27.923597+00:00", "lat": 48.4812915, "lon": -81.3391691, "altitude_m": 2938.86, "source": "sim"} +{"ts": "2025-08-20T02:33:28.373597+00:00", "lat": 48.4813158, "lon": -81.339226, "altitude_m": 2933.89, "source": "sim"} +{"ts": "2025-08-20T02:33:28.823597+00:00", "lat": 48.4813093, "lon": -81.3392403, "altitude_m": 2924.45, "source": "sim"} +{"ts": "2025-08-20T02:33:29.423597+00:00", "lat": 48.4812749, "lon": -81.3391943, "altitude_m": 2927.61, "source": "sim"} +{"ts": "2025-08-20T02:33:29.873597+00:00", "lat": 48.4812879, "lon": -81.3392623, "altitude_m": 2923.38, "source": "sim"} +{"ts": "2025-08-20T02:33:30.323597+00:00", "lat": 48.481271, "lon": -81.3392221, "altitude_m": 2918.87, "source": "sim"} +{"ts": "2025-08-20T02:33:30.823597+00:00", "lat": 48.4812675, "lon": -81.339184, "altitude_m": 2915.0, "source": "sim"} +{"ts": "2025-08-20T02:33:31.373597+00:00", "lat": 48.4812632, "lon": -81.3391937, "altitude_m": 2905.75, "source": "sim"} +{"ts": "2025-08-20T02:33:31.923597+00:00", "lat": 48.4812623, "lon": -81.3391714, "altitude_m": 2903.36, "source": "sim"} +{"ts": "2025-08-20T02:33:32.473597+00:00", "lat": 48.4812303, "lon": -81.3391852, "altitude_m": 2903.47, "source": "sim"} +{"ts": "2025-08-20T02:33:32.973597+00:00", "lat": 48.4812551, "lon": -81.3392144, "altitude_m": 2895.5, "source": "sim"} +{"ts": "2025-08-20T02:33:33.423597+00:00", "lat": 48.4812361, "lon": -81.3391693, "altitude_m": 2891.96, "source": "sim"} +{"ts": "2025-08-20T02:33:33.923597+00:00", "lat": 48.4812465, "lon": -81.3392, "altitude_m": 2888.24, "source": "sim"} +{"ts": "2025-08-20T02:33:34.423597+00:00", "lat": 48.4812389, "lon": -81.3391762, "altitude_m": 2878.38, "source": "sim"} +{"ts": "2025-08-20T02:33:34.823597+00:00", "lat": 48.4812272, "lon": -81.3391749, "altitude_m": 2889.24, "source": "sim"} +{"ts": "2025-08-20T02:33:35.373597+00:00", "lat": 48.4812064, "lon": -81.3391736, "altitude_m": 2880.27, "source": "sim"} +{"ts": "2025-08-20T02:33:35.823597+00:00", "lat": 48.4812289, "lon": -81.3391958, "altitude_m": 2872.74, "source": "sim"} +{"ts": "2025-08-20T02:33:36.323597+00:00", "lat": 48.4812201, "lon": -81.339141, "altitude_m": 2868.14, "source": "sim"} +{"ts": "2025-08-20T02:33:36.873597+00:00", "lat": 48.481219, "lon": -81.3391691, "altitude_m": 2867.54, "source": "sim"} +{"ts": "2025-08-20T02:33:37.473597+00:00", "lat": 48.481212, "lon": -81.3391404, "altitude_m": 2859.07, "source": "sim"} +{"ts": "2025-08-20T02:33:38.023597+00:00", "lat": 48.4811885, "lon": -81.3391651, "altitude_m": 2856.31, "source": "sim"} +{"ts": "2025-08-20T02:33:38.423597+00:00", "lat": 48.4811794, "lon": -81.3391512, "altitude_m": 2851.34, "source": "sim"} +{"ts": "2025-08-20T02:33:38.923597+00:00", "lat": 48.4811859, "lon": -81.339134, "altitude_m": 2854.72, "source": "sim"} +{"ts": "2025-08-20T02:33:39.423597+00:00", "lat": 48.4811791, "lon": -81.3391109, "altitude_m": 2848.3, "source": "sim"} +{"ts": "2025-08-20T02:33:39.973597+00:00", "lat": 48.4811826, "lon": -81.339101, "altitude_m": 2847.95, "source": "sim"} +{"ts": "2025-08-20T02:33:40.473597+00:00", "lat": 48.481163, "lon": -81.3391294, "altitude_m": 2835.82, "source": "sim"} +{"ts": "2025-08-20T02:33:40.873597+00:00", "lat": 48.4811744, "lon": -81.3391712, "altitude_m": 2825.05, "source": "sim"} +{"ts": "2025-08-20T02:33:41.323597+00:00", "lat": 48.4811851, "lon": -81.3391626, "altitude_m": 2821.66, "source": "sim"} +{"ts": "2025-08-20T02:33:41.773597+00:00", "lat": 48.4811771, "lon": -81.3391143, "altitude_m": 2830.46, "source": "sim"} +{"ts": "2025-08-20T02:33:42.173597+00:00", "lat": 48.4811399, "lon": -81.3391388, "altitude_m": 2826.84, "source": "sim"} +{"ts": "2025-08-20T02:33:42.723597+00:00", "lat": 48.4811418, "lon": -81.3391417, "altitude_m": 2820.23, "source": "sim"} +{"ts": "2025-08-20T02:33:43.223597+00:00", "lat": 48.4811283, "lon": -81.33913, "altitude_m": 2807.78, "source": "sim"} +{"ts": "2025-08-20T02:33:43.673597+00:00", "lat": 48.4811236, "lon": -81.3390947, "altitude_m": 2811.69, "source": "sim"} +{"ts": "2025-08-20T02:33:44.223597+00:00", "lat": 48.4811245, "lon": -81.3391181, "altitude_m": 2814.26, "source": "sim"} +{"ts": "2025-08-20T02:33:44.723597+00:00", "lat": 48.4811667, "lon": -81.3391107, "altitude_m": 2802.93, "source": "sim"} +{"ts": "2025-08-20T02:33:45.223597+00:00", "lat": 48.4810945, "lon": -81.3390865, "altitude_m": 2794.21, "source": "sim"} +{"ts": "2025-08-20T02:33:45.773597+00:00", "lat": 48.4811442, "lon": -81.339084, "altitude_m": 2786.79, "source": "sim"} +{"ts": "2025-08-20T02:33:46.273597+00:00", "lat": 48.4811162, "lon": -81.3390941, "altitude_m": 2798.23, "source": "sim"} +{"ts": "2025-08-20T02:33:46.823597+00:00", "lat": 48.4811045, "lon": -81.3390876, "altitude_m": 2788.63, "source": "sim"} +{"ts": "2025-08-20T02:33:47.273597+00:00", "lat": 48.4810799, "lon": -81.3390522, "altitude_m": 2785.87, "source": "sim"} +{"ts": "2025-08-20T02:33:47.723597+00:00", "lat": 48.4811, "lon": -81.3390861, "altitude_m": 2779.63, "source": "sim"} +{"ts": "2025-08-20T02:33:48.123597+00:00", "lat": 48.481051, "lon": -81.3390564, "altitude_m": 2775.53, "source": "sim"} +{"ts": "2025-08-20T02:33:48.673597+00:00", "lat": 48.4810885, "lon": -81.3390397, "altitude_m": 2773.12, "source": "sim"} +{"ts": "2025-08-20T02:33:49.173597+00:00", "lat": 48.4810812, "lon": -81.3390725, "altitude_m": 2764.24, "source": "sim"} +{"ts": "2025-08-20T02:33:49.573597+00:00", "lat": 48.4810739, "lon": -81.3390585, "altitude_m": 2764.63, "source": "sim"} +{"ts": "2025-08-20T02:33:50.123597+00:00", "lat": 48.4810704, "lon": -81.3390529, "altitude_m": 2765.94, "source": "sim"} +{"ts": "2025-08-20T02:33:50.573597+00:00", "lat": 48.4810647, "lon": -81.339107, "altitude_m": 2751.83, "source": "sim"} +{"ts": "2025-08-20T02:33:51.073597+00:00", "lat": 48.4810651, "lon": -81.3390541, "altitude_m": 2748.55, "source": "sim"} +{"ts": "2025-08-20T02:33:51.523597+00:00", "lat": 48.4810462, "lon": -81.3390488, "altitude_m": 2749.09, "source": "sim"} +{"ts": "2025-08-20T02:33:52.023597+00:00", "lat": 48.4810423, "lon": -81.339015, "altitude_m": 2744.4, "source": "sim"} +{"ts": "2025-08-20T02:33:52.473597+00:00", "lat": 48.4810568, "lon": -81.3389917, "altitude_m": 2744.24, "source": "sim"} +{"ts": "2025-08-20T02:33:52.973597+00:00", "lat": 48.4810299, "lon": -81.3390245, "altitude_m": 2735.83, "source": "sim"} +{"ts": "2025-08-20T02:33:53.373597+00:00", "lat": 48.4810273, "lon": -81.338979, "altitude_m": 2725.88, "source": "sim"} +{"ts": "2025-08-20T02:33:53.923597+00:00", "lat": 48.4810373, "lon": -81.3390802, "altitude_m": 2728.19, "source": "sim"} +{"ts": "2025-08-20T02:33:54.373597+00:00", "lat": 48.4810572, "lon": -81.338968, "altitude_m": 2720.89, "source": "sim"} +{"ts": "2025-08-20T02:33:54.873597+00:00", "lat": 48.4810241, "lon": -81.3389836, "altitude_m": 2717.56, "source": "sim"} +{"ts": "2025-08-20T02:33:55.373597+00:00", "lat": 48.4810095, "lon": -81.339014, "altitude_m": 2719.07, "source": "sim"} +{"ts": "2025-08-20T02:33:55.773597+00:00", "lat": 48.4810284, "lon": -81.3390183, "altitude_m": 2714.51, "source": "sim"} +{"ts": "2025-08-20T02:33:56.223597+00:00", "lat": 48.4810009, "lon": -81.3389671, "altitude_m": 2710.78, "source": "sim"} +{"ts": "2025-08-20T02:33:56.673597+00:00", "lat": 48.4810057, "lon": -81.3390121, "altitude_m": 2709.29, "source": "sim"} +{"ts": "2025-08-20T02:33:57.173597+00:00", "lat": 48.4809922, "lon": -81.338982, "altitude_m": 2698.02, "source": "sim"} +{"ts": "2025-08-20T02:33:57.723597+00:00", "lat": 48.4809561, "lon": -81.3390083, "altitude_m": 2696.65, "source": "sim"} +{"ts": "2025-08-20T02:33:58.223597+00:00", "lat": 48.4809872, "lon": -81.3389875, "altitude_m": 2691.57, "source": "sim"} +{"ts": "2025-08-20T02:33:58.673597+00:00", "lat": 48.4809608, "lon": -81.338994, "altitude_m": 2685.79, "source": "sim"} +{"ts": "2025-08-20T02:33:59.223597+00:00", "lat": 48.4809834, "lon": -81.3389502, "altitude_m": 2685.41, "source": "sim"} +{"ts": "2025-08-20T02:33:59.673597+00:00", "lat": 48.4809804, "lon": -81.3389802, "altitude_m": 2677.9, "source": "sim"} +{"ts": "2025-08-20T02:34:00.223597+00:00", "lat": 48.4809321, "lon": -81.3389854, "altitude_m": 2675.05, "source": "sim"} +{"ts": "2025-08-20T02:34:00.623597+00:00", "lat": 48.4809489, "lon": -81.3389443, "altitude_m": 2679.96, "source": "sim"} +{"ts": "2025-08-20T02:34:01.123597+00:00", "lat": 48.4809678, "lon": -81.3389995, "altitude_m": 2671.52, "source": "sim"} +{"ts": "2025-08-20T02:34:01.623597+00:00", "lat": 48.48094, "lon": -81.3388886, "altitude_m": 2669.53, "source": "sim"} +{"ts": "2025-08-20T02:34:02.073597+00:00", "lat": 48.4809497, "lon": -81.3389551, "altitude_m": 2663.77, "source": "sim"} +{"ts": "2025-08-20T02:34:02.523597+00:00", "lat": 48.4809287, "lon": -81.3389441, "altitude_m": 2658.42, "source": "sim"} +{"ts": "2025-08-20T02:34:03.073597+00:00", "lat": 48.480941, "lon": -81.3389272, "altitude_m": 2655.78, "source": "sim"} +{"ts": "2025-08-20T02:34:03.623597+00:00", "lat": 48.4809272, "lon": -81.3389362, "altitude_m": 2645.14, "source": "sim"} +{"ts": "2025-08-20T02:34:04.123597+00:00", "lat": 48.4809063, "lon": -81.3389744, "altitude_m": 2644.87, "source": "sim"} +{"ts": "2025-08-20T02:34:04.573597+00:00", "lat": 48.4808899, "lon": -81.3389316, "altitude_m": 2636.22, "source": "sim"} +{"ts": "2025-08-20T02:34:05.173597+00:00", "lat": 48.4809006, "lon": -81.3389628, "altitude_m": 2640.42, "source": "sim"} +{"ts": "2025-08-20T02:34:05.723597+00:00", "lat": 48.4808975, "lon": -81.3389341, "altitude_m": 2633.91, "source": "sim"} +{"ts": "2025-08-20T02:34:06.223597+00:00", "lat": 48.4809053, "lon": -81.338915, "altitude_m": 2630.58, "source": "sim"} +{"ts": "2025-08-20T02:34:06.673597+00:00", "lat": 48.4808991, "lon": -81.3389178, "altitude_m": 2623.98, "source": "sim"} +{"ts": "2025-08-20T02:34:07.223597+00:00", "lat": 48.4809034, "lon": -81.3389049, "altitude_m": 2619.8, "source": "sim"} +{"ts": "2025-08-20T02:34:07.673597+00:00", "lat": 48.4808774, "lon": -81.3389333, "altitude_m": 2615.1, "source": "sim"} +{"ts": "2025-08-20T02:34:08.223597+00:00", "lat": 48.4808859, "lon": -81.3388937, "altitude_m": 2617.42, "source": "sim"} +{"ts": "2025-08-20T02:34:08.773597+00:00", "lat": 48.480855, "lon": -81.3389094, "altitude_m": 2616.65, "source": "sim"} +{"ts": "2025-08-20T02:34:09.273597+00:00", "lat": 48.4808538, "lon": -81.3389349, "altitude_m": 2611.59, "source": "sim"} +{"ts": "2025-08-20T02:34:09.823597+00:00", "lat": 48.4808744, "lon": -81.338894, "altitude_m": 2599.91, "source": "sim"} +{"ts": "2025-08-20T02:34:10.223597+00:00", "lat": 48.480858, "lon": -81.3389657, "altitude_m": 2602.18, "source": "sim"} +{"ts": "2025-08-20T02:34:10.723597+00:00", "lat": 48.48085, "lon": -81.3388483, "altitude_m": 2598.6, "source": "sim"} +{"ts": "2025-08-20T02:34:11.173597+00:00", "lat": 48.4808344, "lon": -81.3388342, "altitude_m": 2589.48, "source": "sim"} +{"ts": "2025-08-20T02:34:11.623597+00:00", "lat": 48.4808421, "lon": -81.3389071, "altitude_m": 2581.56, "source": "sim"} +{"ts": "2025-08-20T02:34:12.123597+00:00", "lat": 48.4808055, "lon": -81.3388888, "altitude_m": 2582.37, "source": "sim"} +{"ts": "2025-08-20T02:34:12.623597+00:00", "lat": 48.4807984, "lon": -81.3388731, "altitude_m": 2579.27, "source": "sim"} +{"ts": "2025-08-20T02:34:13.073597+00:00", "lat": 48.4808236, "lon": -81.3388877, "altitude_m": 2572.37, "source": "sim"} +{"ts": "2025-08-20T02:34:13.573597+00:00", "lat": 48.4808168, "lon": -81.3388359, "altitude_m": 2566.75, "source": "sim"} +{"ts": "2025-08-20T02:34:14.073597+00:00", "lat": 48.4808065, "lon": -81.3389135, "altitude_m": 2571.86, "source": "sim"} +{"ts": "2025-08-20T02:34:14.623597+00:00", "lat": 48.4807941, "lon": -81.3388603, "altitude_m": 2560.09, "source": "sim"} +{"ts": "2025-08-20T02:34:15.173597+00:00", "lat": 48.4807924, "lon": -81.3388131, "altitude_m": 2552.71, "source": "sim"} +{"ts": "2025-08-20T02:34:15.573597+00:00", "lat": 48.4807779, "lon": -81.3388407, "altitude_m": 2553.66, "source": "sim"} +{"ts": "2025-08-20T02:34:15.973597+00:00", "lat": 48.480788, "lon": -81.3388482, "altitude_m": 2549.95, "source": "sim"} +{"ts": "2025-08-20T02:34:16.573597+00:00", "lat": 48.4807665, "lon": -81.3388222, "altitude_m": 2542.44, "source": "sim"} +{"ts": "2025-08-20T02:34:17.023597+00:00", "lat": 48.4807871, "lon": -81.3388898, "altitude_m": 2550.47, "source": "sim"} +{"ts": "2025-08-20T02:34:17.523597+00:00", "lat": 48.4807674, "lon": -81.3388598, "altitude_m": 2537.73, "source": "sim"} +{"ts": "2025-08-20T02:34:17.973597+00:00", "lat": 48.4807916, "lon": -81.338846, "altitude_m": 2532.9, "source": "sim"} +{"ts": "2025-08-20T02:34:18.473597+00:00", "lat": 48.4807638, "lon": -81.3388227, "altitude_m": 2530.94, "source": "sim"} +{"ts": "2025-08-20T02:34:18.873597+00:00", "lat": 48.4807969, "lon": -81.3388193, "altitude_m": 2528.48, "source": "sim"} +{"ts": "2025-08-20T02:34:19.373597+00:00", "lat": 48.4807523, "lon": -81.3388031, "altitude_m": 2524.36, "source": "sim"} +{"ts": "2025-08-20T02:34:19.873597+00:00", "lat": 48.4807463, "lon": -81.338793, "altitude_m": 2517.55, "source": "sim"} +{"ts": "2025-08-20T02:34:20.323597+00:00", "lat": 48.4807749, "lon": -81.3387993, "altitude_m": 2515.31, "source": "sim"} +{"ts": "2025-08-20T02:34:20.873597+00:00", "lat": 48.4807703, "lon": -81.3388173, "altitude_m": 2509.03, "source": "sim"} +{"ts": "2025-08-20T02:34:21.323597+00:00", "lat": 48.4806962, "lon": -81.3388144, "altitude_m": 2508.86, "source": "sim"} +{"ts": "2025-08-20T02:34:21.873597+00:00", "lat": 48.4807081, "lon": -81.3387933, "altitude_m": 2502.89, "source": "sim"} +{"ts": "2025-08-20T02:34:22.323597+00:00", "lat": 48.4807715, "lon": -81.3387686, "altitude_m": 2504.32, "source": "sim"} +{"ts": "2025-08-20T02:34:22.773597+00:00", "lat": 48.4807439, "lon": -81.3387878, "altitude_m": 2496.94, "source": "sim"} +{"ts": "2025-08-20T02:34:23.173597+00:00", "lat": 48.4807211, "lon": -81.3387812, "altitude_m": 2504.78, "source": "sim"} +{"ts": "2025-08-20T02:34:23.573597+00:00", "lat": 48.4807069, "lon": -81.3388099, "altitude_m": 2488.68, "source": "sim"} +{"ts": "2025-08-20T02:34:23.973597+00:00", "lat": 48.4807054, "lon": -81.3387912, "altitude_m": 2486.43, "source": "sim"} +{"ts": "2025-08-20T02:34:24.473597+00:00", "lat": 48.4806716, "lon": -81.3388183, "altitude_m": 2485.61, "source": "sim"} +{"ts": "2025-08-20T02:34:24.973597+00:00", "lat": 48.4806973, "lon": -81.3387773, "altitude_m": 2477.02, "source": "sim"} +{"ts": "2025-08-20T02:34:25.623597+00:00", "lat": 48.4806704, "lon": -81.3387938, "altitude_m": 2467.92, "source": "sim"} +{"ts": "2025-08-20T02:34:26.173597+00:00", "lat": 48.4806874, "lon": -81.338743, "altitude_m": 2472.19, "source": "sim"} +{"ts": "2025-08-20T02:34:26.723597+00:00", "lat": 48.4807003, "lon": -81.3387195, "altitude_m": 2464.14, "source": "sim"} +{"ts": "2025-08-20T02:34:27.223597+00:00", "lat": 48.4806677, "lon": -81.3387334, "altitude_m": 2461.35, "source": "sim"} +{"ts": "2025-08-20T02:34:27.773597+00:00", "lat": 48.4806641, "lon": -81.3387084, "altitude_m": 2457.53, "source": "sim"} +{"ts": "2025-08-20T02:34:28.273597+00:00", "lat": 48.4806642, "lon": -81.3387551, "altitude_m": 2459.48, "source": "sim"} +{"ts": "2025-08-20T02:34:28.873597+00:00", "lat": 48.4806188, "lon": -81.3387328, "altitude_m": 2450.25, "source": "sim"} +{"ts": "2025-08-20T02:34:29.473597+00:00", "lat": 48.4806418, "lon": -81.3387468, "altitude_m": 2444.3, "source": "sim"} +{"ts": "2025-08-20T02:34:29.973597+00:00", "lat": 48.4806165, "lon": -81.3387361, "altitude_m": 2438.0, "source": "sim"} +{"ts": "2025-08-20T02:34:30.473597+00:00", "lat": 48.4806326, "lon": -81.3387338, "altitude_m": 2433.27, "source": "sim"} +{"ts": "2025-08-20T02:34:30.973597+00:00", "lat": 48.4806479, "lon": -81.3386957, "altitude_m": 2429.64, "source": "sim"} +{"ts": "2025-08-20T02:34:31.473597+00:00", "lat": 48.4806428, "lon": -81.3387097, "altitude_m": 2435.64, "source": "sim"} +{"ts": "2025-08-20T02:34:31.923597+00:00", "lat": 48.4805887, "lon": -81.3387335, "altitude_m": 2417.38, "source": "sim"} +{"ts": "2025-08-20T02:34:32.373597+00:00", "lat": 48.480624, "lon": -81.3387656, "altitude_m": 2409.64, "source": "sim"} +{"ts": "2025-08-20T02:34:32.973597+00:00", "lat": 48.480625, "lon": -81.3387056, "altitude_m": 2410.41, "source": "sim"} +{"ts": "2025-08-20T02:34:33.473597+00:00", "lat": 48.4806241, "lon": -81.3387184, "altitude_m": 2421.93, "source": "sim"} +{"ts": "2025-08-20T02:34:33.923597+00:00", "lat": 48.4805875, "lon": -81.3387088, "altitude_m": 2406.28, "source": "sim"} +{"ts": "2025-08-20T02:34:34.523597+00:00", "lat": 48.4805963, "lon": -81.338685, "altitude_m": 2401.71, "source": "sim"} +{"ts": "2025-08-20T02:34:34.973597+00:00", "lat": 48.480589, "lon": -81.3387457, "altitude_m": 2400.29, "source": "sim"} +{"ts": "2025-08-20T02:34:35.473597+00:00", "lat": 48.480568, "lon": -81.3386826, "altitude_m": 2391.14, "source": "sim"} +{"ts": "2025-08-20T02:34:35.973597+00:00", "lat": 48.4805747, "lon": -81.3386941, "altitude_m": 2394.28, "source": "sim"} +{"ts": "2025-08-20T02:34:36.473597+00:00", "lat": 48.4805558, "lon": -81.3386909, "altitude_m": 2381.27, "source": "sim"} +{"ts": "2025-08-20T02:34:36.873597+00:00", "lat": 48.480572, "lon": -81.3387284, "altitude_m": 2380.72, "source": "sim"} +{"ts": "2025-08-20T02:34:37.323597+00:00", "lat": 48.4805678, "lon": -81.3386611, "altitude_m": 2381.93, "source": "sim"} +{"ts": "2025-08-20T02:34:37.823597+00:00", "lat": 48.4805614, "lon": -81.3386817, "altitude_m": 2370.54, "source": "sim"} +{"ts": "2025-08-20T02:34:38.373597+00:00", "lat": 48.4805763, "lon": -81.3386192, "altitude_m": 2370.5, "source": "sim"} +{"ts": "2025-08-20T02:34:38.873597+00:00", "lat": 48.4805653, "lon": -81.3387112, "altitude_m": 2375.42, "source": "sim"} +{"ts": "2025-08-20T02:34:39.323597+00:00", "lat": 48.4805715, "lon": -81.3386772, "altitude_m": 2368.41, "source": "sim"} +{"ts": "2025-08-20T02:34:39.773597+00:00", "lat": 48.4805288, "lon": -81.3386902, "altitude_m": 2362.77, "source": "sim"} +{"ts": "2025-08-20T02:34:40.273597+00:00", "lat": 48.4804817, "lon": -81.3386521, "altitude_m": 2357.77, "source": "sim"} +{"ts": "2025-08-20T02:34:40.773597+00:00", "lat": 48.4805152, "lon": -81.3386602, "altitude_m": 2352.72, "source": "sim"} +{"ts": "2025-08-20T02:34:41.373597+00:00", "lat": 48.4805304, "lon": -81.3386567, "altitude_m": 2345.92, "source": "sim"} +{"ts": "2025-08-20T02:34:41.873597+00:00", "lat": 48.4805454, "lon": -81.3386654, "altitude_m": 2342.68, "source": "sim"} +{"ts": "2025-08-20T02:34:42.323597+00:00", "lat": 48.4805259, "lon": -81.3385992, "altitude_m": 2341.96, "source": "sim"} +{"ts": "2025-08-20T02:34:42.823597+00:00", "lat": 48.480506, "lon": -81.3386284, "altitude_m": 2336.78, "source": "sim"} +{"ts": "2025-08-20T02:34:43.323597+00:00", "lat": 48.4805109, "lon": -81.3386138, "altitude_m": 2335.58, "source": "sim"} +{"ts": "2025-08-20T02:34:43.773597+00:00", "lat": 48.4805011, "lon": -81.3386418, "altitude_m": 2328.0, "source": "sim"} +{"ts": "2025-08-20T02:34:44.223597+00:00", "lat": 48.4804882, "lon": -81.3386321, "altitude_m": 2328.52, "source": "sim"} +{"ts": "2025-08-20T02:34:44.623597+00:00", "lat": 48.4804952, "lon": -81.3385602, "altitude_m": 2319.96, "source": "sim"} +{"ts": "2025-08-20T02:34:45.123597+00:00", "lat": 48.4805087, "lon": -81.3385899, "altitude_m": 2312.63, "source": "sim"} +{"ts": "2025-08-20T02:34:45.573597+00:00", "lat": 48.48046, "lon": -81.3386572, "altitude_m": 2313.94, "source": "sim"} +{"ts": "2025-08-20T02:34:45.973597+00:00", "lat": 48.4804732, "lon": -81.3386086, "altitude_m": 2314.9, "source": "sim"} +{"ts": "2025-08-20T02:34:46.423597+00:00", "lat": 48.4804632, "lon": -81.3386017, "altitude_m": 2309.63, "source": "sim"} +{"ts": "2025-08-20T02:34:46.873597+00:00", "lat": 48.4804664, "lon": -81.338568, "altitude_m": 2307.35, "source": "sim"} +{"ts": "2025-08-20T02:34:47.373597+00:00", "lat": 48.4804427, "lon": -81.3385895, "altitude_m": 2293.86, "source": "sim"} +{"ts": "2025-08-20T02:34:47.873597+00:00", "lat": 48.4804619, "lon": -81.3386143, "altitude_m": 2292.47, "source": "sim"} +{"ts": "2025-08-20T02:34:48.373597+00:00", "lat": 48.4804376, "lon": -81.3386306, "altitude_m": 2291.26, "source": "sim"} +{"ts": "2025-08-20T02:34:48.873597+00:00", "lat": 48.4804337, "lon": -81.3385654, "altitude_m": 2295.65, "source": "sim"} +{"ts": "2025-08-20T02:34:49.273597+00:00", "lat": 48.4804349, "lon": -81.3385912, "altitude_m": 2288.54, "source": "sim"} +{"ts": "2025-08-20T02:34:49.723597+00:00", "lat": 48.4804278, "lon": -81.3386, "altitude_m": 2279.44, "source": "sim"} +{"ts": "2025-08-20T02:34:50.273597+00:00", "lat": 48.4804308, "lon": -81.338575, "altitude_m": 2281.77, "source": "sim"} +{"ts": "2025-08-20T02:34:50.723597+00:00", "lat": 48.4804382, "lon": -81.3385901, "altitude_m": 2276.23, "source": "sim"} +{"ts": "2025-08-20T02:34:51.273597+00:00", "lat": 48.4804211, "lon": -81.338598, "altitude_m": 2266.72, "source": "sim"} +{"ts": "2025-08-20T02:34:51.673597+00:00", "lat": 48.4803968, "lon": -81.3385776, "altitude_m": 2263.74, "source": "sim"} +{"ts": "2025-08-20T02:34:52.273597+00:00", "lat": 48.4804013, "lon": -81.3385654, "altitude_m": 2263.83, "source": "sim"} +{"ts": "2025-08-20T02:34:52.823597+00:00", "lat": 48.4803728, "lon": -81.3385338, "altitude_m": 2254.01, "source": "sim"} +{"ts": "2025-08-20T02:34:53.223597+00:00", "lat": 48.4803954, "lon": -81.3385638, "altitude_m": 2254.84, "source": "sim"} +{"ts": "2025-08-20T02:34:53.723597+00:00", "lat": 48.4804115, "lon": -81.3385202, "altitude_m": 2257.41, "source": "sim"} +{"ts": "2025-08-20T02:34:54.123597+00:00", "lat": 48.4803737, "lon": -81.3385351, "altitude_m": 2246.91, "source": "sim"} +{"ts": "2025-08-20T02:34:54.623597+00:00", "lat": 48.4803701, "lon": -81.3385501, "altitude_m": 2242.16, "source": "sim"} +{"ts": "2025-08-20T02:34:55.073597+00:00", "lat": 48.4803515, "lon": -81.338535, "altitude_m": 2240.63, "source": "sim"} +{"ts": "2025-08-20T02:34:55.573597+00:00", "lat": 48.4803827, "lon": -81.3385149, "altitude_m": 2235.83, "source": "sim"} +{"ts": "2025-08-20T02:34:56.073597+00:00", "lat": 48.4803583, "lon": -81.3385465, "altitude_m": 2227.81, "source": "sim"} +{"ts": "2025-08-20T02:34:56.673597+00:00", "lat": 48.4803434, "lon": -81.3385379, "altitude_m": 2228.78, "source": "sim"} +{"ts": "2025-08-20T02:34:57.223597+00:00", "lat": 48.4803481, "lon": -81.3385133, "altitude_m": 2221.8, "source": "sim"} +{"ts": "2025-08-20T02:34:57.673597+00:00", "lat": 48.480362, "lon": -81.3384795, "altitude_m": 2220.72, "source": "sim"} +{"ts": "2025-08-20T02:34:58.073597+00:00", "lat": 48.4803311, "lon": -81.3385399, "altitude_m": 2218.47, "source": "sim"} +{"ts": "2025-08-20T02:34:58.473597+00:00", "lat": 48.4803461, "lon": -81.3385236, "altitude_m": 2214.23, "source": "sim"} +{"ts": "2025-08-20T02:34:58.873597+00:00", "lat": 48.4803448, "lon": -81.3385066, "altitude_m": 2207.44, "source": "sim"} +{"ts": "2025-08-20T02:34:59.373597+00:00", "lat": 48.4803282, "lon": -81.3385226, "altitude_m": 2202.93, "source": "sim"} +{"ts": "2025-08-20T02:34:59.973597+00:00", "lat": 48.4803399, "lon": -81.338427, "altitude_m": 2199.58, "source": "sim"} +{"ts": "2025-08-20T02:35:00.523597+00:00", "lat": 48.4803166, "lon": -81.3385429, "altitude_m": 2193.66, "source": "sim"} +{"ts": "2025-08-20T02:35:00.923597+00:00", "lat": 48.480355, "lon": -81.3384893, "altitude_m": 2187.65, "source": "sim"} +{"ts": "2025-08-20T02:35:01.373597+00:00", "lat": 48.4803049, "lon": -81.3385134, "altitude_m": 2182.16, "source": "sim"} +{"ts": "2025-08-20T02:35:01.923597+00:00", "lat": 48.4803058, "lon": -81.3384823, "altitude_m": 2184.68, "source": "sim"} +{"ts": "2025-08-20T02:35:02.423597+00:00", "lat": 48.4802954, "lon": -81.3384769, "altitude_m": 2174.14, "source": "sim"} +{"ts": "2025-08-20T02:35:03.023597+00:00", "lat": 48.4802832, "lon": -81.3384708, "altitude_m": 2171.04, "source": "sim"} +{"ts": "2025-08-20T02:35:03.473597+00:00", "lat": 48.4802815, "lon": -81.3384631, "altitude_m": 2168.36, "source": "sim"} +{"ts": "2025-08-20T02:35:03.973597+00:00", "lat": 48.4803048, "lon": -81.3384676, "altitude_m": 2168.62, "source": "sim"} +{"ts": "2025-08-20T02:35:04.473597+00:00", "lat": 48.4802994, "lon": -81.3384525, "altitude_m": 2158.76, "source": "sim"} +{"ts": "2025-08-20T02:35:05.023597+00:00", "lat": 48.4802567, "lon": -81.338484, "altitude_m": 2162.89, "source": "sim"} +{"ts": "2025-08-20T02:35:05.573597+00:00", "lat": 48.4802662, "lon": -81.3384733, "altitude_m": 2158.65, "source": "sim"} +{"ts": "2025-08-20T02:35:06.073597+00:00", "lat": 48.4802842, "lon": -81.3384391, "altitude_m": 2153.84, "source": "sim"} +{"ts": "2025-08-20T02:35:06.523597+00:00", "lat": 48.4802458, "lon": -81.338492, "altitude_m": 2147.0, "source": "sim"} +{"ts": "2025-08-20T02:35:06.973597+00:00", "lat": 48.4802598, "lon": -81.3384396, "altitude_m": 2143.31, "source": "sim"} +{"ts": "2025-08-20T02:35:07.423597+00:00", "lat": 48.4802489, "lon": -81.3384366, "altitude_m": 2136.27, "source": "sim"} +{"ts": "2025-08-20T02:35:07.973597+00:00", "lat": 48.4802352, "lon": -81.3384461, "altitude_m": 2132.93, "source": "sim"} +{"ts": "2025-08-20T02:35:08.473597+00:00", "lat": 48.4802373, "lon": -81.3384373, "altitude_m": 2134.04, "source": "sim"} +{"ts": "2025-08-20T02:35:08.923597+00:00", "lat": 48.4802081, "lon": -81.3384098, "altitude_m": 2130.83, "source": "sim"} +{"ts": "2025-08-20T02:35:09.373597+00:00", "lat": 48.4802093, "lon": -81.3384403, "altitude_m": 2125.01, "source": "sim"} +{"ts": "2025-08-20T02:35:09.873597+00:00", "lat": 48.4802411, "lon": -81.3384046, "altitude_m": 2118.47, "source": "sim"} +{"ts": "2025-08-20T02:35:10.373597+00:00", "lat": 48.4802353, "lon": -81.3384467, "altitude_m": 2116.19, "source": "sim"} +{"ts": "2025-08-20T02:35:10.873597+00:00", "lat": 48.4802033, "lon": -81.3384327, "altitude_m": 2112.19, "source": "sim"} +{"ts": "2025-08-20T02:35:11.323597+00:00", "lat": 48.4802052, "lon": -81.3383984, "altitude_m": 2110.53, "source": "sim"} +{"ts": "2025-08-20T02:35:11.773597+00:00", "lat": 48.4801862, "lon": -81.3383991, "altitude_m": 2104.06, "source": "sim"} +{"ts": "2025-08-20T02:35:12.273597+00:00", "lat": 48.4802316, "lon": -81.3383913, "altitude_m": 2104.7, "source": "sim"} +{"ts": "2025-08-20T02:35:12.673597+00:00", "lat": 48.480212, "lon": -81.338371, "altitude_m": 2098.12, "source": "sim"} +{"ts": "2025-08-20T02:35:13.173597+00:00", "lat": 48.4802208, "lon": -81.338389, "altitude_m": 2092.06, "source": "sim"} +{"ts": "2025-08-20T02:35:13.823597+00:00", "lat": 48.4801627, "lon": -81.3383826, "altitude_m": 2095.88, "source": "sim"} +{"ts": "2025-08-20T02:35:14.273597+00:00", "lat": 48.480144, "lon": -81.3384225, "altitude_m": 2078.54, "source": "sim"} +{"ts": "2025-08-20T02:35:14.873597+00:00", "lat": 48.4801685, "lon": -81.3383341, "altitude_m": 2078.09, "source": "sim"} +{"ts": "2025-08-20T02:35:15.373597+00:00", "lat": 48.4801432, "lon": -81.3383578, "altitude_m": 2075.07, "source": "sim"} +{"ts": "2025-08-20T02:35:15.773597+00:00", "lat": 48.4801582, "lon": -81.3383711, "altitude_m": 2075.64, "source": "sim"} +{"ts": "2025-08-20T02:35:16.273597+00:00", "lat": 48.4801539, "lon": -81.33837, "altitude_m": 2066.75, "source": "sim"} +{"ts": "2025-08-20T02:35:16.773597+00:00", "lat": 48.4801279, "lon": -81.3383683, "altitude_m": 2061.82, "source": "sim"} +{"ts": "2025-08-20T02:35:17.323597+00:00", "lat": 48.4801613, "lon": -81.3384, "altitude_m": 2059.87, "source": "sim"} +{"ts": "2025-08-20T02:35:17.923597+00:00", "lat": 48.4801473, "lon": -81.3383843, "altitude_m": 2051.03, "source": "sim"} +{"ts": "2025-08-20T02:35:18.423597+00:00", "lat": 48.480116, "lon": -81.338401, "altitude_m": 2058.22, "source": "sim"} +{"ts": "2025-08-20T02:35:18.973597+00:00", "lat": 48.4801448, "lon": -81.3383865, "altitude_m": 2043.83, "source": "sim"} +{"ts": "2025-08-20T02:35:19.473597+00:00", "lat": 48.4801006, "lon": -81.3383635, "altitude_m": 2045.62, "source": "sim"} +{"ts": "2025-08-20T02:35:19.973597+00:00", "lat": 48.4801068, "lon": -81.3383582, "altitude_m": 2041.74, "source": "sim"} +{"ts": "2025-08-20T02:35:20.523597+00:00", "lat": 48.4801148, "lon": -81.3383602, "altitude_m": 2033.63, "source": "sim"} +{"ts": "2025-08-20T02:35:20.923597+00:00", "lat": 48.4801306, "lon": -81.3384132, "altitude_m": 2036.82, "source": "sim"} +{"ts": "2025-08-20T02:35:21.423597+00:00", "lat": 48.4801148, "lon": -81.3383273, "altitude_m": 2027.6, "source": "sim"} +{"ts": "2025-08-20T02:35:21.973597+00:00", "lat": 48.4801116, "lon": -81.3383134, "altitude_m": 2024.06, "source": "sim"} +{"ts": "2025-08-20T02:35:22.423597+00:00", "lat": 48.4800861, "lon": -81.3383248, "altitude_m": 2027.83, "source": "sim"} +{"ts": "2025-08-20T02:35:22.973597+00:00", "lat": 48.4801077, "lon": -81.3383918, "altitude_m": 2011.27, "source": "sim"} +{"ts": "2025-08-20T02:35:23.523597+00:00", "lat": 48.4800813, "lon": -81.3383652, "altitude_m": 2010.41, "source": "sim"} +{"ts": "2025-08-20T02:35:23.973597+00:00", "lat": 48.4800612, "lon": -81.3382946, "altitude_m": 2009.92, "source": "sim"} +{"ts": "2025-08-20T02:35:24.373597+00:00", "lat": 48.4800458, "lon": -81.3383103, "altitude_m": 2001.27, "source": "sim"} +{"ts": "2025-08-20T02:35:24.873597+00:00", "lat": 48.4800572, "lon": -81.3383002, "altitude_m": 1997.83, "source": "sim"} +{"ts": "2025-08-20T02:35:25.323597+00:00", "lat": 48.4800698, "lon": -81.33831, "altitude_m": 2001.96, "source": "sim"} +{"ts": "2025-08-20T02:35:25.723597+00:00", "lat": 48.4800173, "lon": -81.3383169, "altitude_m": 1983.09, "source": "sim"} +{"ts": "2025-08-20T02:35:26.173597+00:00", "lat": 48.480057, "lon": -81.3383015, "altitude_m": 1983.29, "source": "sim"} +{"ts": "2025-08-20T02:35:26.623597+00:00", "lat": 48.4800538, "lon": -81.3382804, "altitude_m": 1987.14, "source": "sim"} +{"ts": "2025-08-20T02:35:27.123597+00:00", "lat": 48.4800617, "lon": -81.3382685, "altitude_m": 1982.56, "source": "sim"} +{"ts": "2025-08-20T02:35:27.573597+00:00", "lat": 48.4800225, "lon": -81.3382595, "altitude_m": 1977.03, "source": "sim"} +{"ts": "2025-08-20T02:35:28.023597+00:00", "lat": 48.4800251, "lon": -81.3382968, "altitude_m": 1965.76, "source": "sim"} +{"ts": "2025-08-20T02:35:28.423597+00:00", "lat": 48.4800138, "lon": -81.3383201, "altitude_m": 1974.4, "source": "sim"} +{"ts": "2025-08-20T02:35:29.023597+00:00", "lat": 48.480018, "lon": -81.3382436, "altitude_m": 1968.39, "source": "sim"} +{"ts": "2025-08-20T02:35:44.823597+00:00", "lat": 48.4798758, "lon": -81.3381436, "altitude_m": 1841.87, "source": "sim"} +{"ts": "2025-08-20T02:35:45.323597+00:00", "lat": 48.47986, "lon": -81.33814, "altitude_m": 1838.35, "source": "sim"} +{"ts": "2025-08-20T02:35:45.773597+00:00", "lat": 48.479837, "lon": -81.3381591, "altitude_m": 1832.53, "source": "sim"} +{"ts": "2025-08-20T02:35:46.373597+00:00", "lat": 48.479816, "lon": -81.3381869, "altitude_m": 1832.79, "source": "sim"} +{"ts": "2025-08-20T02:35:46.923597+00:00", "lat": 48.4797984, "lon": -81.3381429, "altitude_m": 1819.45, "source": "sim"} +{"ts": "2025-08-20T02:35:47.323597+00:00", "lat": 48.4798278, "lon": -81.3381282, "altitude_m": 1819.23, "source": "sim"} +{"ts": "2025-08-20T02:35:47.823597+00:00", "lat": 48.4798244, "lon": -81.3381492, "altitude_m": 1819.01, "source": "sim"} +{"ts": "2025-08-20T02:35:48.323597+00:00", "lat": 48.4798215, "lon": -81.338132, "altitude_m": 1810.01, "source": "sim"} +{"ts": "2025-08-20T02:35:48.873597+00:00", "lat": 48.4798113, "lon": -81.3381192, "altitude_m": 1811.7, "source": "sim"} +{"ts": "2025-08-20T02:35:49.273597+00:00", "lat": 48.4797807, "lon": -81.3381172, "altitude_m": 1807.45, "source": "sim"} +{"ts": "2025-08-20T02:35:49.773597+00:00", "lat": 48.4798182, "lon": -81.3381056, "altitude_m": 1799.77, "source": "sim"} +{"ts": "2025-08-20T02:35:50.273597+00:00", "lat": 48.4797595, "lon": -81.3381667, "altitude_m": 1795.96, "source": "sim"} +{"ts": "2025-08-20T02:35:50.723597+00:00", "lat": 48.4797658, "lon": -81.3381661, "altitude_m": 1791.16, "source": "sim"} +{"ts": "2025-08-20T02:35:51.173597+00:00", "lat": 48.4797548, "lon": -81.3381237, "altitude_m": 1792.0, "source": "sim"} +{"ts": "2025-08-20T02:35:51.723597+00:00", "lat": 48.4797639, "lon": -81.338137, "altitude_m": 1786.55, "source": "sim"} +{"ts": "2025-08-20T02:35:52.273597+00:00", "lat": 48.4797844, "lon": -81.33808, "altitude_m": 1787.03, "source": "sim"} +{"ts": "2025-08-20T02:35:52.773597+00:00", "lat": 48.4797699, "lon": -81.3381197, "altitude_m": 1771.3, "source": "sim"} +{"ts": "2025-08-20T02:35:53.223597+00:00", "lat": 48.4797693, "lon": -81.3381299, "altitude_m": 1767.63, "source": "sim"} +{"ts": "2025-08-20T02:35:53.773597+00:00", "lat": 48.4797669, "lon": -81.3380867, "altitude_m": 1769.89, "source": "sim"} +{"ts": "2025-08-20T02:35:54.223597+00:00", "lat": 48.479767, "lon": -81.3381151, "altitude_m": 1768.17, "source": "sim"} +{"ts": "2025-08-20T02:35:54.823597+00:00", "lat": 48.479736, "lon": -81.3380815, "altitude_m": 1766.16, "source": "sim"} +{"ts": "2025-08-20T02:35:55.273597+00:00", "lat": 48.4797093, "lon": -81.338069, "altitude_m": 1755.96, "source": "sim"} +{"ts": "2025-08-20T02:35:55.773597+00:00", "lat": 48.4797279, "lon": -81.338099, "altitude_m": 1750.22, "source": "sim"} +{"ts": "2025-08-20T02:35:56.323597+00:00", "lat": 48.4797297, "lon": -81.3381027, "altitude_m": 1747.94, "source": "sim"} +{"ts": "2025-08-20T02:35:56.723597+00:00", "lat": 48.4797517, "lon": -81.3380502, "altitude_m": 1746.51, "source": "sim"} +{"ts": "2025-08-20T02:35:57.173597+00:00", "lat": 48.4797474, "lon": -81.338083, "altitude_m": 1744.24, "source": "sim"} +{"ts": "2025-08-20T02:35:57.673597+00:00", "lat": 48.4797117, "lon": -81.3380716, "altitude_m": 1736.79, "source": "sim"} +{"ts": "2025-08-20T02:35:58.173597+00:00", "lat": 48.4797014, "lon": -81.3380614, "altitude_m": 1733.92, "source": "sim"} +{"ts": "2025-08-20T02:35:58.573597+00:00", "lat": 48.479723, "lon": -81.338058, "altitude_m": 1731.43, "source": "sim"} +{"ts": "2025-08-20T02:35:59.173597+00:00", "lat": 48.4796684, "lon": -81.3380574, "altitude_m": 1726.54, "source": "sim"} +{"ts": "2025-08-20T02:35:59.723597+00:00", "lat": 48.4797123, "lon": -81.3380212, "altitude_m": 1721.3, "source": "sim"} +{"ts": "2025-08-20T02:36:00.173597+00:00", "lat": 48.4796967, "lon": -81.338053, "altitude_m": 1721.07, "source": "sim"} +{"ts": "2025-08-20T02:36:00.623597+00:00", "lat": 48.4796922, "lon": -81.3380688, "altitude_m": 1719.53, "source": "sim"} +{"ts": "2025-08-20T02:36:01.223597+00:00", "lat": 48.4796533, "lon": -81.3380168, "altitude_m": 1710.99, "source": "sim"} +{"ts": "2025-08-20T02:36:01.823597+00:00", "lat": 48.4796687, "lon": -81.3380101, "altitude_m": 1705.46, "source": "sim"} +{"ts": "2025-08-20T02:36:02.423597+00:00", "lat": 48.4796851, "lon": -81.3380255, "altitude_m": 1701.0, "source": "sim"} +{"ts": "2025-08-20T02:36:03.023597+00:00", "lat": 48.4796608, "lon": -81.3379803, "altitude_m": 1698.27, "source": "sim"} +{"ts": "2025-08-20T02:36:03.473597+00:00", "lat": 48.4796531, "lon": -81.3380143, "altitude_m": 1685.66, "source": "sim"} +{"ts": "2025-08-20T02:36:03.973597+00:00", "lat": 48.4796432, "lon": -81.3380224, "altitude_m": 1690.79, "source": "sim"} +{"ts": "2025-08-20T02:36:04.573597+00:00", "lat": 48.4796618, "lon": -81.3379569, "altitude_m": 1686.35, "source": "sim"} +{"ts": "2025-08-20T02:36:05.073597+00:00", "lat": 48.4796312, "lon": -81.3380272, "altitude_m": 1680.26, "source": "sim"} +{"ts": "2025-08-20T02:36:05.573597+00:00", "lat": 48.4796103, "lon": -81.3380389, "altitude_m": 1671.68, "source": "sim"} +{"ts": "2025-08-20T02:36:06.073597+00:00", "lat": 48.4796232, "lon": -81.3379783, "altitude_m": 1668.15, "source": "sim"} +{"ts": "2025-08-20T02:36:06.473597+00:00", "lat": 48.4796074, "lon": -81.3380309, "altitude_m": 1667.69, "source": "sim"} +{"ts": "2025-08-20T02:36:06.873597+00:00", "lat": 48.4796243, "lon": -81.3379954, "altitude_m": 1662.74, "source": "sim"} +{"ts": "2025-08-20T02:36:07.373597+00:00", "lat": 48.4796011, "lon": -81.3379917, "altitude_m": 1656.92, "source": "sim"} +{"ts": "2025-08-20T02:36:07.973597+00:00", "lat": 48.479622, "lon": -81.3379898, "altitude_m": 1655.97, "source": "sim"} +{"ts": "2025-08-20T02:36:08.473597+00:00", "lat": 48.4796044, "lon": -81.3379925, "altitude_m": 1649.29, "source": "sim"} +{"ts": "2025-08-20T02:36:08.873597+00:00", "lat": 48.4795914, "lon": -81.3379813, "altitude_m": 1647.86, "source": "sim"} +{"ts": "2025-08-20T02:36:09.473597+00:00", "lat": 48.4795849, "lon": -81.3379673, "altitude_m": 1644.06, "source": "sim"} +{"ts": "2025-08-20T02:36:09.873597+00:00", "lat": 48.4795832, "lon": -81.3379701, "altitude_m": 1640.38, "source": "sim"} +{"ts": "2025-08-20T02:36:10.273597+00:00", "lat": 48.4795718, "lon": -81.3379485, "altitude_m": 1639.88, "source": "sim"} +{"ts": "2025-08-20T02:36:10.923597+00:00", "lat": 48.4795867, "lon": -81.3379783, "altitude_m": 1628.81, "source": "sim"} +{"ts": "2025-08-20T02:36:11.473597+00:00", "lat": 48.4795566, "lon": -81.3379607, "altitude_m": 1626.81, "source": "sim"} +{"ts": "2025-08-20T02:36:12.023597+00:00", "lat": 48.4795707, "lon": -81.3379414, "altitude_m": 1623.76, "source": "sim"} +{"ts": "2025-08-20T02:36:12.573597+00:00", "lat": 48.4795475, "lon": -81.3379279, "altitude_m": 1616.25, "source": "sim"} +{"ts": "2025-08-20T02:36:12.973597+00:00", "lat": 48.479527, "lon": -81.3379274, "altitude_m": 1615.06, "source": "sim"} +{"ts": "2025-08-20T02:36:13.623597+00:00", "lat": 48.4795393, "lon": -81.337935, "altitude_m": 1606.0, "source": "sim"} +{"ts": "2025-08-20T02:36:14.223597+00:00", "lat": 48.4795411, "lon": -81.3379204, "altitude_m": 1600.2, "source": "sim"} +{"ts": "2025-08-20T02:36:14.773597+00:00", "lat": 48.4795122, "lon": -81.3379069, "altitude_m": 1599.7, "source": "sim"} +{"ts": "2025-08-20T02:36:15.223597+00:00", "lat": 48.4795243, "lon": -81.3379264, "altitude_m": 1594.39, "source": "sim"} +{"ts": "2025-08-20T02:36:15.623597+00:00", "lat": 48.4795547, "lon": -81.3379471, "altitude_m": 1591.7, "source": "sim"} +{"ts": "2025-08-20T02:36:16.073597+00:00", "lat": 48.4795231, "lon": -81.3379175, "altitude_m": 1590.84, "source": "sim"} +{"ts": "2025-08-20T02:36:16.473597+00:00", "lat": 48.4795246, "lon": -81.3379107, "altitude_m": 1590.07, "source": "sim"} +{"ts": "2025-08-20T02:36:16.973597+00:00", "lat": 48.4795018, "lon": -81.3379079, "altitude_m": 1579.91, "source": "sim"} +{"ts": "2025-08-20T02:36:17.473597+00:00", "lat": 48.4795243, "lon": -81.3379196, "altitude_m": 1579.9, "source": "sim"} +{"ts": "2025-08-20T02:36:18.023597+00:00", "lat": 48.4795271, "lon": -81.3379082, "altitude_m": 1578.93, "source": "sim"} +{"ts": "2025-08-20T02:36:18.473597+00:00", "lat": 48.4794577, "lon": -81.3378526, "altitude_m": 1571.83, "source": "sim"} +{"ts": "2025-08-20T02:36:18.923597+00:00", "lat": 48.4794863, "lon": -81.3379208, "altitude_m": 1568.51, "source": "sim"} +{"ts": "2025-08-20T02:36:19.473597+00:00", "lat": 48.4794929, "lon": -81.3379222, "altitude_m": 1564.79, "source": "sim"} +{"ts": "2025-08-20T02:36:20.023597+00:00", "lat": 48.4794807, "lon": -81.3378856, "altitude_m": 1557.84, "source": "sim"} +{"ts": "2025-08-20T02:36:20.473597+00:00", "lat": 48.4794687, "lon": -81.3379166, "altitude_m": 1557.71, "source": "sim"} +{"ts": "2025-08-20T02:36:20.923597+00:00", "lat": 48.4794568, "lon": -81.3379137, "altitude_m": 1551.74, "source": "sim"} +{"ts": "2025-08-20T02:36:21.323597+00:00", "lat": 48.4794684, "lon": -81.3379033, "altitude_m": 1551.04, "source": "sim"} +{"ts": "2025-08-20T02:36:21.773597+00:00", "lat": 48.4794691, "lon": -81.3379083, "altitude_m": 1541.74, "source": "sim"} +{"ts": "2025-08-20T02:36:22.323597+00:00", "lat": 48.4794414, "lon": -81.3378457, "altitude_m": 1540.55, "source": "sim"} +{"ts": "2025-08-20T02:36:22.773597+00:00", "lat": 48.4794712, "lon": -81.3378834, "altitude_m": 1543.23, "source": "sim"} +{"ts": "2025-08-20T02:36:23.173597+00:00", "lat": 48.4794481, "lon": -81.3378432, "altitude_m": 1531.68, "source": "sim"} +{"ts": "2025-08-20T02:36:23.773597+00:00", "lat": 48.4794346, "lon": -81.337839, "altitude_m": 1525.98, "source": "sim"} +{"ts": "2025-08-20T02:36:24.223597+00:00", "lat": 48.479433, "lon": -81.337828, "altitude_m": 1519.11, "source": "sim"} +{"ts": "2025-08-20T02:36:24.673597+00:00", "lat": 48.4794308, "lon": -81.3378322, "altitude_m": 1522.81, "source": "sim"} +{"ts": "2025-08-20T02:36:25.123597+00:00", "lat": 48.4794098, "lon": -81.3378172, "altitude_m": 1522.8, "source": "sim"} +{"ts": "2025-08-20T02:36:25.573597+00:00", "lat": 48.4794538, "lon": -81.337863, "altitude_m": 1515.57, "source": "sim"} +{"ts": "2025-08-20T02:36:26.173597+00:00", "lat": 48.4794234, "lon": -81.3378454, "altitude_m": 1511.79, "source": "sim"} +{"ts": "2025-08-20T02:36:26.623597+00:00", "lat": 48.4794125, "lon": -81.3378548, "altitude_m": 1505.46, "source": "sim"} +{"ts": "2025-08-20T02:36:27.073597+00:00", "lat": 48.4794083, "lon": -81.3378072, "altitude_m": 1499.35, "source": "sim"} +{"ts": "2025-08-20T02:36:27.473597+00:00", "lat": 48.4793843, "lon": -81.3377989, "altitude_m": 1499.37, "source": "sim"} +{"ts": "2025-08-20T02:36:27.873597+00:00", "lat": 48.4793762, "lon": -81.3378511, "altitude_m": 1495.21, "source": "sim"} +{"ts": "2025-08-20T02:36:28.323597+00:00", "lat": 48.4794264, "lon": -81.3377983, "altitude_m": 1488.6, "source": "sim"} +{"ts": "2025-08-20T02:36:28.873597+00:00", "lat": 48.4793545, "lon": -81.3377692, "altitude_m": 1489.89, "source": "sim"} +{"ts": "2025-08-20T02:36:29.323597+00:00", "lat": 48.479386, "lon": -81.3377865, "altitude_m": 1489.86, "source": "sim"} +{"ts": "2025-08-20T02:36:29.773597+00:00", "lat": 48.4794001, "lon": -81.3378069, "altitude_m": 1483.65, "source": "sim"} +{"ts": "2025-08-20T02:36:30.173597+00:00", "lat": 48.4794017, "lon": -81.3378083, "altitude_m": 1479.59, "source": "sim"} +{"ts": "2025-08-20T02:36:30.623597+00:00", "lat": 48.4793463, "lon": -81.3377713, "altitude_m": 1479.46, "source": "sim"} +{"ts": "2025-08-20T02:36:31.023597+00:00", "lat": 48.4793724, "lon": -81.3378063, "altitude_m": 1470.51, "source": "sim"} +{"ts": "2025-08-20T02:36:31.423597+00:00", "lat": 48.4793565, "lon": -81.3378241, "altitude_m": 1469.95, "source": "sim"} +{"ts": "2025-08-20T02:36:31.873597+00:00", "lat": 48.4793543, "lon": -81.3377907, "altitude_m": 1461.29, "source": "sim"} +{"ts": "2025-08-20T02:36:32.423597+00:00", "lat": 48.4793397, "lon": -81.3378107, "altitude_m": 1454.44, "source": "sim"} +{"ts": "2025-08-20T02:36:33.023597+00:00", "lat": 48.4793325, "lon": -81.3378194, "altitude_m": 1456.76, "source": "sim"} +{"ts": "2025-08-20T02:36:33.623597+00:00", "lat": 48.479325, "lon": -81.3377566, "altitude_m": 1448.95, "source": "sim"} +{"ts": "2025-08-20T02:36:34.123597+00:00", "lat": 48.4793413, "lon": -81.3377766, "altitude_m": 1445.51, "source": "sim"} +{"ts": "2025-08-20T02:36:34.523597+00:00", "lat": 48.4793439, "lon": -81.3377638, "altitude_m": 1438.99, "source": "sim"} +{"ts": "2025-08-20T02:36:35.023597+00:00", "lat": 48.47928, "lon": -81.3377838, "altitude_m": 1443.31, "source": "sim"} +{"ts": "2025-08-20T02:36:35.523597+00:00", "lat": 48.4792864, "lon": -81.3377858, "altitude_m": 1435.19, "source": "sim"} +{"ts": "2025-08-20T02:36:36.123597+00:00", "lat": 48.4793183, "lon": -81.3377764, "altitude_m": 1426.78, "source": "sim"} +{"ts": "2025-08-20T02:36:36.573597+00:00", "lat": 48.4792846, "lon": -81.3377561, "altitude_m": 1429.26, "source": "sim"} +{"ts": "2025-08-20T02:36:37.073597+00:00", "lat": 48.4792714, "lon": -81.3377275, "altitude_m": 1425.06, "source": "sim"} +{"ts": "2025-08-20T02:36:37.473597+00:00", "lat": 48.4793128, "lon": -81.3377545, "altitude_m": 1409.52, "source": "sim"} +{"ts": "2025-08-20T02:36:37.873597+00:00", "lat": 48.4792879, "lon": -81.3377419, "altitude_m": 1416.84, "source": "sim"} +{"ts": "2025-08-20T02:36:38.273597+00:00", "lat": 48.4792963, "lon": -81.3377601, "altitude_m": 1416.55, "source": "sim"} +{"ts": "2025-08-20T02:36:38.723597+00:00", "lat": 48.4792725, "lon": -81.3377309, "altitude_m": 1407.09, "source": "sim"} +{"ts": "2025-08-20T02:36:39.323597+00:00", "lat": 48.4792678, "lon": -81.3377235, "altitude_m": 1402.49, "source": "sim"} +{"ts": "2025-08-20T02:36:39.823597+00:00", "lat": 48.4793064, "lon": -81.3377504, "altitude_m": 1404.82, "source": "sim"} +{"ts": "2025-08-20T02:36:40.373597+00:00", "lat": 48.4792591, "lon": -81.3377085, "altitude_m": 1396.69, "source": "sim"} +{"ts": "2025-08-20T02:36:40.823597+00:00", "lat": 48.4792482, "lon": -81.3377409, "altitude_m": 1389.02, "source": "sim"} +{"ts": "2025-08-20T02:36:41.423597+00:00", "lat": 48.4792524, "lon": -81.3376957, "altitude_m": 1389.0, "source": "sim"} +{"ts": "2025-08-20T02:36:42.023597+00:00", "lat": 48.4792479, "lon": -81.3377236, "altitude_m": 1386.23, "source": "sim"} +{"ts": "2025-08-20T02:36:42.473597+00:00", "lat": 48.4792395, "lon": -81.3376977, "altitude_m": 1377.08, "source": "sim"} +{"ts": "2025-08-20T02:36:42.923597+00:00", "lat": 48.4792304, "lon": -81.3377289, "altitude_m": 1373.17, "source": "sim"} +{"ts": "2025-08-20T02:36:43.323597+00:00", "lat": 48.4792392, "lon": -81.3376949, "altitude_m": 1369.24, "source": "sim"} +{"ts": "2025-08-20T02:36:43.823597+00:00", "lat": 48.4792083, "lon": -81.3377055, "altitude_m": 1371.47, "source": "sim"} +{"ts": "2025-08-20T02:36:44.373597+00:00", "lat": 48.4792366, "lon": -81.3376707, "altitude_m": 1362.95, "source": "sim"} +{"ts": "2025-08-20T02:36:44.873597+00:00", "lat": 48.4792121, "lon": -81.3377071, "altitude_m": 1357.73, "source": "sim"} +{"ts": "2025-08-20T02:36:45.323597+00:00", "lat": 48.4792018, "lon": -81.3376888, "altitude_m": 1357.67, "source": "sim"} +{"ts": "2025-08-20T02:36:45.723597+00:00", "lat": 48.479194, "lon": -81.3376602, "altitude_m": 1355.46, "source": "sim"} +{"ts": "2025-08-20T02:36:46.223597+00:00", "lat": 48.4791829, "lon": -81.3377025, "altitude_m": 1349.34, "source": "sim"} +{"ts": "2025-08-20T02:36:46.823597+00:00", "lat": 48.4791637, "lon": -81.337684, "altitude_m": 1341.07, "source": "sim"} +{"ts": "2025-08-20T02:36:47.323597+00:00", "lat": 48.4791773, "lon": -81.3377077, "altitude_m": 1344.21, "source": "sim"} +{"ts": "2025-08-20T02:36:47.923597+00:00", "lat": 48.4791896, "lon": -81.3376937, "altitude_m": 1336.41, "source": "sim"} +{"ts": "2025-08-20T02:36:48.423597+00:00", "lat": 48.4791721, "lon": -81.3376776, "altitude_m": 1330.63, "source": "sim"} +{"ts": "2025-08-20T02:36:48.923597+00:00", "lat": 48.479188, "lon": -81.3376402, "altitude_m": 1329.11, "source": "sim"} +{"ts": "2025-08-20T02:36:49.373597+00:00", "lat": 48.4791804, "lon": -81.3376628, "altitude_m": 1333.89, "source": "sim"} +{"ts": "2025-08-20T02:36:49.873597+00:00", "lat": 48.479158, "lon": -81.3376746, "altitude_m": 1315.3, "source": "sim"} +{"ts": "2025-08-20T02:36:50.373597+00:00", "lat": 48.4791794, "lon": -81.3376265, "altitude_m": 1320.05, "source": "sim"} +{"ts": "2025-08-20T02:36:50.873597+00:00", "lat": 48.4791501, "lon": -81.3376393, "altitude_m": 1309.79, "source": "sim"} +{"ts": "2025-08-20T02:36:51.373597+00:00", "lat": 48.4791431, "lon": -81.3376427, "altitude_m": 1307.06, "source": "sim"} +{"ts": "2025-08-20T02:36:51.873597+00:00", "lat": 48.4791473, "lon": -81.3376372, "altitude_m": 1307.01, "source": "sim"} +{"ts": "2025-08-20T02:36:52.423597+00:00", "lat": 48.4791394, "lon": -81.3376525, "altitude_m": 1296.6, "source": "sim"} +{"ts": "2025-08-20T02:36:52.823597+00:00", "lat": 48.4791455, "lon": -81.3376251, "altitude_m": 1290.64, "source": "sim"} +{"ts": "2025-08-20T02:36:53.223597+00:00", "lat": 48.4791068, "lon": -81.3376299, "altitude_m": 1295.67, "source": "sim"} +{"ts": "2025-08-20T02:36:53.673597+00:00", "lat": 48.479107, "lon": -81.337605, "altitude_m": 1296.29, "source": "sim"} +{"ts": "2025-08-20T02:36:54.123597+00:00", "lat": 48.4791046, "lon": -81.3376311, "altitude_m": 1294.68, "source": "sim"} +{"ts": "2025-08-20T02:36:54.523597+00:00", "lat": 48.4791183, "lon": -81.3376097, "altitude_m": 1277.89, "source": "sim"} +{"ts": "2025-08-20T02:36:55.023597+00:00", "lat": 48.4791302, "lon": -81.3376332, "altitude_m": 1278.83, "source": "sim"} +{"ts": "2025-08-20T02:36:55.623597+00:00", "lat": 48.4791073, "lon": -81.3376243, "altitude_m": 1279.25, "source": "sim"} +{"ts": "2025-08-20T02:36:56.173597+00:00", "lat": 48.4790908, "lon": -81.3376483, "altitude_m": 1266.17, "source": "sim"} +{"ts": "2025-08-20T02:36:56.673597+00:00", "lat": 48.4790927, "lon": -81.33762, "altitude_m": 1263.4, "source": "sim"} +{"ts": "2025-08-20T02:36:57.123597+00:00", "lat": 48.4790872, "lon": -81.3376279, "altitude_m": 1263.62, "source": "sim"} +{"ts": "2025-08-20T02:36:57.623597+00:00", "lat": 48.4790934, "lon": -81.3376232, "altitude_m": 1264.58, "source": "sim"} +{"ts": "2025-08-20T02:36:58.073597+00:00", "lat": 48.4790256, "lon": -81.3375917, "altitude_m": 1254.48, "source": "sim"} +{"ts": "2025-08-20T02:36:58.573597+00:00", "lat": 48.4790643, "lon": -81.3375996, "altitude_m": 1249.91, "source": "sim"} +{"ts": "2025-08-20T02:36:59.023597+00:00", "lat": 48.4790835, "lon": -81.3375609, "altitude_m": 1245.31, "source": "sim"} +{"ts": "2025-08-20T02:36:59.473597+00:00", "lat": 48.4790606, "lon": -81.3376018, "altitude_m": 1244.92, "source": "sim"} +{"ts": "2025-08-20T02:36:59.923597+00:00", "lat": 48.4790369, "lon": -81.3376031, "altitude_m": 1237.71, "source": "sim"} +{"ts": "2025-08-20T02:37:00.573597+00:00", "lat": 48.4790402, "lon": -81.3375554, "altitude_m": 1230.76, "source": "sim"} +{"ts": "2025-08-20T02:37:01.023597+00:00", "lat": 48.4790556, "lon": -81.3376019, "altitude_m": 1227.88, "source": "sim"} +{"ts": "2025-08-20T02:37:01.473597+00:00", "lat": 48.4790176, "lon": -81.3375548, "altitude_m": 1228.08, "source": "sim"} +{"ts": "2025-08-20T02:37:01.923597+00:00", "lat": 48.4790201, "lon": -81.3376032, "altitude_m": 1219.24, "source": "sim"} +{"ts": "2025-08-20T02:37:02.323597+00:00", "lat": 48.479022, "lon": -81.3375577, "altitude_m": 1219.15, "source": "sim"} +{"ts": "2025-08-20T02:37:02.773597+00:00", "lat": 48.4790264, "lon": -81.3375618, "altitude_m": 1213.17, "source": "sim"} +{"ts": "2025-08-20T02:37:03.273597+00:00", "lat": 48.4790025, "lon": -81.3375749, "altitude_m": 1215.89, "source": "sim"} +{"ts": "2025-08-20T02:37:03.773597+00:00", "lat": 48.4790047, "lon": -81.3375296, "altitude_m": 1214.73, "source": "sim"} +{"ts": "2025-08-20T02:37:04.273597+00:00", "lat": 48.478994, "lon": -81.3375267, "altitude_m": 1207.04, "source": "sim"} +{"ts": "2025-08-20T02:37:04.773597+00:00", "lat": 48.4790307, "lon": -81.3375867, "altitude_m": 1199.38, "source": "sim"} +{"ts": "2025-08-20T02:37:05.173597+00:00", "lat": 48.4790145, "lon": -81.3375087, "altitude_m": 1198.97, "source": "sim"} +{"ts": "2025-08-20T02:37:05.673597+00:00", "lat": 48.4789964, "lon": -81.3375524, "altitude_m": 1188.64, "source": "sim"} +{"ts": "2025-08-20T02:37:06.123597+00:00", "lat": 48.4789948, "lon": -81.3375599, "altitude_m": 1191.61, "source": "sim"} +{"ts": "2025-08-20T02:37:06.573597+00:00", "lat": 48.4789797, "lon": -81.3375325, "altitude_m": 1186.47, "source": "sim"} +{"ts": "2025-08-20T02:37:06.973597+00:00", "lat": 48.4789747, "lon": -81.3375396, "altitude_m": 1181.47, "source": "sim"} +{"ts": "2025-08-20T02:37:07.473597+00:00", "lat": 48.4789455, "lon": -81.3375426, "altitude_m": 1183.14, "source": "sim"} +{"ts": "2025-08-20T02:37:08.073597+00:00", "lat": 48.478959, "lon": -81.3375277, "altitude_m": 1175.84, "source": "sim"} +{"ts": "2025-08-20T02:37:08.523597+00:00", "lat": 48.4789718, "lon": -81.337488, "altitude_m": 1171.28, "source": "sim"} +{"ts": "2025-08-20T02:37:09.123597+00:00", "lat": 48.4789468, "lon": -81.3375263, "altitude_m": 1162.86, "source": "sim"} +{"ts": "2025-08-20T02:37:09.473597+00:00", "lat": 48.4789328, "lon": -81.3375494, "altitude_m": 1158.14, "source": "sim"} +{"ts": "2025-08-20T02:37:09.623597+00:00", "lat": 48.4789469, "lon": -81.3375391, "altitude_m": 1161.99, "source": "sim"} +{"ts": "2025-08-20T02:37:09.823597+00:00", "lat": 48.4789525, "lon": -81.3375072, "altitude_m": 1158.55, "source": "sim"} +{"ts": "2025-08-20T02:37:09.973597+00:00", "lat": 48.4789461, "lon": -81.3374842, "altitude_m": 1155.76, "source": "sim"} +{"ts": "2025-08-20T02:37:10.073597+00:00", "lat": 48.4789762, "lon": -81.3374858, "altitude_m": 1158.15, "source": "sim"} +{"ts": "2025-08-20T02:37:10.173597+00:00", "lat": 48.4789374, "lon": -81.3375244, "altitude_m": 1163.91, "source": "sim"} +{"ts": "2025-08-20T02:37:10.273597+00:00", "lat": 48.4789459, "lon": -81.3374947, "altitude_m": 1154.13, "source": "sim"} +{"ts": "2025-08-20T02:37:10.423597+00:00", "lat": 48.4789228, "lon": -81.3374987, "altitude_m": 1159.04, "source": "sim"} +{"ts": "2025-08-20T02:37:10.523597+00:00", "lat": 48.4789555, "lon": -81.3374744, "altitude_m": 1157.63, "source": "sim"} +{"ts": "2025-08-20T02:37:10.673597+00:00", "lat": 48.4789325, "lon": -81.3375172, "altitude_m": 1149.14, "source": "sim"} +{"ts": "2025-08-20T02:37:10.823597+00:00", "lat": 48.4789408, "lon": -81.3375344, "altitude_m": 1157.02, "source": "sim"} +{"ts": "2025-08-20T02:37:10.923597+00:00", "lat": 48.4789296, "lon": -81.3374935, "altitude_m": 1155.41, "source": "sim"} +{"ts": "2025-08-20T02:37:11.073597+00:00", "lat": 48.4789369, "lon": -81.3375358, "altitude_m": 1145.11, "source": "sim"} +{"ts": "2025-08-20T02:37:11.173597+00:00", "lat": 48.4789454, "lon": -81.337503, "altitude_m": 1151.61, "source": "sim"} +{"ts": "2025-08-20T02:37:11.273597+00:00", "lat": 48.4789461, "lon": -81.3374995, "altitude_m": 1144.68, "source": "sim"} +{"ts": "2025-08-20T02:37:11.373597+00:00", "lat": 48.478936, "lon": -81.3375037, "altitude_m": 1143.8, "source": "sim"} +{"ts": "2025-08-20T02:37:11.473597+00:00", "lat": 48.4789368, "lon": -81.3375127, "altitude_m": 1145.67, "source": "sim"} +{"ts": "2025-08-20T02:37:11.573597+00:00", "lat": 48.4789118, "lon": -81.3374573, "altitude_m": 1147.92, "source": "sim"} +{"ts": "2025-08-20T02:37:11.723597+00:00", "lat": 48.4789273, "lon": -81.3375024, "altitude_m": 1145.61, "source": "sim"} +{"ts": "2025-08-20T02:37:11.823597+00:00", "lat": 48.478911, "lon": -81.3375032, "altitude_m": 1139.26, "source": "sim"} +{"ts": "2025-08-20T02:37:11.923597+00:00", "lat": 48.4788848, "lon": -81.3375177, "altitude_m": 1151.61, "source": "sim"} +{"ts": "2025-08-20T02:37:12.023597+00:00", "lat": 48.4789515, "lon": -81.337443, "altitude_m": 1141.71, "source": "sim"} +{"ts": "2025-08-20T02:37:12.123597+00:00", "lat": 48.4789314, "lon": -81.3374782, "altitude_m": 1143.59, "source": "sim"} +{"ts": "2025-08-20T02:37:12.273597+00:00", "lat": 48.4789038, "lon": -81.337518, "altitude_m": 1141.68, "source": "sim"} +{"ts": "2025-08-20T02:37:12.373597+00:00", "lat": 48.4789338, "lon": -81.3374904, "altitude_m": 1139.5, "source": "sim"} +{"ts": "2025-08-20T02:37:12.473597+00:00", "lat": 48.4789266, "lon": -81.3374553, "altitude_m": 1143.75, "source": "sim"} +{"ts": "2025-08-20T02:37:12.573597+00:00", "lat": 48.4789397, "lon": -81.3374694, "altitude_m": 1141.3, "source": "sim"} +{"ts": "2025-08-20T02:37:12.673597+00:00", "lat": 48.4789222, "lon": -81.3374624, "altitude_m": 1140.17, "source": "sim"} +{"ts": "2025-08-20T02:37:12.823597+00:00", "lat": 48.4789221, "lon": -81.3374546, "altitude_m": 1140.99, "source": "sim"} +{"ts": "2025-08-20T02:37:12.973597+00:00", "lat": 48.4789193, "lon": -81.3375025, "altitude_m": 1139.0, "source": "sim"} +{"ts": "2025-08-20T02:37:13.073597+00:00", "lat": 48.4789151, "lon": -81.3374525, "altitude_m": 1137.53, "source": "sim"} +{"ts": "2025-08-20T02:37:13.173597+00:00", "lat": 48.4789137, "lon": -81.3374541, "altitude_m": 1131.7, "source": "sim"} +{"ts": "2025-08-20T02:37:13.323597+00:00", "lat": 48.4789224, "lon": -81.3374723, "altitude_m": 1137.32, "source": "sim"} +{"ts": "2025-08-20T02:37:13.473597+00:00", "lat": 48.4789112, "lon": -81.3374889, "altitude_m": 1126.47, "source": "sim"} +{"ts": "2025-08-20T02:37:13.623597+00:00", "lat": 48.4788978, "lon": -81.3374512, "altitude_m": 1126.72, "source": "sim"} +{"ts": "2025-08-20T02:37:13.723597+00:00", "lat": 48.4789215, "lon": -81.3374912, "altitude_m": 1135.25, "source": "sim"} +{"ts": "2025-08-20T02:37:13.823597+00:00", "lat": 48.4789007, "lon": -81.3374503, "altitude_m": 1122.99, "source": "sim"} +{"ts": "2025-08-20T02:37:13.923597+00:00", "lat": 48.4788885, "lon": -81.3374299, "altitude_m": 1132.3, "source": "sim"} +{"ts": "2025-08-20T02:37:14.023597+00:00", "lat": 48.4789078, "lon": -81.3374764, "altitude_m": 1126.86, "source": "sim"} +{"ts": "2025-08-20T02:37:14.123597+00:00", "lat": 48.4788987, "lon": -81.3374716, "altitude_m": 1131.02, "source": "sim"} +{"ts": "2025-08-20T02:37:14.273597+00:00", "lat": 48.4788971, "lon": -81.337484, "altitude_m": 1133.9, "source": "sim"} +{"ts": "2025-08-20T02:37:14.423597+00:00", "lat": 48.4789139, "lon": -81.3374624, "altitude_m": 1125.62, "source": "sim"} +{"ts": "2025-08-20T02:37:14.523597+00:00", "lat": 48.4789101, "lon": -81.3374631, "altitude_m": 1122.09, "source": "sim"} +{"ts": "2025-08-20T02:37:14.673597+00:00", "lat": 48.4788918, "lon": -81.3374547, "altitude_m": 1124.98, "source": "sim"} +{"ts": "2025-08-20T02:37:14.823597+00:00", "lat": 48.4788616, "lon": -81.3374782, "altitude_m": 1122.47, "source": "sim"} +{"ts": "2025-08-20T02:37:14.973597+00:00", "lat": 48.478895, "lon": -81.3374803, "altitude_m": 1126.81, "source": "sim"} +{"ts": "2025-08-20T02:37:15.123597+00:00", "lat": 48.4788886, "lon": -81.3374658, "altitude_m": 1117.38, "source": "sim"} +{"ts": "2025-08-20T02:37:15.223597+00:00", "lat": 48.4789024, "lon": -81.3374528, "altitude_m": 1118.41, "source": "sim"} +{"ts": "2025-08-20T02:37:15.323597+00:00", "lat": 48.4789076, "lon": -81.3374405, "altitude_m": 1108.93, "source": "sim"} +{"ts": "2025-08-20T02:37:15.473597+00:00", "lat": 48.4789024, "lon": -81.3374721, "altitude_m": 1107.28, "source": "sim"} +{"ts": "2025-08-20T02:37:15.623597+00:00", "lat": 48.4788791, "lon": -81.337478, "altitude_m": 1116.03, "source": "sim"} +{"ts": "2025-08-20T02:37:15.773597+00:00", "lat": 48.4788943, "lon": -81.3375034, "altitude_m": 1110.7, "source": "sim"} +{"ts": "2025-08-20T02:37:15.873597+00:00", "lat": 48.4788636, "lon": -81.3374641, "altitude_m": 1109.7, "source": "sim"} +{"ts": "2025-08-20T02:37:15.973597+00:00", "lat": 48.4788989, "lon": -81.337444, "altitude_m": 1117.92, "source": "sim"} +{"ts": "2025-08-20T02:37:16.123597+00:00", "lat": 48.4788794, "lon": -81.337444, "altitude_m": 1111.48, "source": "sim"} +{"ts": "2025-08-20T02:37:16.223597+00:00", "lat": 48.4788654, "lon": -81.3374466, "altitude_m": 1109.53, "source": "sim"} +{"ts": "2025-08-20T02:37:16.323597+00:00", "lat": 48.4788574, "lon": -81.3374483, "altitude_m": 1110.42, "source": "sim"} +{"ts": "2025-08-20T02:37:16.423597+00:00", "lat": 48.4788646, "lon": -81.3374437, "altitude_m": 1100.93, "source": "sim"} +{"ts": "2025-08-20T02:37:16.573597+00:00", "lat": 48.4788957, "lon": -81.3374642, "altitude_m": 1103.98, "source": "sim"} +{"ts": "2025-08-20T02:37:16.673597+00:00", "lat": 48.4788748, "lon": -81.337479, "altitude_m": 1107.26, "source": "sim"} +{"ts": "2025-08-20T02:37:16.773597+00:00", "lat": 48.4788842, "lon": -81.3374563, "altitude_m": 1101.37, "source": "sim"} +{"ts": "2025-08-20T02:37:16.873597+00:00", "lat": 48.4788512, "lon": -81.3374176, "altitude_m": 1099.43, "source": "sim"} +{"ts": "2025-08-20T02:37:16.973597+00:00", "lat": 48.4788792, "lon": -81.3374722, "altitude_m": 1105.02, "source": "sim"} +{"ts": "2025-08-20T02:37:17.073597+00:00", "lat": 48.4788657, "lon": -81.3375084, "altitude_m": 1104.87, "source": "sim"} +{"ts": "2025-08-20T02:37:17.223597+00:00", "lat": 48.4788673, "lon": -81.3374241, "altitude_m": 1098.02, "source": "sim"} +{"ts": "2025-08-20T02:37:17.323597+00:00", "lat": 48.4788481, "lon": -81.3374512, "altitude_m": 1101.3, "source": "sim"} +{"ts": "2025-08-20T02:37:17.423597+00:00", "lat": 48.4788805, "lon": -81.3374239, "altitude_m": 1094.32, "source": "sim"} +{"ts": "2025-08-20T02:37:17.523597+00:00", "lat": 48.4788584, "lon": -81.3374897, "altitude_m": 1100.52, "source": "sim"} +{"ts": "2025-08-20T02:37:17.673597+00:00", "lat": 48.4788588, "lon": -81.3374562, "altitude_m": 1095.59, "source": "sim"} +{"ts": "2025-08-20T02:37:17.773597+00:00", "lat": 48.478865, "lon": -81.3374286, "altitude_m": 1097.37, "source": "sim"} +{"ts": "2025-08-20T02:37:17.923597+00:00", "lat": 48.4788868, "lon": -81.3374987, "altitude_m": 1096.97, "source": "sim"} +{"ts": "2025-08-20T02:37:18.023597+00:00", "lat": 48.4788481, "lon": -81.337472, "altitude_m": 1090.96, "source": "sim"} +{"ts": "2025-08-20T02:37:18.123597+00:00", "lat": 48.4788754, "lon": -81.3374271, "altitude_m": 1094.43, "source": "sim"} +{"ts": "2025-08-20T02:37:18.223597+00:00", "lat": 48.4788442, "lon": -81.3374476, "altitude_m": 1095.95, "source": "sim"} +{"ts": "2025-08-20T02:37:18.373597+00:00", "lat": 48.4788357, "lon": -81.3374457, "altitude_m": 1095.71, "source": "sim"} +{"ts": "2025-08-20T02:37:18.473597+00:00", "lat": 48.4788762, "lon": -81.3374152, "altitude_m": 1091.61, "source": "sim"} +{"ts": "2025-08-20T02:37:18.573597+00:00", "lat": 48.4788487, "lon": -81.3374369, "altitude_m": 1090.61, "source": "sim"} +{"ts": "2025-08-20T02:37:18.723597+00:00", "lat": 48.4788452, "lon": -81.3374294, "altitude_m": 1087.78, "source": "sim"} +{"ts": "2025-08-20T02:37:18.873597+00:00", "lat": 48.4788759, "lon": -81.3374499, "altitude_m": 1097.01, "source": "sim"} +{"ts": "2025-08-20T02:37:19.023597+00:00", "lat": 48.4788305, "lon": -81.3374485, "altitude_m": 1090.26, "source": "sim"} +{"ts": "2025-08-20T02:37:19.123597+00:00", "lat": 48.4788829, "lon": -81.3374319, "altitude_m": 1086.03, "source": "sim"} +{"ts": "2025-08-20T02:37:19.273597+00:00", "lat": 48.4788335, "lon": -81.3374413, "altitude_m": 1086.09, "source": "sim"} +{"ts": "2025-08-20T02:37:19.423597+00:00", "lat": 48.4788256, "lon": -81.3374245, "altitude_m": 1077.99, "source": "sim"} +{"ts": "2025-08-20T02:37:19.573597+00:00", "lat": 48.4788523, "lon": -81.3374047, "altitude_m": 1081.44, "source": "sim"} +{"ts": "2025-08-20T02:37:19.723597+00:00", "lat": 48.4788235, "lon": -81.3374096, "altitude_m": 1082.92, "source": "sim"} +{"ts": "2025-08-20T02:37:19.873597+00:00", "lat": 48.4788456, "lon": -81.3373996, "altitude_m": 1080.92, "source": "sim"} +{"ts": "2025-08-20T02:37:19.973597+00:00", "lat": 48.4788217, "lon": -81.3374444, "altitude_m": 1081.64, "source": "sim"} +{"ts": "2025-08-20T02:37:20.073597+00:00", "lat": 48.4788564, "lon": -81.3374331, "altitude_m": 1080.84, "source": "sim"} +{"ts": "2025-08-20T02:37:20.173597+00:00", "lat": 48.4788407, "lon": -81.3374243, "altitude_m": 1077.6, "source": "sim"} +{"ts": "2025-08-20T02:37:20.273597+00:00", "lat": 48.4788399, "lon": -81.3374277, "altitude_m": 1073.24, "source": "sim"} +{"ts": "2025-08-20T02:37:20.423597+00:00", "lat": 48.4788254, "lon": -81.3374455, "altitude_m": 1072.72, "source": "sim"} +{"ts": "2025-08-20T02:37:20.573597+00:00", "lat": 48.4788303, "lon": -81.3373933, "altitude_m": 1073.58, "source": "sim"} +{"ts": "2025-08-20T02:37:20.723597+00:00", "lat": 48.4788298, "lon": -81.3374457, "altitude_m": 1075.4, "source": "sim"} +{"ts": "2025-08-20T02:37:20.873597+00:00", "lat": 48.4788213, "lon": -81.3374055, "altitude_m": 1072.2, "source": "sim"} +{"ts": "2025-08-20T02:37:20.973597+00:00", "lat": 48.4788147, "lon": -81.3374456, "altitude_m": 1068.38, "source": "sim"} +{"ts": "2025-08-20T02:37:21.073597+00:00", "lat": 48.4788437, "lon": -81.3374237, "altitude_m": 1067.54, "source": "sim"} +{"ts": "2025-08-20T02:37:21.223597+00:00", "lat": 48.4788383, "lon": -81.3374244, "altitude_m": 1063.14, "source": "sim"} +{"ts": "2025-08-20T02:37:21.373597+00:00", "lat": 48.4788101, "lon": -81.3374244, "altitude_m": 1067.3, "source": "sim"} +{"ts": "2025-08-20T02:37:21.523597+00:00", "lat": 48.4788349, "lon": -81.3374045, "altitude_m": 1065.36, "source": "sim"} +{"ts": "2025-08-20T02:37:21.673597+00:00", "lat": 48.4788278, "lon": -81.3374252, "altitude_m": 1066.72, "source": "sim"} +{"ts": "2025-08-20T02:37:21.823597+00:00", "lat": 48.4788399, "lon": -81.3374447, "altitude_m": 1065.69, "source": "sim"} +{"ts": "2025-08-20T02:37:21.923597+00:00", "lat": 48.4788441, "lon": -81.3374181, "altitude_m": 1065.29, "source": "sim"} +{"ts": "2025-08-20T02:37:22.073597+00:00", "lat": 48.4787902, "lon": -81.3374128, "altitude_m": 1056.72, "source": "sim"} +{"ts": "2025-08-20T02:37:22.173597+00:00", "lat": 48.4788169, "lon": -81.3374372, "altitude_m": 1060.78, "source": "sim"} +{"ts": "2025-08-20T02:37:22.273597+00:00", "lat": 48.4787922, "lon": -81.3374011, "altitude_m": 1056.34, "source": "sim"} +{"ts": "2025-08-20T02:37:22.473597+00:00", "lat": 48.4788123, "lon": -81.3374427, "altitude_m": 1063.22, "source": "sim"} +{"ts": "2025-08-20T02:37:22.573597+00:00", "lat": 48.478804, "lon": -81.337373, "altitude_m": 1062.77, "source": "sim"} +{"ts": "2025-08-20T02:37:22.673597+00:00", "lat": 48.478812, "lon": -81.3374427, "altitude_m": 1059.16, "source": "sim"} +{"ts": "2025-08-20T02:37:22.773597+00:00", "lat": 48.4788098, "lon": -81.3374317, "altitude_m": 1057.25, "source": "sim"} +{"ts": "2025-08-20T02:37:22.873597+00:00", "lat": 48.4787916, "lon": -81.3374253, "altitude_m": 1057.37, "source": "sim"} +{"ts": "2025-08-20T02:37:22.973597+00:00", "lat": 48.4787978, "lon": -81.3374243, "altitude_m": 1049.48, "source": "sim"} +{"ts": "2025-08-20T02:37:23.123597+00:00", "lat": 48.4787812, "lon": -81.3374256, "altitude_m": 1054.67, "source": "sim"} +{"ts": "2025-08-20T02:37:23.273597+00:00", "lat": 48.4788029, "lon": -81.3374235, "altitude_m": 1050.5, "source": "sim"} +{"ts": "2025-08-20T02:37:23.423597+00:00", "lat": 48.4788042, "lon": -81.3374064, "altitude_m": 1051.25, "source": "sim"} +{"ts": "2025-08-20T02:37:23.573597+00:00", "lat": 48.4788088, "lon": -81.3374111, "altitude_m": 1049.78, "source": "sim"} +{"ts": "2025-08-20T02:37:23.723597+00:00", "lat": 48.4787715, "lon": -81.3373992, "altitude_m": 1049.52, "source": "sim"} +{"ts": "2025-08-20T02:37:23.873597+00:00", "lat": 48.4787823, "lon": -81.3373833, "altitude_m": 1048.03, "source": "sim"} +{"ts": "2025-08-20T02:37:23.973597+00:00", "lat": 48.4788022, "lon": -81.3374058, "altitude_m": 1049.8, "source": "sim"} +{"ts": "2025-08-20T02:37:24.123597+00:00", "lat": 48.4788, "lon": -81.3374218, "altitude_m": 1048.78, "source": "sim"} +{"ts": "2025-08-20T02:37:24.223597+00:00", "lat": 48.478788, "lon": -81.3373627, "altitude_m": 1045.34, "source": "sim"} +{"ts": "2025-08-20T02:37:24.323597+00:00", "lat": 48.4788003, "lon": -81.3374359, "altitude_m": 1039.09, "source": "sim"} +{"ts": "2025-08-20T02:37:24.423597+00:00", "lat": 48.4788265, "lon": -81.3373838, "altitude_m": 1037.52, "source": "sim"} +{"ts": "2025-08-20T02:37:24.523597+00:00", "lat": 48.4787853, "lon": -81.3374055, "altitude_m": 1042.7, "source": "sim"} +{"ts": "2025-08-20T02:37:24.623597+00:00", "lat": 48.4787967, "lon": -81.3374053, "altitude_m": 1039.69, "source": "sim"} +{"ts": "2025-08-20T02:37:24.723597+00:00", "lat": 48.4787988, "lon": -81.3374351, "altitude_m": 1046.77, "source": "sim"} +{"ts": "2025-08-20T02:37:24.873597+00:00", "lat": 48.4787545, "lon": -81.3373981, "altitude_m": 1043.95, "source": "sim"} +{"ts": "2025-08-20T02:37:25.023597+00:00", "lat": 48.4787568, "lon": -81.3373408, "altitude_m": 1044.79, "source": "sim"} +{"ts": "2025-08-20T02:37:25.173597+00:00", "lat": 48.4788155, "lon": -81.3373621, "altitude_m": 1039.03, "source": "sim"} +{"ts": "2025-08-20T02:37:25.273597+00:00", "lat": 48.4787725, "lon": -81.3373531, "altitude_m": 1041.02, "source": "sim"} +{"ts": "2025-08-20T02:37:25.423597+00:00", "lat": 48.4787757, "lon": -81.337389, "altitude_m": 1041.03, "source": "sim"} +{"ts": "2025-08-20T02:37:25.573597+00:00", "lat": 48.4787982, "lon": -81.3374237, "altitude_m": 1038.09, "source": "sim"} +{"ts": "2025-08-20T02:37:25.673597+00:00", "lat": 48.4787816, "lon": -81.3373866, "altitude_m": 1031.79, "source": "sim"} +{"ts": "2025-08-20T02:37:25.823597+00:00", "lat": 48.4788118, "lon": -81.337403, "altitude_m": 1030.82, "source": "sim"} +{"ts": "2025-08-20T02:37:25.973597+00:00", "lat": 48.4787751, "lon": -81.3373845, "altitude_m": 1030.16, "source": "sim"} +{"ts": "2025-08-20T02:37:26.123597+00:00", "lat": 48.4787629, "lon": -81.3373569, "altitude_m": 1025.13, "source": "sim"} +{"ts": "2025-08-20T02:37:26.223597+00:00", "lat": 48.4787828, "lon": -81.3373766, "altitude_m": 1028.63, "source": "sim"} +{"ts": "2025-08-20T02:37:26.323597+00:00", "lat": 48.4787645, "lon": -81.3373861, "altitude_m": 1025.52, "source": "sim"} +{"ts": "2025-08-20T02:37:26.473597+00:00", "lat": 48.4787553, "lon": -81.3374212, "altitude_m": 1030.54, "source": "sim"} +{"ts": "2025-08-20T02:37:26.573597+00:00", "lat": 48.4787652, "lon": -81.3373599, "altitude_m": 1027.05, "source": "sim"} +{"ts": "2025-08-20T02:37:26.673597+00:00", "lat": 48.4787655, "lon": -81.3374092, "altitude_m": 1023.94, "source": "sim"} +{"ts": "2025-08-20T02:37:26.823597+00:00", "lat": 48.478794, "lon": -81.3373828, "altitude_m": 1026.4, "source": "sim"} +{"ts": "2025-08-20T02:37:26.923597+00:00", "lat": 48.478778, "lon": -81.3373689, "altitude_m": 1022.55, "source": "sim"} +{"ts": "2025-08-20T02:37:27.073597+00:00", "lat": 48.4787584, "lon": -81.337351, "altitude_m": 1025.49, "source": "sim"} +{"ts": "2025-08-20T02:37:27.223597+00:00", "lat": 48.4787774, "lon": -81.3373436, "altitude_m": 1017.3, "source": "sim"} +{"ts": "2025-08-20T02:37:27.373597+00:00", "lat": 48.478768, "lon": -81.3373361, "altitude_m": 1017.23, "source": "sim"} +{"ts": "2025-08-20T02:37:27.473597+00:00", "lat": 48.4787455, "lon": -81.3373542, "altitude_m": 1020.01, "source": "sim"} +{"ts": "2025-08-20T02:37:27.623597+00:00", "lat": 48.4787612, "lon": -81.3373452, "altitude_m": 1017.77, "source": "sim"} +{"ts": "2025-08-20T02:37:27.723597+00:00", "lat": 48.4787546, "lon": -81.3373507, "altitude_m": 1020.59, "source": "sim"} +{"ts": "2025-08-20T02:37:27.823597+00:00", "lat": 48.4787521, "lon": -81.3373943, "altitude_m": 1015.75, "source": "sim"} +{"ts": "2025-08-20T02:37:27.923597+00:00", "lat": 48.4787474, "lon": -81.3373289, "altitude_m": 1019.08, "source": "sim"} +{"ts": "2025-08-20T02:37:28.023597+00:00", "lat": 48.4787349, "lon": -81.3373605, "altitude_m": 1016.55, "source": "sim"} +{"ts": "2025-08-20T02:37:28.173597+00:00", "lat": 48.4787503, "lon": -81.3373533, "altitude_m": 1011.11, "source": "sim"} +{"ts": "2025-08-20T02:37:28.323597+00:00", "lat": 48.478773, "lon": -81.3373762, "altitude_m": 1014.88, "source": "sim"} +{"ts": "2025-08-20T02:37:28.423597+00:00", "lat": 48.4787622, "lon": -81.3373451, "altitude_m": 1011.93, "source": "sim"} +{"ts": "2025-08-20T02:37:28.523597+00:00", "lat": 48.4787589, "lon": -81.3373498, "altitude_m": 1008.11, "source": "sim"} +{"ts": "2025-08-20T02:37:28.673597+00:00", "lat": 48.4787368, "lon": -81.3373475, "altitude_m": 1011.06, "source": "sim"} +{"ts": "2025-08-20T02:37:28.823597+00:00", "lat": 48.4787329, "lon": -81.3373487, "altitude_m": 1014.06, "source": "sim"} +{"ts": "2025-08-20T02:37:28.973597+00:00", "lat": 48.4787299, "lon": -81.3373493, "altitude_m": 1010.41, "source": "sim"} +{"ts": "2025-08-20T02:37:29.123597+00:00", "lat": 48.4787451, "lon": -81.3373496, "altitude_m": 1002.78, "source": "sim"} +{"ts": "2025-08-20T02:37:29.223597+00:00", "lat": 48.4787262, "lon": -81.3373495, "altitude_m": 1003.23, "source": "sim"} +{"ts": "2025-08-20T02:37:29.373597+00:00", "lat": 48.4787483, "lon": -81.3373481, "altitude_m": 1007.8, "source": "sim"} +{"ts": "2025-08-20T02:37:29.473597+00:00", "lat": 48.4787501, "lon": -81.3373642, "altitude_m": 1003.48, "source": "sim"} +{"ts": "2025-08-20T02:37:29.623597+00:00", "lat": 48.4787245, "lon": -81.337385, "altitude_m": 1001.81, "source": "sim"} +{"ts": "2025-08-20T02:37:29.773597+00:00", "lat": 48.478716, "lon": -81.3373994, "altitude_m": 1002.17, "source": "sim"} +{"ts": "2025-08-20T02:37:29.873597+00:00", "lat": 48.47873, "lon": -81.337369, "altitude_m": 996.26, "source": "sim"} +{"ts": "2025-08-20T02:37:29.973597+00:00", "lat": 48.4787404, "lon": -81.3373449, "altitude_m": 1006.56, "source": "sim"} +{"ts": "2025-08-20T02:37:30.123597+00:00", "lat": 48.4787579, "lon": -81.3373613, "altitude_m": 997.37, "source": "sim"} +{"ts": "2025-08-20T02:37:30.223597+00:00", "lat": 48.4787366, "lon": -81.3373656, "altitude_m": 1001.86, "source": "sim"} +{"ts": "2025-08-20T02:37:30.373597+00:00", "lat": 48.4787325, "lon": -81.3373148, "altitude_m": 995.92, "source": "sim"} +{"ts": "2025-08-20T02:37:30.523597+00:00", "lat": 48.4787145, "lon": -81.337339, "altitude_m": 995.95, "source": "sim"} +{"ts": "2025-08-20T02:37:30.623597+00:00", "lat": 48.4787308, "lon": -81.3373137, "altitude_m": 1002.59, "source": "sim"} +{"ts": "2025-08-20T02:37:30.723597+00:00", "lat": 48.4787474, "lon": -81.3373138, "altitude_m": 998.21, "source": "sim"} +{"ts": "2025-08-20T02:37:30.823597+00:00", "lat": 48.4787334, "lon": -81.3373708, "altitude_m": 986.81, "source": "sim"} +{"ts": "2025-08-20T02:37:30.923597+00:00", "lat": 48.4787241, "lon": -81.3373405, "altitude_m": 991.71, "source": "sim"} +{"ts": "2025-08-20T02:37:31.023597+00:00", "lat": 48.4787283, "lon": -81.337337, "altitude_m": 995.71, "source": "sim"} +{"ts": "2025-08-20T02:37:31.173597+00:00", "lat": 48.4787177, "lon": -81.3373537, "altitude_m": 992.36, "source": "sim"} +{"ts": "2025-08-20T02:37:31.323597+00:00", "lat": 48.4787297, "lon": -81.3373286, "altitude_m": 985.81, "source": "sim"} +{"ts": "2025-08-20T02:37:31.473597+00:00", "lat": 48.4787305, "lon": -81.3373598, "altitude_m": 991.48, "source": "sim"} +{"ts": "2025-08-20T02:37:31.573597+00:00", "lat": 48.4786884, "lon": -81.3373384, "altitude_m": 986.97, "source": "sim"} +{"ts": "2025-08-20T02:37:31.723597+00:00", "lat": 48.4787144, "lon": -81.337321, "altitude_m": 981.43, "source": "sim"} +{"ts": "2025-08-20T02:37:31.873597+00:00", "lat": 48.4786804, "lon": -81.3373255, "altitude_m": 987.39, "source": "sim"} +{"ts": "2025-08-20T02:37:32.023597+00:00", "lat": 48.4787054, "lon": -81.3373418, "altitude_m": 986.41, "source": "sim"} +{"ts": "2025-08-20T02:37:32.223597+00:00", "lat": 48.4786861, "lon": -81.3373317, "altitude_m": 981.25, "source": "sim"} +{"ts": "2025-08-20T02:37:32.323597+00:00", "lat": 48.4787207, "lon": -81.3373215, "altitude_m": 983.01, "source": "sim"} +{"ts": "2025-08-20T02:37:32.473597+00:00", "lat": 48.4786812, "lon": -81.3372775, "altitude_m": 978.82, "source": "sim"} +{"ts": "2025-08-20T02:37:32.573597+00:00", "lat": 48.478722, "lon": -81.3373172, "altitude_m": 981.08, "source": "sim"} +{"ts": "2025-08-20T02:37:32.723597+00:00", "lat": 48.4787063, "lon": -81.3373612, "altitude_m": 978.83, "source": "sim"} +{"ts": "2025-08-20T02:37:32.823597+00:00", "lat": 48.478711, "lon": -81.3373242, "altitude_m": 977.16, "source": "sim"} +{"ts": "2025-08-20T02:37:32.923597+00:00", "lat": 48.4787001, "lon": -81.3373293, "altitude_m": 977.48, "source": "sim"} +{"ts": "2025-08-20T02:37:33.073597+00:00", "lat": 48.4787019, "lon": -81.337328, "altitude_m": 976.98, "source": "sim"} +{"ts": "2025-08-20T02:37:33.223597+00:00", "lat": 48.4786984, "lon": -81.3373161, "altitude_m": 975.64, "source": "sim"} +{"ts": "2025-08-20T02:37:33.323597+00:00", "lat": 48.4786822, "lon": -81.337329, "altitude_m": 974.86, "source": "sim"} +{"ts": "2025-08-20T02:37:33.423597+00:00", "lat": 48.4786797, "lon": -81.3373213, "altitude_m": 972.84, "source": "sim"} +{"ts": "2025-08-20T02:37:33.573597+00:00", "lat": 48.4787037, "lon": -81.3373016, "altitude_m": 972.53, "source": "sim"} +{"ts": "2025-08-20T02:37:33.723597+00:00", "lat": 48.4786811, "lon": -81.3373664, "altitude_m": 970.11, "source": "sim"} +{"ts": "2025-08-20T02:37:33.823597+00:00", "lat": 48.478679, "lon": -81.3373206, "altitude_m": 966.4, "source": "sim"} +{"ts": "2025-08-20T02:37:33.973597+00:00", "lat": 48.4786702, "lon": -81.337337, "altitude_m": 972.15, "source": "sim"} +{"ts": "2025-08-20T02:37:34.123597+00:00", "lat": 48.478685, "lon": -81.3372999, "altitude_m": 963.74, "source": "sim"} +{"ts": "2025-08-20T02:37:34.223597+00:00", "lat": 48.4786919, "lon": -81.3373348, "altitude_m": 961.05, "source": "sim"} +{"ts": "2025-08-20T02:37:34.323597+00:00", "lat": 48.4786963, "lon": -81.337361, "altitude_m": 960.07, "source": "sim"} +{"ts": "2025-08-20T02:37:34.473597+00:00", "lat": 48.4786716, "lon": -81.3373269, "altitude_m": 966.28, "source": "sim"} +{"ts": "2025-08-20T02:37:34.573597+00:00", "lat": 48.4786989, "lon": -81.3373047, "altitude_m": 965.56, "source": "sim"} +{"ts": "2025-08-20T02:37:34.723597+00:00", "lat": 48.4787035, "lon": -81.3372964, "altitude_m": 963.65, "source": "sim"} +{"ts": "2025-08-20T02:37:34.823597+00:00", "lat": 48.4787062, "lon": -81.3373003, "altitude_m": 959.96, "source": "sim"} +{"ts": "2025-08-20T02:37:34.973597+00:00", "lat": 48.4786594, "lon": -81.3373605, "altitude_m": 952.85, "source": "sim"} +{"ts": "2025-08-20T02:37:35.123597+00:00", "lat": 48.47867, "lon": -81.3372624, "altitude_m": 957.65, "source": "sim"} +{"ts": "2025-08-20T02:37:35.223597+00:00", "lat": 48.4786613, "lon": -81.3372975, "altitude_m": 955.84, "source": "sim"} +{"ts": "2025-08-20T02:37:35.373597+00:00", "lat": 48.4786606, "lon": -81.3373351, "altitude_m": 962.42, "source": "sim"} +{"ts": "2025-08-20T02:37:35.523597+00:00", "lat": 48.4786719, "lon": -81.3373438, "altitude_m": 949.66, "source": "sim"} +{"ts": "2025-08-20T02:37:35.623597+00:00", "lat": 48.4786576, "lon": -81.3373222, "altitude_m": 953.06, "source": "sim"} +{"ts": "2025-08-20T02:37:35.773597+00:00", "lat": 48.4786619, "lon": -81.3373175, "altitude_m": 952.62, "source": "sim"} +{"ts": "2025-08-20T02:37:35.923597+00:00", "lat": 48.4786886, "lon": -81.3372882, "altitude_m": 951.45, "source": "sim"} +{"ts": "2025-08-20T02:37:36.023597+00:00", "lat": 48.4786423, "lon": -81.3373188, "altitude_m": 948.18, "source": "sim"} +{"ts": "2025-08-20T02:37:36.173597+00:00", "lat": 48.4786854, "lon": -81.337294, "altitude_m": 948.97, "source": "sim"} +{"ts": "2025-08-20T02:37:36.323597+00:00", "lat": 48.4786454, "lon": -81.3372798, "altitude_m": 949.27, "source": "sim"} +{"ts": "2025-08-20T02:37:36.473597+00:00", "lat": 48.4786348, "lon": -81.3373034, "altitude_m": 948.89, "source": "sim"} +{"ts": "2025-08-20T02:37:36.573597+00:00", "lat": 48.478655, "lon": -81.3372602, "altitude_m": 954.34, "source": "sim"} +{"ts": "2025-08-20T02:37:36.673597+00:00", "lat": 48.4786576, "lon": -81.3373134, "altitude_m": 946.01, "source": "sim"} +{"ts": "2025-08-20T02:37:36.773597+00:00", "lat": 48.4786645, "lon": -81.3372578, "altitude_m": 941.02, "source": "sim"} +{"ts": "2025-08-20T02:37:36.923597+00:00", "lat": 48.4786508, "lon": -81.3372984, "altitude_m": 938.52, "source": "sim"} +{"ts": "2025-08-20T02:37:37.023597+00:00", "lat": 48.4786687, "lon": -81.3372822, "altitude_m": 940.72, "source": "sim"} +{"ts": "2025-08-20T02:37:37.123597+00:00", "lat": 48.4786659, "lon": -81.3372895, "altitude_m": 943.53, "source": "sim"} +{"ts": "2025-08-20T02:37:37.223597+00:00", "lat": 48.4786573, "lon": -81.3372908, "altitude_m": 948.63, "source": "sim"} +{"ts": "2025-08-20T02:37:37.323597+00:00", "lat": 48.478659, "lon": -81.3373192, "altitude_m": 939.4, "source": "sim"} +{"ts": "2025-08-20T02:37:37.473597+00:00", "lat": 48.4786233, "lon": -81.3372919, "altitude_m": 940.17, "source": "sim"} +{"ts": "2025-08-20T02:37:37.573597+00:00", "lat": 48.4786697, "lon": -81.3373045, "altitude_m": 932.33, "source": "sim"} +{"ts": "2025-08-20T02:37:37.723597+00:00", "lat": 48.4786517, "lon": -81.3373074, "altitude_m": 939.57, "source": "sim"} +{"ts": "2025-08-20T02:37:37.873597+00:00", "lat": 48.4786274, "lon": -81.3372751, "altitude_m": 941.17, "source": "sim"} +{"ts": "2025-08-20T02:37:37.973597+00:00", "lat": 48.4786283, "lon": -81.3372658, "altitude_m": 939.16, "source": "sim"} +{"ts": "2025-08-20T02:37:38.123597+00:00", "lat": 48.4786333, "lon": -81.3372692, "altitude_m": 937.63, "source": "sim"} +{"ts": "2025-08-20T02:37:38.223597+00:00", "lat": 48.4786442, "lon": -81.3372464, "altitude_m": 937.8, "source": "sim"} +{"ts": "2025-08-20T02:37:38.323597+00:00", "lat": 48.4786237, "lon": -81.3373258, "altitude_m": 935.85, "source": "sim"} +{"ts": "2025-08-20T02:37:38.473597+00:00", "lat": 48.4786246, "lon": -81.3373017, "altitude_m": 931.6, "source": "sim"} +{"ts": "2025-08-20T02:37:38.573597+00:00", "lat": 48.4786503, "lon": -81.3372922, "altitude_m": 930.64, "source": "sim"} +{"ts": "2025-08-20T02:37:38.673597+00:00", "lat": 48.4786292, "lon": -81.3373166, "altitude_m": 932.92, "source": "sim"} +{"ts": "2025-08-20T02:37:38.823597+00:00", "lat": 48.4786585, "lon": -81.3372693, "altitude_m": 928.06, "source": "sim"} +{"ts": "2025-08-20T02:37:38.973597+00:00", "lat": 48.4786506, "lon": -81.3372757, "altitude_m": 923.23, "source": "sim"} +{"ts": "2025-08-20T02:37:39.123597+00:00", "lat": 48.4786361, "lon": -81.3372422, "altitude_m": 927.32, "source": "sim"} +{"ts": "2025-08-20T02:37:39.273597+00:00", "lat": 48.4786244, "lon": -81.3372614, "altitude_m": 932.19, "source": "sim"} +{"ts": "2025-08-20T02:37:39.423597+00:00", "lat": 48.4786123, "lon": -81.3373013, "altitude_m": 926.72, "source": "sim"} +{"ts": "2025-08-20T02:37:39.573597+00:00", "lat": 48.4786358, "lon": -81.3372982, "altitude_m": 923.71, "source": "sim"} +{"ts": "2025-08-20T02:37:39.723597+00:00", "lat": 48.4786227, "lon": -81.337268, "altitude_m": 923.04, "source": "sim"} +{"ts": "2025-08-20T02:37:39.823597+00:00", "lat": 48.4786523, "lon": -81.33724, "altitude_m": 917.14, "source": "sim"} +{"ts": "2025-08-20T02:37:39.923597+00:00", "lat": 48.4786291, "lon": -81.3372609, "altitude_m": 915.25, "source": "sim"} +{"ts": "2025-08-20T02:37:40.073597+00:00", "lat": 48.4786239, "lon": -81.3372895, "altitude_m": 914.75, "source": "sim"} +{"ts": "2025-08-20T02:37:40.223597+00:00", "lat": 48.4786274, "lon": -81.3372735, "altitude_m": 916.57, "source": "sim"} +{"ts": "2025-08-20T02:37:40.323597+00:00", "lat": 48.4786103, "lon": -81.3372466, "altitude_m": 912.68, "source": "sim"} +{"ts": "2025-08-20T02:37:40.473597+00:00", "lat": 48.4786407, "lon": -81.3372584, "altitude_m": 915.86, "source": "sim"} +{"ts": "2025-08-20T02:37:40.623597+00:00", "lat": 48.4786065, "lon": -81.3372657, "altitude_m": 916.37, "source": "sim"} +{"ts": "2025-08-20T02:37:40.773597+00:00", "lat": 48.4786377, "lon": -81.3372594, "altitude_m": 920.32, "source": "sim"} +{"ts": "2025-08-20T02:37:40.873597+00:00", "lat": 48.4786206, "lon": -81.3372345, "altitude_m": 913.35, "source": "sim"} +{"ts": "2025-08-20T02:37:40.973597+00:00", "lat": 48.4786235, "lon": -81.3372577, "altitude_m": 912.18, "source": "sim"} +{"ts": "2025-08-20T02:37:41.123597+00:00", "lat": 48.4786066, "lon": -81.3372533, "altitude_m": 905.99, "source": "sim"} +{"ts": "2025-08-20T02:37:41.273597+00:00", "lat": 48.4786176, "lon": -81.3372862, "altitude_m": 908.79, "source": "sim"} +{"ts": "2025-08-20T02:37:41.423597+00:00", "lat": 48.4785933, "lon": -81.3372676, "altitude_m": 904.9, "source": "sim"} +{"ts": "2025-08-20T02:37:41.523597+00:00", "lat": 48.4786147, "lon": -81.3372518, "altitude_m": 905.98, "source": "sim"} +{"ts": "2025-08-20T02:37:41.623597+00:00", "lat": 48.478613, "lon": -81.3372534, "altitude_m": 908.02, "source": "sim"} +{"ts": "2025-08-20T02:37:41.723597+00:00", "lat": 48.4785934, "lon": -81.3372634, "altitude_m": 904.96, "source": "sim"} +{"ts": "2025-08-20T02:37:41.873597+00:00", "lat": 48.478629, "lon": -81.3372895, "altitude_m": 902.13, "source": "sim"} +{"ts": "2025-08-20T02:37:42.023597+00:00", "lat": 48.4786017, "lon": -81.3372438, "altitude_m": 899.66, "source": "sim"} +{"ts": "2025-08-20T02:37:42.123597+00:00", "lat": 48.4786228, "lon": -81.3372806, "altitude_m": 903.51, "source": "sim"} +{"ts": "2025-08-20T02:37:42.273597+00:00", "lat": 48.4786085, "lon": -81.3372728, "altitude_m": 896.79, "source": "sim"} +{"ts": "2025-08-20T02:37:42.373597+00:00", "lat": 48.4785736, "lon": -81.3372613, "altitude_m": 902.36, "source": "sim"} +{"ts": "2025-08-20T02:37:42.473597+00:00", "lat": 48.4785778, "lon": -81.3372393, "altitude_m": 904.07, "source": "sim"} +{"ts": "2025-08-20T02:37:42.573597+00:00", "lat": 48.4785985, "lon": -81.3372303, "altitude_m": 900.39, "source": "sim"} +{"ts": "2025-08-20T02:37:42.673597+00:00", "lat": 48.4786236, "lon": -81.3372601, "altitude_m": 896.33, "source": "sim"} +{"ts": "2025-08-20T02:37:42.823597+00:00", "lat": 48.4786054, "lon": -81.3372784, "altitude_m": 899.19, "source": "sim"} +{"ts": "2025-08-20T02:37:42.923597+00:00", "lat": 48.4785973, "lon": -81.3372179, "altitude_m": 897.96, "source": "sim"} +{"ts": "2025-08-20T02:37:43.073597+00:00", "lat": 48.4785928, "lon": -81.3372502, "altitude_m": 896.59, "source": "sim"} +{"ts": "2025-08-20T02:37:43.223597+00:00", "lat": 48.4785767, "lon": -81.3372321, "altitude_m": 892.83, "source": "sim"} +{"ts": "2025-08-20T02:37:43.373597+00:00", "lat": 48.4786139, "lon": -81.3372662, "altitude_m": 891.53, "source": "sim"} +{"ts": "2025-08-20T02:37:43.473597+00:00", "lat": 48.4785858, "lon": -81.3372414, "altitude_m": 891.53, "source": "sim"} +{"ts": "2025-08-20T02:37:43.623597+00:00", "lat": 48.4785921, "lon": -81.3372761, "altitude_m": 888.47, "source": "sim"} +{"ts": "2025-08-20T02:37:43.723597+00:00", "lat": 48.4785708, "lon": -81.3372583, "altitude_m": 893.15, "source": "sim"} +{"ts": "2025-08-20T02:37:43.823597+00:00", "lat": 48.4785839, "lon": -81.3372189, "altitude_m": 888.97, "source": "sim"} +{"ts": "2025-08-20T02:37:43.973597+00:00", "lat": 48.4786067, "lon": -81.3371951, "altitude_m": 889.63, "source": "sim"} +{"ts": "2025-08-20T02:37:44.073597+00:00", "lat": 48.4785718, "lon": -81.3372293, "altitude_m": 888.34, "source": "sim"} +{"ts": "2025-08-20T02:37:44.223597+00:00", "lat": 48.4785945, "lon": -81.3372495, "altitude_m": 883.22, "source": "sim"} +{"ts": "2025-08-20T02:37:44.323597+00:00", "lat": 48.478582, "lon": -81.3371931, "altitude_m": 885.68, "source": "sim"} +{"ts": "2025-08-20T02:37:44.473597+00:00", "lat": 48.4785688, "lon": -81.3371957, "altitude_m": 883.89, "source": "sim"} +{"ts": "2025-08-20T02:37:44.623597+00:00", "lat": 48.4786112, "lon": -81.3372416, "altitude_m": 882.1, "source": "sim"} +{"ts": "2025-08-20T02:37:44.773597+00:00", "lat": 48.4785828, "lon": -81.3372538, "altitude_m": 882.57, "source": "sim"} +{"ts": "2025-08-20T02:37:44.923597+00:00", "lat": 48.4785698, "lon": -81.3372211, "altitude_m": 880.19, "source": "sim"} +{"ts": "2025-08-20T02:37:45.023597+00:00", "lat": 48.4785708, "lon": -81.33724, "altitude_m": 877.76, "source": "sim"} +{"ts": "2025-08-20T02:37:45.123597+00:00", "lat": 48.4785468, "lon": -81.3372876, "altitude_m": 881.21, "source": "sim"} +{"ts": "2025-08-20T02:37:45.223597+00:00", "lat": 48.4785731, "lon": -81.3372167, "altitude_m": 879.57, "source": "sim"} +{"ts": "2025-08-20T02:37:45.373597+00:00", "lat": 48.4785519, "lon": -81.3372184, "altitude_m": 871.01, "source": "sim"} +{"ts": "2025-08-20T02:37:45.473597+00:00", "lat": 48.4785784, "lon": -81.3372395, "altitude_m": 879.68, "source": "sim"} +{"ts": "2025-08-20T02:37:45.573597+00:00", "lat": 48.4785597, "lon": -81.33726, "altitude_m": 870.78, "source": "sim"} +{"ts": "2025-08-20T02:37:45.673597+00:00", "lat": 48.4785892, "lon": -81.3372227, "altitude_m": 873.62, "source": "sim"} +{"ts": "2025-08-20T02:37:45.823597+00:00", "lat": 48.4785794, "lon": -81.3372115, "altitude_m": 876.06, "source": "sim"} +{"ts": "2025-08-20T02:37:45.973597+00:00", "lat": 48.4785557, "lon": -81.3372079, "altitude_m": 864.71, "source": "sim"} +{"ts": "2025-08-20T02:37:46.123597+00:00", "lat": 48.4785533, "lon": -81.3372241, "altitude_m": 868.21, "source": "sim"} +{"ts": "2025-08-20T02:37:46.273597+00:00", "lat": 48.4785529, "lon": -81.3372095, "altitude_m": 872.52, "source": "sim"} +{"ts": "2025-08-20T02:37:46.373597+00:00", "lat": 48.478537, "lon": -81.337214, "altitude_m": 858.48, "source": "sim"} +{"ts": "2025-08-20T02:37:46.473597+00:00", "lat": 48.4785693, "lon": -81.3372383, "altitude_m": 868.88, "source": "sim"} +{"ts": "2025-08-20T02:37:46.623597+00:00", "lat": 48.4785501, "lon": -81.337207, "altitude_m": 866.75, "source": "sim"} +{"ts": "2025-08-20T02:37:46.773597+00:00", "lat": 48.4785459, "lon": -81.3372117, "altitude_m": 863.95, "source": "sim"} +{"ts": "2025-08-20T02:37:46.873597+00:00", "lat": 48.478531, "lon": -81.3372152, "altitude_m": 862.71, "source": "sim"} +{"ts": "2025-08-20T02:37:47.023597+00:00", "lat": 48.4785361, "lon": -81.3372107, "altitude_m": 864.07, "source": "sim"} +{"ts": "2025-08-20T02:37:47.173597+00:00", "lat": 48.4785726, "lon": -81.3372159, "altitude_m": 859.18, "source": "sim"} +{"ts": "2025-08-20T02:37:47.323597+00:00", "lat": 48.4785472, "lon": -81.3372253, "altitude_m": 856.62, "source": "sim"} +{"ts": "2025-08-20T02:37:47.473597+00:00", "lat": 48.478555, "lon": -81.337193, "altitude_m": 860.77, "source": "sim"} +{"ts": "2025-08-20T02:37:47.573597+00:00", "lat": 48.4785449, "lon": -81.337241, "altitude_m": 853.17, "source": "sim"} +{"ts": "2025-08-20T02:37:47.723597+00:00", "lat": 48.4785376, "lon": -81.337217, "altitude_m": 854.72, "source": "sim"} +{"ts": "2025-08-20T02:37:47.873597+00:00", "lat": 48.4785464, "lon": -81.3372085, "altitude_m": 854.93, "source": "sim"} +{"ts": "2025-08-20T02:37:48.023597+00:00", "lat": 48.4785374, "lon": -81.3372679, "altitude_m": 851.95, "source": "sim"} +{"ts": "2025-08-20T02:37:48.123597+00:00", "lat": 48.4785449, "lon": -81.3372076, "altitude_m": 852.27, "source": "sim"} +{"ts": "2025-08-20T02:37:48.223597+00:00", "lat": 48.4785298, "lon": -81.3372042, "altitude_m": 854.3, "source": "sim"} +{"ts": "2025-08-20T02:37:48.373597+00:00", "lat": 48.4785404, "lon": -81.3372019, "altitude_m": 854.62, "source": "sim"} +{"ts": "2025-08-20T02:37:48.473597+00:00", "lat": 48.4785259, "lon": -81.3372219, "altitude_m": 851.27, "source": "sim"} +{"ts": "2025-08-20T02:37:48.623597+00:00", "lat": 48.4785601, "lon": -81.3372179, "altitude_m": 852.81, "source": "sim"} +{"ts": "2025-08-20T02:37:48.773597+00:00", "lat": 48.4785523, "lon": -81.3371747, "altitude_m": 849.99, "source": "sim"} +{"ts": "2025-08-20T02:37:48.873597+00:00", "lat": 48.478549, "lon": -81.3371929, "altitude_m": 845.62, "source": "sim"} +{"ts": "2025-08-20T02:37:49.023597+00:00", "lat": 48.4785178, "lon": -81.3372064, "altitude_m": 848.87, "source": "sim"} +{"ts": "2025-08-20T02:37:49.123597+00:00", "lat": 48.4785338, "lon": -81.3372022, "altitude_m": 846.8, "source": "sim"} +{"ts": "2025-08-20T02:37:49.223597+00:00", "lat": 48.478536, "lon": -81.3371997, "altitude_m": 850.25, "source": "sim"} +{"ts": "2025-08-20T02:37:49.323597+00:00", "lat": 48.4785213, "lon": -81.3371905, "altitude_m": 840.12, "source": "sim"} +{"ts": "2025-08-20T02:37:49.423597+00:00", "lat": 48.4785406, "lon": -81.3371766, "altitude_m": 842.5, "source": "sim"} +{"ts": "2025-08-20T02:37:49.523597+00:00", "lat": 48.4785414, "lon": -81.3371996, "altitude_m": 838.71, "source": "sim"} +{"ts": "2025-08-20T02:37:49.673597+00:00", "lat": 48.4785412, "lon": -81.3371729, "altitude_m": 844.16, "source": "sim"} +{"ts": "2025-08-20T02:37:49.823597+00:00", "lat": 48.4785187, "lon": -81.3372368, "altitude_m": 839.83, "source": "sim"} +{"ts": "2025-08-20T02:37:49.973597+00:00", "lat": 48.4785605, "lon": -81.3372187, "altitude_m": 841.53, "source": "sim"} +{"ts": "2025-08-20T02:37:50.073597+00:00", "lat": 48.4785289, "lon": -81.3372133, "altitude_m": 837.9, "source": "sim"} +{"ts": "2025-08-20T02:37:50.223597+00:00", "lat": 48.4784982, "lon": -81.3371756, "altitude_m": 834.92, "source": "sim"} +{"ts": "2025-08-20T02:37:50.323597+00:00", "lat": 48.4785131, "lon": -81.3371908, "altitude_m": 832.96, "source": "sim"} +{"ts": "2025-08-20T02:37:50.473597+00:00", "lat": 48.4785237, "lon": -81.3372085, "altitude_m": 837.46, "source": "sim"} +{"ts": "2025-08-20T02:37:50.573597+00:00", "lat": 48.4785175, "lon": -81.3371596, "altitude_m": 831.54, "source": "sim"} +{"ts": "2025-08-20T02:37:50.723597+00:00", "lat": 48.4785183, "lon": -81.3372035, "altitude_m": 833.33, "source": "sim"} +{"ts": "2025-08-20T02:37:50.823597+00:00", "lat": 48.4784887, "lon": -81.3371968, "altitude_m": 835.77, "source": "sim"} +{"ts": "2025-08-20T02:37:50.973597+00:00", "lat": 48.4785105, "lon": -81.3372115, "altitude_m": 832.0, "source": "sim"} +{"ts": "2025-08-20T02:37:51.073597+00:00", "lat": 48.4785592, "lon": -81.3371866, "altitude_m": 836.05, "source": "sim"} +{"ts": "2025-08-20T02:37:51.223597+00:00", "lat": 48.478527, "lon": -81.3371706, "altitude_m": 830.9, "source": "sim"} +{"ts": "2025-08-20T02:37:51.373597+00:00", "lat": 48.4785061, "lon": -81.3372238, "altitude_m": 826.79, "source": "sim"} +{"ts": "2025-08-20T02:37:51.473597+00:00", "lat": 48.4785197, "lon": -81.3371451, "altitude_m": 821.18, "source": "sim"} +{"ts": "2025-08-20T02:37:51.573597+00:00", "lat": 48.4785163, "lon": -81.3372064, "altitude_m": 831.48, "source": "sim"} +{"ts": "2025-08-20T02:37:51.673597+00:00", "lat": 48.4784975, "lon": -81.3371843, "altitude_m": 824.46, "source": "sim"} +{"ts": "2025-08-20T02:37:51.773597+00:00", "lat": 48.4784904, "lon": -81.3372092, "altitude_m": 826.57, "source": "sim"} +{"ts": "2025-08-20T02:37:51.873597+00:00", "lat": 48.4784899, "lon": -81.3372003, "altitude_m": 823.64, "source": "sim"} +{"ts": "2025-08-20T02:37:52.023597+00:00", "lat": 48.478507, "lon": -81.3371976, "altitude_m": 825.15, "source": "sim"} +{"ts": "2025-08-20T02:37:52.123597+00:00", "lat": 48.4784974, "lon": -81.33717, "altitude_m": 823.54, "source": "sim"} +{"ts": "2025-08-20T02:37:52.273597+00:00", "lat": 48.4784983, "lon": -81.3371945, "altitude_m": 821.56, "source": "sim"} +{"ts": "2025-08-20T02:37:52.423597+00:00", "lat": 48.4784913, "lon": -81.3371633, "altitude_m": 821.97, "source": "sim"} +{"ts": "2025-08-20T02:37:52.573597+00:00", "lat": 48.4784564, "lon": -81.33718, "altitude_m": 816.89, "source": "sim"} +{"ts": "2025-08-20T02:37:52.723597+00:00", "lat": 48.4784647, "lon": -81.3371996, "altitude_m": 814.28, "source": "sim"} +{"ts": "2025-08-20T02:37:52.823597+00:00", "lat": 48.4784906, "lon": -81.33715, "altitude_m": 821.94, "source": "sim"} +{"ts": "2025-08-20T02:37:52.923597+00:00", "lat": 48.4784972, "lon": -81.3371696, "altitude_m": 815.15, "source": "sim"} +{"ts": "2025-08-20T02:37:53.023597+00:00", "lat": 48.4785068, "lon": -81.3372301, "altitude_m": 814.97, "source": "sim"} +{"ts": "2025-08-20T02:37:53.173597+00:00", "lat": 48.4784704, "lon": -81.337175, "altitude_m": 814.94, "source": "sim"} +{"ts": "2025-08-20T02:37:53.273597+00:00", "lat": 48.4784701, "lon": -81.3371586, "altitude_m": 814.0, "source": "sim"} +{"ts": "2025-08-20T02:37:53.373597+00:00", "lat": 48.4784931, "lon": -81.337161, "altitude_m": 813.19, "source": "sim"} +{"ts": "2025-08-20T02:37:53.473597+00:00", "lat": 48.4784812, "lon": -81.337171, "altitude_m": 813.22, "source": "sim"} +{"ts": "2025-08-20T02:37:53.623597+00:00", "lat": 48.4785099, "lon": -81.3371959, "altitude_m": 814.68, "source": "sim"} +{"ts": "2025-08-20T02:37:53.723597+00:00", "lat": 48.4784681, "lon": -81.3371189, "altitude_m": 810.46, "source": "sim"} +{"ts": "2025-08-20T02:37:53.823597+00:00", "lat": 48.4784708, "lon": -81.3371808, "altitude_m": 804.11, "source": "sim"} +{"ts": "2025-08-20T02:37:53.973597+00:00", "lat": 48.4784704, "lon": -81.3371907, "altitude_m": 811.89, "source": "sim"} +{"ts": "2025-08-20T02:37:54.123597+00:00", "lat": 48.4784696, "lon": -81.3371882, "altitude_m": 807.12, "source": "sim"} +{"ts": "2025-08-20T02:37:54.223597+00:00", "lat": 48.4785014, "lon": -81.3371178, "altitude_m": 810.75, "source": "sim"} +{"ts": "2025-08-20T02:37:54.423597+00:00", "lat": 48.4784545, "lon": -81.3371692, "altitude_m": 803.12, "source": "sim"} +{"ts": "2025-08-20T02:37:54.573597+00:00", "lat": 48.4784652, "lon": -81.3371118, "altitude_m": 797.14, "source": "sim"} +{"ts": "2025-08-20T02:37:54.723597+00:00", "lat": 48.4784934, "lon": -81.3371608, "altitude_m": 794.99, "source": "sim"} +{"ts": "2025-08-20T02:37:54.823597+00:00", "lat": 48.4784565, "lon": -81.3371169, "altitude_m": 800.38, "source": "sim"} +{"ts": "2025-08-20T02:37:54.923597+00:00", "lat": 48.4784695, "lon": -81.3371462, "altitude_m": 796.69, "source": "sim"} +{"ts": "2025-08-20T02:37:55.023597+00:00", "lat": 48.4784585, "lon": -81.3371417, "altitude_m": 792.13, "source": "sim"} +{"ts": "2025-08-20T02:37:55.123597+00:00", "lat": 48.4784527, "lon": -81.3371597, "altitude_m": 797.08, "source": "sim"} +{"ts": "2025-08-20T02:37:55.273597+00:00", "lat": 48.4784643, "lon": -81.3371776, "altitude_m": 799.3, "source": "sim"} +{"ts": "2025-08-20T02:37:55.373597+00:00", "lat": 48.4784681, "lon": -81.3371528, "altitude_m": 794.28, "source": "sim"} +{"ts": "2025-08-20T02:37:55.523597+00:00", "lat": 48.4784671, "lon": -81.3371292, "altitude_m": 793.97, "source": "sim"} +{"ts": "2025-08-20T02:37:55.673597+00:00", "lat": 48.4784759, "lon": -81.3371498, "altitude_m": 789.07, "source": "sim"} +{"ts": "2025-08-20T02:37:55.773597+00:00", "lat": 48.4784638, "lon": -81.3371573, "altitude_m": 792.21, "source": "sim"} +{"ts": "2025-08-20T02:37:55.923597+00:00", "lat": 48.4784545, "lon": -81.3371548, "altitude_m": 791.09, "source": "sim"} +{"ts": "2025-08-20T02:37:56.023597+00:00", "lat": 48.4784823, "lon": -81.3371239, "altitude_m": 789.42, "source": "sim"} +{"ts": "2025-08-20T02:37:56.173597+00:00", "lat": 48.4784604, "lon": -81.3371731, "altitude_m": 786.01, "source": "sim"} +{"ts": "2025-08-20T02:37:56.273597+00:00", "lat": 48.4784687, "lon": -81.3371881, "altitude_m": 792.34, "source": "sim"} +{"ts": "2025-08-20T02:37:56.423597+00:00", "lat": 48.4784261, "lon": -81.3371176, "altitude_m": 792.91, "source": "sim"} +{"ts": "2025-08-20T02:37:56.523597+00:00", "lat": 48.4784274, "lon": -81.3371649, "altitude_m": 786.84, "source": "sim"} +{"ts": "2025-08-20T02:37:56.623597+00:00", "lat": 48.4784636, "lon": -81.33715, "altitude_m": 787.35, "source": "sim"} +{"ts": "2025-08-20T02:37:56.723597+00:00", "lat": 48.4784388, "lon": -81.3371471, "altitude_m": 787.16, "source": "sim"} +{"ts": "2025-08-20T02:37:56.923597+00:00", "lat": 48.4784356, "lon": -81.3371786, "altitude_m": 785.53, "source": "sim"} +{"ts": "2025-08-20T02:37:57.073597+00:00", "lat": 48.4784543, "lon": -81.3370963, "altitude_m": 781.72, "source": "sim"} +{"ts": "2025-08-20T02:37:57.173597+00:00", "lat": 48.4784384, "lon": -81.3371503, "altitude_m": 788.71, "source": "sim"} +{"ts": "2025-08-20T02:37:57.273597+00:00", "lat": 48.4784267, "lon": -81.337149, "altitude_m": 779.82, "source": "sim"} +{"ts": "2025-08-20T02:37:57.373597+00:00", "lat": 48.4784686, "lon": -81.3371202, "altitude_m": 785.12, "source": "sim"} +{"ts": "2025-08-20T02:37:57.523597+00:00", "lat": 48.4784273, "lon": -81.3371743, "altitude_m": 776.89, "source": "sim"} +{"ts": "2025-08-20T02:37:57.623597+00:00", "lat": 48.4784486, "lon": -81.3371622, "altitude_m": 782.67, "source": "sim"} +{"ts": "2025-08-20T02:37:57.773597+00:00", "lat": 48.4784168, "lon": -81.3370984, "altitude_m": 776.59, "source": "sim"} +{"ts": "2025-08-20T02:37:57.873597+00:00", "lat": 48.4784266, "lon": -81.3371512, "altitude_m": 774.57, "source": "sim"} +{"ts": "2025-08-20T02:37:58.023597+00:00", "lat": 48.478447, "lon": -81.3371331, "altitude_m": 774.81, "source": "sim"} +{"ts": "2025-08-20T02:37:58.173597+00:00", "lat": 48.4784224, "lon": -81.3371353, "altitude_m": 774.95, "source": "sim"} +{"ts": "2025-08-20T02:37:58.323597+00:00", "lat": 48.4784335, "lon": -81.3371279, "altitude_m": 771.59, "source": "sim"} +{"ts": "2025-08-20T02:37:58.423597+00:00", "lat": 48.4784568, "lon": -81.3371339, "altitude_m": 779.11, "source": "sim"} +{"ts": "2025-08-20T02:37:58.523597+00:00", "lat": 48.4784495, "lon": -81.3371518, "altitude_m": 772.83, "source": "sim"} +{"ts": "2025-08-20T02:37:58.673597+00:00", "lat": 48.4784218, "lon": -81.3371478, "altitude_m": 769.72, "source": "sim"} +{"ts": "2025-08-20T02:37:58.773597+00:00", "lat": 48.4784276, "lon": -81.3371556, "altitude_m": 769.9, "source": "sim"} +{"ts": "2025-08-20T02:37:58.873597+00:00", "lat": 48.4784096, "lon": -81.3371415, "altitude_m": 767.71, "source": "sim"} +{"ts": "2025-08-20T02:37:58.973597+00:00", "lat": 48.4784255, "lon": -81.3371556, "altitude_m": 769.01, "source": "sim"} +{"ts": "2025-08-20T02:37:59.073597+00:00", "lat": 48.4784238, "lon": -81.3371332, "altitude_m": 770.57, "source": "sim"} +{"ts": "2025-08-20T02:37:59.173597+00:00", "lat": 48.4784222, "lon": -81.3370885, "altitude_m": 762.26, "source": "sim"} +{"ts": "2025-08-20T02:37:59.323597+00:00", "lat": 48.4784042, "lon": -81.3371137, "altitude_m": 766.96, "source": "sim"} +{"ts": "2025-08-20T02:37:59.473597+00:00", "lat": 48.4784251, "lon": -81.3371054, "altitude_m": 765.07, "source": "sim"} +{"ts": "2025-08-20T02:37:59.623597+00:00", "lat": 48.4783983, "lon": -81.3370815, "altitude_m": 759.81, "source": "sim"} +{"ts": "2025-08-20T02:37:59.723597+00:00", "lat": 48.4784045, "lon": -81.3371233, "altitude_m": 761.9, "source": "sim"} +{"ts": "2025-08-20T02:37:59.873597+00:00", "lat": 48.4784367, "lon": -81.337148, "altitude_m": 750.04, "source": "sim"} +{"ts": "2025-08-20T02:38:00.023597+00:00", "lat": 48.4783984, "lon": -81.3370941, "altitude_m": 761.06, "source": "sim"} +{"ts": "2025-08-20T02:38:00.123597+00:00", "lat": 48.4784078, "lon": -81.3371077, "altitude_m": 751.97, "source": "sim"} +{"ts": "2025-08-20T02:38:00.223597+00:00", "lat": 48.4784462, "lon": -81.3371492, "altitude_m": 760.09, "source": "sim"} +{"ts": "2025-08-20T02:38:00.373597+00:00", "lat": 48.4784049, "lon": -81.3371107, "altitude_m": 756.07, "source": "sim"} +{"ts": "2025-08-20T02:38:00.473597+00:00", "lat": 48.4784332, "lon": -81.3371344, "altitude_m": 757.64, "source": "sim"} +{"ts": "2025-08-20T02:38:00.623597+00:00", "lat": 48.4784178, "lon": -81.3371114, "altitude_m": 753.14, "source": "sim"} +{"ts": "2025-08-20T02:38:00.723597+00:00", "lat": 48.4784147, "lon": -81.3371163, "altitude_m": 754.77, "source": "sim"} +{"ts": "2025-08-20T02:38:00.873597+00:00", "lat": 48.4784041, "lon": -81.3371044, "altitude_m": 753.29, "source": "sim"} +{"ts": "2025-08-20T02:38:01.023597+00:00", "lat": 48.478415, "lon": -81.3371039, "altitude_m": 755.38, "source": "sim"} +{"ts": "2025-08-20T02:38:01.123597+00:00", "lat": 48.4783987, "lon": -81.3371044, "altitude_m": 751.22, "source": "sim"} +{"ts": "2025-08-20T02:38:01.223597+00:00", "lat": 48.4783854, "lon": -81.3371077, "altitude_m": 754.21, "source": "sim"} +{"ts": "2025-08-20T02:38:01.373597+00:00", "lat": 48.4784013, "lon": -81.3371262, "altitude_m": 746.93, "source": "sim"} +{"ts": "2025-08-20T02:38:01.473597+00:00", "lat": 48.4784169, "lon": -81.3371081, "altitude_m": 747.84, "source": "sim"} +{"ts": "2025-08-20T02:38:01.623597+00:00", "lat": 48.4783634, "lon": -81.3371358, "altitude_m": 744.27, "source": "sim"} +{"ts": "2025-08-20T02:38:01.723597+00:00", "lat": 48.4783681, "lon": -81.3371102, "altitude_m": 741.87, "source": "sim"} +{"ts": "2025-08-20T02:38:01.823597+00:00", "lat": 48.4784002, "lon": -81.3370975, "altitude_m": 745.69, "source": "sim"} +{"ts": "2025-08-20T02:38:01.923597+00:00", "lat": 48.4784115, "lon": -81.3371065, "altitude_m": 749.71, "source": "sim"} +{"ts": "2025-08-20T02:38:02.023597+00:00", "lat": 48.478404, "lon": -81.3371035, "altitude_m": 739.63, "source": "sim"} +{"ts": "2025-08-20T02:38:02.123597+00:00", "lat": 48.4783825, "lon": -81.3370753, "altitude_m": 746.31, "source": "sim"} +{"ts": "2025-08-20T02:38:02.273597+00:00", "lat": 48.4783775, "lon": -81.3371092, "altitude_m": 738.73, "source": "sim"} +{"ts": "2025-08-20T02:38:02.423597+00:00", "lat": 48.4783789, "lon": -81.3370938, "altitude_m": 743.29, "source": "sim"} +{"ts": "2025-08-20T02:38:02.573597+00:00", "lat": 48.4783762, "lon": -81.3371015, "altitude_m": 736.65, "source": "sim"} +{"ts": "2025-08-20T02:38:02.673597+00:00", "lat": 48.4783805, "lon": -81.3370783, "altitude_m": 735.61, "source": "sim"} +{"ts": "2025-08-20T02:38:02.823597+00:00", "lat": 48.4783958, "lon": -81.3370543, "altitude_m": 738.5, "source": "sim"} +{"ts": "2025-08-20T02:38:02.973597+00:00", "lat": 48.4783956, "lon": -81.3370964, "altitude_m": 734.94, "source": "sim"} +{"ts": "2025-08-20T02:38:03.073597+00:00", "lat": 48.478365, "lon": -81.337103, "altitude_m": 731.64, "source": "sim"} +{"ts": "2025-08-20T02:38:03.223597+00:00", "lat": 48.4783761, "lon": -81.3370963, "altitude_m": 732.38, "source": "sim"} +{"ts": "2025-08-20T02:38:03.373597+00:00", "lat": 48.4783648, "lon": -81.3371145, "altitude_m": 736.29, "source": "sim"} +{"ts": "2025-08-20T02:38:03.473597+00:00", "lat": 48.4783928, "lon": -81.3371155, "altitude_m": 731.04, "source": "sim"} +{"ts": "2025-08-20T02:38:03.573597+00:00", "lat": 48.4783959, "lon": -81.3370838, "altitude_m": 733.22, "source": "sim"} +{"ts": "2025-08-20T02:38:03.723597+00:00", "lat": 48.4783855, "lon": -81.3370517, "altitude_m": 724.48, "source": "sim"} +{"ts": "2025-08-20T02:38:03.823597+00:00", "lat": 48.4783777, "lon": -81.3370972, "altitude_m": 726.94, "source": "sim"} +{"ts": "2025-08-20T02:38:03.923597+00:00", "lat": 48.4783867, "lon": -81.3370964, "altitude_m": 727.74, "source": "sim"} +{"ts": "2025-08-20T02:38:04.073597+00:00", "lat": 48.4783606, "lon": -81.3370973, "altitude_m": 726.73, "source": "sim"} +{"ts": "2025-08-20T02:38:04.173597+00:00", "lat": 48.478374, "lon": -81.3370908, "altitude_m": 725.16, "source": "sim"} +{"ts": "2025-08-20T02:38:04.323597+00:00", "lat": 48.4783518, "lon": -81.337084, "altitude_m": 722.4, "source": "sim"} +{"ts": "2025-08-20T02:38:04.423597+00:00", "lat": 48.4783752, "lon": -81.3370875, "altitude_m": 720.07, "source": "sim"} +{"ts": "2025-08-20T02:38:04.523597+00:00", "lat": 48.4783719, "lon": -81.3370884, "altitude_m": 725.73, "source": "sim"} +{"ts": "2025-08-20T02:38:04.673597+00:00", "lat": 48.4783749, "lon": -81.3370967, "altitude_m": 725.82, "source": "sim"} +{"ts": "2025-08-20T02:38:04.773597+00:00", "lat": 48.4783631, "lon": -81.3371093, "altitude_m": 717.34, "source": "sim"} +{"ts": "2025-08-20T02:38:04.873597+00:00", "lat": 48.4783697, "lon": -81.3371034, "altitude_m": 725.85, "source": "sim"} +{"ts": "2025-08-20T02:38:05.023597+00:00", "lat": 48.4783613, "lon": -81.3370675, "altitude_m": 719.89, "source": "sim"} +{"ts": "2025-08-20T02:38:05.173597+00:00", "lat": 48.478348, "lon": -81.3370734, "altitude_m": 715.49, "source": "sim"} +{"ts": "2025-08-20T02:38:05.273597+00:00", "lat": 48.4783594, "lon": -81.3370679, "altitude_m": 714.48, "source": "sim"} +{"ts": "2025-08-20T02:38:05.373597+00:00", "lat": 48.4783462, "lon": -81.3370547, "altitude_m": 717.11, "source": "sim"} +{"ts": "2025-08-20T02:38:05.473597+00:00", "lat": 48.4783525, "lon": -81.3370938, "altitude_m": 717.78, "source": "sim"} +{"ts": "2025-08-20T02:38:05.623597+00:00", "lat": 48.4783299, "lon": -81.337067, "altitude_m": 716.2, "source": "sim"} +{"ts": "2025-08-20T02:38:05.773597+00:00", "lat": 48.4783621, "lon": -81.3370714, "altitude_m": 710.99, "source": "sim"} +{"ts": "2025-08-20T02:38:05.923597+00:00", "lat": 48.4783242, "lon": -81.337059, "altitude_m": 713.44, "source": "sim"} +{"ts": "2025-08-20T02:38:06.023597+00:00", "lat": 48.4783568, "lon": -81.3371126, "altitude_m": 714.37, "source": "sim"} +{"ts": "2025-08-20T02:38:06.173597+00:00", "lat": 48.4783448, "lon": -81.3370725, "altitude_m": 707.89, "source": "sim"} +{"ts": "2025-08-20T02:38:06.273597+00:00", "lat": 48.4783403, "lon": -81.3370579, "altitude_m": 707.93, "source": "sim"} +{"ts": "2025-08-20T02:38:06.373597+00:00", "lat": 48.4783544, "lon": -81.3370376, "altitude_m": 707.71, "source": "sim"} +{"ts": "2025-08-20T02:38:06.523597+00:00", "lat": 48.478349, "lon": -81.3370789, "altitude_m": 709.16, "source": "sim"} +{"ts": "2025-08-20T02:38:06.623597+00:00", "lat": 48.4783797, "lon": -81.3370819, "altitude_m": 707.55, "source": "sim"} +{"ts": "2025-08-20T02:38:06.773597+00:00", "lat": 48.478359, "lon": -81.3370873, "altitude_m": 702.06, "source": "sim"} +{"ts": "2025-08-20T02:38:06.923597+00:00", "lat": 48.4783437, "lon": -81.3370859, "altitude_m": 704.53, "source": "sim"} +{"ts": "2025-08-20T02:38:07.023597+00:00", "lat": 48.4783351, "lon": -81.3370932, "altitude_m": 704.3, "source": "sim"} +{"ts": "2025-08-20T02:38:07.123597+00:00", "lat": 48.4783593, "lon": -81.3370481, "altitude_m": 705.02, "source": "sim"} +{"ts": "2025-08-20T02:38:07.223597+00:00", "lat": 48.4783547, "lon": -81.3370501, "altitude_m": 701.11, "source": "sim"} +{"ts": "2025-08-20T02:38:07.323597+00:00", "lat": 48.4783323, "lon": -81.337036, "altitude_m": 697.85, "source": "sim"} +{"ts": "2025-08-20T02:38:07.423597+00:00", "lat": 48.4783584, "lon": -81.3370629, "altitude_m": 702.12, "source": "sim"} +{"ts": "2025-08-20T02:38:07.523597+00:00", "lat": 48.4783346, "lon": -81.3370562, "altitude_m": 695.6, "source": "sim"} +{"ts": "2025-08-20T02:38:07.623597+00:00", "lat": 48.4783161, "lon": -81.337055, "altitude_m": 701.57, "source": "sim"} +{"ts": "2025-08-20T02:38:07.773597+00:00", "lat": 48.4783319, "lon": -81.3370468, "altitude_m": 698.73, "source": "sim"} +{"ts": "2025-08-20T02:38:07.873597+00:00", "lat": 48.4783032, "lon": -81.3370665, "altitude_m": 692.31, "source": "sim"} +{"ts": "2025-08-20T02:38:07.973597+00:00", "lat": 48.4783388, "lon": -81.3370917, "altitude_m": 701.41, "source": "sim"} +{"ts": "2025-08-20T02:38:08.073597+00:00", "lat": 48.478286, "lon": -81.3370524, "altitude_m": 698.32, "source": "sim"} +{"ts": "2025-08-20T02:38:08.173597+00:00", "lat": 48.4783341, "lon": -81.3370689, "altitude_m": 697.12, "source": "sim"} +{"ts": "2025-08-20T02:38:08.323597+00:00", "lat": 48.4783469, "lon": -81.3370202, "altitude_m": 695.31, "source": "sim"} +{"ts": "2025-08-20T02:38:08.473597+00:00", "lat": 48.4783226, "lon": -81.3370464, "altitude_m": 689.52, "source": "sim"} +{"ts": "2025-08-20T02:38:08.623597+00:00", "lat": 48.4783243, "lon": -81.3370542, "altitude_m": 686.97, "source": "sim"} +{"ts": "2025-08-20T02:38:08.773597+00:00", "lat": 48.4783347, "lon": -81.3370475, "altitude_m": 686.72, "source": "sim"} +{"ts": "2025-08-20T02:38:08.873597+00:00", "lat": 48.4783207, "lon": -81.3370607, "altitude_m": 687.57, "source": "sim"} +{"ts": "2025-08-20T02:38:08.973597+00:00", "lat": 48.4783068, "lon": -81.3371031, "altitude_m": 685.77, "source": "sim"} +{"ts": "2025-08-20T02:38:09.073597+00:00", "lat": 48.4783169, "lon": -81.3370634, "altitude_m": 690.73, "source": "sim"} +{"ts": "2025-08-20T02:38:09.173597+00:00", "lat": 48.4783242, "lon": -81.3370512, "altitude_m": 681.24, "source": "sim"} +{"ts": "2025-08-20T02:38:09.323597+00:00", "lat": 48.4782867, "lon": -81.3370586, "altitude_m": 684.82, "source": "sim"} +{"ts": "2025-08-20T02:38:09.423597+00:00", "lat": 48.4783329, "lon": -81.3370684, "altitude_m": 685.09, "source": "sim"} +{"ts": "2025-08-20T02:38:09.573597+00:00", "lat": 48.4783064, "lon": -81.3370461, "altitude_m": 679.98, "source": "sim"} +{"ts": "2025-08-20T02:38:09.723597+00:00", "lat": 48.4783244, "lon": -81.3370499, "altitude_m": 680.11, "source": "sim"} +{"ts": "2025-08-20T02:38:09.873597+00:00", "lat": 48.4783158, "lon": -81.3370576, "altitude_m": 682.21, "source": "sim"} +{"ts": "2025-08-20T02:38:09.973597+00:00", "lat": 48.4783042, "lon": -81.3370334, "altitude_m": 681.47, "source": "sim"} +{"ts": "2025-08-20T02:38:10.073597+00:00", "lat": 48.4783043, "lon": -81.3370444, "altitude_m": 675.62, "source": "sim"} +{"ts": "2025-08-20T02:38:10.273597+00:00", "lat": 48.4782863, "lon": -81.337035, "altitude_m": 676.62, "source": "sim"} +{"ts": "2025-08-20T02:38:10.373597+00:00", "lat": 48.4782965, "lon": -81.3370965, "altitude_m": 672.56, "source": "sim"} +{"ts": "2025-08-20T02:38:10.523597+00:00", "lat": 48.4782976, "lon": -81.3370331, "altitude_m": 670.33, "source": "sim"} +{"ts": "2025-08-20T02:38:10.623597+00:00", "lat": 48.478303, "lon": -81.337022, "altitude_m": 672.22, "source": "sim"} +{"ts": "2025-08-20T02:38:10.723597+00:00", "lat": 48.4783206, "lon": -81.3370434, "altitude_m": 673.58, "source": "sim"} +{"ts": "2025-08-20T02:38:10.823597+00:00", "lat": 48.478328, "lon": -81.3370592, "altitude_m": 674.17, "source": "sim"} +{"ts": "2025-08-20T02:38:10.923597+00:00", "lat": 48.4782942, "lon": -81.3370533, "altitude_m": 672.75, "source": "sim"} +{"ts": "2025-08-20T02:38:11.073597+00:00", "lat": 48.4782751, "lon": -81.3370252, "altitude_m": 671.74, "source": "sim"} +{"ts": "2025-08-20T02:38:11.173597+00:00", "lat": 48.4782921, "lon": -81.337036, "altitude_m": 672.61, "source": "sim"} +{"ts": "2025-08-20T02:38:11.273597+00:00", "lat": 48.478287, "lon": -81.3370549, "altitude_m": 670.9, "source": "sim"} +{"ts": "2025-08-20T02:38:11.423597+00:00", "lat": 48.4782969, "lon": -81.3370113, "altitude_m": 667.62, "source": "sim"} +{"ts": "2025-08-20T02:38:11.573597+00:00", "lat": 48.4782724, "lon": -81.3370328, "altitude_m": 663.43, "source": "sim"} +{"ts": "2025-08-20T02:38:11.723597+00:00", "lat": 48.4783128, "lon": -81.3370231, "altitude_m": 664.0, "source": "sim"} +{"ts": "2025-08-20T02:38:11.823597+00:00", "lat": 48.478282, "lon": -81.3370269, "altitude_m": 663.36, "source": "sim"} +{"ts": "2025-08-20T02:38:11.973597+00:00", "lat": 48.4782638, "lon": -81.3370001, "altitude_m": 669.96, "source": "sim"} +{"ts": "2025-08-20T02:38:12.123597+00:00", "lat": 48.4783025, "lon": -81.3370212, "altitude_m": 658.36, "source": "sim"} +{"ts": "2025-08-20T02:38:12.223597+00:00", "lat": 48.478268, "lon": -81.3370252, "altitude_m": 661.07, "source": "sim"} +{"ts": "2025-08-20T02:38:12.323597+00:00", "lat": 48.4782725, "lon": -81.3370438, "altitude_m": 661.37, "source": "sim"} +{"ts": "2025-08-20T02:38:12.473597+00:00", "lat": 48.4782671, "lon": -81.3369877, "altitude_m": 658.67, "source": "sim"} +{"ts": "2025-08-20T02:38:12.573597+00:00", "lat": 48.4782805, "lon": -81.33701, "altitude_m": 665.97, "source": "sim"} +{"ts": "2025-08-20T02:38:12.673597+00:00", "lat": 48.4782772, "lon": -81.3370041, "altitude_m": 652.87, "source": "sim"} +{"ts": "2025-08-20T02:38:12.773597+00:00", "lat": 48.4782836, "lon": -81.3370018, "altitude_m": 661.13, "source": "sim"} +{"ts": "2025-08-20T02:38:12.873597+00:00", "lat": 48.4782822, "lon": -81.3370237, "altitude_m": 654.8, "source": "sim"} +{"ts": "2025-08-20T02:38:12.973597+00:00", "lat": 48.478272, "lon": -81.3370268, "altitude_m": 651.93, "source": "sim"} +{"ts": "2025-08-20T02:38:13.123597+00:00", "lat": 48.4782776, "lon": -81.3369843, "altitude_m": 654.76, "source": "sim"} +{"ts": "2025-08-20T02:38:13.273597+00:00", "lat": 48.4782688, "lon": -81.3370538, "altitude_m": 651.31, "source": "sim"} +{"ts": "2025-08-20T02:38:13.423597+00:00", "lat": 48.4782678, "lon": -81.3370243, "altitude_m": 648.58, "source": "sim"} +{"ts": "2025-08-20T02:38:13.573597+00:00", "lat": 48.4782554, "lon": -81.3370064, "altitude_m": 645.53, "source": "sim"} +{"ts": "2025-08-20T02:38:13.723597+00:00", "lat": 48.4782436, "lon": -81.3370156, "altitude_m": 646.74, "source": "sim"} +{"ts": "2025-08-20T02:38:13.873597+00:00", "lat": 48.4782529, "lon": -81.3370202, "altitude_m": 646.6, "source": "sim"} +{"ts": "2025-08-20T02:38:13.973597+00:00", "lat": 48.478291, "lon": -81.336985, "altitude_m": 647.35, "source": "sim"} +{"ts": "2025-08-20T02:38:14.073597+00:00", "lat": 48.4782778, "lon": -81.3370164, "altitude_m": 646.84, "source": "sim"} +{"ts": "2025-08-20T02:38:14.273597+00:00", "lat": 48.4782597, "lon": -81.3370178, "altitude_m": 642.86, "source": "sim"} +{"ts": "2025-08-20T02:38:14.423597+00:00", "lat": 48.4782544, "lon": -81.3370044, "altitude_m": 644.69, "source": "sim"} +{"ts": "2025-08-20T02:38:14.573597+00:00", "lat": 48.4782628, "lon": -81.3370096, "altitude_m": 647.25, "source": "sim"} +{"ts": "2025-08-20T02:38:14.723597+00:00", "lat": 48.4782393, "lon": -81.3369859, "altitude_m": 638.74, "source": "sim"} +{"ts": "2025-08-20T02:38:14.823597+00:00", "lat": 48.4782648, "lon": -81.3370155, "altitude_m": 640.2, "source": "sim"} +{"ts": "2025-08-20T02:38:14.923597+00:00", "lat": 48.4782665, "lon": -81.3369568, "altitude_m": 636.19, "source": "sim"} +{"ts": "2025-08-20T02:38:15.023597+00:00", "lat": 48.4782507, "lon": -81.3370126, "altitude_m": 639.73, "source": "sim"} +{"ts": "2025-08-20T02:38:15.173597+00:00", "lat": 48.4782846, "lon": -81.3369733, "altitude_m": 643.34, "source": "sim"} +{"ts": "2025-08-20T02:38:15.323597+00:00", "lat": 48.4782502, "lon": -81.3369684, "altitude_m": 633.82, "source": "sim"} +{"ts": "2025-08-20T02:38:15.423597+00:00", "lat": 48.478265, "lon": -81.3369989, "altitude_m": 629.27, "source": "sim"} +{"ts": "2025-08-20T02:38:15.523597+00:00", "lat": 48.4782335, "lon": -81.3370539, "altitude_m": 631.6, "source": "sim"} +{"ts": "2025-08-20T02:38:15.623597+00:00", "lat": 48.4782667, "lon": -81.3370001, "altitude_m": 635.82, "source": "sim"} +{"ts": "2025-08-20T02:38:15.723597+00:00", "lat": 48.478244, "lon": -81.3369851, "altitude_m": 637.95, "source": "sim"} +{"ts": "2025-08-20T02:38:15.823597+00:00", "lat": 48.4782571, "lon": -81.3369687, "altitude_m": 633.17, "source": "sim"} +{"ts": "2025-08-20T02:38:15.923597+00:00", "lat": 48.4782441, "lon": -81.3369921, "altitude_m": 631.04, "source": "sim"} +{"ts": "2025-08-20T02:38:16.073597+00:00", "lat": 48.4782378, "lon": -81.3370135, "altitude_m": 631.58, "source": "sim"} +{"ts": "2025-08-20T02:38:16.173597+00:00", "lat": 48.4782341, "lon": -81.3369716, "altitude_m": 626.98, "source": "sim"} +{"ts": "2025-08-20T02:38:16.273597+00:00", "lat": 48.4782182, "lon": -81.3370157, "altitude_m": 627.34, "source": "sim"} +{"ts": "2025-08-20T02:38:16.373597+00:00", "lat": 48.4782427, "lon": -81.3370125, "altitude_m": 629.87, "source": "sim"} +{"ts": "2025-08-20T02:38:16.473597+00:00", "lat": 48.4782361, "lon": -81.3369901, "altitude_m": 630.67, "source": "sim"} +{"ts": "2025-08-20T02:38:16.573597+00:00", "lat": 48.4782277, "lon": -81.3369849, "altitude_m": 628.95, "source": "sim"} +{"ts": "2025-08-20T02:38:16.723597+00:00", "lat": 48.4782065, "lon": -81.3369966, "altitude_m": 623.39, "source": "sim"} +{"ts": "2025-08-20T02:38:16.823597+00:00", "lat": 48.4782198, "lon": -81.337006, "altitude_m": 628.19, "source": "sim"} +{"ts": "2025-08-20T02:38:16.923597+00:00", "lat": 48.4782507, "lon": -81.3369743, "altitude_m": 625.21, "source": "sim"} +{"ts": "2025-08-20T02:38:17.073597+00:00", "lat": 48.4782448, "lon": -81.3370174, "altitude_m": 625.74, "source": "sim"} +{"ts": "2025-08-20T02:38:17.173597+00:00", "lat": 48.4782367, "lon": -81.3370094, "altitude_m": 624.7, "source": "sim"} +{"ts": "2025-08-20T02:38:17.323597+00:00", "lat": 48.4782169, "lon": -81.3369652, "altitude_m": 623.69, "source": "sim"} +{"ts": "2025-08-20T02:38:17.473597+00:00", "lat": 48.4782386, "lon": -81.3370158, "altitude_m": 620.21, "source": "sim"} +{"ts": "2025-08-20T02:38:17.623597+00:00", "lat": 48.4782252, "lon": -81.3369813, "altitude_m": 620.19, "source": "sim"} +{"ts": "2025-08-20T02:38:17.723597+00:00", "lat": 48.4782323, "lon": -81.3369998, "altitude_m": 620.51, "source": "sim"} +{"ts": "2025-08-20T02:38:17.873597+00:00", "lat": 48.478194, "lon": -81.3369595, "altitude_m": 620.56, "source": "sim"} +{"ts": "2025-08-20T02:38:17.973597+00:00", "lat": 48.4782189, "lon": -81.3369875, "altitude_m": 616.23, "source": "sim"} +{"ts": "2025-08-20T02:38:18.123597+00:00", "lat": 48.4782251, "lon": -81.3369457, "altitude_m": 606.81, "source": "sim"} +{"ts": "2025-08-20T02:38:18.223597+00:00", "lat": 48.4782141, "lon": -81.3369549, "altitude_m": 610.89, "source": "sim"} +{"ts": "2025-08-20T02:38:18.373597+00:00", "lat": 48.478221, "lon": -81.3369732, "altitude_m": 608.35, "source": "sim"} +{"ts": "2025-08-20T02:38:18.473597+00:00", "lat": 48.4782169, "lon": -81.3370132, "altitude_m": 613.03, "source": "sim"} +{"ts": "2025-08-20T02:38:18.573597+00:00", "lat": 48.4782323, "lon": -81.3369401, "altitude_m": 613.2, "source": "sim"} +{"ts": "2025-08-20T02:38:18.673597+00:00", "lat": 48.4782114, "lon": -81.3369744, "altitude_m": 615.74, "source": "sim"} +{"ts": "2025-08-20T02:38:18.773597+00:00", "lat": 48.4782139, "lon": -81.3369825, "altitude_m": 604.0, "source": "sim"} +{"ts": "2025-08-20T02:38:18.923597+00:00", "lat": 48.4781935, "lon": -81.3370035, "altitude_m": 605.22, "source": "sim"} +{"ts": "2025-08-20T02:38:19.023597+00:00", "lat": 48.4782162, "lon": -81.3369965, "altitude_m": 612.74, "source": "sim"} +{"ts": "2025-08-20T02:38:19.123597+00:00", "lat": 48.4782128, "lon": -81.3369668, "altitude_m": 603.23, "source": "sim"} +{"ts": "2025-08-20T02:38:19.273597+00:00", "lat": 48.4782067, "lon": -81.3369861, "altitude_m": 607.82, "source": "sim"} +{"ts": "2025-08-20T02:38:19.373597+00:00", "lat": 48.4781912, "lon": -81.336948, "altitude_m": 602.76, "source": "sim"} +{"ts": "2025-08-20T02:38:19.473597+00:00", "lat": 48.4782244, "lon": -81.3369519, "altitude_m": 604.59, "source": "sim"} +{"ts": "2025-08-20T02:38:19.573597+00:00", "lat": 48.4781895, "lon": -81.3369923, "altitude_m": 601.96, "source": "sim"} +{"ts": "2025-08-20T02:38:19.673597+00:00", "lat": 48.4782035, "lon": -81.3369961, "altitude_m": 604.99, "source": "sim"} +{"ts": "2025-08-20T02:38:19.773597+00:00", "lat": 48.4781965, "lon": -81.3369476, "altitude_m": 597.27, "source": "sim"} +{"ts": "2025-08-20T02:38:19.873597+00:00", "lat": 48.4781985, "lon": -81.3369629, "altitude_m": 606.11, "source": "sim"} +{"ts": "2025-08-20T02:38:19.973597+00:00", "lat": 48.4781907, "lon": -81.3369564, "altitude_m": 601.05, "source": "sim"} +{"ts": "2025-08-20T02:38:20.073597+00:00", "lat": 48.4781865, "lon": -81.3369476, "altitude_m": 599.5, "source": "sim"} +{"ts": "2025-08-20T02:38:20.173597+00:00", "lat": 48.4781946, "lon": -81.336965, "altitude_m": 597.24, "source": "sim"} +{"ts": "2025-08-20T02:38:20.323597+00:00", "lat": 48.4781965, "lon": -81.3369504, "altitude_m": 600.87, "source": "sim"} +{"ts": "2025-08-20T02:38:20.423597+00:00", "lat": 48.4781807, "lon": -81.3369848, "altitude_m": 600.9, "source": "sim"} +{"ts": "2025-08-20T02:38:20.523597+00:00", "lat": 48.4781833, "lon": -81.3369785, "altitude_m": 596.34, "source": "sim"} +{"ts": "2025-08-20T02:38:20.673597+00:00", "lat": 48.4782343, "lon": -81.3369703, "altitude_m": 594.95, "source": "sim"} +{"ts": "2025-08-20T02:38:20.773597+00:00", "lat": 48.4781938, "lon": -81.3369597, "altitude_m": 599.64, "source": "sim"} +{"ts": "2025-08-20T02:38:20.873597+00:00", "lat": 48.478188, "lon": -81.3369725, "altitude_m": 593.82, "source": "sim"} +{"ts": "2025-08-20T02:38:21.023597+00:00", "lat": 48.4781986, "lon": -81.3369344, "altitude_m": 589.96, "source": "sim"} +{"ts": "2025-08-20T02:38:21.173597+00:00", "lat": 48.4782052, "lon": -81.3369386, "altitude_m": 589.23, "source": "sim"} +{"ts": "2025-08-20T02:38:21.323597+00:00", "lat": 48.4781944, "lon": -81.3369854, "altitude_m": 588.14, "source": "sim"} +{"ts": "2025-08-20T02:38:21.423597+00:00", "lat": 48.4781818, "lon": -81.3369627, "altitude_m": 592.23, "source": "sim"} +{"ts": "2025-08-20T02:38:21.523597+00:00", "lat": 48.4781801, "lon": -81.3369497, "altitude_m": 584.59, "source": "sim"} +{"ts": "2025-08-20T02:38:21.673597+00:00", "lat": 48.4781739, "lon": -81.3369864, "altitude_m": 585.35, "source": "sim"} +{"ts": "2025-08-20T02:38:21.823597+00:00", "lat": 48.4781712, "lon": -81.3369716, "altitude_m": 588.08, "source": "sim"} +{"ts": "2025-08-20T02:38:21.923597+00:00", "lat": 48.4781856, "lon": -81.3369571, "altitude_m": 585.71, "source": "sim"} +{"ts": "2025-08-20T02:38:22.073597+00:00", "lat": 48.478176, "lon": -81.3369295, "altitude_m": 580.04, "source": "sim"} +{"ts": "2025-08-20T02:38:22.173597+00:00", "lat": 48.4781904, "lon": -81.3369589, "altitude_m": 576.15, "source": "sim"} +{"ts": "2025-08-20T02:38:22.273597+00:00", "lat": 48.4781726, "lon": -81.3369317, "altitude_m": 581.07, "source": "sim"} +{"ts": "2025-08-20T02:38:22.373597+00:00", "lat": 48.4781951, "lon": -81.3369618, "altitude_m": 584.86, "source": "sim"} +{"ts": "2025-08-20T02:38:22.473597+00:00", "lat": 48.478186, "lon": -81.3369545, "altitude_m": 573.82, "source": "sim"} +{"ts": "2025-08-20T02:38:22.623597+00:00", "lat": 48.4781643, "lon": -81.3369397, "altitude_m": 583.51, "source": "sim"} +{"ts": "2025-08-20T02:38:22.773597+00:00", "lat": 48.4781926, "lon": -81.3369133, "altitude_m": 581.17, "source": "sim"} +{"ts": "2025-08-20T02:38:22.973597+00:00", "lat": 48.4781531, "lon": -81.3369087, "altitude_m": 580.14, "source": "sim"} +{"ts": "2025-08-20T02:38:23.123597+00:00", "lat": 48.4781667, "lon": -81.3369404, "altitude_m": 574.44, "source": "sim"} +{"ts": "2025-08-20T02:38:23.273597+00:00", "lat": 48.4781418, "lon": -81.3369459, "altitude_m": 577.58, "source": "sim"} +{"ts": "2025-08-20T02:38:23.373597+00:00", "lat": 48.4781581, "lon": -81.3369199, "altitude_m": 575.87, "source": "sim"} +{"ts": "2025-08-20T02:38:23.523597+00:00", "lat": 48.478167, "lon": -81.3369435, "altitude_m": 573.0, "source": "sim"} +{"ts": "2025-08-20T02:38:23.673597+00:00", "lat": 48.4781505, "lon": -81.3369213, "altitude_m": 570.98, "source": "sim"} +{"ts": "2025-08-20T02:38:23.823597+00:00", "lat": 48.4781502, "lon": -81.3369226, "altitude_m": 567.93, "source": "sim"} +{"ts": "2025-08-20T02:38:23.923597+00:00", "lat": 48.4781364, "lon": -81.33694, "altitude_m": 569.51, "source": "sim"} +{"ts": "2025-08-20T02:38:24.023597+00:00", "lat": 48.478126, "lon": -81.3369322, "altitude_m": 566.4, "source": "sim"} +{"ts": "2025-08-20T02:38:24.173597+00:00", "lat": 48.47815, "lon": -81.3369258, "altitude_m": 564.29, "source": "sim"} +{"ts": "2025-08-20T02:38:24.323597+00:00", "lat": 48.4781571, "lon": -81.3369615, "altitude_m": 566.65, "source": "sim"} +{"ts": "2025-08-20T02:38:24.473597+00:00", "lat": 48.4781618, "lon": -81.3369475, "altitude_m": 570.27, "source": "sim"} +{"ts": "2025-08-20T02:38:24.623597+00:00", "lat": 48.4781572, "lon": -81.3369473, "altitude_m": 559.65, "source": "sim"} +{"ts": "2025-08-20T02:38:24.723597+00:00", "lat": 48.478153, "lon": -81.3369344, "altitude_m": 559.36, "source": "sim"} +{"ts": "2025-08-20T02:38:24.873597+00:00", "lat": 48.4781397, "lon": -81.3369039, "altitude_m": 553.32, "source": "sim"} +{"ts": "2025-08-20T02:38:24.973597+00:00", "lat": 48.4781598, "lon": -81.3369018, "altitude_m": 564.01, "source": "sim"} +{"ts": "2025-08-20T02:38:25.123597+00:00", "lat": 48.4781645, "lon": -81.3369286, "altitude_m": 553.13, "source": "sim"} +{"ts": "2025-08-20T02:38:25.273597+00:00", "lat": 48.478163, "lon": -81.3368925, "altitude_m": 559.08, "source": "sim"} +{"ts": "2025-08-20T02:38:25.423597+00:00", "lat": 48.4781704, "lon": -81.3369146, "altitude_m": 559.3, "source": "sim"} +{"ts": "2025-08-20T02:38:25.523597+00:00", "lat": 48.4781337, "lon": -81.3369214, "altitude_m": 557.63, "source": "sim"} +{"ts": "2025-08-20T02:38:25.673597+00:00", "lat": 48.4781596, "lon": -81.3369542, "altitude_m": 555.49, "source": "sim"} +{"ts": "2025-08-20T02:38:25.823597+00:00", "lat": 48.4781442, "lon": -81.3369168, "altitude_m": 555.96, "source": "sim"} +{"ts": "2025-08-20T02:38:25.973597+00:00", "lat": 48.4781382, "lon": -81.3369077, "altitude_m": 552.25, "source": "sim"} +{"ts": "2025-08-20T02:38:26.073597+00:00", "lat": 48.4781351, "lon": -81.3369008, "altitude_m": 548.11, "source": "sim"} +{"ts": "2025-08-20T02:38:26.223597+00:00", "lat": 48.4781508, "lon": -81.3369757, "altitude_m": 551.68, "source": "sim"} +{"ts": "2025-08-20T02:38:26.323597+00:00", "lat": 48.4781177, "lon": -81.3369087, "altitude_m": 547.74, "source": "sim"} +{"ts": "2025-08-20T02:38:26.423597+00:00", "lat": 48.4781372, "lon": -81.3368982, "altitude_m": 546.55, "source": "sim"} +{"ts": "2025-08-20T02:38:26.523597+00:00", "lat": 48.4781267, "lon": -81.3369349, "altitude_m": 543.95, "source": "sim"} +{"ts": "2025-08-20T02:38:26.623597+00:00", "lat": 48.4781419, "lon": -81.3369277, "altitude_m": 544.14, "source": "sim"} +{"ts": "2025-08-20T02:38:26.773597+00:00", "lat": 48.4781386, "lon": -81.3369001, "altitude_m": 550.5, "source": "sim"} +{"ts": "2025-08-20T02:38:26.923597+00:00", "lat": 48.4781094, "lon": -81.3369198, "altitude_m": 546.79, "source": "sim"} +{"ts": "2025-08-20T02:38:27.073597+00:00", "lat": 48.4781211, "lon": -81.3368724, "altitude_m": 541.23, "source": "sim"} +{"ts": "2025-08-20T02:38:27.223597+00:00", "lat": 48.4781006, "lon": -81.3369088, "altitude_m": 542.67, "source": "sim"} +{"ts": "2025-08-20T02:38:27.323597+00:00", "lat": 48.4781193, "lon": -81.3369037, "altitude_m": 545.06, "source": "sim"} +{"ts": "2025-08-20T02:38:27.523597+00:00", "lat": 48.4781325, "lon": -81.3369252, "altitude_m": 540.4, "source": "sim"} +{"ts": "2025-08-20T02:38:27.673597+00:00", "lat": 48.4781248, "lon": -81.3368853, "altitude_m": 534.17, "source": "sim"} +{"ts": "2025-08-20T02:38:27.823597+00:00", "lat": 48.4781211, "lon": -81.3369141, "altitude_m": 538.92, "source": "sim"} +{"ts": "2025-08-20T02:38:27.923597+00:00", "lat": 48.4781208, "lon": -81.3369198, "altitude_m": 541.54, "source": "sim"} +{"ts": "2025-08-20T02:38:28.073597+00:00", "lat": 48.4781148, "lon": -81.3369079, "altitude_m": 531.63, "source": "sim"} +{"ts": "2025-08-20T02:38:28.223597+00:00", "lat": 48.4781108, "lon": -81.3369434, "altitude_m": 534.08, "source": "sim"} +{"ts": "2025-08-20T02:38:28.323597+00:00", "lat": 48.4781243, "lon": -81.3368562, "altitude_m": 536.09, "source": "sim"} +{"ts": "2025-08-20T02:38:28.423597+00:00", "lat": 48.4781194, "lon": -81.3369125, "altitude_m": 531.31, "source": "sim"} +{"ts": "2025-08-20T02:38:28.573597+00:00", "lat": 48.4781031, "lon": -81.336899, "altitude_m": 531.84, "source": "sim"} +{"ts": "2025-08-20T02:38:28.723597+00:00", "lat": 48.4780972, "lon": -81.3369576, "altitude_m": 536.35, "source": "sim"} +{"ts": "2025-08-20T02:38:28.873597+00:00", "lat": 48.4781235, "lon": -81.336896, "altitude_m": 527.0, "source": "sim"} +{"ts": "2025-08-20T02:38:28.973597+00:00", "lat": 48.4780965, "lon": -81.3369475, "altitude_m": 526.82, "source": "sim"} +{"ts": "2025-08-20T02:38:29.073597+00:00", "lat": 48.4781284, "lon": -81.3368795, "altitude_m": 524.29, "source": "sim"} +{"ts": "2025-08-20T02:38:29.173597+00:00", "lat": 48.478085, "lon": -81.3369209, "altitude_m": 524.74, "source": "sim"} +{"ts": "2025-08-20T02:38:29.323597+00:00", "lat": 48.4781157, "lon": -81.3368851, "altitude_m": 525.79, "source": "sim"} +{"ts": "2025-08-20T02:38:29.473597+00:00", "lat": 48.4781071, "lon": -81.336912, "altitude_m": 523.59, "source": "sim"} +{"ts": "2025-08-20T02:38:29.573597+00:00", "lat": 48.4780884, "lon": -81.3368829, "altitude_m": 526.31, "source": "sim"} +{"ts": "2025-08-20T02:38:29.723597+00:00", "lat": 48.4781101, "lon": -81.3369006, "altitude_m": 520.06, "source": "sim"} +{"ts": "2025-08-20T02:38:29.873597+00:00", "lat": 48.4781085, "lon": -81.3368786, "altitude_m": 525.88, "source": "sim"} +{"ts": "2025-08-20T02:38:30.023597+00:00", "lat": 48.4780863, "lon": -81.3369081, "altitude_m": 516.69, "source": "sim"} +{"ts": "2025-08-20T02:38:30.123597+00:00", "lat": 48.4781079, "lon": -81.336854, "altitude_m": 520.83, "source": "sim"} +{"ts": "2025-08-20T02:38:30.223597+00:00", "lat": 48.4780723, "lon": -81.3368952, "altitude_m": 515.62, "source": "sim"} +{"ts": "2025-08-20T02:38:30.323597+00:00", "lat": 48.4780979, "lon": -81.3369086, "altitude_m": 516.04, "source": "sim"} +{"ts": "2025-08-20T02:38:30.473597+00:00", "lat": 48.4780946, "lon": -81.3368742, "altitude_m": 517.1, "source": "sim"} +{"ts": "2025-08-20T02:38:30.623597+00:00", "lat": 48.478124, "lon": -81.3369312, "altitude_m": 519.7, "source": "sim"} +{"ts": "2025-08-20T02:38:30.773597+00:00", "lat": 48.4780897, "lon": -81.3368615, "altitude_m": 511.27, "source": "sim"} +{"ts": "2025-08-20T02:38:30.923597+00:00", "lat": 48.4780846, "lon": -81.3368304, "altitude_m": 511.21, "source": "sim"} +{"ts": "2025-08-20T02:38:31.073597+00:00", "lat": 48.4780768, "lon": -81.3368818, "altitude_m": 515.87, "source": "sim"} +{"ts": "2025-08-20T02:38:31.223597+00:00", "lat": 48.4780763, "lon": -81.3368718, "altitude_m": 511.41, "source": "sim"} +{"ts": "2025-08-20T02:38:31.373597+00:00", "lat": 48.4780519, "lon": -81.3368554, "altitude_m": 508.14, "source": "sim"} +{"ts": "2025-08-20T02:38:31.473597+00:00", "lat": 48.478081, "lon": -81.3368833, "altitude_m": 507.86, "source": "sim"} +{"ts": "2025-08-20T02:38:31.573597+00:00", "lat": 48.4780834, "lon": -81.3368642, "altitude_m": 504.45, "source": "sim"} +{"ts": "2025-08-20T02:38:31.723597+00:00", "lat": 48.478083, "lon": -81.3368659, "altitude_m": 501.87, "source": "sim"} +{"ts": "2025-08-20T02:38:31.873597+00:00", "lat": 48.4780729, "lon": -81.3368667, "altitude_m": 506.34, "source": "sim"} +{"ts": "2025-08-20T02:38:32.023597+00:00", "lat": 48.478087, "lon": -81.3368522, "altitude_m": 496.87, "source": "sim"} +{"ts": "2025-08-20T02:38:32.173597+00:00", "lat": 48.4780766, "lon": -81.3368604, "altitude_m": 503.26, "source": "sim"} +{"ts": "2025-08-20T02:38:32.273597+00:00", "lat": 48.4780841, "lon": -81.3368357, "altitude_m": 507.22, "source": "sim"} +{"ts": "2025-08-20T02:38:32.423597+00:00", "lat": 48.4780664, "lon": -81.3368373, "altitude_m": 499.21, "source": "sim"} +{"ts": "2025-08-20T02:38:32.573597+00:00", "lat": 48.478056, "lon": -81.3368816, "altitude_m": 495.73, "source": "sim"} +{"ts": "2025-08-20T02:38:32.723597+00:00", "lat": 48.4780698, "lon": -81.3368483, "altitude_m": 501.26, "source": "sim"} +{"ts": "2025-08-20T02:38:32.823597+00:00", "lat": 48.4780685, "lon": -81.3368712, "altitude_m": 492.1, "source": "sim"} +{"ts": "2025-08-20T02:38:32.973597+00:00", "lat": 48.4780518, "lon": -81.3368501, "altitude_m": 488.89, "source": "sim"} +{"ts": "2025-08-20T02:38:33.123597+00:00", "lat": 48.4780583, "lon": -81.3368775, "altitude_m": 490.52, "source": "sim"} +{"ts": "2025-08-20T02:38:33.273597+00:00", "lat": 48.4780641, "lon": -81.3368644, "altitude_m": 493.99, "source": "sim"} +{"ts": "2025-08-20T02:38:33.373597+00:00", "lat": 48.4780624, "lon": -81.3368251, "altitude_m": 492.73, "source": "sim"} +{"ts": "2025-08-20T02:38:33.523597+00:00", "lat": 48.4780811, "lon": -81.3369007, "altitude_m": 485.4, "source": "sim"} +{"ts": "2025-08-20T02:38:33.673597+00:00", "lat": 48.4780577, "lon": -81.3368969, "altitude_m": 489.67, "source": "sim"} +{"ts": "2025-08-20T02:38:33.823597+00:00", "lat": 48.4780385, "lon": -81.336867, "altitude_m": 491.85, "source": "sim"} +{"ts": "2025-08-20T02:38:33.973597+00:00", "lat": 48.478081, "lon": -81.3368552, "altitude_m": 485.31, "source": "sim"} +{"ts": "2025-08-20T02:38:34.073597+00:00", "lat": 48.4780604, "lon": -81.3368775, "altitude_m": 485.28, "source": "sim"} +{"ts": "2025-08-20T02:38:34.223597+00:00", "lat": 48.4780678, "lon": -81.3368877, "altitude_m": 487.3, "source": "sim"} +{"ts": "2025-08-20T02:38:34.373597+00:00", "lat": 48.4780278, "lon": -81.336885, "altitude_m": 493.73, "source": "sim"} +{"ts": "2025-08-20T02:38:34.523597+00:00", "lat": 48.4780382, "lon": -81.3368692, "altitude_m": 487.05, "source": "sim"} +{"ts": "2025-08-20T02:38:34.723597+00:00", "lat": 48.4780238, "lon": -81.3368079, "altitude_m": 478.41, "source": "sim"} +{"ts": "2025-08-20T02:38:34.873597+00:00", "lat": 48.4780452, "lon": -81.3368209, "altitude_m": 483.58, "source": "sim"} +{"ts": "2025-08-20T02:38:35.023597+00:00", "lat": 48.4780398, "lon": -81.3368584, "altitude_m": 478.59, "source": "sim"} +{"ts": "2025-08-20T02:38:35.123597+00:00", "lat": 48.4780758, "lon": -81.3368405, "altitude_m": 481.49, "source": "sim"} +{"ts": "2025-08-20T02:38:35.223597+00:00", "lat": 48.4780331, "lon": -81.3368712, "altitude_m": 475.81, "source": "sim"} +{"ts": "2025-08-20T02:38:35.323597+00:00", "lat": 48.4780117, "lon": -81.336862, "altitude_m": 479.97, "source": "sim"} +{"ts": "2025-08-20T02:38:35.423597+00:00", "lat": 48.4780344, "lon": -81.3368411, "altitude_m": 471.4, "source": "sim"} +{"ts": "2025-08-20T02:38:35.623597+00:00", "lat": 48.4780325, "lon": -81.3368526, "altitude_m": 474.69, "source": "sim"} +{"ts": "2025-08-20T02:38:35.773597+00:00", "lat": 48.4780252, "lon": -81.336847, "altitude_m": 470.91, "source": "sim"} +{"ts": "2025-08-20T02:38:35.923597+00:00", "lat": 48.478024, "lon": -81.3368383, "altitude_m": 467.66, "source": "sim"} +{"ts": "2025-08-20T02:38:36.023597+00:00", "lat": 48.4780268, "lon": -81.3368097, "altitude_m": 469.02, "source": "sim"} +{"ts": "2025-08-20T02:38:36.123597+00:00", "lat": 48.4779997, "lon": -81.3368465, "altitude_m": 466.64, "source": "sim"} +{"ts": "2025-08-20T02:38:36.273597+00:00", "lat": 48.4780369, "lon": -81.3368485, "altitude_m": 472.99, "source": "sim"} +{"ts": "2025-08-20T02:38:36.373597+00:00", "lat": 48.4780091, "lon": -81.3368366, "altitude_m": 472.79, "source": "sim"} +{"ts": "2025-08-20T02:38:36.523597+00:00", "lat": 48.4780376, "lon": -81.3368255, "altitude_m": 468.24, "source": "sim"} +{"ts": "2025-08-20T02:38:36.673597+00:00", "lat": 48.4780281, "lon": -81.3368053, "altitude_m": 465.08, "source": "sim"} +{"ts": "2025-08-20T02:38:36.823597+00:00", "lat": 48.4780373, "lon": -81.3368287, "altitude_m": 464.74, "source": "sim"} +{"ts": "2025-08-20T02:38:36.923597+00:00", "lat": 48.4780256, "lon": -81.3367874, "altitude_m": 465.51, "source": "sim"} +{"ts": "2025-08-20T02:38:37.023597+00:00", "lat": 48.4780197, "lon": -81.3368452, "altitude_m": 465.22, "source": "sim"} +{"ts": "2025-08-20T02:38:37.123597+00:00", "lat": 48.4780415, "lon": -81.3368261, "altitude_m": 460.97, "source": "sim"} +{"ts": "2025-08-20T02:38:37.273597+00:00", "lat": 48.4780392, "lon": -81.336808, "altitude_m": 459.17, "source": "sim"} +{"ts": "2025-08-20T02:38:37.423597+00:00", "lat": 48.4780384, "lon": -81.3367899, "altitude_m": 462.32, "source": "sim"} +{"ts": "2025-08-20T02:38:37.573597+00:00", "lat": 48.4780168, "lon": -81.3368219, "altitude_m": 461.54, "source": "sim"} +{"ts": "2025-08-20T02:38:37.723597+00:00", "lat": 48.4780085, "lon": -81.3368402, "altitude_m": 455.13, "source": "sim"} +{"ts": "2025-08-20T02:38:37.873597+00:00", "lat": 48.4780136, "lon": -81.3367894, "altitude_m": 449.87, "source": "sim"} +{"ts": "2025-08-20T02:38:37.973597+00:00", "lat": 48.4780012, "lon": -81.3368366, "altitude_m": 455.6, "source": "sim"} +{"ts": "2025-08-20T02:38:38.123597+00:00", "lat": 48.4780349, "lon": -81.336797, "altitude_m": 457.21, "source": "sim"} +{"ts": "2025-08-20T02:38:38.273597+00:00", "lat": 48.47801, "lon": -81.3368112, "altitude_m": 455.02, "source": "sim"} +{"ts": "2025-08-20T02:38:38.373597+00:00", "lat": 48.4780024, "lon": -81.3368061, "altitude_m": 449.14, "source": "sim"} +{"ts": "2025-08-20T02:38:38.473597+00:00", "lat": 48.4780202, "lon": -81.3368688, "altitude_m": 450.16, "source": "sim"} +{"ts": "2025-08-20T02:38:38.573597+00:00", "lat": 48.4779585, "lon": -81.3368182, "altitude_m": 442.65, "source": "sim"} +{"ts": "2025-08-20T02:38:38.723597+00:00", "lat": 48.4779773, "lon": -81.3368398, "altitude_m": 451.62, "source": "sim"} +{"ts": "2025-08-20T02:38:38.823597+00:00", "lat": 48.4779981, "lon": -81.3368123, "altitude_m": 445.46, "source": "sim"} +{"ts": "2025-08-20T02:38:38.923597+00:00", "lat": 48.4779941, "lon": -81.3368331, "altitude_m": 449.03, "source": "sim"} +{"ts": "2025-08-20T02:38:39.073597+00:00", "lat": 48.4780101, "lon": -81.3367659, "altitude_m": 447.24, "source": "sim"} +{"ts": "2025-08-20T02:38:39.173597+00:00", "lat": 48.4780176, "lon": -81.3367839, "altitude_m": 442.39, "source": "sim"} +{"ts": "2025-08-20T02:38:39.323597+00:00", "lat": 48.4779922, "lon": -81.3368055, "altitude_m": 442.36, "source": "sim"} +{"ts": "2025-08-20T02:38:39.423597+00:00", "lat": 48.4780102, "lon": -81.3367948, "altitude_m": 445.79, "source": "sim"} +{"ts": "2025-08-20T02:38:39.523597+00:00", "lat": 48.477996, "lon": -81.3368173, "altitude_m": 443.24, "source": "sim"} +{"ts": "2025-08-20T02:38:39.673597+00:00", "lat": 48.4779879, "lon": -81.3368121, "altitude_m": 442.62, "source": "sim"} +{"ts": "2025-08-20T02:38:39.773597+00:00", "lat": 48.4779996, "lon": -81.3368178, "altitude_m": 440.46, "source": "sim"} +{"ts": "2025-08-20T02:38:39.923597+00:00", "lat": 48.4779973, "lon": -81.3368357, "altitude_m": 443.17, "source": "sim"} +{"ts": "2025-08-20T02:38:40.023597+00:00", "lat": 48.4779883, "lon": -81.3367995, "altitude_m": 440.77, "source": "sim"} +{"ts": "2025-08-20T02:38:40.173597+00:00", "lat": 48.4779946, "lon": -81.3368404, "altitude_m": 440.72, "source": "sim"} +{"ts": "2025-08-20T02:38:40.323597+00:00", "lat": 48.4779736, "lon": -81.3367751, "altitude_m": 430.17, "source": "sim"} +{"ts": "2025-08-20T02:38:40.423597+00:00", "lat": 48.4779821, "lon": -81.3368122, "altitude_m": 431.47, "source": "sim"} +{"ts": "2025-08-20T02:38:40.523597+00:00", "lat": 48.4779786, "lon": -81.3368257, "altitude_m": 435.88, "source": "sim"} +{"ts": "2025-08-20T02:38:40.673597+00:00", "lat": 48.4779863, "lon": -81.3368286, "altitude_m": 437.08, "source": "sim"} +{"ts": "2025-08-20T02:38:40.773597+00:00", "lat": 48.4779802, "lon": -81.3368114, "altitude_m": 431.06, "source": "sim"} +{"ts": "2025-08-20T02:38:40.923597+00:00", "lat": 48.4779804, "lon": -81.3368116, "altitude_m": 427.37, "source": "sim"} +{"ts": "2025-08-20T02:38:41.073597+00:00", "lat": 48.4779829, "lon": -81.3368332, "altitude_m": 434.26, "source": "sim"} +{"ts": "2025-08-20T02:38:41.173597+00:00", "lat": 48.4779821, "lon": -81.3367849, "altitude_m": 430.43, "source": "sim"} +{"ts": "2025-08-20T02:38:41.323597+00:00", "lat": 48.4779756, "lon": -81.3367795, "altitude_m": 429.29, "source": "sim"} +{"ts": "2025-08-20T02:38:41.473597+00:00", "lat": 48.4779814, "lon": -81.3367715, "altitude_m": 423.79, "source": "sim"} +{"ts": "2025-08-20T02:38:41.623597+00:00", "lat": 48.4779903, "lon": -81.3367908, "altitude_m": 421.26, "source": "sim"} +{"ts": "2025-08-20T02:38:41.773597+00:00", "lat": 48.4779528, "lon": -81.3367596, "altitude_m": 425.14, "source": "sim"} +{"ts": "2025-08-20T02:38:41.923597+00:00", "lat": 48.4779548, "lon": -81.3367634, "altitude_m": 422.85, "source": "sim"} +{"ts": "2025-08-20T02:38:42.073597+00:00", "lat": 48.4779665, "lon": -81.3367397, "altitude_m": 423.92, "source": "sim"} +{"ts": "2025-08-20T02:38:42.173597+00:00", "lat": 48.4779767, "lon": -81.3367713, "altitude_m": 424.73, "source": "sim"} +{"ts": "2025-08-20T02:38:42.323597+00:00", "lat": 48.477968, "lon": -81.3367602, "altitude_m": 421.84, "source": "sim"} +{"ts": "2025-08-20T02:38:42.423597+00:00", "lat": 48.4779583, "lon": -81.3368181, "altitude_m": 415.9, "source": "sim"} +{"ts": "2025-08-20T02:38:42.623597+00:00", "lat": 48.4779677, "lon": -81.3367955, "altitude_m": 416.65, "source": "sim"} +{"ts": "2025-08-20T02:38:42.773597+00:00", "lat": 48.4779538, "lon": -81.336794, "altitude_m": 417.33, "source": "sim"} +{"ts": "2025-08-20T02:38:42.923597+00:00", "lat": 48.4779478, "lon": -81.3367694, "altitude_m": 416.36, "source": "sim"} +{"ts": "2025-08-20T02:38:43.073597+00:00", "lat": 48.4779649, "lon": -81.3367826, "altitude_m": 414.94, "source": "sim"} +{"ts": "2025-08-20T02:38:43.223597+00:00", "lat": 48.4779674, "lon": -81.33681, "altitude_m": 415.83, "source": "sim"} +{"ts": "2025-08-20T02:38:43.373597+00:00", "lat": 48.4779506, "lon": -81.3367638, "altitude_m": 410.91, "source": "sim"} +{"ts": "2025-08-20T02:38:43.523597+00:00", "lat": 48.4779601, "lon": -81.3367902, "altitude_m": 411.46, "source": "sim"} +{"ts": "2025-08-20T02:38:43.673597+00:00", "lat": 48.4779496, "lon": -81.3367831, "altitude_m": 412.23, "source": "sim"} +{"ts": "2025-08-20T02:38:43.773597+00:00", "lat": 48.4779816, "lon": -81.3367766, "altitude_m": 411.66, "source": "sim"} +{"ts": "2025-08-20T02:38:43.923597+00:00", "lat": 48.4779392, "lon": -81.3367837, "altitude_m": 411.52, "source": "sim"} +{"ts": "2025-08-20T02:38:44.023597+00:00", "lat": 48.4779201, "lon": -81.3367532, "altitude_m": 407.43, "source": "sim"} +{"ts": "2025-08-20T02:38:44.223597+00:00", "lat": 48.477966, "lon": -81.3367462, "altitude_m": 405.95, "source": "sim"} +{"ts": "2025-08-20T02:38:44.323597+00:00", "lat": 48.4779679, "lon": -81.336742, "altitude_m": 406.35, "source": "sim"} +{"ts": "2025-08-20T02:38:44.473597+00:00", "lat": 48.4779473, "lon": -81.3367681, "altitude_m": 403.26, "source": "sim"} +{"ts": "2025-08-20T02:38:44.623597+00:00", "lat": 48.4779409, "lon": -81.3367677, "altitude_m": 404.62, "source": "sim"} +{"ts": "2025-08-20T02:38:44.773597+00:00", "lat": 48.4779156, "lon": -81.3367885, "altitude_m": 398.26, "source": "sim"} +{"ts": "2025-08-20T02:38:44.873597+00:00", "lat": 48.4779417, "lon": -81.3367402, "altitude_m": 406.37, "source": "sim"} +{"ts": "2025-08-20T02:38:45.023597+00:00", "lat": 48.4779404, "lon": -81.3367894, "altitude_m": 400.51, "source": "sim"} +{"ts": "2025-08-20T02:38:45.123597+00:00", "lat": 48.4779285, "lon": -81.3367749, "altitude_m": 398.17, "source": "sim"} +{"ts": "2025-08-20T02:38:45.273597+00:00", "lat": 48.4779355, "lon": -81.3367642, "altitude_m": 399.57, "source": "sim"} +{"ts": "2025-08-20T02:38:45.423597+00:00", "lat": 48.4779074, "lon": -81.3367428, "altitude_m": 399.43, "source": "sim"} +{"ts": "2025-08-20T02:38:45.573597+00:00", "lat": 48.4779097, "lon": -81.3367797, "altitude_m": 391.97, "source": "sim"} +{"ts": "2025-08-20T02:38:45.673597+00:00", "lat": 48.4779082, "lon": -81.3367613, "altitude_m": 390.26, "source": "sim"} +{"ts": "2025-08-20T02:38:45.823597+00:00", "lat": 48.4779481, "lon": -81.336745, "altitude_m": 391.41, "source": "sim"} +{"ts": "2025-08-20T02:38:45.973597+00:00", "lat": 48.4779292, "lon": -81.336765, "altitude_m": 389.37, "source": "sim"} +{"ts": "2025-08-20T02:38:46.123597+00:00", "lat": 48.4779058, "lon": -81.3367427, "altitude_m": 391.06, "source": "sim"} +{"ts": "2025-08-20T02:38:46.273597+00:00", "lat": 48.4779173, "lon": -81.3367677, "altitude_m": 390.57, "source": "sim"} +{"ts": "2025-08-20T02:38:46.373597+00:00", "lat": 48.4779121, "lon": -81.3367945, "altitude_m": 389.01, "source": "sim"} +{"ts": "2025-08-20T02:38:46.473597+00:00", "lat": 48.477905, "lon": -81.3367489, "altitude_m": 388.06, "source": "sim"} +{"ts": "2025-08-20T02:38:46.623597+00:00", "lat": 48.4779332, "lon": -81.336776, "altitude_m": 382.5, "source": "sim"} +{"ts": "2025-08-20T02:38:46.723597+00:00", "lat": 48.4779355, "lon": -81.3367272, "altitude_m": 380.04, "source": "sim"} +{"ts": "2025-08-20T02:38:46.873597+00:00", "lat": 48.477923, "lon": -81.3367903, "altitude_m": 386.93, "source": "sim"} +{"ts": "2025-08-20T02:38:47.023597+00:00", "lat": 48.4779057, "lon": -81.336768, "altitude_m": 376.51, "source": "sim"} +{"ts": "2025-08-20T02:38:47.123597+00:00", "lat": 48.4779231, "lon": -81.3367321, "altitude_m": 380.23, "source": "sim"} +{"ts": "2025-08-20T02:38:47.273597+00:00", "lat": 48.4779092, "lon": -81.3367404, "altitude_m": 383.78, "source": "sim"} +{"ts": "2025-08-20T02:38:47.373597+00:00", "lat": 48.4779012, "lon": -81.3367507, "altitude_m": 378.68, "source": "sim"} +{"ts": "2025-08-20T02:38:47.523597+00:00", "lat": 48.4778746, "lon": -81.3367699, "altitude_m": 382.99, "source": "sim"} +{"ts": "2025-08-20T02:38:47.673597+00:00", "lat": 48.4778912, "lon": -81.3367612, "altitude_m": 378.99, "source": "sim"} +{"ts": "2025-08-20T02:38:47.773597+00:00", "lat": 48.4779061, "lon": -81.336735, "altitude_m": 377.56, "source": "sim"} +{"ts": "2025-08-20T02:38:47.873597+00:00", "lat": 48.4778957, "lon": -81.3367606, "altitude_m": 374.98, "source": "sim"} +{"ts": "2025-08-20T02:38:48.023597+00:00", "lat": 48.4779087, "lon": -81.3367439, "altitude_m": 376.82, "source": "sim"} +{"ts": "2025-08-20T02:38:48.173597+00:00", "lat": 48.4778987, "lon": -81.3367381, "altitude_m": 373.66, "source": "sim"} +{"ts": "2025-08-20T02:38:48.323597+00:00", "lat": 48.4779049, "lon": -81.3367333, "altitude_m": 376.1, "source": "sim"} +{"ts": "2025-08-20T02:38:48.473597+00:00", "lat": 48.4779204, "lon": -81.3367177, "altitude_m": 369.23, "source": "sim"} +{"ts": "2025-08-20T02:38:48.673597+00:00", "lat": 48.4778944, "lon": -81.3367163, "altitude_m": 367.84, "source": "sim"} +{"ts": "2025-08-20T02:38:48.823597+00:00", "lat": 48.4779138, "lon": -81.3367223, "altitude_m": 371.57, "source": "sim"} +{"ts": "2025-08-20T02:38:48.973597+00:00", "lat": 48.4779117, "lon": -81.3367641, "altitude_m": 366.68, "source": "sim"} +{"ts": "2025-08-20T02:38:49.073597+00:00", "lat": 48.4778957, "lon": -81.3368009, "altitude_m": 371.08, "source": "sim"} +{"ts": "2025-08-20T02:38:49.173597+00:00", "lat": 48.4778908, "lon": -81.3367472, "altitude_m": 358.97, "source": "sim"} +{"ts": "2025-08-20T02:38:49.273597+00:00", "lat": 48.4779001, "lon": -81.336728, "altitude_m": 363.19, "source": "sim"} +{"ts": "2025-08-20T02:38:49.373597+00:00", "lat": 48.4779072, "lon": -81.3367002, "altitude_m": 366.7, "source": "sim"} +{"ts": "2025-08-20T02:38:49.473597+00:00", "lat": 48.4778796, "lon": -81.3367427, "altitude_m": 364.62, "source": "sim"} +{"ts": "2025-08-20T02:38:49.573597+00:00", "lat": 48.4778891, "lon": -81.3367167, "altitude_m": 364.63, "source": "sim"} +{"ts": "2025-08-20T02:38:49.673597+00:00", "lat": 48.4778785, "lon": -81.336672, "altitude_m": 370.13, "source": "sim"} +{"ts": "2025-08-20T02:38:49.773597+00:00", "lat": 48.4778917, "lon": -81.3367522, "altitude_m": 364.1, "source": "sim"} +{"ts": "2025-08-20T02:38:49.923597+00:00", "lat": 48.477891, "lon": -81.336708, "altitude_m": 360.54, "source": "sim"} +{"ts": "2025-08-20T02:38:50.073597+00:00", "lat": 48.4778909, "lon": -81.336715, "altitude_m": 364.44, "source": "sim"} +{"ts": "2025-08-20T02:38:50.173597+00:00", "lat": 48.4778725, "lon": -81.3367248, "altitude_m": 363.37, "source": "sim"} +{"ts": "2025-08-20T02:38:50.323597+00:00", "lat": 48.4778906, "lon": -81.3367102, "altitude_m": 353.84, "source": "sim"} +{"ts": "2025-08-20T02:38:50.473597+00:00", "lat": 48.4778866, "lon": -81.3367451, "altitude_m": 355.17, "source": "sim"} +{"ts": "2025-08-20T02:38:50.573597+00:00", "lat": 48.4778558, "lon": -81.3367473, "altitude_m": 356.0, "source": "sim"} +{"ts": "2025-08-20T02:38:50.673597+00:00", "lat": 48.4778678, "lon": -81.336703, "altitude_m": 353.83, "source": "sim"} +{"ts": "2025-08-20T02:38:50.773597+00:00", "lat": 48.4778579, "lon": -81.336707, "altitude_m": 354.55, "source": "sim"} +{"ts": "2025-08-20T02:38:50.923597+00:00", "lat": 48.4778588, "lon": -81.3366948, "altitude_m": 351.54, "source": "sim"} +{"ts": "2025-08-20T02:38:51.073597+00:00", "lat": 48.477889, "lon": -81.3366873, "altitude_m": 348.38, "source": "sim"} +{"ts": "2025-08-20T02:38:51.223597+00:00", "lat": 48.4778667, "lon": -81.3367034, "altitude_m": 349.95, "source": "sim"} +{"ts": "2025-08-20T02:38:51.373597+00:00", "lat": 48.4778421, "lon": -81.3367008, "altitude_m": 349.63, "source": "sim"} +{"ts": "2025-08-20T02:38:51.523597+00:00", "lat": 48.4778882, "lon": -81.3367584, "altitude_m": 344.3, "source": "sim"} +{"ts": "2025-08-20T02:38:51.673597+00:00", "lat": 48.4778713, "lon": -81.3367329, "altitude_m": 345.89, "source": "sim"} +{"ts": "2025-08-20T02:38:51.823597+00:00", "lat": 48.4778757, "lon": -81.3367133, "altitude_m": 347.47, "source": "sim"} +{"ts": "2025-08-20T02:38:51.973597+00:00", "lat": 48.4778512, "lon": -81.3367073, "altitude_m": 346.57, "source": "sim"} +{"ts": "2025-08-20T02:38:52.123597+00:00", "lat": 48.4778515, "lon": -81.3366922, "altitude_m": 349.1, "source": "sim"} +{"ts": "2025-08-20T02:38:52.223597+00:00", "lat": 48.4778709, "lon": -81.3367371, "altitude_m": 340.43, "source": "sim"} +{"ts": "2025-08-20T02:38:52.323597+00:00", "lat": 48.4778661, "lon": -81.3366973, "altitude_m": 341.23, "source": "sim"} +{"ts": "2025-08-20T02:38:52.473597+00:00", "lat": 48.477843, "lon": -81.3367411, "altitude_m": 346.39, "source": "sim"} +{"ts": "2025-08-20T02:38:52.623597+00:00", "lat": 48.4778497, "lon": -81.3367305, "altitude_m": 339.66, "source": "sim"} +{"ts": "2025-08-20T02:38:52.773597+00:00", "lat": 48.4778609, "lon": -81.3367215, "altitude_m": 335.52, "source": "sim"} +{"ts": "2025-08-20T02:38:52.873597+00:00", "lat": 48.477865, "lon": -81.3367666, "altitude_m": 338.51, "source": "sim"} +{"ts": "2025-08-20T02:38:52.973597+00:00", "lat": 48.477844, "lon": -81.3366935, "altitude_m": 326.73, "source": "sim"} +{"ts": "2025-08-20T02:38:53.123597+00:00", "lat": 48.4778647, "lon": -81.3367097, "altitude_m": 336.89, "source": "sim"} +{"ts": "2025-08-20T02:38:53.273597+00:00", "lat": 48.4778842, "lon": -81.3367136, "altitude_m": 333.24, "source": "sim"} +{"ts": "2025-08-20T02:38:53.423597+00:00", "lat": 48.4778326, "lon": -81.3367217, "altitude_m": 332.25, "source": "sim"} +{"ts": "2025-08-20T02:38:53.523597+00:00", "lat": 48.4778708, "lon": -81.3366594, "altitude_m": 329.27, "source": "sim"} +{"ts": "2025-08-20T02:38:53.673597+00:00", "lat": 48.4778567, "lon": -81.3367092, "altitude_m": 328.36, "source": "sim"} +{"ts": "2025-08-20T02:38:53.773597+00:00", "lat": 48.4778611, "lon": -81.3366712, "altitude_m": 330.74, "source": "sim"} +{"ts": "2025-08-20T02:38:53.873597+00:00", "lat": 48.4778667, "lon": -81.3366951, "altitude_m": 329.75, "source": "sim"} +{"ts": "2025-08-20T02:38:53.973597+00:00", "lat": 48.4778408, "lon": -81.3366478, "altitude_m": 327.22, "source": "sim"} +{"ts": "2025-08-20T02:38:54.073597+00:00", "lat": 48.4778363, "lon": -81.3367318, "altitude_m": 332.29, "source": "sim"} +{"ts": "2025-08-20T02:38:54.173597+00:00", "lat": 48.4778536, "lon": -81.3366647, "altitude_m": 319.85, "source": "sim"} +{"ts": "2025-08-20T02:38:54.273597+00:00", "lat": 48.4778443, "lon": -81.3366921, "altitude_m": 322.69, "source": "sim"} +{"ts": "2025-08-20T02:38:54.373597+00:00", "lat": 48.477865, "lon": -81.3366783, "altitude_m": 328.53, "source": "sim"} +{"ts": "2025-08-20T02:38:54.473597+00:00", "lat": 48.4778446, "lon": -81.3366996, "altitude_m": 318.39, "source": "sim"} +{"ts": "2025-08-20T02:38:54.623597+00:00", "lat": 48.4778295, "lon": -81.3366536, "altitude_m": 329.09, "source": "sim"} +{"ts": "2025-08-20T02:38:54.773597+00:00", "lat": 48.4778459, "lon": -81.3367369, "altitude_m": 322.07, "source": "sim"} +{"ts": "2025-08-20T02:38:54.873597+00:00", "lat": 48.4778437, "lon": -81.3367098, "altitude_m": 316.8, "source": "sim"} +{"ts": "2025-08-20T02:38:54.973597+00:00", "lat": 48.4778273, "lon": -81.3366963, "altitude_m": 315.28, "source": "sim"} +{"ts": "2025-08-20T02:38:55.073597+00:00", "lat": 48.4778407, "lon": -81.3366921, "altitude_m": 322.82, "source": "sim"} +{"ts": "2025-08-20T02:38:55.223597+00:00", "lat": 48.4778257, "lon": -81.3366895, "altitude_m": 312.28, "source": "sim"} +{"ts": "2025-08-20T02:38:55.373597+00:00", "lat": 48.4778455, "lon": -81.3366971, "altitude_m": 312.48, "source": "sim"} +{"ts": "2025-08-20T02:38:55.523597+00:00", "lat": 48.4778405, "lon": -81.3366852, "altitude_m": 311.44, "source": "sim"} +{"ts": "2025-08-20T02:38:55.623597+00:00", "lat": 48.4778143, "lon": -81.3366803, "altitude_m": 316.17, "source": "sim"} +{"ts": "2025-08-20T02:38:55.773597+00:00", "lat": 48.4778374, "lon": -81.3366782, "altitude_m": 318.6, "source": "sim"} +{"ts": "2025-08-20T02:38:55.873597+00:00", "lat": 48.4778191, "lon": -81.3366564, "altitude_m": 311.79, "source": "sim"} +{"ts": "2025-08-20T02:38:56.023597+00:00", "lat": 48.4777984, "lon": -81.3366825, "altitude_m": 313.64, "source": "sim"} +{"ts": "2025-08-20T02:38:56.173597+00:00", "lat": 48.4778232, "lon": -81.3367197, "altitude_m": 309.69, "source": "sim"} +{"ts": "2025-08-20T02:38:56.323597+00:00", "lat": 48.477811, "lon": -81.3366647, "altitude_m": 306.25, "source": "sim"} +{"ts": "2025-08-20T02:38:56.473597+00:00", "lat": 48.4778184, "lon": -81.3367068, "altitude_m": 307.96, "source": "sim"} +{"ts": "2025-08-20T02:38:56.573597+00:00", "lat": 48.4778369, "lon": -81.3366569, "altitude_m": 307.67, "source": "sim"} +{"ts": "2025-08-20T02:38:56.723597+00:00", "lat": 48.4778082, "lon": -81.3366699, "altitude_m": 308.31, "source": "sim"} +{"ts": "2025-08-20T02:38:56.823597+00:00", "lat": 48.4777827, "lon": -81.3367057, "altitude_m": 305.48, "source": "sim"} +{"ts": "2025-08-20T02:38:56.973597+00:00", "lat": 48.4777984, "lon": -81.3366746, "altitude_m": 311.51, "source": "sim"} +{"ts": "2025-08-20T02:38:57.123597+00:00", "lat": 48.4778017, "lon": -81.3366806, "altitude_m": 307.17, "source": "sim"} +{"ts": "2025-08-20T02:38:57.273597+00:00", "lat": 48.4777874, "lon": -81.3366932, "altitude_m": 300.56, "source": "sim"} +{"ts": "2025-08-20T02:38:57.373597+00:00", "lat": 48.477829, "lon": -81.3366174, "altitude_m": 302.3, "source": "sim"} +{"ts": "2025-08-20T02:38:57.523597+00:00", "lat": 48.4778105, "lon": -81.3366813, "altitude_m": 303.48, "source": "sim"} +{"ts": "2025-08-20T02:38:57.623597+00:00", "lat": 48.4778097, "lon": -81.3366802, "altitude_m": 298.87, "source": "sim"} +{"ts": "2025-08-20T02:38:57.773597+00:00", "lat": 48.4777981, "lon": -81.3366847, "altitude_m": 293.2, "source": "sim"} +{"ts": "2025-08-20T02:38:57.923597+00:00", "lat": 48.4778208, "lon": -81.3366573, "altitude_m": 299.53, "source": "sim"} +{"ts": "2025-08-20T02:38:58.073597+00:00", "lat": 48.4777859, "lon": -81.3366986, "altitude_m": 299.09, "source": "sim"} diff --git a/apps/antenna-tracker/src/main.rs b/apps/antenna-tracker/src/main.rs new file mode 100644 index 0000000..3d0bf2f --- /dev/null +++ b/apps/antenna-tracker/src/main.rs @@ -0,0 +1,179 @@ +use anyhow::Result; +use chrono::Utc; +use clap::Parser; +use mavlink::uorocketry::MavMessage; +use messages_prost::{radio, sbg::sbg_data}; +use prost::Message as _; +use serde::Serialize; +use tracing::{error, info, warn}; + +#[derive(Parser, Debug, Clone)] +#[command( + version, + about = "Stream latest GPS coordinates and altitude from MAVLink POSTCARD messages as JSON lines" +)] +struct Args { + /// MAVLink connection string (e.g., tcpout:127.0.0.1:5656, udpin:0.0.0.0:14550) + #[arg(long, default_value = "tcpout:127.0.0.1:5656")] + connection: String, + + /// Print pretty JSON (multi-line) instead of compact single-line JSON + #[arg(long, default_value_t = false)] + pretty: bool, +} + +#[derive(Default, Debug, Clone, Serialize)] +struct LatestSample { + lat: Option, + lon: Option, + altitude_m: Option, + ts: String, + source: &'static str, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); + + let args = Args::parse(); + + let mut latest = LatestSample { + ts: Utc::now().to_rfc3339(), + source: "gps-ingest", + ..Default::default() + }; + + loop { + info!("Connecting to MAVLink at {}", args.connection); + let conn = match mavlink::connect::(&args.connection) { + Ok(c) => c, + Err(e) => { + error!("Failed to connect to MAVLink: {}", e); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + }; + info!("Connected!"); + + // Receive loop; on any error, break to reconnect after 1s + loop { + match conn.recv() { + Ok((_header, msg)) => match msg { + MavMessage::POSTCARD_MESSAGE(card) => { + // Each POSTCARD message wraps a length-delimited RadioFrame (protobuf) + match messages_prost::radio::RadioFrame::decode_length_delimited(&card.message[..]) { + Ok(frame) => handle_radio_frame(frame, &mut latest, args.pretty), + Err(_) => warn!("Failed to decode RadioFrame from POSTCARD message"), + } + } + MavMessage::RADIO_STATUS(status) => { + info!("Radio status: rssi={}, remrssi={}", status.rssi, status.remrssi); + } + _other => { + // Not relevant for GPS/altitude output + } + }, + Err(err) => match err { + mavlink::error::MessageReadError::Io(io_err) => match io_err.kind() { + std::io::ErrorKind::WouldBlock => { + // No data available; yield briefly + tokio::task::yield_now().await; + } + _ => { + error!("MAVLink IO error: {:?}. Will reconnect.", io_err); + break; + } + }, + other => { + error!("MAVLink recv error: {:?}. Will reconnect.", other); + break; + } + }, + } + } + + // Reconnect delay + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } +} + +fn handle_radio_frame( + frame: radio::RadioFrame, + latest: &mut LatestSample, + pretty: bool, +) { + use radio::radio_frame::Payload; + + match frame.payload { + Some(Payload::Sbg(sbg)) => match sbg.data { + Some(sbg_data::Data::GpsPos(gps_pos)) => { + if let Some(d) = gps_pos.data { + let lat = d.latitude; + let lon = d.longitude; + + // Skip if lat or lon is very close to 0 (invalid GPS coordinates) + if lat.abs() < 0.001 || lon.abs() < 0.001 { + warn!("Invalid GPS coordinates (near 0,0): lat={}, lon={}", lat, lon); + return; + } + + // Check if coordinates are within Canada's bounds + // Bounds: [minLat, minLon, maxLat, maxLon] = [41.68, -141.00, 83.11, -52.62] + if !(41.68..=83.11).contains(&lat) || !(-141.00..=-52.62).contains(&lon) { + warn!("GPS coordinates outside Canada bounds: lat={}, lon={}", lat, lon); + return; + } + + latest.lat = Some(lat); + latest.lon = Some(lon); + // Do not use GPS altitude; we prefer barometric altitude from SbgAir + latest.ts = Utc::now().to_rfc3339(); + // Only output if we have all required fields + if latest.lat.is_some() && latest.lon.is_some() && latest.altitude_m.is_some() { + output(latest, pretty); + } + } + } + Some(sbg_data::Data::Air(air)) => { + if let Some(d) = air.data { + latest.altitude_m = Some(d.altitude as f64); + latest.ts = Utc::now().to_rfc3339(); + // Only output if we have all required fields + if latest.lat.is_some() && latest.lon.is_some() && latest.altitude_m.is_some() { + output(latest, pretty); + } + } + } + // Other SBG data not needed for this tool + _ => { + // println!("other sbg"); + } + }, + // We intentionally ignore other payloads (IMU, logs, etc.) + _payload => { + // println!("other payload: {:?}", _payload); + // print payload json + // if let Ok(s) = serde_json::to_string_pretty(&payload) { + // println!("{}", s); + // } + } + } +} + +fn output( + sample: &LatestSample, + pretty: bool, +) { + // Additional validation to ensure we never output invalid data + if sample.lat.is_none() || sample.lon.is_none() || sample.altitude_m.is_none() { + return; + } + + if pretty { + if let Ok(s) = serde_json::to_string_pretty(sample) { + println!("{}", s); + } + } else if let Ok(s) = serde_json::to_string(sample) { + println!("{}", s); + } +} diff --git a/apps/antenna-tracker/tests/conftest.py b/apps/antenna-tracker/tests/conftest.py new file mode 100644 index 0000000..764c57b --- /dev/null +++ b/apps/antenna-tracker/tests/conftest.py @@ -0,0 +1,7 @@ +import os +import sys + +# Ensure project root (directory containing predictor_service.py) is on sys.path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) diff --git a/apps/antenna-tracker/tests/test_continuous_unwrap.py b/apps/antenna-tracker/tests/test_continuous_unwrap.py new file mode 100644 index 0000000..778d76d --- /dev/null +++ b/apps/antenna-tracker/tests/test_continuous_unwrap.py @@ -0,0 +1,71 @@ +import pytest + +from predictor_service import PredictorConfig, PredictorService + + +def normalize_0_360(deg: float) -> float: + return (deg % 360.0 + 360.0) % 360.0 + + +def normalize_pm180(deg: float) -> float: + d = normalize_0_360(deg) + return d - 360.0 if d > 180.0 else d + + +def step_ok(a, b, tol=1e-9): + return abs(a - b) <= tol + + +def unwrap_seq_check(bearing_seq, yaw_zero=194.0, seed_mode="pm180"): + pcfg = PredictorConfig( + antenna_yaw_zero_deg=yaw_zero, + yaw_normalization="continuous", + use_real_gps_ingest=False, + ) + svc = PredictorService(pcfg) + svc.reset_yaw_continuity() + + def rot_basic(b): + raw = b - yaw_zero + return normalize_pm180(raw) if seed_mode != "0to360" else normalize_0_360(raw) + + def rot_cont(b, ref): + desired_mod = normalize_0_360(b - yaw_zero) + ref_mod = normalize_0_360(ref) + delta = normalize_pm180(desired_mod - ref_mod) + return ref + delta + + cont_vals = [] + for i, b in enumerate(bearing_seq): + if i == 0: + y = rot_basic(b) + else: + y = rot_cont(b, cont_vals[-1]) + cont_vals.append(y) + + for i in range(1, len(bearing_seq)): + bearing_step = bearing_seq[i] - bearing_seq[i - 1] + expected = normalize_pm180(bearing_step) + got = cont_vals[i] - cont_vals[i - 1] + assert step_ok(got, expected, tol=1e-7), f"step {i} expected {expected}, got {got}" + + return cont_vals + + +@pytest.mark.parametrize( + "seq", + [ + list(range(350, 371, 5)) + [0, 5, 10, 15, 20, 25, 30], + [30, 25, 20, 15, 10, 5, 0, 355, 350, 345, 340], + list(range(0, 1081, 15)), + [350, 355, 0, 5, 10, 5, 0, 355, 350, 355, 0, 5], + ], +) +def test_continuous_unwrap(seq): + _ = unwrap_seq_check(seq) + + +def test_clamping_interplay_with_accumulator(): + seq = list(range(0, 721, 10)) + vals = unwrap_seq_check(seq) + assert vals[-1] > 350.0 diff --git a/apps/antenna-tracker/tests/test_pointing_math.py b/apps/antenna-tracker/tests/test_pointing_math.py new file mode 100644 index 0000000..91c3fff --- /dev/null +++ b/apps/antenna-tracker/tests/test_pointing_math.py @@ -0,0 +1,81 @@ +import math +import pytest + +from predictor_service import PredictorConfig, PredictorService +from antenna_service import AntennaConfig, AntennaService + + +def normalize_0_360(deg: float) -> float: + return (deg % 360.0 + 360.0) % 360.0 + + +def normalize_pm180(deg: float) -> float: + d = normalize_0_360(deg) + return d - 360.0 if d > 180.0 else d + + +def destination_point(lat_deg, lon_deg, bearing_deg, distance_m, radius_m=6371000.0): + lat1 = math.radians(lat_deg) + lon1 = math.radians(lon_deg) + brng = math.radians(bearing_deg) + dr = distance_m / radius_m + sin_lat1 = math.sin(lat1) + cos_lat1 = math.cos(lat1) + sin_dr = math.sin(dr) + cos_dr = math.cos(dr) + sin_lat2 = sin_lat1 * cos_dr + cos_lat1 * sin_dr * math.cos(brng) + lat2 = math.asin(sin_lat2) + y = math.sin(brng) * sin_dr * cos_lat1 + x = cos_dr - sin_lat1 * sin_lat2 + lon2 = lon1 + math.atan2(y, x) + return math.degrees(lat2), (math.degrees(lon2) + 540) % 360 - 180 + + +def expected_el(distance_m: float, alt_delta_m: float) -> float: + return math.degrees(math.atan2(alt_delta_m, max(distance_m, 1e-9))) + + +bearings = [0, 45, 90, 135, 180, 225, 270, 315] +distances = [1.0, 100.0, 1000.0] +alt_deltas = [0.0, +5.0, -5.0, +100.0, -100.0] + + +@pytest.mark.parametrize("d", distances) +@pytest.mark.parametrize("dh", alt_deltas) +@pytest.mark.parametrize("b", bearings) +def test_az_el_yaw_and_commands(b, d, dh): + pcfg = PredictorConfig(yaw_normalization="pm180", use_real_gps_ingest=False) + svc = PredictorService(pcfg) + acfg = AntennaConfig(use_real_hardware=False) + + tgt_lat, tgt_lon = destination_point(pcfg.antenna_lat_deg, pcfg.antenna_lon_deg, b, d) + tgt_alt = pcfg.antenna_alt_m + dh + + az, el = svc.compute_az_el(tgt_lat, tgt_lon, tgt_alt) + + az_exp = b % 360.0 + el_exp = expected_el(d, dh) + + tol_az = 0.5 + tol_el = 0.5 + assert abs((az - az_exp + 540) % 360 - 180) <= tol_az + assert abs(el - el_exp) <= tol_el + + yaw = normalize_pm180(az - pcfg.antenna_yaw_zero_deg) + yaw_exp = normalize_pm180(az_exp - pcfg.antenna_yaw_zero_deg) + assert abs(yaw - yaw_exp) <= tol_az + + yaw_lo, yaw_hi = acfg.limits_yaw_deg + pit_lo, pit_hi = acfg.limits_pitch_deg + yaw_cmd = max(yaw_lo, min(yaw_hi, yaw)) + pit_cmd = max(pit_lo, min(pit_hi, el)) + + yaw_turns = (acfg.dir_yaw * yaw_cmd / 360.0) * acfg.gear_ratio_yaw + pit_turns = (acfg.dir_pitch * pit_cmd / 360.0) * acfg.gear_ratio_pitch + + ant = AntennaService(acfg) + ok = ant.set_position(pitch=el, yaw=yaw) + assert ok + pos = ant.get_position() + assert math.isclose(pos["yaw"], yaw_cmd, abs_tol=1e-6) + assert math.isclose(pos["pitch"], pit_cmd, abs_tol=1e-6) diff --git a/apps/antenna-tracker/uv.lock b/apps/antenna-tracker/uv.lock new file mode 100644 index 0000000..965ca7d --- /dev/null +++ b/apps/antenna-tracker/uv.lock @@ -0,0 +1,698 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "gps-ingest" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "odrive" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.3.2" }, + { name = "odrive", specifier = ">=0.6.10.post0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0.0" }] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "ipython" +version = "9.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/80/406f9e3bde1c1fd9bf5a0be9d090f8ae623e401b7670d8f6fdf2ab679891/ipython-9.4.0.tar.gz", hash = "sha256:c033c6d4e7914c3d9768aabe76bbe87ba1dc66a92a05db6bfa1125d81f2ee270", size = 4385338, upload-time = "2025-07-01T11:11:30.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f8/0031ee2b906a15a33d6bfc12dd09c3dfa966b3cb5b284ecfb7549e6ac3c4/ipython-9.4.0-py3-none-any.whl", hash = "sha256:25850f025a446d9b359e8d296ba175a36aedd32e83ca9b5060430fe16801f066", size = 611021, upload-time = "2025-07-01T11:11:27.85Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, +] + +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420, upload-time = "2025-07-24T20:28:18.002Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660, upload-time = "2025-07-24T20:28:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382, upload-time = "2025-07-24T20:28:48.544Z" }, + { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258, upload-time = "2025-07-24T20:28:59.104Z" }, + { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409, upload-time = "2025-07-24T20:40:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317, upload-time = "2025-07-24T20:40:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262, upload-time = "2025-07-24T20:41:20.797Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342, upload-time = "2025-07-24T20:41:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610, upload-time = "2025-07-24T20:42:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292, upload-time = "2025-07-24T20:42:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071, upload-time = "2025-07-24T20:42:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074, upload-time = "2025-07-24T20:43:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311, upload-time = "2025-07-24T20:43:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022, upload-time = "2025-07-24T20:43:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135, upload-time = "2025-07-24T20:43:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147, upload-time = "2025-07-24T20:44:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989, upload-time = "2025-07-24T20:44:34.88Z" }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052, upload-time = "2025-07-24T20:44:58.872Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955, upload-time = "2025-07-24T20:45:26.714Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843, upload-time = "2025-07-24T20:49:24.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876, upload-time = "2025-07-24T20:49:43.227Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786, upload-time = "2025-07-24T20:49:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395, upload-time = "2025-07-24T20:45:58.821Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374, upload-time = "2025-07-24T20:46:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864, upload-time = "2025-07-24T20:46:30.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533, upload-time = "2025-07-24T20:46:46.111Z" }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007, upload-time = "2025-07-24T20:47:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914, upload-time = "2025-07-24T20:47:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708, upload-time = "2025-07-24T20:47:58.129Z" }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678, upload-time = "2025-07-24T20:48:25.402Z" }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832, upload-time = "2025-07-24T20:48:37.181Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049, upload-time = "2025-07-24T20:48:56.24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935, upload-time = "2025-07-24T20:49:13.136Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906, upload-time = "2025-07-24T20:50:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607, upload-time = "2025-07-24T20:50:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110, upload-time = "2025-07-24T20:51:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050, upload-time = "2025-07-24T20:51:11.64Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292, upload-time = "2025-07-24T20:51:33.488Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913, upload-time = "2025-07-24T20:51:58.517Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180, upload-time = "2025-07-24T20:52:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809, upload-time = "2025-07-24T20:52:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410, upload-time = "2025-07-24T20:56:44.949Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821, upload-time = "2025-07-24T20:57:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303, upload-time = "2025-07-24T20:57:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524, upload-time = "2025-07-24T20:53:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519, upload-time = "2025-07-24T20:53:44.053Z" }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972, upload-time = "2025-07-24T20:53:53.81Z" }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439, upload-time = "2025-07-24T20:54:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479, upload-time = "2025-07-24T20:54:25.819Z" }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805, upload-time = "2025-07-24T20:54:50.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830, upload-time = "2025-07-24T20:55:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665, upload-time = "2025-07-24T20:55:46.665Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777, upload-time = "2025-07-24T20:55:57.66Z" }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856, upload-time = "2025-07-24T20:56:17.318Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, +] + +[[package]] +name = "odrive" +version = "0.6.10.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "appdirs" }, + { name = "ipython" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/df/1351f79f7fac91d0fc4465c5a074d16a5ec928cb02896c8e56efa512806f/odrive-0.6.10.post0-py37-none-macosx_12_0_arm64.whl", hash = "sha256:28909c265183fc846e5da0706a9fdd481ba95099aeea66b49f88836455aa50f9", size = 272583, upload-time = "2025-04-15T06:08:07.423Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/c8bb662c6464304748532c5869cfbfa7d03af447d5b9af2f6c97c9bdf654/odrive-0.6.10.post0-py37-none-macosx_12_0_x86_64.whl", hash = "sha256:9066d93b7c03f74921d3544f9a706b485fa819d4063fdf39b689f2d90f3b69b1", size = 279275, upload-time = "2025-04-15T06:08:08.905Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6d/b45ee16ce3a5264de76ff17974894008a941475774becb3f0f0e2580ac82/odrive-0.6.10.post0-py37-none-manylinux_2_28_aarch64.whl", hash = "sha256:4104c61a33eaa9bb3e5befbb29de717e0fcab6028d77c40e2a55c076ee3e0de8", size = 724037, upload-time = "2025-04-15T06:08:10.239Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d8/3ca69192838807f220ae389325dcd2487ebc7c8833cffe3602bbb01f6211/odrive-0.6.10.post0-py37-none-manylinux_2_28_x86_64.whl", hash = "sha256:a20f0aa7fd96745a867717ff5eeaffd224b899a7bcb5e3a7a3815d1bfd05853f", size = 729407, upload-time = "2025-04-15T06:08:11.584Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/54b4aa0d70cb0680f3187e1da89b860ed448a494f675d91b7950722c8139/odrive-0.6.10.post0-py37-none-win_amd64.whl", hash = "sha256:75e9d363b7ae4150376531c20967b73f0d7d7696db04142aa0d7401d1c3aa619", size = 913400, upload-time = "2025-04-15T06:08:12.938Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] diff --git a/apps/command-dispatcher/Cargo.toml b/apps/command-dispatcher/Cargo.toml new file mode 100644 index 0000000..2e499c2 --- /dev/null +++ b/apps/command-dispatcher/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "command-dispatcher" +version.workspace = true +authors.workspace = true +publish.workspace = true +license.workspace = true +edition = "2021" + +[dependencies] +messages-prost = { workspace = true } +mavlink = { workspace = true } +serde = { workspace = true, features = ["derive", "std"] } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +chrono = { workspace = true, features = ["clock", "serde", "std"] } +tokio = { workspace = true } +libsql = { workspace = true } +serde_json = { workspace = true } +hostname = { workspace = true } +prost = { workspace = true } diff --git a/apps/command-dispatcher/README.md b/apps/command-dispatcher/README.md new file mode 100644 index 0000000..5f8db0a --- /dev/null +++ b/apps/command-dispatcher/README.md @@ -0,0 +1,66 @@ +# RGS Command Dispatcher + +A service that monitors the `OutgoingCommand` table in the RGS database for pending commands and sends them to the designated gateway via a TCP MAVLink connection. It also reports its own health status. + +## Features + +- Polls the `OutgoingCommand` table for commands with status 'Pending'. +- Parses command types and parameters from the database. +- Constructs appropriate `messages::RadioMessage` payloads. +- Serializes messages using `postcard`. +- Sends MAVLink `POSTCARD_MESSAGE` packets to a TCP gateway. +- Updates command status (`Sending`, `Sent`, `Failed`) in the database. +- Manages connection to the TCP gateway, attempting reconnection on failure. +- Reports its operational status to the `ServiceStatus` table. +- Queues a periodic Ping keepalive, replacing the former standalone heartbeat service. + +## Prerequisites + +- RGS LibSQL database (with `OutgoingCommand` and `ServiceStatus` tables) accessible. +- RGS Gateway service running and accepting TCP MAVLink connections. + +## Usage + +### Running the Dispatcher + +```sh +# From the rgs workspace root +cargo run -p command-dispatcher -- [options] +# Or directly if built: +# ./target/debug/command-dispatcher [options] +``` + +### Parameters + +| Parameter | Description | Default | +|-----------------------------|--------------------------------------------------------------------------|----------------------------| +| `--libsql-url` | LibSQL database URL | http://localhost:8080 | +| `--libsql-auth-token` | Auth token for remote LibSQL DB | *empty* | +| `--gateway-connection-string` | MAVLink gateway connection string (e.g., `tcpout:localhost:5656`) | `tcpout:127.0.0.1:5656` | +| `--poll-interval-secs` | DB polling interval (seconds) | 5 | +| `--keepalive-interval-secs` | Automatic Ping interval; set to `0` to disable | 30 | + +## Operational Details + +- Checks the database for pending commands every `--poll-interval-secs` seconds. +- Attempts to maintain a persistent connection to the gateway, reconnecting if the connection drops. +- Processes commands sequentially in the order they are fetched (ordered by `created_at`). +- Updates its status in the `ServiceStatus` table every 15 seconds. +- Keeps at most one dispatcher-generated Ping pending or sending, preventing backlog growth while the gateway is unavailable. +- Handles command types: `Ping`, `DeployDrogue`, `DeployMain`, `PowerDown`, `RadioRateChange`. + +## Troubleshooting + +1. **Database Issues** + - Verify LibSQL server is running and accessible. + - Check `--libsql-url` and `--libsql-auth-token` parameters. + - Ensure the `OutgoingCommand` and `ServiceStatus` tables exist. +2. **Gateway Connection Issues** + - Verify the Gateway service is running and accessible. + - Check `--gateway-connection-string` parameter. + - Ensure network connectivity between the dispatcher and the gateway. + - Check dispatcher logs for connection attempt errors. +3. **Commands Stuck in 'Pending' or 'Sending'** + - Check dispatcher logs for errors during command processing (parsing, serialization, sending). + - Verify the gateway is correctly receiving and acknowledging messages (if applicable). + - Check the command `attempts` count and `error_message` in the `OutgoingCommand` table. diff --git a/apps/command-dispatcher/src/cli.rs b/apps/command-dispatcher/src/cli.rs new file mode 100644 index 0000000..ce4357f --- /dev/null +++ b/apps/command-dispatcher/src/cli.rs @@ -0,0 +1,24 @@ +use clap::Parser; + +#[derive(Parser, Debug, Clone)] +#[command(version, about, long_about = None)] +pub struct Args { + #[arg(long, default_value = "http://localhost:8080")] + pub libsql_url: String, + + #[arg(long, default_value = "")] + pub libsql_auth_token: String, + + #[arg( + long, + default_value = "tcpout:127.0.0.1:5656", + help = "Gateway MAVLink connection string (e.g., tcpout:localhost:5656, udpin:0.0.0.0:14550)" + )] + pub gateway_connection_string: String, + + #[arg(long, default_value_t = 5)] + pub poll_interval_secs: u64, + + #[arg(long, default_value_t = 30, help = "Seconds between automatic Ping keepalives; set to 0 to disable")] + pub keepalive_interval_secs: u64, +} diff --git a/apps/command-dispatcher/src/commands.rs b/apps/command-dispatcher/src/commands.rs new file mode 100644 index 0000000..145b06a --- /dev/null +++ b/apps/command-dispatcher/src/commands.rs @@ -0,0 +1,211 @@ +use std::time::Instant; + +use chrono::Utc; +use libsql::{params as libsql_params, Connection}; +use mavlink::{uorocketry::MavMessage, MavConnection, MavHeader}; +use messages_prost::command as cmd; +use messages_prost::common::Node; +use messages_prost::radio::radio_frame::Payload; +use messages_prost::radio::RadioFrame; +use prost::Message as _; +use serde::Deserialize; +use tracing::{error, info}; + +// Struct to represent a row from the OutgoingCommand table +#[derive(Debug)] +pub struct OutgoingCommandRow { + pub id: i64, + pub command_type: String, + pub parameters: Option, + pub source_service: String, +} + +// Parameter structs for deserialization +#[derive(Deserialize, Debug)] +struct DeployDrogueParams { + val: bool, +} +#[derive(Deserialize, Debug)] +struct DeployMainParams { + val: bool, +} +#[derive(Deserialize, Debug)] +struct PowerDownParams { + board: String, +} +#[derive(Deserialize, Debug)] +struct RadioRateChangeParams { + rate: String, +} +#[derive(Deserialize, Debug)] +struct PingParams { + id: u32, +} + +fn parse_board(s: &str) -> Node { + match s { + "PressureBoard" => Node::PressureBoard, + "StrainBoard" => Node::StrainBoard, + "TemperatureBoard" => Node::TemperatureBoard, + "GroundStation" => Node::GroundStation, + "Phoenix" => Node::Phoenix, + _ => Node::Unspecified, + } +} + +fn parse_radio_rate(s: &str) -> i32 { + match s.to_lowercase().as_str() { + "low" | "slow" | "0" => cmd::RadioRate::RateLow as i32, + "medium" | "1" => cmd::RadioRate::RateMedium as i32, + "high" | "fast" | "2" => cmd::RadioRate::RateHigh as i32, + _ => cmd::RadioRate::RateLow as i32, + } +} + +pub async fn process_single_command( + db_conn: &Connection, + gateway_conn: &mut Box + Sync + Send>, + command_row: OutgoingCommandRow, + start_instant: Instant, +) -> Result<(), (i64, Box)> { + let cmd_id = command_row.id; + info!( + "[Cmd ID: {}] Processing command of type '{}' from '{}'. Params: {:?}", + cmd_id, command_row.command_type, command_row.source_service, command_row.parameters + ); + + let queued_at_ts = Utc::now().timestamp(); + db_conn + .execute( + "UPDATE OutgoingCommand SET status = 'Sending', queued_at = ?, attempts = attempts + 1 WHERE id = ?", + libsql_params![queued_at_ts, cmd_id], + ) + .await + .map_err(|e| (cmd_id, e.into()))?; + info!("[Cmd ID: {}] Marked as 'Sending'.", cmd_id); + + let default_target = Node::PressureBoard as i32; + + let command_payload: cmd::Command = match command_row.command_type.as_str() { + "DeployDrogue" => { + let params: DeployDrogueParams = serde_json::from_str(command_row.parameters.as_deref().unwrap_or("{}")) + .map_err(|e| (cmd_id, Box::new(e) as Box))?; + cmd::Command { + node: default_target, + data: Some(cmd::command::Data::DeployDrogue(cmd::DeployDrogue { val: params.val })), + } + } + "DeployMain" => { + let params: DeployMainParams = serde_json::from_str(command_row.parameters.as_deref().unwrap_or("{}")) + .map_err(|e| (cmd_id, Box::new(e) as Box))?; + cmd::Command { + node: default_target, + data: Some(cmd::command::Data::DeployMain(cmd::DeployMain { val: params.val })), + } + } + "PowerDown" => { + let params_str = command_row.parameters.as_deref().ok_or_else(|| { + ( + cmd_id, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing parameters for PowerDown")) as Box, + ) + })?; + let params: PowerDownParams = serde_json::from_str(params_str).map_err(|e| (cmd_id, Box::new(e) as Box))?; + let board = parse_board(¶ms.board) as i32; + cmd::Command { + node: board, + data: Some(cmd::command::Data::PowerDown(cmd::PowerDown { board })), + } + } + "PowerUpCamera" => cmd::Command { + node: default_target, + data: Some(cmd::command::Data::PowerUpCamera(cmd::PowerUpCamera {})), + }, + "PowerDownCamera" => cmd::Command { + node: default_target, + data: Some(cmd::command::Data::PowerDownCamera(cmd::PowerDownCamera {})), + }, + "RadioRateChange" => { + let params_str = command_row.parameters.as_deref().ok_or_else(|| { + ( + cmd_id, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Missing parameters for RadioRateChange", + )) as Box, + ) + })?; + let params: RadioRateChangeParams = serde_json::from_str(params_str).map_err(|e| (cmd_id, Box::new(e) as Box))?; + cmd::Command { + node: default_target, + data: Some(cmd::command::Data::RadioRateChange(cmd::RadioRateChange { + rate: parse_radio_rate(¶ms.rate), + })), + } + } + "Ping" => { + let id = command_row + .parameters + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .map(|p| p.id) + .unwrap_or(0); + cmd::Command { + node: default_target, + data: Some(cmd::command::Data::Ping(cmd::Ping { id })), + } + } + _ => { + let err_msg = format!("Unknown command type: {}", command_row.command_type); + error!("[Cmd ID: {}] {}", cmd_id, err_msg); + db_conn + .execute( + "UPDATE OutgoingCommand SET status = 'Failed', sent_at = ?, error_message = ? WHERE id = ?", + libsql_params![Utc::now().timestamp(), err_msg.clone(), cmd_id], + ) + .await + .map_err(|e| (cmd_id, e.into()))?; + return Err((cmd_id, err_msg.into())); + } + }; + + let millis_since_start = start_instant.elapsed().as_millis() as u64; + let frame = RadioFrame { + node: Node::GroundStation as i32, + payload: Some(Payload::Command(command_payload)), + millis_since_start, + }; + let bytes = RadioFrame::encode_length_delimited_to_vec(&frame); + + let mut fixed_payload = [0u8; 255]; + let len = bytes.len().min(255); + fixed_payload[..len].copy_from_slice(&bytes[..len]); + let send_msg = MavMessage::POSTCARD_MESSAGE(mavlink::uorocketry::POSTCARD_MESSAGE_DATA { message: fixed_payload }); + + info!("[Cmd ID: {}] Sending MAVLink message...", cmd_id); + match gateway_conn.send(&MavHeader::default(), &send_msg) { + Ok(_) => { + info!("[Cmd ID: {}] Successfully sent. Updating status to 'Sent'.", cmd_id); + db_conn + .execute( + "UPDATE OutgoingCommand SET status = 'Sent', sent_at = ? WHERE id = ?", + libsql_params![Utc::now().timestamp(), cmd_id], + ) + .await + .map_err(|e| (cmd_id, e.into()))?; + Ok(()) + } + Err(mav_err) => { + let err_msg = format!("Failed to send MAVLink message: {}", mav_err); + error!("[Cmd ID: {}] {}", cmd_id, err_msg); + db_conn + .execute( + "UPDATE OutgoingCommand SET status = 'Failed', sent_at = ?, error_message = ? WHERE id = ?", + libsql_params![Utc::now().timestamp(), err_msg.clone(), cmd_id], + ) + .await + .map_err(|e_db| (cmd_id, e_db.into()))?; + Err((cmd_id, mav_err.into())) + } + } +} diff --git a/apps/command-dispatcher/src/dispatcher.rs b/apps/command-dispatcher/src/dispatcher.rs new file mode 100644 index 0000000..d5cb022 --- /dev/null +++ b/apps/command-dispatcher/src/dispatcher.rs @@ -0,0 +1,124 @@ +use std::time::{Duration, Instant}; + +use libsql::{params as libsql_params, Connection}; +use mavlink::{connect, uorocketry::MavMessage, MavConnection}; +use tracing::{error, info, warn}; + +use crate::cli::Args; +use crate::commands::{process_single_command, OutgoingCommandRow}; + +pub async fn run_dispatcher( + db_conn: Connection, + args: Args, + start_instant: Instant, +) -> Result<(), Box> { + info!( + "Dispatcher loop starting. Poll Interval: {}s. Gateway: {}", + args.poll_interval_secs, args.gateway_connection_string + ); + + let mut gateway_conn_opt: Option + Sync + Send>> = None; + let gateway_connection_string = args.gateway_connection_string.clone(); + // let host_string = hostname::get() + // .unwrap_or_default() + // .to_string_lossy() + // .to_string(); + // let instance_id_string = format!( + // "{}@{}-{}", + // crate::SERVICE_ID, + // host_string, + // std::process::id() + // ); + + loop { + if gateway_conn_opt.is_none() { + info!("Attempting to connect to gateway: {}", gateway_connection_string); + match connect::(&gateway_connection_string) { + Ok(conn) => { + info!("Successfully connected to gateway."); + gateway_conn_opt = Some(conn); + // TODO: Consider logic to reset status of commands stuck in 'Sending' from a previous session/connection drop. + } + Err(error) => { + error!("Failed to connect to gateway: {}. Retrying in 5 seconds...", error); + gateway_conn_opt = None; + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + } + } + + if let Some(ref mut current_gateway_conn) = gateway_conn_opt { + info!("Fetching pending commands..."); + let stmt = match db_conn.prepare( + "SELECT id, command_type, parameters, source_service FROM OutgoingCommand WHERE status = 'Pending' ORDER BY created_at ASC LIMIT 10" + ).await { + Ok(s) => s, + Err(e) => { + error!("Failed to prepare DB statement: {:?}. Retrying poll cycle.", e); + tokio::time::sleep(Duration::from_secs(args.poll_interval_secs)).await; + continue; + } + }; + + let rows_result = stmt.query(libsql_params![]).await; + let mut commands: Vec = Vec::new(); + + match rows_result { + Ok(mut rows) => loop { + match rows.next().await { + Ok(Some(row)) => { + commands.push(OutgoingCommandRow { + id: row.get(0)?, + command_type: row.get(1)?, + parameters: row.get(2)?, + source_service: row.get(3)?, + }); + } + Ok(None) => { + break; + } + Err(e) => { + error!("Error fetching a row from database: {:?}. Processing partial batch if any.", e); + break; + } + } + }, + Err(e) => { + error!("Failed to execute query for pending commands: {:?}. Retrying poll cycle.", e); + tokio::time::sleep(Duration::from_secs(args.poll_interval_secs)).await; + continue; + } + } + + if commands.is_empty() { + // info!("No pending commands found."); // Keep commented out info for potential debugging + } else { + info!("Fetched {} pending command(s). Processing...", commands.len()); + for command_row in commands { + let process_result = process_single_command(&db_conn, current_gateway_conn, command_row, start_instant).await; + + if let Err(e) = process_result { + let (cmd_id, err_details) = e; + error!("Error processing command (ID: {}): {:?}", cmd_id, err_details); + let error_string = err_details.to_string().to_lowercase(); + if error_string.contains("connection") + || error_string.contains("broken pipe") + || error_string.contains("os error 104") + || error_string.contains("os error 32") + || error_string.contains("failed to send") + { + warn!("Gateway connection likely lost (error: {}). Resetting connection.", err_details); + break; + } + } + } + } + } + + // Always sleep a bit so we don't busy loop even when not connected + tokio::time::sleep(Duration::from_secs(args.poll_interval_secs)).await; + } + // Unreachable code + // Ok(()) +} diff --git a/apps/command-dispatcher/src/health.rs b/apps/command-dispatcher/src/health.rs new file mode 100644 index 0000000..83065ad --- /dev/null +++ b/apps/command-dispatcher/src/health.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use chrono::Utc; +use libsql::{params as libsql_params, Connection}; +use tracing::{info, warn}; + +use crate::SERVICE_ID; + +pub async fn run_service_status_task(db_conn: Connection) -> Result<(), Box> { + let hostname_str = hostname::get() + .map(|s| s.into_string().unwrap_or_else(|_| "invalid_hostname".to_string())) + .unwrap_or_else(|_| "unknown_hostname".to_string()); + + let service_instance_id = format!("{}@{}-{}", SERVICE_ID, hostname_str, std::process::id()); + let start_time_ts = Utc::now().timestamp(); + + info!("Service status task started for instance: {}. Update interval: 15s", service_instance_id); + + loop { + let current_timestamp = Utc::now().timestamp(); + let status_message = "Running and polling DB for commands".to_string(); + let current_status = "Running"; + + let instance_id_clone = service_instance_id.clone(); + let hostname_clone = hostname_str.clone(); + + let result = db_conn.execute( + "INSERT INTO ServiceStatus (service_instance_id, service_name, hostname, status, status_message, last_heartbeat_at, start_time) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(service_instance_id) DO UPDATE SET service_name=excluded.service_name, hostname=excluded.hostname, status=excluded.status, status_message=excluded.status_message, last_heartbeat_at=excluded.last_heartbeat_at", + libsql_params![ + instance_id_clone, + SERVICE_ID, + hostname_clone, + current_status, + status_message, + current_timestamp, + start_time_ts, + ], + ).await; + + if let Err(e) = result { + warn!("Failed to update ServiceStatus for {}: DB Error: {}", service_instance_id, e); + } + + tokio::time::sleep(Duration::from_secs(15)).await; + } + // Unreachable code + // Ok(()) +} diff --git a/apps/command-dispatcher/src/keepalive.rs b/apps/command-dispatcher/src/keepalive.rs new file mode 100644 index 0000000..422103e --- /dev/null +++ b/apps/command-dispatcher/src/keepalive.rs @@ -0,0 +1,127 @@ +use std::time::Duration; + +use chrono::Utc; +use libsql::{params as libsql_params, Connection}; +use serde::Serialize; +use tracing::{info, warn}; + +const KEEPALIVE_SOURCE: &str = "command-dispatcher-keepalive"; + +#[derive(Debug, Serialize)] +struct PingParameters { + id: u32, +} + +fn ping_parameters(id: u32) -> Result { + serde_json::to_string(&PingParameters { id }) +} + +async fn enqueue_keepalive( + db_conn: &Connection, + ping_id: u32, +) -> Result> { + let parameters = ping_parameters(ping_id)?; + let result = db_conn + .execute( + "INSERT INTO OutgoingCommand (command_type, parameters, status, created_at, source_service) \ + SELECT 'Ping', ?1, 'Pending', ?2, ?3 \ + WHERE NOT EXISTS ( \ + SELECT 1 FROM OutgoingCommand \ + WHERE command_type = 'Ping' AND source_service = ?3 \ + AND status IN ('Pending', 'Sending') \ + )", + libsql_params![parameters, Utc::now().timestamp(), KEEPALIVE_SOURCE], + ) + .await?; + Ok(result > 0) +} + +pub async fn run_keepalive_task( + db_conn: Connection, + interval_secs: u64, +) -> Result<(), Box> { + if interval_secs == 0 { + info!("Automatic command keepalives are disabled"); + return Ok(()); + } + + info!("Command keepalive task started. Interval: {}s", interval_secs); + let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); + interval.tick().await; + let mut next_ping_id = 1_u32; + + loop { + interval.tick().await; + match enqueue_keepalive(&db_conn, next_ping_id).await { + Ok(true) => { + info!("Queued automatic Ping keepalive {}", next_ping_id); + next_ping_id = next_ping_id.wrapping_add(1); + } + Ok(false) => { + info!("Keepalive already pending; skipping this interval"); + } + Err(error) => { + warn!("Failed to queue automatic Ping keepalive: {}", error); + } + } + } +} + +#[cfg(test)] +mod tests { + use libsql::Builder; + + use super::*; + + #[test] + fn serializes_ping_parameters_for_the_dispatcher() { + assert_eq!(ping_parameters(42).unwrap(), r#"{"id":42}"#); + } + + #[test] + fn keepalive_source_is_distinct_from_user_commands() { + assert_eq!(KEEPALIVE_SOURCE, "command-dispatcher-keepalive"); + } + + #[tokio::test] + async fn keeps_only_one_active_keepalive_in_the_queue() { + let db = Builder::new_local(":memory:").build().await.unwrap(); + let connection = db.connect().unwrap(); + connection + .execute( + "CREATE TABLE OutgoingCommand ( \ + id INTEGER PRIMARY KEY AUTOINCREMENT, \ + command_type TEXT NOT NULL, \ + parameters TEXT, \ + status TEXT NOT NULL, \ + created_at INTEGER NOT NULL, \ + source_service TEXT NOT NULL \ + )", + (), + ) + .await + .unwrap(); + + assert!(enqueue_keepalive(&connection, 1).await.unwrap()); + assert!(!enqueue_keepalive(&connection, 2).await.unwrap()); + + connection + .execute("UPDATE OutgoingCommand SET status = 'Sent' WHERE id = 1", ()) + .await + .unwrap(); + assert!(enqueue_keepalive(&connection, 2).await.unwrap()); + + let mut rows = connection + .query("SELECT parameters, source_service FROM OutgoingCommand ORDER BY id", ()) + .await + .unwrap(); + let first = rows.next().await.unwrap().unwrap(); + let first_parameters = first.get::(0).unwrap(); + let second = rows.next().await.unwrap().unwrap(); + let second_parameters = second.get::(0).unwrap(); + let second_source = second.get::(1).unwrap(); + assert_eq!(first_parameters, r#"{"id":1}"#); + assert_eq!(second_parameters, r#"{"id":2}"#); + assert_eq!(second_source, KEEPALIVE_SOURCE); + } +} diff --git a/apps/command-dispatcher/src/main.rs b/apps/command-dispatcher/src/main.rs new file mode 100644 index 0000000..66c42d7 --- /dev/null +++ b/apps/command-dispatcher/src/main.rs @@ -0,0 +1,67 @@ +mod cli; +use cli::Args; +mod health; +use health::run_service_status_task; +mod keepalive; +use keepalive::run_keepalive_task; +mod dispatcher; +use dispatcher::run_dispatcher; +mod commands; + +use clap::Parser; // For Args::parse() +use libsql::Builder; +use tracing::{error, info}; // Only error and info are used in main + // Tokio is brought in by #[tokio::main] + +pub const SERVICE_ID: &str = "command-dispatcher"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + let start_instant = std::time::Instant::now(); + tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); + + info!("{} starting...", SERVICE_ID); + + info!("Attempting to connect to database: {}", args.libsql_url); + let db = Builder::new_remote(args.libsql_url.clone(), args.libsql_auth_token.clone()) + .build() + .await?; + let db_connection = match db.connect() { + Ok(connection) => { + info!("Database connection established successfully"); + connection + } + Err(e) => { + error!("Failed to get database connection: {:?}", e); + return Err(e.into()); + } + }; + info!("Obtained database connection handle."); + + let db_conn_for_health = db_connection.clone(); + let service_status_handle = tokio::spawn(async move { + if let Err(e) = run_service_status_task(db_conn_for_health).await { + error!("Service status task exited with error: {:?}", e); + } + }); + + let db_conn_for_keepalive = db_connection.clone(); + let keepalive_interval_secs = args.keepalive_interval_secs; + let keepalive_handle = tokio::spawn(async move { + if let Err(e) = run_keepalive_task(db_conn_for_keepalive, keepalive_interval_secs).await { + error!("Command keepalive task exited with error: {:?}", e); + } + }); + + info!("Command dispatcher initialized. Starting dispatch loop..."); + + if let Err(e) = run_dispatcher(db_connection, args, start_instant).await { + error!("Dispatcher loop exited with critical error: {:?}", e); + service_status_handle.abort(); + keepalive_handle.abort(); + return Err(e); + } + + Ok(()) +} diff --git a/apps/ground-station-web/.dockerignore b/apps/ground-station-web/.dockerignore new file mode 100644 index 0000000..16db3bc --- /dev/null +++ b/apps/ground-station-web/.dockerignore @@ -0,0 +1,21 @@ +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +Makefile +helm-charts +.env +.editorconfig +.idea +coverage* +node_modules +.svelte-kit +build +dist +*.log +.DS_Store +Thumbs.db diff --git a/apps/ground-station-web/.eslintignore b/apps/ground-station-web/.eslintignore new file mode 100644 index 0000000..de8342d --- /dev/null +++ b/apps/ground-station-web/.eslintignore @@ -0,0 +1,14 @@ +.DS_Store +node_modules +/build +/.svelte-kit +svelte.config.js +/package +.env +.env.* +!.env.example + +target/ +tailwind.config.ts +playwright.config.ts +proto/ diff --git a/apps/ground-station-web/.eslintrc.cjs b/apps/ground-station-web/.eslintrc.cjs new file mode 100644 index 0000000..d246c7f --- /dev/null +++ b/apps/ground-station-web/.eslintrc.cjs @@ -0,0 +1,30 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'plugin:svelte/recommended' + ], + plugins: ['@typescript-eslint'], + ignorePatterns: ['*.cjs', 'e2e/'], + overrides: [ + { + files: ['*.svelte'], + parser: 'svelte-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser' + } + } + ], + parserOptions: { + project: './tsconfig.json', + extraFileExtensions: ['.svelte'] // This is a required setting in `@typescript-eslint/parser` v4.24.0. + }, + env: { + browser: true, + es2017: true, + node: true + } +}; diff --git a/apps/ground-station-web/.gitignore b/apps/ground-station-web/.gitignore new file mode 100644 index 0000000..e62c128 --- /dev/null +++ b/apps/ground-station-web/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +node_modules +/build +.svelte-kit +/package +.env +!.env.example +.vercel +.output +vite.config.ts.*.mjs +/test-results/ +/playwright-report/ +/playwright/.cache/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/apps/ground-station-web/.prettierignore b/apps/ground-station-web/.prettierignore new file mode 100644 index 0000000..8b8963b --- /dev/null +++ b/apps/ground-station-web/.prettierignore @@ -0,0 +1,15 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore lock files for package managers +package-lock.json +yarn.lock +bun.lockb +data +proto diff --git a/apps/ground-station-web/.prettierrc b/apps/ground-station-web/.prettierrc new file mode 100644 index 0000000..9573023 --- /dev/null +++ b/apps/ground-station-web/.prettierrc @@ -0,0 +1,8 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/apps/ground-station-web/Dockerfile b/apps/ground-station-web/Dockerfile new file mode 100644 index 0000000..e2db007 --- /dev/null +++ b/apps/ground-station-web/Dockerfile @@ -0,0 +1,43 @@ +# Use the official Bun image +FROM oven/bun:1 AS base +WORKDIR /usr/src/app + +# Install dependencies into temp directory +# This will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lock /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# Install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lock /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production --ignore-scripts + +# Copy node_modules from temp directory +# Then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# Build the application +ENV NODE_ENV=production +RUN bun run postinstall && bun run build + +# Copy production dependencies and built application into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/build build +COPY --from=prerelease /usr/src/app/package.json . + +USER bun + +# Expose the port the app runs on +EXPOSE 3000/tcp + +# Set environment variables for production +ENV NODE_ENV=production +ENV PORT=3000 + +# Start the application +CMD ["bun", "build/index.js"] diff --git a/apps/ground-station-web/README.md b/apps/ground-station-web/README.md new file mode 100644 index 0000000..b61230a --- /dev/null +++ b/apps/ground-station-web/README.md @@ -0,0 +1,16 @@ +# RGS Web + +The web frontend for the uORocketry Ground Station. + +## Getting Started + +Install [Bun](https://bun.sh/), then run these commands from `apps/ground-station-web`: + +Then, run the following + +```bash +bun install --frozen-lockfile +bun run dev +``` + +The Vite development server prints its local URL when it starts. Use `deployments/ground-station/compose.yaml` when the complete backend is required. diff --git a/apps/ground-station-web/bun.lock b/apps/ground-station-web/bun.lock new file mode 100644 index 0000000..f7ee2c0 --- /dev/null +++ b/apps/ground-station-web/bun.lock @@ -0,0 +1,1457 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "@rgs/web", + "dependencies": { + "@carbon/charts": "^1.27.16", + "@carbon/charts-svelte": "^1.22.18", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@jupyterlab/theme-light-extension": "^4.6.1", + "@libsql/client": "^0.15.15", + "@lumino/default-theme": "^2.1.16", + "@lumino/messaging": "^2.0.5", + "@lumino/widgets": "^2.9.0", + "@sveltejs/kit": "^2.57.1", + "@tailwindcss/vite": "^4.3.3", + "@types/cesium": "^1.70.4", + "@types/leaflet": "^1.9.21", + "carbon-components-svelte": "^0.89.11", + "carbon-icons-svelte": "^12.26.0", + "d3": "^7.9.0", + "gsap": "^3.15.0", + "leaflet": "^1.9.4", + "svelte-adapter-bun": "^0.5.2", + }, + "devDependencies": { + "@floating-ui/dom": "^1.8.0", + "@fortawesome/fontawesome-free": "6.7.2", + "@sveltejs/adapter-auto": "^6.1.1", + "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@threlte/core": "^8.5.16", + "@threlte/extras": "^9.21.0", + "@types/node": "^22.20.1", + "@types/three": "^0.176.0", + "@types/w3c-generic-sensor": "^1.0.10", + "@typescript-eslint/eslint-plugin": "^8.64.0", + "@typescript-eslint/parser": "^8.64.0", + "@vitejs/plugin-basic-ssl": "^2.3.0", + "carbon-preprocess-svelte": "^0.11.37", + "cesium": "^1.143.0", + "eslint": "^9.39.5", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.20.0", + "prettier": "^3.9.5", + "prettier-plugin-svelte": "^3.5.2", + "svelte": "^5.56.5", + "svelte-check": "^4.7.3", + "svelte-preprocess": "^6.0.5", + "tailwindcss": "^4.3.3", + "three": "^0.176.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "vite": "^6.4.3", + "vite-plugin-cesium": "^1.2.23", + "vite-plugin-mkcert": "^1.17.12", + "vite-plugin-static-copy": "^2.3.2", + "vitest": "^3.2.7", + }, + }, + }, + "overrides": { + "ajv": "^8.18.0", + "axios": "^1.7.9", + "cookie": "^0.7.0", + "devalue": "^5.3.2", + "dompurify": "^3.2.7", + "flatted": "^3.4.0", + "follow-redirects": "^1.15.12", + "minimatch": "^9.0.5", + "picomatch": "^2.3.2", + "postcss": "^8.5.10", + "protobufjs": "^7.5.7", + "rollup": "^4.59.0", + "ws": "^8.20.1", + "yaml": "^2.7.0", + }, + "packages": { + "@carbon/charts": ["@carbon/charts@1.27.16", "", { "dependencies": { "@carbon/colors": "^11.37.0", "@carbon/utils-position": "^1.3.0", "@ibm/telemetry-js": "^1.9.1", "@types/d3": "^7.4.3", "@types/topojson": "^3.2.6", "d3": "^7.9.0", "d3-cloud": "^1.2.7", "d3-sankey": "^0.12.3", "date-fns": "^4.1.0", "dompurify": "^3.4.0", "html-to-image": "1.11.11", "lodash-es": "^4.18.0", "topojson-client": "^3.1.0", "tslib": "^2.8.1" } }, "sha512-r/Q2ujNWJzJ/nQBh35kO8gGbb+WJppkoBXY3+rFvDua8YjrQ32erWis26NJ9BUzUW0iZUhW1K1k/xuL1JzPXGw=="], + + "@carbon/charts-svelte": ["@carbon/charts-svelte@1.22.18", "", { "dependencies": { "@carbon/charts": "1.22.18", "@ibm/telemetry-js": "^1.9.1" }, "peerDependencies": { "svelte": "^3.31.0 || ^4.0.0 || ^5.20.0" } }, "sha512-nm5eixRK5TrKsFIII922lqHxGFWYcQHFIB/RsbF4YGMkZ3xOKTRQoVnPDgaYpam/jn1rwiFWLC9mmglnfcOfCA=="], + + "@carbon/colors": ["@carbon/colors@11.38.0", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.0" } }, "sha512-5cTvIZalgauRalk4g15rDvYmNufuZWsGh6WTBCLlvynyLRnXbS6wpNrhYj9D+HTJb0jzon+6nYcMJ5H8kfUNqw=="], + + "@carbon/utils-position": ["@carbon/utils-position@1.3.0", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-bfar2dV+fQ15syIrH3n9ujY4PXd1Q+AF2VcTLJIC04IDe2f80zOnJlLNPc/RktHcWTZ7WSQm80cQo3abGcsfTA=="], + + "@cesium/engine": ["@cesium/engine@26.1.0", "", { "dependencies": { "@cesium/wasm-splats": "^0.1.0-alpha.2", "@spz-loader/core": "0.3.1", "@tweenjs/tween.js": "^25.0.0", "@zip.js/zip.js": "^2.8.1", "autolinker": "^4.0.0", "bitmap-sdf": "^1.0.3", "dompurify": "^3.3.0", "draco3d": "^1.5.1", "earcut": "3.0.2", "grapheme-splitter": "^1.0.4", "jsep": "^1.3.8", "kdbush": "^4.0.1", "ktx-parse": "^1.0.0", "lerc": "^2.0.0", "mersenne-twister": "^1.1.0", "meshoptimizer": "^1.0.1", "pako": "^3.0.0", "protobufjs": "^8.6.5", "rbush": "^4.0.1", "topojson-client": "^3.1.0", "urijs": "^1.19.7" } }, "sha512-WzeJEcmGD7/htBJ8JEZt95OgZjErPwUHqyAlJY/mCzmDm7hfwpuucLBSUhiynWW3khqBT1C7dhibjb7tdic7oA=="], + + "@cesium/wasm-splats": ["@cesium/wasm-splats@0.1.0-alpha.2", "", {}, "sha512-t9pMkknv31hhIbLpMa8yPvmqfpvs5UkUjgqlQv9SeO8VerCXOYnyP8/486BDaFrztM0A7FMbRjsXtNeKvqQghA=="], + + "@cesium/widgets": ["@cesium/widgets@16.1.0", "", { "dependencies": { "@cesium/engine": "^26.1.0", "nosleep.js": "^0.12.0" } }, "sha512-c+jt8F3YIZs2x6+X+HS4q0S9nd2jVcbPVShUmlrDK1s+C7IlEvXceDGrkjivHRElAVCzfaWEEwY1g7Lg5HCGmA=="], + + "@codemirror/state": ["@codemirror/state@6.7.1", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="], + + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.6", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA=="], + + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], + + "@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.7.2", "", {}, "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.2", "", {}, "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ=="], + + "@ibm/telemetry-js": ["@ibm/telemetry-js@1.9.1", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-qq8RPafUJHUQieXVCte1kbJEx6JctWzbA/YkXzopbfzIDRT2+hbR9QmgH+KH7bDDNRcDbdHWvHfwJKzThlMtPg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@jupyter/react-components": ["@jupyter/react-components@0.16.7", "", { "dependencies": { "@jupyter/web-components": "^0.16.7", "react": ">=17.0.0 <19.0.0" } }, "sha512-BKIPkJ9V011uhtdq1xBOu2M3up59CqsRbDS4aq8XhnHR4pwqfRV6k6irE5YBOETCoIwWZZ5RZO+cJcZ3DcsT5A=="], + + "@jupyter/web-components": ["@jupyter/web-components@0.16.7", "", { "dependencies": { "@microsoft/fast-colors": "^5.3.1", "@microsoft/fast-element": "^1.12.0", "@microsoft/fast-foundation": "^2.49.4", "@microsoft/fast-web-utilities": "^5.4.1" } }, "sha512-1a8awgvvP9J9pCV5vBRuQxdBk29764qiMJsJYEndrWH3cB/FlaO+sZIBm4OTf56Eqdgl8R3/ZSLM1+3mgXOkPg=="], + + "@jupyter/ydoc": ["@jupyter/ydoc@4.1.1", "", { "dependencies": { "@jupyterlab/nbformat": "^3.0.0 || ^4.0.0-alpha.21 || ^4.0.0", "@lumino/coreutils": "^1.11.0 || ^2.0.0", "@lumino/disposable": "^1.10.0 || ^2.0.0", "@lumino/signaling": "^1.10.0 || ^2.0.0", "y-protocols": "^1.0.5", "yjs": "^13.5.40" } }, "sha512-fO6WMy2j+Hu+/SjfLycCQZq2OIPBPJtdgrY02mgaHUAsGJ+THQ9JmVgm/Lbai4x1+npXT663FJwVm7z0zJd3Pw=="], + + "@jupyterlab/application": ["@jupyterlab/application@4.6.1", "", { "dependencies": { "@fortawesome/fontawesome-free": "^5.12.0", "@jupyterlab/apputils": "^4.7.1", "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/docregistry": "^4.6.1", "@jupyterlab/rendermime": "^4.6.1", "@jupyterlab/rendermime-interfaces": "^3.14.1", "@jupyterlab/services": "^7.6.1", "@jupyterlab/statedb": "^4.6.1", "@jupyterlab/translation": "^4.6.1", "@jupyterlab/ui-components": "^4.6.1", "@lumino/algorithm": "^2.0.4", "@lumino/application": "^2.4.9", "@lumino/commands": "^2.3.3", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/messaging": "^2.0.4", "@lumino/polling": "^2.1.5", "@lumino/properties": "^2.0.4", "@lumino/signaling": "^2.1.5", "@lumino/widgets": "^2.8.0" } }, "sha512-n188POIQZmymoMNX/KSz5SfH2rEXO3307kokDa7NmdCjXR6JFtjSjJINvfRQwbpoj7dMbVSD9mn0Qz4F8bFsrw=="], + + "@jupyterlab/apputils": ["@jupyterlab/apputils@4.7.1", "", { "dependencies": { "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/observables": "^5.6.1", "@jupyterlab/rendermime-interfaces": "^3.14.1", "@jupyterlab/services": "^7.6.1", "@jupyterlab/settingregistry": "^4.6.1", "@jupyterlab/statedb": "^4.6.1", "@jupyterlab/statusbar": "^4.6.1", "@jupyterlab/translation": "^4.6.1", "@jupyterlab/ui-components": "^4.6.1", "@lumino/algorithm": "^2.0.4", "@lumino/commands": "^2.3.3", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/domutils": "^2.0.4", "@lumino/messaging": "^2.0.4", "@lumino/signaling": "^2.1.5", "@lumino/virtualdom": "^2.0.4", "@lumino/widgets": "^2.8.0", "@types/react": "^18.0.26", "react": "^18.2.0", "sanitize-html": "~2.12.1" } }, "sha512-pJU0RaYzPF8pmoCqMIoM5764lEcQ8gE/3I85nGSeDk15xkVCyWNtMg7wnt6x0U4+II9UUp6dGu8ZRPHr0l+uww=="], + + "@jupyterlab/codeeditor": ["@jupyterlab/codeeditor@4.6.1", "", { "dependencies": { "@codemirror/state": "^6.5.4", "@jupyter/ydoc": "^4.0.0", "@jupyterlab/apputils": "^4.7.1", "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/nbformat": "^4.6.1", "@jupyterlab/observables": "^5.6.1", "@jupyterlab/statusbar": "^4.6.1", "@jupyterlab/translation": "^4.6.1", "@jupyterlab/ui-components": "^4.6.1", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/dragdrop": "^2.1.8", "@lumino/messaging": "^2.0.4", "@lumino/signaling": "^2.1.5", "@lumino/widgets": "^2.8.0", "react": "^18.2.0" } }, "sha512-PBzm8/WGRZONf5b3kwmgnvwkzFwMGZGSL4XtdggZcTphxkT6y0eM4RlrL//7kU5uqhtrOOcraJwXn6gMxpaC/Q=="], + + "@jupyterlab/coreutils": ["@jupyterlab/coreutils@6.6.1", "", { "dependencies": { "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/signaling": "^2.1.5", "minimist": "~1.2.0", "path-browserify": "^1.0.0", "url-parse": "~1.5.4" } }, "sha512-1DjMEoiHi3CwycFVM0RWfn53+0zGhJ064SKqVeYKxxFFJdN3u7ezClduRiIGEQXAiums8w+9QLlljBx/M+kaUw=="], + + "@jupyterlab/docregistry": ["@jupyterlab/docregistry@4.6.1", "", { "dependencies": { "@jupyter/ydoc": "^4.0.0", "@jupyterlab/apputils": "^4.7.1", "@jupyterlab/codeeditor": "^4.6.1", "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/observables": "^5.6.1", "@jupyterlab/rendermime": "^4.6.1", "@jupyterlab/rendermime-interfaces": "^3.14.1", "@jupyterlab/services": "^7.6.1", "@jupyterlab/translation": "^4.6.1", "@jupyterlab/ui-components": "^4.6.1", "@lumino/algorithm": "^2.0.4", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/messaging": "^2.0.4", "@lumino/properties": "^2.0.4", "@lumino/signaling": "^2.1.5", "@lumino/widgets": "^2.8.0", "react": "^18.2.0" } }, "sha512-vdKj/3Ta1k2zuq2xy7bg/pKlXLYxLCbiXWnlJCcaRSgmsauzVSQoHWWXT3piSklurO2fwL4UJO7L8IK121xbEg=="], + + "@jupyterlab/nbformat": ["@jupyterlab/nbformat@4.6.1", "", { "dependencies": { "@lumino/coreutils": "^2.2.2" } }, "sha512-+dB/edJjn7JDFiyN4nGw+quxBwUFGktgbR8udQeJweDne5mPMArpwCez/HBFkIW/816UX6+gSji01wTP4y10Lw=="], + + "@jupyterlab/observables": ["@jupyterlab/observables@5.6.1", "", { "dependencies": { "@lumino/algorithm": "^2.0.4", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/messaging": "^2.0.4", "@lumino/signaling": "^2.1.5" } }, "sha512-WB9N5WEZxwPLh5ttTBTp5QSFiKh+jv1wo/AgH9fAP/HtLLkcZne7j5eKF7XOjOhSjlAyrIiOhJ0o/vzEYvA+6Q=="], + + "@jupyterlab/rendermime": ["@jupyterlab/rendermime@4.6.1", "", { "dependencies": { "@jupyterlab/apputils": "^4.7.1", "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/nbformat": "^4.6.1", "@jupyterlab/observables": "^5.6.1", "@jupyterlab/rendermime-interfaces": "^3.14.1", "@jupyterlab/services": "^7.6.1", "@jupyterlab/translation": "^4.6.1", "@lumino/coreutils": "^2.2.2", "@lumino/messaging": "^2.0.4", "@lumino/signaling": "^2.1.5", "@lumino/widgets": "^2.8.0", "lodash.escape": "^4.0.1" } }, "sha512-9Zlj+eqyRbvZv0+7neU0A6kXyQqxxlEIgLYs1ESVE8DIBcy81/74RMO7haft+eFwZ79PEpS1JFNc6M/RvphOLg=="], + + "@jupyterlab/rendermime-interfaces": ["@jupyterlab/rendermime-interfaces@3.14.1", "", { "dependencies": { "@lumino/coreutils": "^1.11.0 || ^2.2.2", "@lumino/widgets": "^1.37.2 || ^2.8.0" } }, "sha512-h2NK52OUqLyUHABCZbLXS1C5mXyJG60ptxn4D4hAAw5rNvYEkp7Bt6xVaEJZY6GMf073lvVX11sVIQcdBAi3zA=="], + + "@jupyterlab/services": ["@jupyterlab/services@7.6.1", "", { "dependencies": { "@jupyter/ydoc": "^4.0.0", "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/nbformat": "^4.6.1", "@jupyterlab/settingregistry": "^4.6.1", "@jupyterlab/statedb": "^4.6.1", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/polling": "^2.1.5", "@lumino/properties": "^2.0.4", "@lumino/signaling": "^2.1.5", "ws": "^8.11.0" } }, "sha512-pZ3B+m6bPZs3oUO0hgNd61hI9LlDKN3q931XWe4gWr8S/BwY9RHxoIe/mDgP38G+dS5P1ONKpaFXGICJkvUoQg=="], + + "@jupyterlab/settingregistry": ["@jupyterlab/settingregistry@4.6.1", "", { "dependencies": { "@jupyterlab/nbformat": "^4.6.1", "@jupyterlab/statedb": "^4.6.1", "@lumino/commands": "^2.3.3", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/signaling": "^2.1.5", "@rjsf/utils": "^5.13.4", "ajv": "^8.12.0", "json5": "^2.2.3" }, "peerDependencies": { "react": ">=16" } }, "sha512-iQlzSchm7TsnXZUKSXJApGHO3KB1WQE3svenGkPBuqP+kO6DiXY7w5v4c+SCRmAXSrvSZ03QfGLWzCeAi8DsRQ=="], + + "@jupyterlab/statedb": ["@jupyterlab/statedb@4.6.1", "", { "dependencies": { "@lumino/commands": "^2.3.3", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/properties": "^2.0.4", "@lumino/signaling": "^2.1.5" } }, "sha512-rF+3oTMCCyiw4OV0caIB0E/lUHncxR73S2uxFEzMxTnIPyA1usO3X5Ln92XqrLLj0acUqexZELCY2PYZSX0wZA=="], + + "@jupyterlab/statusbar": ["@jupyterlab/statusbar@4.6.1", "", { "dependencies": { "@jupyterlab/ui-components": "^4.6.1", "@lumino/algorithm": "^2.0.4", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/messaging": "^2.0.4", "@lumino/signaling": "^2.1.5", "@lumino/widgets": "^2.8.0", "react": "^18.2.0" } }, "sha512-FbBExIpHFvm+OpoQ+h898VqBus4ffsdfbhfEagZvHwJtn+P2H7JxglrIzM9HXm4UhDpe6F9Ax8iVJ9aob9CViw=="], + + "@jupyterlab/theme-light-extension": ["@jupyterlab/theme-light-extension@4.6.1", "", { "dependencies": { "@jupyterlab/application": "^4.6.1", "@jupyterlab/apputils": "^4.7.1", "@jupyterlab/translation": "^4.6.1" } }, "sha512-yTf3oXpdEq0tA6mvTe2T1B8F3Yc+eqZzReza0MiOm0Id0otuAesYEBaZL3d04SUo8WnrcLUrWWeGEOr1qVgt0Q=="], + + "@jupyterlab/translation": ["@jupyterlab/translation@4.6.1", "", { "dependencies": { "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/rendermime-interfaces": "^3.14.1", "@jupyterlab/services": "^7.6.1", "@jupyterlab/statedb": "^4.6.1", "@lumino/coreutils": "^2.2.2" } }, "sha512-8XF6sCWb2xxjF8TDTI9D9UW5mdCFGFx0sKqW4j/rRihXgdNYbR/d1p2V1zcCwBQpu0X0lmS0CEOFlLaKjZmHJA=="], + + "@jupyterlab/ui-components": ["@jupyterlab/ui-components@4.6.1", "", { "dependencies": { "@jupyter/react-components": "^0.16.6", "@jupyter/web-components": "^0.16.6", "@jupyterlab/coreutils": "^6.6.1", "@jupyterlab/observables": "^5.6.1", "@jupyterlab/rendermime-interfaces": "^3.14.1", "@jupyterlab/translation": "^4.6.1", "@lumino/algorithm": "^2.0.4", "@lumino/commands": "^2.3.3", "@lumino/coreutils": "^2.2.2", "@lumino/disposable": "^2.1.5", "@lumino/messaging": "^2.0.4", "@lumino/polling": "^2.1.5", "@lumino/properties": "^2.0.4", "@lumino/signaling": "^2.1.5", "@lumino/virtualdom": "^2.0.4", "@lumino/widgets": "^2.8.0", "@rjsf/core": "^5.13.4", "@rjsf/utils": "^5.13.4", "react": "^18.2.0", "react-dom": "^18.2.0", "typestyle": "^2.0.4" } }, "sha512-U5Qp/UzeYkGA/MjzMG9bpqqLl3QJ3XFJE/nJ/ayJaaWuzArFHUvCDmn9aT2RlQxvKT1eQnzjVeMDuaV0wdcc4A=="], + + "@libsql/client": ["@libsql/client@0.15.15", "", { "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.22", "promise-limit": "^2.7.0" } }, "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w=="], + + "@libsql/core": ["@libsql/core@0.15.15", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA=="], + + "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="], + + "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="], + + "@libsql/hrana-client": ["@libsql/hrana-client@0.7.0", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.3.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw=="], + + "@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.3.1", "", {}, "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw=="], + + "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], + + "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="], + + "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="], + + "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="], + + "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="], + + "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="], + + "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="], + + "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], + + "@lumino/algorithm": ["@lumino/algorithm@2.0.5", "", {}, "sha512-yW5k0IRrNlNDL9/hLOPr0XaMYf7YSZ5a/uJ++a92ziBe7v7a3xzBJ1+Awhb6fMyanE0Mz97UOQIsHa3vWB0UQA=="], + + "@lumino/application": ["@lumino/application@2.4.10", "", { "dependencies": { "@lumino/commands": "^2.3.4", "@lumino/coreutils": "^2.2.3", "@lumino/widgets": "^2.9.0" } }, "sha512-NlOMzs9G7e9hIKybJ9JY0DoFOt151NTBcxCc4Z+RllYxU6ZaS1UWd1M4rk+VBJnfo95RNreT94/yKFLT0zynBA=="], + + "@lumino/collections": ["@lumino/collections@2.0.5", "", { "dependencies": { "@lumino/algorithm": "^2.0.5" } }, "sha512-iRthCb6cMoq1rcd4OC8dNFU7v/YbJOw647kzqrMxLPlQmMeeY5wHxF/W9qCb01VUI3zyxvh0/ND4DHB4mruJfA=="], + + "@lumino/commands": ["@lumino/commands@2.3.4", "", { "dependencies": { "@lumino/algorithm": "^2.0.5", "@lumino/coreutils": "^2.2.3", "@lumino/disposable": "^2.1.6", "@lumino/domutils": "^2.0.5", "@lumino/keyboard": "^2.0.5", "@lumino/signaling": "^2.1.6", "@lumino/virtualdom": "^2.0.5" } }, "sha512-bmCdM7jxX25s9ydKZcWmF7xL051MsG5fJ9IyCHoOPcKm3keuwX3wY1PpVERuooUvU6zZfI7+LFaolWX0eCs2Hw=="], + + "@lumino/coreutils": ["@lumino/coreutils@2.2.3", "", { "dependencies": { "@lumino/algorithm": "^2.0.5" } }, "sha512-YxmayMeq8VJCPqo1okpsA3oOoz0eZeG5qedOLmopm+N/GwQT7S34QPPts49omVUwTJ0Xo8fGzOsxnS/LwnVl1Q=="], + + "@lumino/default-theme": ["@lumino/default-theme@2.1.16", "", { "dependencies": { "@lumino/dragdrop": "^2.1.9", "@lumino/widgets": "^2.9.0" } }, "sha512-TRFmlAhqq6wDTUXRPGJBBW/CCmWWnofR2XPGHwui5DK0UB9TOsq7pZTzGKsHIADMWPgTH5wNGVYQf+IFFBrTPw=="], + + "@lumino/disposable": ["@lumino/disposable@2.1.6", "", { "dependencies": { "@lumino/signaling": "^2.1.6" } }, "sha512-mTDBsftt+dWkP3MeeLuYjJ7mtOcrMld6Pq4h7EY6HzhPSdPXzvQELZ29vpc1lq6EWz/fzCfpoE+W8AzNUCHTLg=="], + + "@lumino/domutils": ["@lumino/domutils@2.0.5", "", {}, "sha512-4zsSqWqAHvOFJTtvyAZsQu7qL5ZZRu37mTzppZNAI3a91jlIben3NbAMGZiyZeAnv3b9Z7S5sPOVWZKkJyhegg=="], + + "@lumino/dragdrop": ["@lumino/dragdrop@2.1.9", "", { "dependencies": { "@lumino/coreutils": "^2.2.3", "@lumino/disposable": "^2.1.6" } }, "sha512-gwkdJNLrRE8GVh4Z70Yj5fhpasQNDkt8Z/aOneQbL4T1bpsMTwzdn3t/2pIZaWngrEqCGnj0ofUUuQAYZuQMyw=="], + + "@lumino/keyboard": ["@lumino/keyboard@2.0.5", "", {}, "sha512-YB7kmE0+ZeZ6oN/Qps6Ln8MXe4rZQh+27ex4GTJuxTMvvbqFhDW6mc/zN6av22HYxDlOF55YlzNhjk2LpD8k7A=="], + + "@lumino/messaging": ["@lumino/messaging@2.0.5", "", { "dependencies": { "@lumino/algorithm": "^2.0.5", "@lumino/collections": "^2.0.5" } }, "sha512-LNvV9xW6pkVuW8n4vFYxboJFOtt2WRJZMrg3Ta6C6LcfazVQy+ubiC8GRqBMq2ufZ1zclOWQII5QFC1wxFm6dg=="], + + "@lumino/polling": ["@lumino/polling@2.1.6", "", { "dependencies": { "@lumino/coreutils": "^2.2.3", "@lumino/disposable": "^2.1.6", "@lumino/signaling": "^2.1.6" } }, "sha512-q1+Ii2AH8rdLoeJvvjpNnmQU0PcDSXLVm0+XO447eJwYFRg56zjvZ/ZrbFUwPA0jz0xf2hbeHkdyAkmcVI4Qcw=="], + + "@lumino/properties": ["@lumino/properties@2.0.5", "", {}, "sha512-+Yoo9lb/hzNKFXpkRaEC4r7aMe8prbEhscAbA/vyzqmY4NYjZsiqhxHUgyKgtGHosrrpY59Qv4ha+5csU08zsw=="], + + "@lumino/signaling": ["@lumino/signaling@2.1.6", "", { "dependencies": { "@lumino/algorithm": "^2.0.5", "@lumino/coreutils": "^2.2.3" } }, "sha512-PEB2nuNyippF/vj28oiDm7Zy8vAeVq07NE59L79GhaTe9i37TCjtA3+67DJnVGn1+fQPfexs6HdrY/90JFFwiw=="], + + "@lumino/virtualdom": ["@lumino/virtualdom@2.0.5", "", { "dependencies": { "@lumino/algorithm": "^2.0.5" } }, "sha512-Qgvtv9Iruny+p2y/jPWo9tOcUqS+Nlcwg14+m+xJEclFyVTpY8gGJKTDY8Y1wVO5PpQjRPNdbF8lxzrGR/89IA=="], + + "@lumino/widgets": ["@lumino/widgets@2.9.0", "", { "dependencies": { "@lumino/algorithm": "^2.0.5", "@lumino/commands": "^2.3.4", "@lumino/coreutils": "^2.2.3", "@lumino/disposable": "^2.1.6", "@lumino/domutils": "^2.0.5", "@lumino/dragdrop": "^2.1.9", "@lumino/keyboard": "^2.0.5", "@lumino/messaging": "^2.0.5", "@lumino/properties": "^2.0.5", "@lumino/signaling": "^2.1.6", "@lumino/virtualdom": "^2.0.5" } }, "sha512-cLiHV/QJxP54Bg+Ixa24VDzj48YkgtW7drBI4gRAZqM1e3zcDF4HoDT0BsobRq4A7oaFmqNhzD/PMTtw9w9zJg=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + + "@microsoft/fast-colors": ["@microsoft/fast-colors@5.3.1", "", {}, "sha512-72RZXVfCbwQzvo5sXXkuLXLT7rMeYaSf5r/6ewQiv/trBtqpWRm4DEH2EilHw/iWTBKOXs1qZNQndgUMa5n4LA=="], + + "@microsoft/fast-element": ["@microsoft/fast-element@1.14.0", "", {}, "sha512-zXvuSOzvsu8zDTy9eby8ix8VqLop2rwKRgp++ZN2kTCsoB3+QJVoaGD2T/Cyso2ViZQFXNpiNCVKfnmxBvmWkQ=="], + + "@microsoft/fast-foundation": ["@microsoft/fast-foundation@2.50.0", "", { "dependencies": { "@microsoft/fast-element": "^1.14.0", "@microsoft/fast-web-utilities": "^5.4.1", "tabbable": "^5.2.0", "tslib": "^1.13.0" } }, "sha512-8mFYG88Xea1jZf2TI9Lm/jzZ6RWR8x29r24mGuLojNYqIR2Bl8+hnswoV6laApKdCbGMPKnsAL/O68Q0sRxeVg=="], + + "@microsoft/fast-web-utilities": ["@microsoft/fast-web-utilities@5.4.1", "", { "dependencies": { "exenv-es6": "^1.1.1" } }, "sha512-ReWYncndjV3c8D8iq9tp7NcFNc1vbVHvcBFPME2nNFKNbS1XCesYZGlIlf3ot5EmuOXPlrzUHOWzQ2vFpIkqDg=="], + + "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@rjsf/core": ["@rjsf/core@5.24.10", "", { "dependencies": { "lodash": "^4.17.21", "lodash-es": "^4.17.21", "markdown-to-jsx": "^7.4.1", "nanoid": "^3.3.7", "prop-types": "^15.8.1" }, "peerDependencies": { "@rjsf/utils": "^5.24.x", "react": "^16.14.0 || >=17" } }, "sha512-DJe2OECdDoBHacQKCPOAiuoEydM7ZJUUFqgcO4VswRs13QglRaEAEk4ylWR6f2gQx7wBg0u302n3VvRoHRxegQ=="], + + "@rjsf/utils": ["@rjsf/utils@5.24.10", "", { "dependencies": { "json-schema-merge-allof": "^0.8.1", "jsonpointer": "^5.0.1", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "react-is": "^18.2.0" }, "peerDependencies": { "react": "^16.14.0 || >=17" } }, "sha512-afsi+oKNV12p6OnBydJ0R4M87h7SaArqAhqJPqVXbMldnbPnW632THCtod5GG+IYtkqUkx0iQq2xpHw1OBf+3A=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@spz-loader/core": ["@spz-loader/core@0.3.1", "", {}, "sha512-8qJ1WIBXaJu8HjnJAjYniE0kYcr0kCe5Hp7kDzYiGVvvd7zyrOBwbF5imoW5mvwx1Qba0hxGEK5R9jEoaHKJFA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], + + "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.1.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.69.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-cphwqMRcE19/9VkrIPr5qZhQ0SptSSDfDzRUpYHu9OJDFGuYBFyJzK+KQA27wB4YG32O/yF2QjBkDmTyo0vtCw=="], + + "@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@threejs-kit/instanced-sprite-mesh": ["@threejs-kit/instanced-sprite-mesh@2.5.1", "", { "dependencies": { "diet-sprite": "^0.0.1", "earcut": "^2.2.4", "maath": "^0.10.7", "three-instanced-uniforms-mesh": "^0.52.4", "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.170.0" } }, "sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA=="], + + "@threlte/core": ["@threlte/core@8.5.16", "", { "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-+aa9Zgz0/Qat82H0hTm1pTEtS2BIP1Nfu7pKaei+3+TvDqER0YnZ4hdgI9fYU9Z5r1lrzY3JxMyrdISp4B0kfw=="], + + "@threlte/extras": ["@threlte/extras@9.21.0", "", { "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-ZkBx4IH+7vcxVM09Rex9XJNMKKbQt4JZ5J/3TnBd/Fge8IzSgl0ydgn70riDf/8tDLMDID6VdZPPrlr5vCHHJA=="], + + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + + "@types/cesium": ["@types/cesium@1.70.4", "", { "dependencies": { "cesium": "*" } }, "sha512-dSBhF5qftTCyB8xYRpumjzeEdEiV0wjDfmLtXF+1cNYUHvn2xs2HcvxCKFCqH6hEcATPWDP0WW6aaCCqrUo9dQ=="], + + "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="], + + "@types/react": ["@types/react@18.3.21", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-gXLBtmlcRJeT09/sI4PxVwyrku6SaNUj/6cMubjE6T6XdY1fDmBL7r0nX0jbSZPU/Xr0KuwLLZh6aOYY5d91Xw=="], + + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.176.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "^0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw=="], + + "@types/topojson": ["@types/topojson@3.2.6", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-client": "*", "@types/topojson-server": "*", "@types/topojson-simplify": "*", "@types/topojson-specification": "*" } }, "sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw=="], + + "@types/topojson-client": ["@types/topojson-client@3.1.5", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw=="], + + "@types/topojson-server": ["@types/topojson-server@3.0.4", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-5+ieK8ePfP+K2VH6Vgs1VCt+fO1U8XZHj0UsF+NktaF0DavAo1q3IvCBXgokk/xmtvoPltSUs6vxuR/zMdOE1g=="], + + "@types/topojson-simplify": ["@types/topojson-simplify@3.0.3", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-sBO5UZ0O2dB0bNwo0vut2yLHhj3neUGi9uL7/ROdm8Gs6dtt4jcB9OGDKr+M2isZwQM2RuzVmifnMZpxj4IGNw=="], + + "@types/topojson-specification": ["@types/topojson-specification@1.0.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/w3c-generic-sensor": ["@types/w3c-generic-sensor@1.0.10", "", { "dependencies": { "typescript": "^2.2.2" } }, "sha512-C56c4FhueauZcGyez3WHMzQ4LgRkJ6TNYimmYTWF0u1ncaipghmjfB+971/rU7UfrwajJg5OTcv+fp0VjjTzig=="], + + "@types/webxr": ["@types/webxr@0.5.22", "", {}, "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.64.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/type-utils": "8.64.0", "@typescript-eslint/utils": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.64.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.64.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.64.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.64.0", "@typescript-eslint/types": "^8.64.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0" } }, "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.64.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/utils": "8.64.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.64.0", "", {}, "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.64.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.64.0", "@typescript-eslint/tsconfig-utils": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.64.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw=="], + + "@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="], + + "@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + + "@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + + "@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + + "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + + "@webgpu/types": ["@webgpu/types@0.1.60", "", {}, "sha512-8B/tdfRFKdrnejqmvq95ogp8tf52oZ51p3f4QD5m5Paey/qlX4Rhhy5Y8tgFMi7Ms70HzcMMw3EQjH/jdhTwlA=="], + + "@zip.js/zip.js": ["@zip.js/zip.js@2.8.31", "", {}, "sha512-2NLmow9ax/5IdBbJKxNQp3Ur9mNxmgLfN2Yp/FKNh1ZIGs3OYkiC3AIUfIZouTPSgeW5+F1bzTAZAyD2Y4ZfFA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + + "autolinker": ["autolinker@4.1.5", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "bitmap-sdf": ["bitmap-sdf@1.0.4", "", {}, "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg=="], + + "brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], + + "carbon-components-svelte": ["carbon-components-svelte@0.89.11", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.0", "flatpickr": "4.6.9" } }, "sha512-91R5/b0wqiN2d+ljadj9yA+mOQ+ZfRGIuN2j4cQUpXWNz+OYRD4qjRdZ4cxX/+PDEpuQ43WzToNpNFT8bjgBug=="], + + "carbon-icons-svelte": ["carbon-icons-svelte@12.26.0", "", {}, "sha512-UnYW6G5YTZj4EtzLP0IykFG41rWlzodtgOo1OsIP5VGrGN2L16h5MqRS9vhfseG4+ypkJ0OA2xqsstfQhdVyuQ=="], + + "carbon-preprocess-svelte": ["carbon-preprocess-svelte@0.11.37", "", {}, "sha512-koopn13dVfKwyOTX0xfhBnFADcgKo234VzT+8SHKz5hO4hIdt3GgzYWitOPi17CEgjfph9+Jm4zNpLTM3i9Zqw=="], + + "cesium": ["cesium@1.143.0", "", { "dependencies": { "@cesium/engine": "^26.1.0", "@cesium/widgets": "^16.1.0", "protobufjs": "^8.6.5" } }, "sha512-EHz61NBp/2UPYE4iWCZ+YK9Jcb0d2hzRfqkj4DRaUM3cJmA9hE2FTmcPPZ2iL7w+W0YUScI0gc9XeSTXlPIQtA=="], + + "chai": ["chai@5.2.0", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "compute-gcd": ["compute-gcd@1.2.1", "", { "dependencies": { "validate.io-array": "^1.0.3", "validate.io-function": "^1.0.2", "validate.io-integer-array": "^1.0.0" } }, "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg=="], + + "compute-lcm": ["compute-lcm@1.1.2", "", { "dependencies": { "compute-gcd": "^1.2.1", "validate.io-array": "^1.0.3", "validate.io-function": "^1.0.2", "validate.io-integer-array": "^1.0.0" } }, "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-cloud": ["d3-cloud@1.2.7", "", { "dependencies": { "d3-dispatch": "^1.0.3" } }, "sha512-8TrgcgwRIpoZYQp7s3fGB7tATWfhckRb8KcVd1bOgqkNdkJRDGWfdSf4HkHHzZxSczwQJdSxvfPudwir5IAJ3w=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "diet-sprite": ["diet-sprite@0.0.1", "", {}, "sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], + + "earcut": ["earcut@2.2.4", "", {}, "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-plugin-svelte": ["eslint-plugin-svelte@3.20.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.7.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-AElKLVt7Hjy4d7ljwhrhw9hux60DCxCNkmK8cY/aAXvjs8tpR7PvU4DlyI/SA1PaJww1gh0wPGo2pbyURuEwxQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "exenv-es6": ["exenv-es6@1.1.1", "", {}, "sha512-vlVu3N8d6yEMpMsEm+7sUBAI81aqYYuEvfK0jNqmdb/OPXzzH7QWDDnVjMvDSY47JdHEqx/dfC/q8WkfoTmpGQ=="], + + "expect-type": ["expect-type@1.2.1", "", {}, "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatpickr": ["flatpickr@4.6.9", "", {}, "sha512-F0azNNi8foVWKSF+8X+ZJzz8r9sE1G4hl06RyceIaLvyltKvDl6vqk9Lm/6AUUCi5HWaIjiUbk7UpeE/fOXOpw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "free-style": ["free-style@3.1.0", "", {}, "sha512-vJujYSIyT30iDoaoeigNAxX4yB1RUrh+N2ZMhIElMr3BvCuGXOw7XNJMEEJkDUeamK2Rnb/IKFGKRKlTWIGRWA=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="], + + "globalyzer": ["globalyzer@0.1.0", "", {}, "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "grapheme-splitter": ["grapheme-splitter@1.0.4", "", {}, "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="], + + "gsap": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-to-image": ["html-to-image@1.11.11", "", {}, "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA=="], + + "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + + "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-compare": ["json-schema-compare@0.2.2", "", { "dependencies": { "lodash": "^4.17.4" } }, "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ=="], + + "json-schema-merge-allof": ["json-schema-merge-allof@0.8.1", "", { "dependencies": { "compute-lcm": "^1.1.2", "json-schema-compare": "^0.2.2", "lodash": "^4.17.20" } }, "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], + + "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + + "kdbush": ["kdbush@4.0.2", "", {}, "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], + + "ktx-parse": ["ktx-parse@1.0.0", "", {}, "sha512-Z31kVizz4DF/6vo9YiSYVBhuXAfyQy9bGxlW3+mB5OELoZjfXVZQpRoctsx8IEDKxBd6SagXKo7qRvu38i8Jfg=="], + + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + + "lerc": ["lerc@2.0.0", "", {}, "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lib0": ["lib0@0.2.107", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0gentesthtml": "bin/gentesthtml.js", "0serve": "bin/0serve.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-2xih/AugT0dJSgeSfsW/bqIPILlsqzEtmw8hXzWEnMLrOz12DTK5z9rjNgUT21/HkBjHSznOQBr67bcZdc8Ltg=="], + + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + + "lodash.escape": ["lodash.escape@4.0.1", "", {}, "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="], + + "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], + + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "markdown-to-jsx": ["markdown-to-jsx@7.7.6", "", { "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-/PWFFoKKMidk4Ut06F5hs5sluq1aJ0CGvUJWsnCK6hx/LPM8vlhvKAxtGHJ+U+V2Il2wmnfO6r81ICD3xZRVaw=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "mersenne-twister": ["mersenne-twister@1.1.0", "", {}, "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA=="], + + "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nosleep.js": ["nosleep.js@0.12.0", "", {}, "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="], + + "pako": ["pako@3.0.1", "", {}, "sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + + "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], + + "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="], + + "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + + "prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.2", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ=="], + + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + + "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "rbush": ["rbush@4.0.1", "", { "dependencies": { "quickselect": "^3.0.0" } }, "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "rollup-plugin-external-globals": ["rollup-plugin-external-globals@0.6.1", "", { "dependencies": { "@rollup/pluginutils": "^4.0.0", "estree-walker": "^2.0.1", "is-reference": "^1.2.1", "magic-string": "^0.25.7" }, "peerDependencies": { "rollup": "^2.25.0" } }, "sha512-mlp3KNa5sE4Sp9UUR2rjBrxjG79OyZAh/QC18RHIjM+iYkbBwNXSo8DHRMZWtzJTrH8GxQ+SJvCTN3i14uMXIA=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sanitize-html": ["sanitize-html@2.12.1", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^8.0.0", "is-plain-object": "^5.0.0", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + + "send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], + + "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "svelte": ["svelte@5.56.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-P03YJmUy2JoOxYHb4Ka3oFat1hq2ko2it1MOItSjsJ4B6WgZPLc3+RyyU+57OGWhs3Ieq74EJpZtSnNXdAGgDw=="], + + "svelte-adapter-bun": ["svelte-adapter-bun@0.5.2", "", { "dependencies": { "tiny-glob": "^0.2.9" } }, "sha512-xEtFgaal6UgrCwwkSIcapO9kopoFNUYCYqyKCikdqxX9bz2TDYnrWQZ7qBnkunMxi1HOIERUCvTcebYGiarZLA=="], + + "svelte-check": ["svelte-check@4.7.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg=="], + + "svelte-eslint-parser": ["svelte-eslint-parser@1.8.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg=="], + + "svelte-preprocess": ["svelte-preprocess@6.0.5", "", { "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", "postcss-load-config": ">=3", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": ">=0.55", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", "svelte": "^4.0.0 || ^5.0.0-next.100 || ^5.0.0", "typescript": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["@babel/core", "coffeescript", "less", "postcss", "postcss-load-config", "pug", "sass", "stylus", "sugarss", "typescript"] }, "sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg=="], + + "tabbable": ["tabbable@5.3.3", "", {}, "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "three": ["three@0.176.0", "", {}, "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA=="], + + "three-instanced-uniforms-mesh": ["three-instanced-uniforms-mesh@0.52.4", "", { "dependencies": { "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-YwDBy05hfKZQtU+Rp0KyDf9yH4GxfhxMbVt9OYruxdgLfPwmDG5oAbGoW0DrKtKZSM3BfFcCiejiOHCjFBTeng=="], + + "three-mesh-bvh": ["three-mesh-bvh@0.9.11", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-PtGT02vWRQiP6szrcorR1GEClhHSr/+O45pyH/cQ8sY+UOlicecuUVjAHmIsbIri3ZO/8WTWBq3h7227F/TVBw=="], + + "three-perf": ["three-perf@1.0.11", "", { "dependencies": { "troika-three-text": "^0.52.0", "tweakpane": "^3.1.10" }, "peerDependencies": { "three": ">=0.170" } }, "sha512-OgBpZjwL+csQKGKZjpkH/QHdbGFMxqngMbSEJeSnVNfXDYd6On7WHNv/GhUZH4YxIpNMwMahBWrNnsJvnbSJHQ=="], + + "three-viewport-gizmo": ["three-viewport-gizmo@2.2.0", "", { "peerDependencies": { "three": ">=0.162.0 <1.0.0" } }, "sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q=="], + + "tiny-glob": ["tiny-glob@0.2.9", "", { "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="], + + "troika-three-utils": ["troika-three-utils@0.52.4", "", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="], + + "troika-worker-utils": ["troika-worker-utils@0.52.0", "", {}, "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tweakpane": ["tweakpane@3.1.10", "", {}, "sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typestyle": ["typestyle@2.4.0", "", { "dependencies": { "csstype": "3.0.10", "free-style": "3.1.0" } }, "sha512-/d1BL6Qi+YlMLEydnUEB8KL/CAjAN8cyt3/UyGnOyBrWf7bLGcR/6yhmsaUstO2IcYwZfagjE7AIzuI2vUW9mg=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="], + + "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate.io-array": ["validate.io-array@1.0.6", "", {}, "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg=="], + + "validate.io-function": ["validate.io-function@1.0.2", "", {}, "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ=="], + + "validate.io-integer": ["validate.io-integer@1.0.5", "", { "dependencies": { "validate.io-number": "^1.0.3" } }, "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ=="], + + "validate.io-integer-array": ["validate.io-integer-array@1.0.0", "", { "dependencies": { "validate.io-array": "^1.0.3", "validate.io-integer": "^1.0.4" } }, "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA=="], + + "validate.io-number": ["validate.io-number@1.0.3", "", {}, "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg=="], + + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vite-plugin-cesium": ["vite-plugin-cesium@1.2.23", "", { "dependencies": { "fs-extra": "^9.1.0", "rollup-plugin-external-globals": "^0.6.1", "serve-static": "^1.14.1" }, "peerDependencies": { "cesium": "^1.95.0", "vite": ">=2.7.1" } }, "sha512-x9A8ZCEoegceXg/E+LnxKr0XBsI9CR4cgYWQ2Dd3cUEYwKcTnHQ3kBfpol7BUcGtgQnQos/mtVrRmuVQBXFjHw=="], + + "vite-plugin-mkcert": ["vite-plugin-mkcert@1.17.12", "", { "dependencies": { "debug": "^4.4.3", "picocolors": "^1.1.1" }, "peerDependencies": { "vite": ">=3" } }, "sha512-HhLyUZnvkKLuk9o+HBaMlGIa6CqPTfLbVlLFBjrkN4mpZ4saYFRfFx5R12a46UxmzWT9dO8fijmblVvCYvCD3A=="], + + "vite-plugin-static-copy": ["vite-plugin-static-copy@2.3.2", "", { "dependencies": { "chokidar": "^3.5.3", "fast-glob": "^3.2.11", "fs-extra": "^11.1.0", "p-map": "^7.0.3", "picocolors": "^1.0.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0" } }, "sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg=="], + + "vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="], + + "vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + + "y-protocols": ["y-protocols@1.0.6", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yjs": ["yjs@13.6.26", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-wiARO3wixu7mtoRP5f7LqpUtsURP9SmNgXUt3RlnZg4qDuF7dUjthwIvwxIDmK55dPw4Wl4QdW5A3ag0atwu7g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="], + + "@carbon/charts-svelte/@carbon/charts": ["@carbon/charts@1.22.18", "", { "dependencies": { "@carbon/colors": "^11.29.0", "@carbon/utils-position": "^1.3.0", "@ibm/telemetry-js": "^1.9.1", "@types/d3": "^7.4.3", "@types/topojson": "^3.2.6", "d3": "^7.9.0", "d3-cloud": "^1.2.7", "d3-sankey": "^0.12.3", "date-fns": "^4.1.0", "dompurify": "^3.2.4", "html-to-image": "^1.11.13", "lodash-es": "^4.17.21", "topojson-client": "^3.1.0", "tslib": "^2.8.1" } }, "sha512-OjQTv3c8KGeoHCIJYpNnaa3FHEU3mdW15Yx1eG6i7CRiH5A+yr3GzFOXvpqhQNFd+vmhKHQEIwioS+HbrBhjZA=="], + + "@cesium/engine/@tweenjs/tween.js": ["@tweenjs/tween.js@25.0.0", "", {}, "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="], + + "@cesium/engine/earcut": ["earcut@3.0.2", "", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], + + "@cesium/engine/meshoptimizer": ["meshoptimizer@1.2.0", "", {}, "sha512-davRZeIJbxJrE24cwQle7ZDsxjdk/OphNOV83oX+efQinyoHY9Jcyz3MHbaoG0qySZajldGztNZ1RN/T19PZsg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/config-array/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@eslint/eslintrc/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@jupyterlab/application/@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@5.15.4", "", {}, "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg=="], + + "@microsoft/fast-foundation/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "@rjsf/core/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "@rjsf/core/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "@rjsf/utils/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "@sveltejs/kit/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "@sveltejs/kit/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "@sveltejs/vite-plugin-svelte-inspector/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@types/cesium/cesium": ["cesium@1.129.0", "", { "dependencies": { "@cesium/engine": "^17.0.0", "@cesium/widgets": "^12.0.0" } }, "sha512-XDEJKLnr8s5/Q/1wcdZVEJCgx+xbgfDzkVKd9OxRZzYttazip6KffusMHexRdExVMPfDcTohuqcuHNxj9CUNFA=="], + + "@types/w3c-generic-sensor/typescript": ["typescript@2.9.2", "", { "bin": { "tsc": "./bin/tsc", "tsserver": "./bin/tsserver" } }, "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w=="], + + "@types/ws/@types/node": ["@types/node@22.15.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g=="], + + "@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/project-service/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "@vitest/mocker/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "chai/loupe": ["loupe@3.1.3", "", {}, "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug=="], + + "d3-cloud/d3-dispatch": ["d3-dispatch@1.0.6", "", {}, "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "eslint/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "eslint-plugin-svelte/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], + + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "rollup/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "rollup-plugin-external-globals/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], + + "rollup-plugin-external-globals/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "svelte-eslint-parser/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "typestyle/csstype": ["csstype@3.0.10", "", {}, "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA=="], + + "vite-node/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], + + "vite-plugin-mkcert/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "vite-plugin-static-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "vite-plugin-static-copy/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], + + "vitest/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@carbon/charts-svelte/@carbon/charts/html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], + + "@carbon/charts-svelte/@carbon/charts/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "@sveltejs/kit/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@types/cesium/cesium/@cesium/engine": ["@cesium/engine@17.0.0", "", { "dependencies": { "@tweenjs/tween.js": "^25.0.0", "@zip.js/zip.js": "^2.7.34", "autolinker": "^4.0.0", "bitmap-sdf": "^1.0.3", "dompurify": "^3.0.2", "draco3d": "^1.5.1", "earcut": "^3.0.0", "grapheme-splitter": "^1.0.4", "jsep": "^1.3.8", "kdbush": "^4.0.1", "ktx-parse": "^1.0.0", "lerc": "^2.0.0", "mersenne-twister": "^1.1.0", "meshoptimizer": "^0.23.0", "pako": "^2.0.4", "protobufjs": "^7.1.0", "rbush": "3.0.1", "topojson-client": "^3.1.0", "urijs": "^1.19.7" } }, "sha512-bvLWmWVY4QX9rfcx/zfBzA8R1xR8KzmaCDOVL6pFkNZeYmRtt8JN//IICYR3P45lznlcb0Dklw1iCb37t4tvLA=="], + + "@types/cesium/cesium/@cesium/widgets": ["@cesium/widgets@12.0.0", "", { "dependencies": { "@cesium/engine": "^17.0.0", "nosleep.js": "^0.12.0" } }, "sha512-5Re06LU8lRPEZInHrpOCGBiLoj9VNJ0JJtPrzdnw1qhIDpf3v1W75OJf59k8CCFvUYkZzuTTi8qTVbpzmcbOuw=="], + + "@typescript-eslint/typescript-estree/tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "@vitest/mocker/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "eslint-plugin-svelte/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "vite-node/vite/tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="], + + "vite-plugin-static-copy/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "vite-plugin-static-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "@types/cesium/cesium/@cesium/engine/@tweenjs/tween.js": ["@tweenjs/tween.js@25.0.0", "", {}, "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A=="], + + "@types/cesium/cesium/@cesium/engine/@zip.js/zip.js": ["@zip.js/zip.js@2.7.60", "", {}, "sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA=="], + + "@types/cesium/cesium/@cesium/engine/earcut": ["earcut@3.0.1", "", {}, "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw=="], + + "@types/cesium/cesium/@cesium/engine/meshoptimizer": ["meshoptimizer@0.23.0", "", {}, "sha512-zAZcfhHE3wBbwEN8MfCMI9PKRyOpz8491wcR2dxkv3IlNwDZrq2hEs5JZVtzfBrmjWhBZZtZZUO0OBSNFq5iUQ=="], + + "@types/cesium/cesium/@cesium/engine/pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + + "@types/cesium/cesium/@cesium/engine/rbush": ["rbush@3.0.1", "", { "dependencies": { "quickselect": "^2.0.0" } }, "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w=="], + + "@types/cesium/cesium/@cesium/engine/rbush/quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="], + } +} diff --git a/apps/ground-station-web/package.json b/apps/ground-station-web/package.json new file mode 100644 index 0000000..52be6d3 --- /dev/null +++ b/apps/ground-station-web/package.json @@ -0,0 +1,87 @@ +{ + "name": "@rgs/web", + "version": "0.0.1", + "type": "module", + "scripts": { + "postinstall": "svelte-kit sync", + "dev": "vite dev", + "build": "vite build", + "watch": "vite build --watch", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", + "lint": "prettier --check . && eslint .", + "format": "prettier --write .", + "grpc-gen": "node scripts/generate-grpc.cjs" + }, + "devDependencies": { + "@floating-ui/dom": "^1.8.0", + "@fortawesome/fontawesome-free": "6.7.2", + "@sveltejs/adapter-auto": "^6.1.1", + "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@threlte/core": "^8.5.16", + "@threlte/extras": "^9.21.0", + "@types/node": "^22.20.1", + "@types/three": "^0.176.0", + "@types/w3c-generic-sensor": "^1.0.10", + "@typescript-eslint/eslint-plugin": "^8.64.0", + "@typescript-eslint/parser": "^8.64.0", + "@vitejs/plugin-basic-ssl": "^2.3.0", + "carbon-preprocess-svelte": "^0.11.37", + "cesium": "^1.143.0", + "eslint": "^9.39.5", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.20.0", + "prettier": "^3.9.5", + "prettier-plugin-svelte": "^3.5.2", + "svelte": "^5.56.5", + "svelte-check": "^4.7.3", + "svelte-preprocess": "^6.0.5", + "tailwindcss": "^4.3.3", + "three": "^0.176.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "vite": "^6.4.3", + "vite-plugin-cesium": "^1.2.23", + "vite-plugin-mkcert": "^1.17.12", + "vite-plugin-static-copy": "^2.3.2", + "vitest": "^3.2.7" + }, + "dependencies": { + "@carbon/charts": "^1.27.16", + "@carbon/charts-svelte": "^1.22.18", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@jupyterlab/theme-light-extension": "^4.6.1", + "@libsql/client": "^0.15.15", + "@sveltejs/kit": "^2.57.1", + "@lumino/default-theme": "^2.1.16", + "@lumino/messaging": "^2.0.5", + "@lumino/widgets": "^2.9.0", + "@tailwindcss/vite": "^4.3.3", + "@types/cesium": "^1.70.4", + "@types/leaflet": "^1.9.21", + "carbon-components-svelte": "^0.89.11", + "carbon-icons-svelte": "^12.26.0", + "d3": "^7.9.0", + "gsap": "^3.15.0", + "leaflet": "^1.9.4", + "svelte-adapter-bun": "^0.5.2" + }, + "overrides": { + "dompurify": "^3.2.7", + "protobufjs": "^7.5.7", + "follow-redirects": "^1.15.12", + "minimatch": "^9.0.5", + "axios": "^1.7.9", + "flatted": "^3.4.0", + "devalue": "^5.3.2", + "picomatch": "^2.3.2", + "postcss": "^8.5.10", + "ajv": "^8.18.0", + "cookie": "^0.7.0", + "rollup": "^4.59.0", + "ws": "^8.20.1", + "yaml": "^2.7.0" + } +} diff --git a/apps/ground-station-web/src/app.css b/apps/ground-station-web/src/app.css new file mode 100644 index 0000000..6445e45 --- /dev/null +++ b/apps/ground-station-web/src/app.css @@ -0,0 +1,393 @@ +@import '@fontsource/ibm-plex-mono/latin-400.css'; +@import '@fontsource/ibm-plex-mono/latin-500.css'; +@import '@fontsource/ibm-plex-mono/latin-600.css'; +@import 'tailwindcss'; +@import 'carbon-components-svelte/css/all.css'; +@import '@carbon/charts-svelte/styles.css'; +@import '@fortawesome/fontawesome-free/css/all.min.css'; + +:root { + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 14px; + --rgs-header-h: 40px; + --rgs-rail-w: 184px; + --rgs-rail-collapsed-w: 48px; + --rgs-space-1: 4px; + --rgs-space-2: 8px; + --rgs-space-3: 12px; + --rgs-space-4: 16px; + --rgs-bg: #f4f6f8; + --rgs-surface: #ffffff; + --rgs-surface-alt: #e9edf1; + --rgs-surface-active: #dce8f2; + --rgs-text: #101418; + --rgs-text-muted: #36414a; + --rgs-border: #66717a; + --rgs-border-subtle: #c5ccd1; + --rgs-accent: #005ea8; + --rgs-good: #176b2c; + --rgs-warn: #7a4b00; + --rgs-danger: #b42318; + --rgs-focus: #005fcc; + --rgs-shadow: 0 1px 2px rgb(16 20 24 / 14%); +} + +[data-carbon-theme='g80'], +[data-carbon-theme='g90'], +[data-carbon-theme='g100'] { + --rgs-bg: #0b0f12; + --rgs-surface: #151b20; + --rgs-surface-alt: #20282e; + --rgs-surface-active: #17364b; + --rgs-text: #f5f7f8; + --rgs-text-muted: #c1c8cd; + --rgs-border: #7d8991; + --rgs-border-subtle: #3c474f; + --rgs-accent: #78b9ee; + --rgs-good: #6fdc8c; + --rgs-warn: #f1c21b; + --rgs-danger: #ff8389; + --rgs-focus: #78a9ff; + --rgs-shadow: none; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--rgs-bg); + color: var(--rgs-text); +} + +body, +button, +input, +select, +textarea { + font-family: inherit !important; +} + +button, +a, +input, +select, +textarea { + font-variant-numeric: tabular-nums; +} + +:focus-visible { + outline: 2px solid var(--rgs-focus) !important; + outline-offset: 2px; +} + +a { + color: var(--rgs-accent); +} + +h1, +h2, +h3, +p { + margin-block-start: 0; +} + +h1 { + font-size: 1.25rem; + line-height: 1.4; +} + +h2 { + font-size: 1rem; + line-height: 1.4; +} + +h3 { + font-size: 0.875rem; + line-height: 1.4; +} + +.rgs-page { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--rgs-space-2); + padding: var(--rgs-space-3); +} + +.rgs-page-header { + display: flex; + min-height: 32px; + align-items: center; + justify-content: space-between; + gap: var(--rgs-space-2); +} + +.rgs-page-title { + margin: 0; + font-size: 1.15rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.rgs-page-actions { + display: flex; + align-items: center; + gap: var(--rgs-space-1); + flex-wrap: wrap; +} + +.rgs-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--rgs-space-2); +} + +.rgs-panel { + min-width: 0; + border: 1px solid var(--rgs-border-subtle); + background: var(--rgs-surface); + box-shadow: var(--rgs-shadow); +} + +.rgs-panel-header { + display: flex; + min-height: 32px; + align-items: center; + justify-content: space-between; + gap: var(--rgs-space-2); + padding: var(--rgs-space-1) var(--rgs-space-3); + border-bottom: 1px solid var(--rgs-border-subtle); + background: var(--rgs-surface-alt); +} + +.rgs-panel-header h2, +.rgs-panel-header h3 { + margin: 0; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.rgs-panel-body { + padding: var(--rgs-space-3); +} + +.rgs-muted { + color: var(--rgs-text-muted); +} + +.rgs-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.rgs-status::before { + width: 8px; + height: 8px; + border: 1px solid currentColor; + border-radius: 50%; + background: currentColor; + content: ''; +} + +.rgs-status--good { + color: var(--rgs-good); +} +.rgs-status--warn { + color: var(--rgs-warn); +} +.rgs-status--danger { + color: var(--rgs-danger); +} +.rgs-status--neutral { + color: var(--rgs-text-muted); +} + +.rgs-empty { + display: grid; + min-height: 96px; + place-items: center; + padding: var(--rgs-space-4); + color: var(--rgs-text-muted); + text-align: center; +} + +.rgs-value { + font-size: clamp(1.4rem, 2.5vw, 2rem); + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.rgs-unit { + margin-left: 0.3em; + color: var(--rgs-text-muted); + font-size: 0.55em; + font-weight: 500; +} + +/* Carbon remains the control toolkit, but RGS owns density and composition. */ +.cds--content, +.bx--content { + padding: 0 !important; + background: transparent !important; +} + +.cds--grid, +.bx--grid { + max-width: none !important; + padding: var(--rgs-space-3) !important; +} + +.cds--row, +.bx--row { + margin-block-end: var(--rgs-space-2) !important; +} + +.cds--tile, +.bx--tile { + min-height: 0 !important; + padding: var(--rgs-space-3) !important; + border: 1px solid var(--rgs-border-subtle); + background: var(--rgs-surface) !important; +} + +.cds--btn, +.bx--btn { + min-height: 32px !important; + padding-block: 0 !important; +} + +.cds--data-table td, +.cds--data-table th, +.bx--data-table td, +.bx--data-table th { + height: 32px !important; + padding: 4px 8px !important; + font-family: inherit !important; +} + +.cds--data-table-container, +.bx--data-table-container, +.cds--structured-list, +.bx--structured-list { + max-width: 100%; + overflow-x: auto; +} + +.rgs-route-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--rgs-space-2); + margin-bottom: var(--rgs-space-2); +} + +.rgs-route-title h1, +.rgs-route-title h2 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.rgs-route > :first-child > [class*='col'] > .cds--tile:first-child, +.rgs-route > :first-child > [class*='col'] > .bx--tile:first-child { + box-shadow: none; +} + +.rgs-route h1, +.rgs-route h2 { + letter-spacing: 0.02em; +} + +.telemetry-route > .cds--row > [class*='col'] > .cds--tile, +.telemetry-route > .bx--row > [class*='col'] > .bx--tile { + padding: 0 !important; + border: 0; + background: transparent !important; + box-shadow: none; +} + +.telemetry-route > .cds--row > [class*='col'] > .cds--tile > .cds--tile, +.telemetry-route > .bx--row > [class*='col'] > .bx--tile > .bx--tile { + margin-top: var(--rgs-space-2); +} + +@media (max-width: 767px) { + .rgs-route .cds--row, + .rgs-route .bx--row { + margin-inline: 0 !important; + } + + .rgs-route [class*='col'] { + max-width: 100% !important; + flex-basis: 100% !important; + } +} + +.cds--inline-notification, +.bx--inline-notification { + min-height: 36px !important; + margin: var(--rgs-space-2) 0 !important; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 8px; + background: color-mix(in srgb, var(--rgs-border) 70%, transparent); + background-clip: padding-box; +} + +@media (max-width: 767px) { + .rgs-page { + padding: var(--rgs-space-2); + } + .rgs-page-header { + align-items: flex-start; + flex-direction: column; + } + .rgs-page-actions { + width: 100%; + } + .rgs-grid { + grid-template-columns: 1fr; + } + .rgs-panel-body { + padding: var(--rgs-space-2); + } + .cds--grid, + .bx--grid { + padding: var(--rgs-space-2) !important; + } + .cds--btn, + .bx--btn { + min-height: 44px !important; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/apps/ground-station-web/src/app.d.ts b/apps/ground-station-web/src/app.d.ts new file mode 100644 index 0000000..fde5a49 --- /dev/null +++ b/apps/ground-station-web/src/app.d.ts @@ -0,0 +1,14 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + interface Locals { + clientIp?: string; + remoteAddress?: string; + } + // interface PageData {} + // interface Platform {} + } +} + +export { }; diff --git a/apps/ground-station-web/src/app.html b/apps/ground-station-web/src/app.html new file mode 100644 index 0000000..cbe80cf --- /dev/null +++ b/apps/ground-station-web/src/app.html @@ -0,0 +1,19 @@ + + + + + + + RGS Client + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/ground-station-web/src/hooks.server.ts b/apps/ground-station-web/src/hooks.server.ts new file mode 100644 index 0000000..3444281 --- /dev/null +++ b/apps/ground-station-web/src/hooks.server.ts @@ -0,0 +1,113 @@ +import type { Handle } from '@sveltejs/kit'; + +function getClientIp(event: Parameters[0]['event']): string | null { + // X-Forwarded-For may include a list. Take the first non-empty token (original client). + const xff = event.request.headers.get('x-forwarded-for'); + if (xff) { + const parts = xff.split(',').map((s) => s.trim()).filter(Boolean); + const first = parts[0]; + if (first) return first; + } + const realIp = event.request.headers.get('x-real-ip'); + if (realIp) return realIp; + // Fall back to platform specific data + const remote = event.getClientAddress?.() || event.locals.remoteAddress; + if (typeof remote === 'string' && remote.length > 0) return remote; + return null; +} + +type Cidr = { base: bigint; maskBits: number }; + +function parseIpToBigInt(ip: string): bigint | null { + // Strip IPv6 zone or port if present + const cleaned = ip.replace(/^\[|\]$/g, '').replace(/%.*$/, ''); + if (cleaned.includes(':')) { + // IPv6 + const parts = cleaned.split('::'); + let hextets: string[] = []; + if (parts.length === 1) { + hextets = cleaned.split(':'); + } else if (parts.length === 2) { + const left = parts[0] ? parts[0].split(':') : []; + const right = parts[1] ? parts[1].split(':') : []; + const fill = new Array(8 - (left.length + right.length)).fill('0'); + hextets = [...left, ...fill, ...right]; + } else { + return null; + } + if (hextets.length !== 8) return null; + let value = 0n; + for (const h of hextets) { + const n = BigInt(parseInt(h || '0', 16)); + value = (value << 16n) + n; + } + return value; + } else { + // IPv4 possibly with port + const withoutPort = cleaned.split(':')[0]; + const segs = withoutPort.split('.'); + if (segs.length !== 4) return null; + let val = 0n; + for (const s of segs) { + const n = Number(s); + if (Number.isNaN(n) || n < 0 || n > 255) return null; + val = (val << 8n) + BigInt(n); + } + // map IPv4 into IPv6-mapped space for uniform compare: ::ffff:a.b.c.d + return (0xffffn << 32n) + val; + } +} + +function parseCidr(cidr: string): Cidr | null { + const [ip, bitsStr] = cidr.trim().split('/'); + if (!ip || !bitsStr) return null; + const bits = Number(bitsStr); + if (Number.isNaN(bits) || bits < 0 || bits > 128) return null; + const ipVal = parseIpToBigInt(ip); + if (ipVal === null) return null; + return { base: ipVal, maskBits: bits }; +} + +function ipInCidrs(ip: string, cidrs: string[]): boolean { + const ipVal = parseIpToBigInt(ip); + if (ipVal === null) return false; + for (const c of cidrs) { + const parsed = parseCidr(c); + if (!parsed) continue; + const { base, maskBits } = parsed; + const mask = maskBits === 0 ? 0n : (~0n) << BigInt(128 - maskBits); + if ((ipVal & mask) === (base & mask)) return true; + } + return false; +} + +function isProtectedPath(pathname: string, protectedPrefixes: string[]): boolean { + return protectedPrefixes.some((p) => p && pathname.startsWith(p)); +} + +export const handle: Handle = async ({ event, resolve }) => { + const ip = getClientIp(event) ?? 'unknown'; + + // Make IP available to endpoints via locals + event.locals.clientIp = ip; + + // Pull config from env + const subnetEnv = process.env['ALLOWED_SUBNETS'] ?? ''; + const protectedEnv = process.env['PROTECTED_PATH_PREFIXES'] ?? ''; + const allowedCidrs = subnetEnv.split(',').map((s) => s.trim()).filter(Boolean); + const protectedPrefixes = protectedEnv.split(',').map((s) => s.trim()).filter(Boolean); + + // Log all incoming requests with IP and path + console.log(`[req] ${event.request.method} ${event.url.pathname} ip=${ip}`); + + // Enforce subnet on protected paths + if (isProtectedPath(event.url.pathname, protectedPrefixes)) { + const ok = ip !== 'unknown' && ipInCidrs(ip, allowedCidrs); + if (!ok) { + console.log(`[req] ${event.request.method} ${event.url.pathname} ip=${ip} denied`); + return new Response('Forbidden! This page is for internal use only. Want to know more? Come visit our booth!', { status: 403 }); + } + } + + return resolve(event); +}; diff --git a/apps/ground-station-web/src/lib/assets/hydra_provider_logo.svg b/apps/ground-station-web/src/lib/assets/hydra_provider_logo.svg new file mode 100644 index 0000000..625a146 --- /dev/null +++ b/apps/ground-station-web/src/lib/assets/hydra_provider_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ground-station-web/src/lib/common/LRUCache.ts b/apps/ground-station-web/src/lib/common/LRUCache.ts new file mode 100644 index 0000000..68a2f52 --- /dev/null +++ b/apps/ground-station-web/src/lib/common/LRUCache.ts @@ -0,0 +1,33 @@ +/** + * Least Recently Used (LRU) cache implementation. + */ +export class LRUCache { + public cache = new Map(); + public capacity: number; + + constructor(capacity: number) { + this.capacity = capacity; + } + + get(key: K): V | undefined { + const value = this.cache.get(key); + if (value) { + this.cache.delete(key); + this.cache.set(key, value); // Move to the end + } + return value; + } + + put(key: K, value: V): void { + this.cache.delete(key); + this.cache.set(key, value); + if (this.cache.size > this.capacity) { + const oldest = this.cache.keys().next(); + if (!oldest.done) this.cache.delete(oldest.value); + } + } + + clear(): void { + this.cache.clear(); + } +} diff --git a/apps/ground-station-web/src/lib/common/persistentStorage.ts b/apps/ground-station-web/src/lib/common/persistentStorage.ts new file mode 100644 index 0000000..6ccd2bd --- /dev/null +++ b/apps/ground-station-web/src/lib/common/persistentStorage.ts @@ -0,0 +1,54 @@ +import { writable, type Writable } from "svelte/store"; + +// Custom replacement for Skeleton's localStorageStore +export function persistentStore(key: string, initialValue: T): Writable { + // Check for browser environment + if (typeof window === 'undefined' || !window.localStorage) { + console.warn(`localStorage not available for key "${key}", using non-persistent store.`); + return writable(initialValue); + } + + let storedValue: T | undefined = undefined; + try { + const storedItem = window.localStorage.getItem(key); + if (storedItem !== null) { + storedValue = JSON.parse(storedItem) as T; + } + } catch (e) { + console.error(`Error reading localStorage key “${key}”:`, e); + storedValue = undefined; // Use initial value if parsing fails + } + + const store = writable(storedValue !== undefined ? storedValue : initialValue); + + store.subscribe((value) => { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.error(`Error setting localStorage key “${key}”:`, e); + } + }); + + // Sync store with changes from other tabs + const handleStorage = (event: StorageEvent) => { + if (event.key === key && event.newValue !== null) { + try { + store.set(JSON.parse(event.newValue) as T); + } catch (e) { + console.error(`Error parsing storage event for key “${key}”:`, e); + } + } else if (event.key === key && event.newValue === null) { + // Item was removed in another tab, reset to initial + store.set(initialValue); + } + }; + + window.addEventListener('storage', handleStorage); + + // Return a store that cleans up the event listener on unsubscribe + // (This requires modifying the return slightly if true cleanup is needed, + // but for settings, they usually live for the app lifetime) + // For simplicity now, we don't return the enhanced store with cleanup. + + return store; +} \ No newline at end of file diff --git a/apps/ground-station-web/src/lib/common/settings.ts b/apps/ground-station-web/src/lib/common/settings.ts new file mode 100644 index 0000000..e91c5dd --- /dev/null +++ b/apps/ground-station-web/src/lib/common/settings.ts @@ -0,0 +1,149 @@ +import { writable, type Writable } from 'svelte/store'; +import { persistentStore } from './persistentStorage'; + +// Settings type definitions + +type ValueT = + | { + valueDescription: 'string'; + value: Writable; + placeholder?: string; + } + | { + valueDescription: 'number'; + value: Writable; + min?: number; + max?: number; + step?: number; + } + | { + valueDescription: 'boolean'; + value: Writable; + } + | { + valueDescription: 'array'; + value: Writable; + } + | { + valueDescription: 'kv'; + value: Writable<{ [key: string]: string }>; + } + | { + valueDescription: 'enum'; + options: string[]; + value: Writable; + }; + +type Setting = { name: string; description?: string } & ValueT; + +type SettingsGroup = { + name: string; + settings: Setting[]; +}; + +// Settings + +const uiSettings = { + name: 'ui', + settings: [ + { + name: 'theme', + description: 'Select the application theme', + valueDescription: 'enum', + options: ['white', 'g100'], + value: persistentStore('ui.theme', 'white') + } + ] +} satisfies SettingsGroup; + +const notificationSettings = { + name: 'notifications', + settings: [ + { + name: 'consoleNotifications', + description: 'Enable on-screen console notifications', + valueDescription: 'boolean', + value: persistentStore('notifications.consoleNotifications', false) + } + ] +} satisfies SettingsGroup; + +const flightSettings = { + name: 'flight', + settings: [ + // Altitude page + { + name: 'altitudeMinutes', + valueDescription: 'number', + description: 'Minutes to look back for altitude', + value: persistentStore('flight.altitudeMinutes', 15), + min: 1, + max: 1440, + step: 1 + }, + { + name: 'altitudeQnhKpa', + valueDescription: 'number', + description: 'QNH (kPa) for barometric altitude', + value: persistentStore('flight.altitudeQnhKpa', 100), + min: 80, + max: 110, + step: 0.1 + }, + // IMU page + { + name: 'imuMinutes', + valueDescription: 'number', + description: 'Minutes to look back for IMU', + value: persistentStore('flight.imuMinutes', 10), + min: 1, + max: 1440, + step: 1 + }, + // Trajectory page + { + name: 'trajMinutes', + valueDescription: 'number', + description: 'Minutes to look back for trajectory', + value: persistentStore('flight.trajMinutes', 240), + min: 1, + max: 1440, + step: 1 + }, + { + name: 'trajMaxPoints', + valueDescription: 'number', + description: 'Max points on map path', + value: persistentStore('flight.trajMaxPoints', 3000), + min: 100, + max: 20000, + step: 100 + }, + { + name: 'trajRadiusKm', + valueDescription: 'number', + description: 'Radius around center (km)', + value: persistentStore('flight.trajRadiusKm', 50), + min: 1, + max: 500, + step: 1 + } + ] +} satisfies SettingsGroup; + +export const settings = [uiSettings, notificationSettings, flightSettings]; + +export const findSetting = (name: string): Setting | undefined => { + const groupName = name.split('.')[0]; + const group = settings.find((group) => group.name === groupName); + if (group) { + // Find setting in group + const settingName = name.split('.')[1]; + const setting = group.settings.find((setting) => setting.name === settingName); + if (setting) { + return setting; + } + } + + return undefined; +}; diff --git a/apps/ground-station-web/src/lib/common/stringCmp.ts b/apps/ground-station-web/src/lib/common/stringCmp.ts new file mode 100644 index 0000000..bec1b6e --- /dev/null +++ b/apps/ground-station-web/src/lib/common/stringCmp.ts @@ -0,0 +1,75 @@ +// https://github.com/aceakash/string-similarity + +/** + * Calculates the similarity between two strings. + * @param first string to compare + * @param second string to compare + * @returns a number between 0 and 1, where 1 is a perfect match + */ + +export function getStringDifference(first: string, second: string) { + first = first.replace(/\s+/g, ''); + second = second.replace(/\s+/g, ''); + + if (first === second) return 1; // identical or empty + if (first.length < 2 || second.length < 2) return 0; // if either is a 0-letter or 1-letter string + + const firstBigrams = new Map(); + for (let i = 0; i < first.length - 1; i++) { + const bigram = first.substring(i, i + 2); + const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) + 1 : 1; + + firstBigrams.set(bigram, count); + } + + let intersectionSize = 0; + for (let i = 0; i < second.length - 1; i++) { + const bigram = second.substring(i, i + 2); + const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) : 0; + + if (count > 0) { + firstBigrams.set(bigram, count - 1); + intersectionSize++; + } + } + + return (2.0 * intersectionSize) / (first.length + second.length - 2); +} + +/** + * Helper function that maps all strings to a score + * but uses shortcuts to avoid calling getStringDifference on all strings + * @param mainString + * @param targetStrings + * @returns an array with the score of each string in the array + */ +export function getStringScores(baseString: string, stringLst: string[]): number[] { + baseString = baseString.toLowerCase(); + // Calculate starts with score + const scores: number[] = stringLst.map((curStr) => { + curStr = curStr.toLocaleLowerCase().replace('-', ' '); + if (curStr === baseString) { + return 4; + } + // Starting or ending with gets priority over including + if (curStr.startsWith(baseString)) { + return 3; + } + + // Starting or ending with gets priority over including + if (curStr.endsWith(baseString)) { + return 2; + } + + if (curStr.includes(baseString)) { + return 1; + } + return getStringDifference(baseString, curStr); + }); + + return scores; +} + +export function sum(a: number, b: number) { + return a + b; +} diff --git a/apps/ground-station-web/src/lib/common/theme.ts b/apps/ground-station-web/src/lib/common/theme.ts new file mode 100644 index 0000000..2e04055 --- /dev/null +++ b/apps/ground-station-web/src/lib/common/theme.ts @@ -0,0 +1,36 @@ +import { browser } from '$app/environment'; +import { findSetting } from '$lib/common/settings'; +import { derived, writable, type Readable, type Writable } from 'svelte/store'; + +export type CarbonTheme = 'white' | 'g10' | 'g80' | 'g90' | 'g100'; + +const allowedThemes: CarbonTheme[] = ['white', 'g10', 'g80', 'g90', 'g100']; + +export function resolveCarbonTheme(value: string): CarbonTheme { + return (allowedThemes as readonly string[]).includes(value) ? (value as CarbonTheme) : 'g100'; +} + +// Source store from settings; fallback to writable if missing +const themeSetting = findSetting('ui.theme'); +const rawThemeStore: Writable = + themeSetting && themeSetting.valueDescription === 'enum' + ? (themeSetting.value as Writable) + : writable('white'); + +export const carbonTheme: Readable = derived(rawThemeStore, (v, set) => { + set(resolveCarbonTheme(v)); +}); + +// Apply to document root for Carbon styling +if (browser) { + carbonTheme.subscribe((t) => { + const html = document.documentElement; + html.setAttribute('theme', t); + html.setAttribute('data-carbon-theme', t); + }); +} + +// Optional setter for programmatic changes +export function setAppTheme(value: string) { + rawThemeStore.set(value); +} diff --git a/apps/ground-station-web/src/lib/common/utils.ts b/apps/ground-station-web/src/lib/common/utils.ts new file mode 100644 index 0000000..1b72aaa --- /dev/null +++ b/apps/ground-station-web/src/lib/common/utils.ts @@ -0,0 +1,103 @@ +import { onDestroy, onMount } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; + +export const theme: Writable = writable(); + +export function onThemeChange(callback: () => void) { + const unsubscribe = theme.subscribe(callback); + onDestroy(() => { + unsubscribe(); + }); + return unsubscribe; +} + +export function onInterval(callback: () => void, milliseconds: number) { + onMount(() => { + const interval = setInterval(callback, milliseconds); + return () => { + clearInterval(interval); + }; + }); +} + +export function formatVariableName(name: string): string { + return name + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +// ThisIsCamelCase -> This Is Camel Case +export function formatCamelCase(name: string): string { + return name.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()); +} + +export function getRandomHexColorFromString(str: string, contrastThreshold = 0.8): string { + // Convert the string to a unique number by summing the character codes + let num = 0; + for (let i = 0; i < str.length; i++) { + num += str.charCodeAt(i); + } + + // Use the string-based number as the seed for the random number generator + const random = Math.sin(num) * 10000; + const randomNumber = Math.floor((random - Math.floor(random)) * 16777216); + + // Convert the random number to a hex color string + const hexColor = '#' + randomNumber.toString(16).padStart(6, '0'); + + // Calculate the relative luminance of the color + const r = parseInt(hexColor.substring(1, 3), 16); + const g = parseInt(hexColor.substring(3, 5), 16); + const b = parseInt(hexColor.substring(5, 7), 16); + const relativeLuminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + + // Adjust the color if the contrast is too low + if (relativeLuminance > contrastThreshold) { + return getRandomHexColorFromString(str + 'a', contrastThreshold); + } + + return hexColor; +} + +export function max(a: number, b: number) { + return a > b ? a : b; +} + +type LatLngLiteral = { lat: number; lng: number }; + +export function haversineDistance(coord1: LatLngLiteral, coord2: LatLngLiteral): number { + const R = 6371; // Radius of the Earth in km + const dLat = toRad(coord2.lat - coord1.lat); + const dLon = toRad(coord2.lng - coord1.lng); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(coord1.lat)) * + Math.cos(toRad(coord2.lat)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function toRad(v: number) { + return (v * Math.PI) / 180; +} + +export function roundToDecimalPlaces(num: number, decimalPlaces: number) { + const factor = Math.pow(10, decimalPlaces); + return Math.round(num * factor) / factor; +} + +// 1.04, 3 -> 1.040 +// 1.5, 2 -> 1.50 +export function padFloatToDecimalPlaces(num: number, decimalPlaces: number) { + const factor = Math.pow(10, decimalPlaces); + return (Math.round(num * factor) / factor).toFixed(decimalPlaces); +} + +export function toPlainObject(instance: T) { + return Object.assign({}, instance); +} diff --git a/apps/ground-station-web/src/lib/components/Common/ConfirmModal.svelte b/apps/ground-station-web/src/lib/components/Common/ConfirmModal.svelte new file mode 100644 index 0000000..c8aebdf --- /dev/null +++ b/apps/ground-station-web/src/lib/components/Common/ConfirmModal.svelte @@ -0,0 +1,109 @@ + + + { + event.preventDefault(); + cancel(); + }} +> +
+ {title} +
+

{message}

+
+ + +
+
+ + diff --git a/apps/ground-station-web/src/lib/components/Common/Timeline/Timeline.svelte b/apps/ground-station-web/src/lib/components/Common/Timeline/Timeline.svelte new file mode 100644 index 0000000..9897b16 --- /dev/null +++ b/apps/ground-station-web/src/lib/components/Common/Timeline/Timeline.svelte @@ -0,0 +1,173 @@ + + +
+ {#each ticks as tick} +
+
+ {#if zoomLevel === 0} + {tickFormatterHour.format(tickPositionToTime(tick))} + {:else} + {tickFormatterShort.format(tickPositionToTime(tick))} + {/if} +
+
+ {/each} + +
+
+ {needleFormatter.format(needleTime)} +
+
+
+ + diff --git a/apps/ground-station-web/src/lib/components/Common/ToastContainer.svelte b/apps/ground-station-web/src/lib/components/Common/ToastContainer.svelte new file mode 100644 index 0000000..00ca62a --- /dev/null +++ b/apps/ground-station-web/src/lib/components/Common/ToastContainer.svelte @@ -0,0 +1,32 @@ + + +{#if $toasts.length > 0} +
+ {#each $toasts as toast (toast.id)} + toastStore.remove(toast.id)} + /> + {/each} +
+{/if} + + diff --git a/apps/ground-station-web/src/lib/components/HeadingCompass/HeadingCompass.svelte b/apps/ground-station-web/src/lib/components/HeadingCompass/HeadingCompass.svelte new file mode 100644 index 0000000..95aa4af --- /dev/null +++ b/apps/ground-station-web/src/lib/components/HeadingCompass/HeadingCompass.svelte @@ -0,0 +1,178 @@ + + +
+ + + + {#each lines as line} + + {/each} + + {#each texts as text} + + {text.text} + + {/each} + + + + + + + + + + {($headingTweened * RAD2DEG).toFixed(2)}° + + +
+ + diff --git a/apps/ground-station-web/src/lib/components/Navball/NavBall.svelte b/apps/ground-station-web/src/lib/components/Navball/NavBall.svelte new file mode 100644 index 0000000..f8b3b08 --- /dev/null +++ b/apps/ground-station-web/src/lib/components/Navball/NavBall.svelte @@ -0,0 +1,37 @@ + + +
+ +
+ + diff --git a/apps/ground-station-web/src/lib/components/Navball/NavBallScene.svelte b/apps/ground-station-web/src/lib/components/Navball/NavBallScene.svelte new file mode 100644 index 0000000..1b42ca4 --- /dev/null +++ b/apps/ground-station-web/src/lib/components/Navball/NavBallScene.svelte @@ -0,0 +1,67 @@ + + + + + + + + + + + + { + v.x *= 0.5; + v.y *= 0.5; + v.x -= 0.25; + v.y -= 0.25; + return v; + })} + /> + + + + +{#await map then tex} + + + + +{/await} diff --git a/apps/ground-station-web/src/lib/components/Navball/NavBallShader/fragment.glsl b/apps/ground-station-web/src/lib/components/Navball/NavBallShader/fragment.glsl new file mode 100644 index 0000000..4ed0a47 --- /dev/null +++ b/apps/ground-station-web/src/lib/components/Navball/NavBallShader/fragment.glsl @@ -0,0 +1,20 @@ +uniform sampler2D uTexture; + +varying vec2 vUv; +varying vec3 vNormal; +varying vec3 vViewPosition; + +void main() { + vec3 viewDirection = normalize(-vViewPosition); + + float fresnelEffect = pow(1.0 - dot(viewDirection, normalize(vNormal)), 2.0); + fresnelEffect = clamp(fresnelEffect, 0.0, 1.0); + + + vec4 textureColor = texture2D(uTexture, vUv) ; + vec4 edgeColor = vec4(0.0, 0.0, 0.0, 1.0); + + vec4 finalColor = mix(textureColor, edgeColor, fresnelEffect); + + gl_FragColor = finalColor; +} diff --git a/apps/ground-station-web/src/lib/components/Navball/NavBallShader/vertex.glsl b/apps/ground-station-web/src/lib/components/Navball/NavBallShader/vertex.glsl new file mode 100644 index 0000000..726fadf --- /dev/null +++ b/apps/ground-station-web/src/lib/components/Navball/NavBallShader/vertex.glsl @@ -0,0 +1,11 @@ +varying vec2 vUv; +varying vec3 vNormal; +varying vec3 vViewPosition; + +void main() { + vUv = uv; + vNormal = normalize(normalMatrix * normal); + vec4 pos = modelViewMatrix * vec4(position, 1.0); + vViewPosition = pos.xyz; + gl_Position = projectionMatrix * pos; +} diff --git a/apps/ground-station-web/src/lib/realtime/flightDirector.ts b/apps/ground-station-web/src/lib/realtime/flightDirector.ts new file mode 100644 index 0000000..0ac4680 --- /dev/null +++ b/apps/ground-station-web/src/lib/realtime/flightDirector.ts @@ -0,0 +1,9 @@ +export const defaultLaunchCoordinates = [45.4215, -75.6972]; +export const defaultLaunchCoords = { + lat: defaultLaunchCoordinates[0], + lng: defaultLaunchCoordinates[1] +}; +export const defaultTargetAltitude = 7000; +export const defaultRelativeAltitude = 360; + +// export const flightDirector = latestCollectionWritable(Collections.FlightDirector); diff --git a/apps/ground-station-web/src/lib/realtime/metrics.ts b/apps/ground-station-web/src/lib/realtime/metrics.ts new file mode 100644 index 0000000..247ad45 --- /dev/null +++ b/apps/ground-station-web/src/lib/realtime/metrics.ts @@ -0,0 +1,38 @@ +// ground_altitude = ($air?.altitude ?? 0) - $flightDirector.relativeAltitude; +// export const ground_altitude: Readable = derived( +// [flightDirector, air], +// ([$flightDirector, $air]) => { +// return ($air?.altitude ?? 0) - ($flightDirector?.relativeAltitude ?? 0); +// } +// ); + +// $: total_traveled_distance = haversineDistance($rocketPosition, $launchPoint); +// export const total_traveled_distance: Readable = derived( +// [rocketPos, flightDirector], +// ([$rocketPos, $flightDirector]) => { +// return haversineDistance( +// { lat: $rocketPos?.latitude ?? 0, lng: $rocketPos?.longitude ?? 0 }, +// { lat: $flightDirector?.latitude ?? 0, lng: $flightDirector?.longitude ?? 0 } +// ); +// } +// ); + +// For reference: +// function calcGForce(vf: number, t: number) { +// return vf / (t * 9.81); +// } + +// FIXME: The G-Force calculation is wrong since it's not given a proper time delta +// Should also be calculated on database view (?) + +// export const g_force: Readable = derived(nav, ($nav) => { +// if ($nav.velocity) { +// return calcGForce($nav.velocity[1], 0.1); +// } +// return 0; +// }); + +// const _g_force = 0; +// export const max_g_force: Readable = derived(g_force, ($g_force) => { +// return max($g_force, _g_force); +// }); diff --git a/apps/ground-station-web/src/lib/realtime/sse.test.ts b/apps/ground-station-web/src/lib/realtime/sse.test.ts new file mode 100644 index 0000000..ddc1fb6 --- /dev/null +++ b/apps/ground-station-web/src/lib/realtime/sse.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getFreshnessStatus } from './sse'; + +describe('getFreshnessStatus', () => { + const now = 100_000; + + it('reports connecting before the first update', () => { + expect(getFreshnessStatus(null, now, 5_000, 60_000)).toBe('connecting'); + }); + + it('uses the configured live, stale, and offline boundaries', () => { + expect(getFreshnessStatus(now - 5_000, now, 5_000, 60_000)).toBe('live'); + expect(getFreshnessStatus(now - 5_001, now, 5_000, 60_000)).toBe('stale'); + expect(getFreshnessStatus(now - 60_001, now, 5_000, 60_000)).toBe('offline'); + }); +}); diff --git a/apps/ground-station-web/src/lib/realtime/sse.ts b/apps/ground-station-web/src/lib/realtime/sse.ts new file mode 100644 index 0000000..393b543 --- /dev/null +++ b/apps/ground-station-web/src/lib/realtime/sse.ts @@ -0,0 +1,96 @@ +import { browser } from '$app/environment'; +import { readable, type Readable } from 'svelte/store'; +import type { ConnectionStatus } from './types'; + +export type SseResource = { + data: T | null; + status: ConnectionStatus; + error: string | null; + lastUpdated: number | null; +}; + +type Options = { + event?: string; + staleAfterMs?: number; + offlineAfterMs?: number; +}; + +export function getFreshnessStatus( + lastUpdated: number | null, + now: number, + staleAfterMs: number, + offlineAfterMs: number +): ConnectionStatus { + if (lastUpdated === null) return 'connecting'; + const age = now - lastUpdated; + if (age > offlineAfterMs) return 'offline'; + if (age > staleAfterMs) return 'stale'; + return 'live'; +} + +export function createSseResource(url: string, options: Options = {}): Readable> { + const eventName = options.event ?? 'message'; + const staleAfterMs = options.staleAfterMs ?? 5_000; + const offlineAfterMs = options.offlineAfterMs ?? 60_000; + + return readable>( + { data: null, status: 'connecting', error: null, lastUpdated: null }, + (set) => { + if (!browser) return; + + let source: EventSource | null = null; + let data: T | null = null; + let lastUpdated: number | null = null; + let stopped = false; + let attempts = 0; + let reconnectTimer: ReturnType | null = null; + + const publish = (status: ConnectionStatus, error: string | null = null) => + set({ data, status, error, lastUpdated }); + + const connect = () => { + if (stopped) return; + publish(data ? 'stale' : 'connecting'); + source = new EventSource(url); + source.onopen = () => { + attempts = 0; + if (data) publish('live'); + }; + const handleMessage = (event: MessageEvent) => { + try { + data = JSON.parse(event.data) as T; + lastUpdated = Date.now(); + publish('live'); + } catch { + publish('error', 'Received malformed realtime data'); + } + }; + if (eventName === 'message') source.onmessage = handleMessage; + else source.addEventListener(eventName, handleMessage as EventListener); + source.onerror = () => { + source?.close(); + source = null; + attempts += 1; + publish(data ? 'stale' : 'offline', 'Realtime connection interrupted'); + const delay = Math.min(30_000, 500 * 2 ** Math.max(0, attempts - 1)); + reconnectTimer = setTimeout(connect, delay); + }; + }; + + const freshnessTimer = setInterval(() => { + if (!lastUpdated || !data) return; + const freshness = getFreshnessStatus(lastUpdated, Date.now(), staleAfterMs, offlineAfterMs); + if (freshness === 'offline') publish('offline', 'No recent data'); + else if (freshness === 'stale') publish('stale', 'Data is stale'); + }, 1_000); + + connect(); + return () => { + stopped = true; + source?.close(); + if (reconnectTimer) clearTimeout(reconnectTimer); + clearInterval(freshnessTimer); + }; + } + ); +} diff --git a/apps/ground-station-web/src/lib/realtime/types.ts b/apps/ground-station-web/src/lib/realtime/types.ts new file mode 100644 index 0000000..e8be546 --- /dev/null +++ b/apps/ground-station-web/src/lib/realtime/types.ts @@ -0,0 +1,49 @@ +export type ConnectionStatus = 'connecting' | 'live' | 'stale' | 'offline' | 'error'; + +export type TimedValue = { + value: T | null; + updatedAt: number | null; +}; + +export type FlightTransition = { + id: number; + kind: 'State' | 'Event'; + value: string; + timestamp: number | null; +}; + +export type TrajectoryPoint = { + timestamp: number; + lat: number; + lon: number; +}; + +export type OverviewSnapshot = { + generatedAt: number; + flight: { + state: FlightTransition | null; + event: FlightTransition | null; + altitudeM: TimedValue; + gnssAltitudeM: TimedValue; + verticalVelocityMps: TimedValue; + accelerationMps2: TimedValue; + position: TimedValue<{ lat: number; lon: number }>; + satellites: TimedValue; + }; + radio: { + updatedAt: number | null; + rssiDbm: number | null; + framesPerSecond: number; + packetLossPercent: number; + totalFrames: number; + }; + services: { + updatedAt: number; + running: number; + total: number; + degraded: string[]; + available: boolean; + }; + recentTransitions: FlightTransition[]; + trajectory: TrajectoryPoint[]; +}; diff --git a/apps/ground-station-web/src/lib/server/db.ts b/apps/ground-station-web/src/lib/server/db.ts new file mode 100644 index 0000000..ebac47b --- /dev/null +++ b/apps/ground-station-web/src/lib/server/db.ts @@ -0,0 +1,27 @@ +import { createClient, type Client } from "@libsql/client"; + +const LIBSQL_URL = process.env.DB_URL || "http://localhost:5001"; // Prefer Traefik DNS in deployment; override with DB_URL +let client: Client | null = null; + +/** + * Returns a singleton instance of the LibSQL database client. + * Initializes the client on the first call. + */ +export function getDbClient(): Client { + if (!client) { + client = createClient({ + url: LIBSQL_URL, + }); + console.log("LibSQL client initialized."); // More generic message now + } + return client; +} + +// Optional: Add a function to close the client if needed during app shutdown +export function closeDbClient(): void { + if (client) { + client.close(); + client = null; + console.log("LibSQL client closed."); + } +} \ No newline at end of file diff --git a/apps/ground-station-web/src/lib/server/services.ts b/apps/ground-station-web/src/lib/server/services.ts new file mode 100644 index 0000000..204d07e --- /dev/null +++ b/apps/ground-station-web/src/lib/server/services.ts @@ -0,0 +1,57 @@ +import http from 'node:http'; +import type { IncomingMessage } from 'node:http'; + +export type ServiceStatus = 'running' | 'exited' | 'restarting' | 'unknown'; +export type ServiceSummary = { name: string; status: ServiceStatus; composeService: string | null }; + +const DOCKER_SOCKET = '/var/run/docker.sock'; +const COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME ?? 'ground-station'; + +export function dockerRequest( + path: string, + method = 'GET', + body?: string +): Promise<{ status: number; text: () => Promise; json: () => Promise; ok: boolean }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + socketPath: DOCKER_SOCKET, + path, + method, + headers: body + ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } + : undefined + }, + (res: IncomingMessage) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + res.on('end', () => { + const buffer = Buffer.concat(chunks); + const status = res.statusCode ?? 500; + resolve({ + status, + ok: status >= 200 && status < 300, + text: async () => buffer.toString('utf8'), + json: async () => JSON.parse(buffer.toString('utf8') || 'null') + }); + }); + } + ); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +export async function listServices(): Promise { + const response = await dockerRequest('/containers/json?all=true'); + if (!response.ok) throw new Error(`Docker API error: ${response.status}`); + const containers: any[] = await response.json(); + return containers + .filter((container) => container.Labels?.['com.docker.compose.project'] === COMPOSE_PROJECT) + .map((container) => ({ + name: container.Names?.[0]?.replace(/^\//, '') ?? container.Id.substring(0, 12), + status: (container.State as ServiceStatus) ?? 'unknown', + composeService: container.Labels?.['com.docker.compose.service'] ?? null + })); +} diff --git a/apps/ground-station-web/src/lib/stores.ts b/apps/ground-station-web/src/lib/stores.ts new file mode 100644 index 0000000..8bd8175 --- /dev/null +++ b/apps/ground-station-web/src/lib/stores.ts @@ -0,0 +1,29 @@ +// import { cacheExchange, Client, fetchExchange, subscriptionExchange } from '@urql/svelte'; +import { writable, type Writable } from 'svelte/store'; +// import { createClient as createWSClient } from 'graphql-ws'; + +export const commandBoxToggle: Writable = writable(); + +// const graphQLWSEndpoint = window.location.origin.replace(/^http/, 'ws') + '/graphql'; +// const wsClient = createWSClient({ +// url: graphQLWSEndpoint +// }); + +// export const gqlClient = new Client({ +// url: '/graphql', +// exchanges: [ +// cacheExchange, +// fetchExchange, +// subscriptionExchange({ +// forwardSubscription(request) { +// const input = { ...request, query: request.query || '' }; +// return { +// subscribe(sink) { +// const unsubscribe = wsClient.subscribe(input, sink); +// return { unsubscribe }; +// } +// }; +// } +// }) +// ] +// }); diff --git a/apps/ground-station-web/src/lib/stores/toastStore.ts b/apps/ground-station-web/src/lib/stores/toastStore.ts new file mode 100644 index 0000000..fb944c0 --- /dev/null +++ b/apps/ground-station-web/src/lib/stores/toastStore.ts @@ -0,0 +1,33 @@ +import { writable } from 'svelte/store'; + +export type ToastType = 'info' | 'success' | 'warning' | 'error'; + +export interface Toast { + id: number; + message: string; + type: ToastType; + duration?: number; // Optional duration in ms; used by Carbon's ToastNotification +} + +const { subscribe, update } = writable([]); + +let nextId = 0; + +function addToast(message: string, type: ToastType = 'info', duration: number = 3000) { + const id = nextId++; + update((toasts) => [...toasts, { id, message, type, duration }]); +} + +function removeToast(id: number) { + update((toasts) => toasts.filter((toast) => toast.id !== id)); +} + +export const toastStore = { + subscribe, + add: addToast, + remove: removeToast, + info: (message: string, duration?: number) => addToast(message, 'info', duration), + success: (message: string, duration?: number) => addToast(message, 'success', duration), + warning: (message: string, duration?: number) => addToast(message, 'warning', duration), + error: (message: string, duration?: number) => addToast(message, 'error', duration) +}; \ No newline at end of file diff --git a/apps/ground-station-web/src/lib/stores/uiStore.ts b/apps/ground-station-web/src/lib/stores/uiStore.ts new file mode 100644 index 0000000..99865da --- /dev/null +++ b/apps/ground-station-web/src/lib/stores/uiStore.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const isImmersiveMode = writable(false); \ No newline at end of file diff --git a/apps/ground-station-web/src/routes/+layout.svelte b/apps/ground-station-web/src/routes/+layout.svelte new file mode 100644 index 0000000..1311d73 --- /dev/null +++ b/apps/ground-station-web/src/routes/+layout.svelte @@ -0,0 +1,407 @@ + + + + + {#if $isImmersiveMode} +
+ + {@render children?.()} +
+ {:else} +
+
+ + + RGSGROUND STATION + +
+ + {online ? 'CLIENT ONLINE' : 'CLIENT OFFLINE'} + + + +
+
+ + {#if mobileOpen} + + {/if} + + + +
{@render children?.()}
+
+ {/if} +
+ + diff --git a/apps/ground-station-web/src/routes/+layout.ts b/apps/ground-station-web/src/routes/+layout.ts new file mode 100644 index 0000000..d72160a --- /dev/null +++ b/apps/ground-station-web/src/routes/+layout.ts @@ -0,0 +1,3 @@ +export const ssr = false; +export const prerender = false; +export const csr = true; diff --git a/apps/ground-station-web/src/routes/+page.svelte b/apps/ground-station-web/src/routes/+page.svelte new file mode 100644 index 0000000..bd899f5 --- /dev/null +++ b/apps/ground-station-web/src/routes/+page.svelte @@ -0,0 +1,481 @@ + + + + Mission Overview | RGS + + + +
+
+
+

Mission Overview

+
REALTIME FLIGHT AND GROUND SYSTEM STATUS
+
+
{$overview.status}
+
+ + {#if !$overview.data} +
+ {#each Array(9) as _}
{/each} +
+ {:else} + {@const snapshot = $overview.data} +
+
CURRENT FLIGHT STATE
+
{snapshot.flight.state?.value ?? 'NO STATE'}
+
+ T+ {duration(snapshot.flight.state?.timestamp)} + EVENT {snapshot.flight.event?.value ?? '---'} + UPDATED {age(snapshot.flight.state?.timestamp)} +
+
+ +
+ + BARO ALTITUDE + {fixed(value(snapshot.flight.altitudeM))}M + {age(snapshot.flight.altitudeM.updatedAt)} + + + VERT VELOCITY + {fixed(value(snapshot.flight.verticalVelocityMps))}M/S + {age(snapshot.flight.verticalVelocityMps.updatedAt)} + + + ACCELERATION + {fixed(value(snapshot.flight.accelerationMps2), 2)}M/S² + {age(snapshot.flight.accelerationMps2.updatedAt)} + + + RADIO RSSI + {fixed(snapshot.radio.rssiDbm, 0)}DBM + {age(snapshot.radio.updatedAt)} + + + PACKET LOSS + {fixed(snapshot.radio.packetLossPercent, 2)}% + {snapshot.radio.framesPerSecond.toFixed(1)} FPS + + + GNSS SATELLITES + {fixed(value(snapshot.flight.satellites), 0)}SV + {age(snapshot.flight.satellites.updatedAt)} + +
+ +
+ +
+

Trajectory

+ OPEN MAP → +
+
+ {#if snapshot.trajectory.length > 1} + + + + + +
+ LAT {value(snapshot.flight.position)?.lat.toFixed(6)} + LON {value(snapshot.flight.position)?.lon.toFixed(6)} +
+ {:else} +
NO VALID POSITION TRACK
+ {/if} +
+
+ +
+

System Health

+ + {#if snapshot.services.degraded.length} +
+ ATTENTION{snapshot.services.degraded.join(', ')} +
+ {/if} +
+ +
+
+

Recent Transitions

+ VIEW HISTORY → +
+ {#if snapshot.recentTransitions.length} +
    + {#each snapshot.recentTransitions.slice(0, 8) as transition} +
  1. + {transition.kind}{transition.value} +
  2. + {/each} +
+ {:else}
NO FLIGHT TRANSITIONS RECORDED
{/if} +
+
+ {/if} +
+ + diff --git a/apps/ground-station-web/src/routes/altitude/+page.svelte b/apps/ground-station-web/src/routes/altitude/+page.svelte new file mode 100644 index 0000000..1543e63 --- /dev/null +++ b/apps/ground-station-web/src/routes/altitude/+page.svelte @@ -0,0 +1,248 @@ + + +
+
+
+

Altitude

+
BAROMETRIC AND GNSS ALTITUDE SOURCES
+
+
+ {connected ? 'LIVE' : error ? 'ERROR' : 'PAUSED'} + + +
+
+ {#if error}{/if} +
+
+ BAROMETER{latest?.barometer ? latest.barometer.altitude_m.toFixed(1) : '---'}M{latest?.barometer + ? `${mToFeet(latest.barometer.altitude_m).toFixed(1)} FT · ${latest.barometer.pressure_kpa.toFixed(3)} KPA` + : 'NO DATA'} +
+
+ SBG AIR{latest?.sbgAir ? latest.sbgAir.altitude_m.toFixed(1) : '---'}M{latest?.sbgAir + ? `${mToFeet(latest.sbgAir.altitude_m).toFixed(1)} FT · ${latest.sbgAir.pressure_kpa.toFixed(3)} KPA` + : 'NO DATA'} +
+
+ GNSS{latest?.gnss ? latest.gnss.altitude_m.toFixed(1) : '---'}M{latest?.gnss ? `${mToFeet(latest.gnss.altitude_m).toFixed(1)} FT · GPS` : 'NO DATA'} +
+
+
+
+

Altitude History

+ QNH {qnhKpa.toFixed(1)} KPA +
+
+
+
+ + diff --git a/apps/ground-station-web/src/routes/altitude/api/+server.ts b/apps/ground-station-web/src/routes/altitude/api/+server.ts new file mode 100644 index 0000000..a0a9665 --- /dev/null +++ b/apps/ground-station-web/src/routes/altitude/api/+server.ts @@ -0,0 +1,187 @@ +import { getDbClient } from '$lib/server/db'; +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const DEFAULT_QNH_KPA = 102.5; // Estimated Timmin's ON average for August 2025 at CYTS + +function altitudeFromPressureKPa(pressureKpa: number, qnhKpa: number = DEFAULT_QNH_KPA): number { + if (!isFinite(pressureKpa) || pressureKpa <= 0) return NaN; + const ratio = pressureKpa / qnhKpa; + return 44330 * (1 - Math.pow(ratio, 0.190263)); +} + +function altitudeFromPressurePa(pressurePa: number, qnhKpa: number = DEFAULT_QNH_KPA): number { + return altitudeFromPressureKPa(pressurePa / 1000, qnhKpa); +} + +async function getLatestSnapshot(qnhKpa: number) { + const db = getDbClient(); + + const [baroLatest, airLatest, gnssLatest] = await Promise.all([ + // Barometer latest (RadioFrame timestamp) + db.execute({ + sql: + "SELECT rf.timestamp_epoch AS ts, b.id AS id, b.pressure_kpa AS pressure_kpa, b.temperature_celsius AS temperature_celsius FROM RadioFrame rf JOIN Barometer b ON rf.data_type = 'Barometer' AND rf.data_id = b.id ORDER BY rf.timestamp_epoch DESC LIMIT 1", + args: [] + }), + // SbgAir latest (RadioFrame timestamp) + db.execute({ + sql: + "SELECT rf.timestamp_epoch AS ts, a.id AS id, a.pressure_abs AS pressure_pa FROM RadioFrame rf JOIN SbgAir a ON rf.data_type = 'SbgAir' AND rf.data_id = a.id ORDER BY rf.timestamp_epoch DESC LIMIT 1", + args: [] + }), + // SbgGpsPos latest valid altitude > 0 (RadioFrame timestamp) + db.execute({ + sql: + "SELECT rf.timestamp_epoch AS ts, p.id AS id, p.altitude AS altitude_m FROM RadioFrame rf JOIN SbgGpsPos p ON rf.data_type = 'SbgGpsPos' AND rf.data_id = p.id WHERE p.altitude IS NOT NULL AND p.altitude > 0 ORDER BY rf.timestamp_epoch DESC LIMIT 1", + args: [] + }) + ]); + + const latestBaroRow: any = baroLatest.rows[0] ?? null; + const latestAirRow: any = airLatest.rows[0] ?? null; + const latestGnssRow: any = gnssLatest.rows[0] ?? null; + + const latest = { + barometer: latestBaroRow + ? { + ts: Number(latestBaroRow.ts), + pressure_kpa: Number(latestBaroRow.pressure_kpa), + temperature_celsius: latestBaroRow.temperature_celsius !== null ? Number(latestBaroRow.temperature_celsius) : null, + altitude_m: altitudeFromPressureKPa(Number(latestBaroRow.pressure_kpa), qnhKpa) + } + : null, + sbgAir: latestAirRow + ? { + ts: Number(latestAirRow.ts), + pressure_pa: Number(latestAirRow.pressure_pa), + pressure_kpa: Number(latestAirRow.pressure_pa) / 1000, + altitude_m: altitudeFromPressurePa(Number(latestAirRow.pressure_pa), qnhKpa) + } + : null, + gnss: latestGnssRow + ? { + ts: Number(latestGnssRow.ts), + altitude_m: Number(latestGnssRow.altitude_m) + } + : null + }; + + return latest; +} + +async function getSeries(minutes: number, qnhKpa: number, maxPoints: number) { + const db = getDbClient(); + const seconds = Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes * 60) : 900; + const targetPoints = Number.isFinite(maxPoints) && maxPoints > 0 ? Math.floor(maxPoints) : 1500; + const bucketSizeSec = Math.max(1, Math.ceil(seconds / targetPoints)); + + const [baroSeriesRes, airSeriesRes, gnssSeriesRes] = await Promise.all([ + // Barometer series joined with RadioFrame for timestamps, bucket-averaged + db.execute({ + sql: + "SELECT CAST(rf.timestamp_epoch / ? AS INTEGER) * ? AS bucket_ts, AVG(b.pressure_kpa) AS pressure_kpa FROM RadioFrame rf JOIN Barometer b ON rf.data_type = 'Barometer' AND rf.data_id = b.id WHERE rf.timestamp_epoch >= strftime('%s','now') - ? GROUP BY bucket_ts ORDER BY bucket_ts ASC", + args: [bucketSizeSec, bucketSizeSec, seconds] + }), + // SbgAir series bucket-averaged + db.execute({ + sql: + "SELECT CAST(rf.timestamp_epoch / ? AS INTEGER) * ? AS bucket_ts, AVG(a.pressure_abs) AS pressure_pa FROM RadioFrame rf JOIN SbgAir a ON rf.data_type = 'SbgAir' AND rf.data_id = a.id WHERE rf.timestamp_epoch >= strftime('%s','now') - ? GROUP BY bucket_ts ORDER BY bucket_ts ASC", + args: [bucketSizeSec, bucketSizeSec, seconds] + }), + // GNSS series (ignore <= 0), bucket-averaged + db.execute({ + sql: + "SELECT CAST(rf.timestamp_epoch / ? AS INTEGER) * ? AS bucket_ts, AVG(p.altitude) AS altitude_m FROM RadioFrame rf JOIN SbgGpsPos p ON rf.data_type = 'SbgGpsPos' AND rf.data_id = p.id WHERE rf.timestamp_epoch >= strftime('%s','now') - ? AND p.altitude IS NOT NULL AND p.altitude > 0 GROUP BY bucket_ts ORDER BY bucket_ts ASC", + args: [bucketSizeSec, bucketSizeSec, seconds] + }) + ]); + + // Convert to Carbon line chart format + const data: Array<{ group: string; date: Date; value: number }> = []; + + for (const row of baroSeriesRes.rows as any[]) { + const ts = Number((row as any).bucket_ts); + const pressureKpa = Number((row as any).pressure_kpa); + const alt = altitudeFromPressureKPa(pressureKpa, qnhKpa); + if (isFinite(alt)) data.push({ group: 'Altitude (Barometer)', date: new Date(ts * 1000), value: alt }); + } + for (const row of airSeriesRes.rows as any[]) { + const ts = Number((row as any).bucket_ts); + const pressurePa = Number((row as any).pressure_pa); + const alt = altitudeFromPressurePa(pressurePa, qnhKpa); + if (isFinite(alt)) data.push({ group: 'Altitude (SBG Air)', date: new Date(ts * 1000), value: alt }); + } + for (const row of gnssSeriesRes.rows as any[]) { + const ts = Number((row as any).bucket_ts); + const alt = Number((row as any).altitude_m); + if (isFinite(alt) && alt > 0) data.push({ group: 'Altitude (GNSS)', date: new Date(ts * 1000), value: alt }); + } + + return { data }; +} + +export const GET: RequestHandler = async ({ url, request }) => { + const minutes = Number(url.searchParams.get('minutes') || '999'); + const maxPoints = Number(url.searchParams.get('max_points') || '1500'); + // Ignore any client-provided qnh_kpa. Use DEFAULT_QNH_KPA. + const qnhKpa = DEFAULT_QNH_KPA; + const useSSE = url.searchParams.get('sse') === '1'; + + if (useSSE) { + const encoder = new TextEncoder(); + const activeControllers: Set = (globalThis as any).__altitudeControllers ?? new Set(); + (globalThis as any).__altitudeControllers = activeControllers; + let poller: NodeJS.Timeout | null = (globalThis as any).__altitudePoller ?? null; + const stream = new ReadableStream({ + start(controller) { + activeControllers.add(controller); + const poll = async () => { + try { + const [latest, series] = await Promise.all([getLatestSnapshot(qnhKpa), getSeries(minutes, qnhKpa, maxPoints)]); + const chunk = encoder.encode(`event: altitude\n` + `data: ${JSON.stringify({ latest, series, qnh_kpa: qnhKpa })}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(chunk); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + } catch (e) { + const err = encoder.encode(`event: error\n` + `data: {"message":"Failed to fetch altitude data"}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(err); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + console.error('Altitude central poll error:', e); + } + }; + if (!poller) { + poller = setInterval(poll, 1000); + (globalThis as any).__altitudePoller = poller; + poll(); + } + request.signal.addEventListener('abort', () => { + activeControllers.delete(controller); + try { controller.close(); } catch { } + if (activeControllers.size === 0 && poller) { + clearInterval(poller); + (globalThis as any).__altitudePoller = null; + } + }); + }, + cancel() { } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); + } + + try { + const [latest, series] = await Promise.all([getLatestSnapshot(qnhKpa), getSeries(minutes, qnhKpa, maxPoints)]); + return json({ latest, series, qnh_kpa: qnhKpa }); + } catch (e: any) { + console.error('Altitude API GET error:', e); + throw error(500, `Failed to fetch altitude data: ${e.message}`); + } +}; diff --git a/apps/ground-station-web/src/routes/commands/+page.server.ts b/apps/ground-station-web/src/routes/commands/+page.server.ts new file mode 100644 index 0000000..9b5b622 --- /dev/null +++ b/apps/ground-station-web/src/routes/commands/+page.server.ts @@ -0,0 +1,151 @@ +// This file will handle loading existing commands and processing the dispatch form action. +import { getDbClient } from '$lib/server/db'; +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +// Load existing commands +export const load: PageServerLoad = async () => { + console.log('Loading commands for /commands page...'); + const db = getDbClient(); + try { + const result = await db.execute({ + sql: 'SELECT id, command_type, parameters, status, created_at, queued_at, sent_at, attempts, error_message, source_service FROM OutgoingCommand ORDER BY created_at DESC LIMIT 100', + args: [] + }); + console.log(`Loaded ${result.rows.length} commands.`); + // Convert row data if necessary (e.g., timestamps) + const commands = result.rows.map(row => ({ + ...row, + // Ensure numeric fields are numbers, text is string, etc. + id: Number(row.id), + created_at: Number(row.created_at), + queued_at: row.queued_at ? Number(row.queued_at) : null, + sent_at: row.sent_at ? Number(row.sent_at) : null, + attempts: Number(row.attempts), + // Keep text fields as is (assuming they come correctly typed from libsql client) + command_type: String(row.command_type), + parameters: row.parameters as string | null, // Type assertion might be needed depending on client + status: String(row.status), + error_message: row.error_message as string | null, + source_service: String(row.source_service) + })); + return { commands }; + } catch (e: any) { + console.error('Error loading commands:', e); + // Return empty array or an error state + return { commands: [], error: 'Failed to load commands.' }; + } +}; + +// Handle form submission for dispatching new commands and deleting existing ones +export const actions: Actions = { + dispatch: async ({ request }) => { + const formData = await request.formData(); + const command_type = formData.get('command_type') as string; + + let parameters: string | null = null; + const params: Record = {}; + + try { + switch (command_type) { + case 'DeployDrogue': + case 'DeployMain': + // These commands might not need user parameters, set defaults if needed + params.val = true; // Example default + break; + case 'PowerDown': + const board = formData.get('param_board') as string; + if (!board) throw new Error('Board parameter is required for PowerDown.'); + params.board = board; + break; + case 'PowerUpCamera': + // no parameters + break; + case 'PowerDownCamera': + // no parameters + break; + case 'RadioRateChange': + const rate = formData.get('param_rate') as string; + if (!rate) throw new Error('Rate parameter is required for RadioRateChange.'); + params.rate = rate; + break; + case 'Ping': + // No parameters needed + break; + default: + return fail(400, { error: `Unknown command type: ${command_type}` }); + } + + // Only serialize parameters if needed + if (Object.keys(params).length > 0) { + parameters = JSON.stringify(params); + } + + const db = getDbClient(); + const now = Math.floor(Date.now() / 1000); + const source_service = 'rgs-web-commands'; + + console.log(`Dispatching command: ${command_type} with params: ${parameters}`); + + await db.execute({ + sql: 'INSERT INTO OutgoingCommand (command_type, parameters, status, created_at, attempts, source_service) VALUES (?, ?, ?, ?, ?, ?)', + args: [command_type, parameters, 'Pending', now, 0, source_service] + }); + + console.log('Command inserted successfully.'); + return { success: true, message: `Command '${command_type}' dispatched.` }; + + } catch (e: any) { + console.error('Error dispatching command:', e); + return fail(500, { error: e.message || 'Failed to dispatch command.' }); + } + }, + + // Action to delete a single command + deleteSingle: async ({ request }) => { + const formData = await request.formData(); + const idValue = formData.get('id'); + + if (!idValue || typeof idValue !== 'string') { + return fail(400, { error: 'Invalid or missing command ID for deletion.' }); + } + const id = idValue as string; + + const db = getDbClient(); + try { + console.log(`Deleting command with ID: ${id}`); + const result = await db.execute({ + sql: 'DELETE FROM OutgoingCommand WHERE id = ?', + args: [id] + }); + + if (result.rowsAffected === 0) { + console.warn(`Command with ID ${id} not found for deletion.`); + return fail(404, { error: `Command with ID ${id} not found.` }); + } + + console.log(`Command ${id} deleted successfully.`); + return { success: true, message: `Command ${id} deleted.` }; + } catch (e: any) { + console.error(`Error deleting command ${id}:`, e); + return fail(500, { error: e.message || `Failed to delete command ${id}.` }); + } + }, + + // Action to delete all commands + deleteAll: async () => { + const db = getDbClient(); + try { + console.log('Deleting all commands...'); + const result = await db.execute({ + sql: 'DELETE FROM OutgoingCommand', + args: [] + }); + console.log(`${result.rowsAffected} commands deleted.`); + return { success: true, message: `All (${result.rowsAffected}) commands deleted.` }; + } catch (e: any) { + console.error('Error deleting all commands:', e); + return fail(500, { error: e.message || 'Failed to delete all commands.' }); + } + } +}; \ No newline at end of file diff --git a/apps/ground-station-web/src/routes/commands/+page.svelte b/apps/ground-station-web/src/routes/commands/+page.svelte new file mode 100644 index 0000000..665e155 --- /dev/null +++ b/apps/ground-station-web/src/routes/commands/+page.svelte @@ -0,0 +1,310 @@ + + + + + + +

Dispatch New Command

+
{ + dispatching = true; + return async ({ update }) => { + await update({ reset: false }); + }; + }} + > + ({ id: c, text: c }))} + selectedId={selectedCommand} + label="Command Type" + on:select={(e) => (selectedCommand = e.detail.selectedItem.id)} + size="sm" + /> + + {#if needsParams} +
+

Parameters

+ {#if currentParams.includes('board')} + ({ id: b, text: b }))} + selectedId={param_board} + label="Target Board" + on:select={(e) => (param_board = e.detail.selectedItem.id)} + size="sm" + /> + + {/if} + {#if currentParams.includes('rate')} + ({ id: r, text: r }))} + selectedId={param_rate} + label="Radio Rate" + on:select={(e) => (param_rate = e.detail.selectedItem.id)} + size="sm" + /> + + {/if} +
+ {/if} +
+ +
+ +
+
+
+ + + + +
+

Command History (Last 100)

+
+ +
{ + if ( + !requestDelete( + formElement, + 'Delete all command history? This action cannot be undone.', + cancel + ) + ) + return; + return async ({ update }) => { + await update(); + }; + }} + style="display:inline-block; margin-left: 0.5rem;" + > + +
+
+
+ {#if data.error} + + {/if} + + + + ID + Type + Params + Status + Attempts + Created + Queued + Sent + Source + Error + Actions + + + + {#each data.commands as command (command.id)} + + {command.id} + {command.command_type} + {command.parameters || '-'} + {command.status} + {command.attempts} + {formatTimestamp(command.created_at)} + {formatTimestamp(command.queued_at)} + {formatTimestamp(command.sent_at)} + {command.source_service} + {command.error_message || '-'} + +
{ + if ( + !requestDelete( + formElement, + `Delete command ${command.id}? This action cannot be undone.`, + cancel + ) + ) + return; + return async ({ update }) => { + await update(); + }; + }} + class="inline-block" + > + + + +
+
+ {:else} + + No commands found. + + {/each} +
+
+
+
+
+
+ + (pendingDeleteForm = null)} +/> diff --git a/apps/ground-station-web/src/routes/debug/+layout.svelte b/apps/ground-station-web/src/routes/debug/+layout.svelte new file mode 100644 index 0000000..250394e --- /dev/null +++ b/apps/ground-station-web/src/routes/debug/+layout.svelte @@ -0,0 +1,9 @@ + + + + {@render children?.()} + diff --git a/apps/ground-station-web/src/routes/debug/coord-mock/+page.svelte b/apps/ground-station-web/src/routes/debug/coord-mock/+page.svelte new file mode 100644 index 0000000..83fbf87 --- /dev/null +++ b/apps/ground-station-web/src/routes/debug/coord-mock/+page.svelte @@ -0,0 +1,174 @@ + + +
+
+ +
+ +

Coordinate Mock

+
+
Lat: {selectedLat != null ? selectedLat.toFixed(6) : '—'}
+
Lon: {selectedLon != null ? selectedLon.toFixed(6) : '—'}
+
+ + + +
+ +
+ + {#if notice} + + {/if} +
+
+
+ + diff --git a/apps/ground-station-web/src/routes/debug/coord-mock/api/+server.ts b/apps/ground-station-web/src/routes/debug/coord-mock/api/+server.ts new file mode 100644 index 0000000..c3ffba4 --- /dev/null +++ b/apps/ground-station-web/src/routes/debug/coord-mock/api/+server.ts @@ -0,0 +1,119 @@ +import { getDbClient } from '$lib/server/db'; +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +function nowEpoch(): number { + return Math.floor(Date.now() / 1000); +} + +function clampAltitudeMeters(v: unknown): number { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(5000, Math.round(n))); +} + +function pressureKpaFromAltitude(altitudeMeters: number, qnhKpa: number = 102.5): number { + // Invert standard atmosphere: p = p0 * (1 - h/44330)^(1/0.190263) + const ratio = 1 - Math.max(0, altitudeMeters) / 44330; + const p = qnhKpa * Math.pow(Math.max(1e-6, ratio), 1 / 0.190263); + return p; +} + +function normalizeLatLon(v: unknown, min: number, max: number): number { + const n = Number(v); + if (!Number.isFinite(n)) throw error(400, 'Invalid coordinate'); + if (n < min || n > max) throw error(400, 'Coordinate out of range'); + return n; +} + +export const POST: RequestHandler = async ({ request }) => { + const db = getDbClient(); + const body = await request.json().catch(() => null); + if (!body) throw error(400, 'Invalid JSON'); + + const latitude = normalizeLatLon(body.lat, -90, 90); + const longitude = normalizeLatLon(body.lon, -180, 180); + const altitude_m = clampAltitudeMeters(body.alt); + + const ts = nowEpoch(); + + const transaction = await db.transaction("write"); + try { + // Insert SbgGpsPos + const gpsRes = await transaction.execute({ + sql: `INSERT INTO SbgGpsPos (latitude, longitude, time_of_week, undulation, altitude, time_stamp, status, latitude_accuracy, longitude_accuracy, altitude_accuracy, num_sv_used, base_station_id, differential_age) + VALUES (?, ?, 0, 0.0, ?, ?, 'OK', 0.0, 0.0, 0.0, 0, 0, 0)`, + args: [latitude, longitude, altitude_m, ts] + }); + const gpsId = Number(gpsRes.lastInsertRowid ?? 0); + + // Insert SbgEkfNav + const ekfRes = await transaction.execute({ + sql: `INSERT INTO SbgEkfNav (status, velocity_north, velocity_east, velocity_down, velocity_std_dev_north, velocity_std_dev_east, velocity_std_dev_down, position_latitude, position_longitude, position_altitude, position_std_dev_latitude, position_std_dev_longitude, position_std_dev_altitude, time_stamp, undulation) + VALUES ('OK', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ?, ?, ?, 0.0, 0.0, 0.0, ?, 0.0)`, + args: [latitude, longitude, altitude_m, ts] + }); + + // Insert SbgAir + const airRes = await transaction.execute({ + sql: `INSERT INTO SbgAir (time_stamp, status, pressure_abs, altitude, pressure_diff, true_airspeed, air_temperature) + VALUES (?, 'OK', ?, ?, NULL, NULL, NULL)`, + args: [ts, Math.round(pressureKpaFromAltitude(altitude_m) * 1000), altitude_m] + }); + const airId = Number(airRes.lastInsertRowid ?? 0); + + // Insert Barometer + const baroRes = await transaction.execute({ + sql: `INSERT INTO Barometer (pressure_kpa, temperature_celsius) + VALUES (?, NULL)`, + args: [pressureKpaFromAltitude(altitude_m)] + }); + const baroId = Number(baroRes.lastInsertRowid ?? 0); + + // Create RadioFrame rows for each insert so downstream queries can join on timestamps + const isoTs = new Date(ts * 1000).toISOString(); + await transaction.execute({ + sql: `INSERT INTO RadioFrame (timestamp, timestamp_epoch, node, data_type, data_id, millis_since_start) + VALUES (?, ?, 'Gps', 'SbgGpsPos', ?, NULL)`, + args: [isoTs, ts, gpsId] + }); + await transaction.execute({ + sql: `INSERT INTO RadioFrame (timestamp, timestamp_epoch, node, data_type, data_id, millis_since_start) + VALUES (?, ?, 'Sbg', 'SbgAir', ?, NULL)`, + args: [isoTs, ts, airId] + }); + await transaction.execute({ + sql: `INSERT INTO RadioFrame (timestamp, timestamp_epoch, node, data_type, data_id, millis_since_start) + VALUES (?, ?, 'Barometer', 'Barometer', ?, NULL)`, + args: [isoTs, ts, baroId] + }); + + // Commit the transaction + await transaction.commit(); + + return json({ + success: true, + inserted: { + gps: gpsRes.rowsAffected, + ekf: ekfRes.rowsAffected, + air: airRes.rowsAffected, + baro: baroRes.rowsAffected + }, + lat: latitude, + lon: longitude, + alt: altitude_m + }); + } catch (e: any) { + // Rollback on error + try { + await transaction.rollback(); + } catch (rollbackError) { + console.error('Rollback failed:', rollbackError); + } + console.error('Coord-mock insert failed', e); + throw error(500, e?.message ?? 'Insert failed'); + } finally { + // Always close the transaction + transaction.close(); + } +}; diff --git a/apps/ground-station-web/src/routes/imu/+page.svelte b/apps/ground-station-web/src/routes/imu/+page.svelte new file mode 100644 index 0000000..32a0015 --- /dev/null +++ b/apps/ground-station-web/src/routes/imu/+page.svelte @@ -0,0 +1,255 @@ + + + + + + +
+

IMU Metrics

+
+ + {#if connected} + + {:else} + + {/if} +
+
+ + {#if error} + + {/if} + + + + + +

Peak Accel

+
+ {stats ? stats.peak_accel_mps2.toFixed(2) : '—'} m/s² +
+ from sensor +
+
+ + +

Peak Accel (Δv/Δt)

+
+ {stats ? stats.peak_accel_from_dvdt_mps2.toFixed(2) : '—'} m/s² +
+ from integration +
+
+ + +

Peak |ω|

+
+ {stats ? stats.peak_gyro_rads.toFixed(2) : '—'} rad/s +
+ gyro +
+
+ + +

Peak Rate (Δθ/Δt)

+
+ {stats ? stats.peak_rate_from_dadt_rads.toFixed(2) : '—'} rad/s +
+ from integration +
+
+
+ + + + +

Cumulative Δv

+
+ {stats ? stats.cumulative_delta_v_mps.toFixed(2) : '—'} m/s +
+
+
+ + +

Rotation

+
+ {stats ? stats.cumulative_rotation_turns.toFixed(2) : '—'} turns +
+
+
+ + +

IMU Temperature

+
+ {stats && stats.latest_temperature_c != null + ? stats.latest_temperature_c.toFixed(1) + : '—'} °C +
+
+
+
+ + + + + + + +
+
+
+
diff --git a/apps/ground-station-web/src/routes/imu/api/+server.ts b/apps/ground-station-web/src/routes/imu/api/+server.ts new file mode 100644 index 0000000..e420b3c --- /dev/null +++ b/apps/ground-station-web/src/routes/imu/api/+server.ts @@ -0,0 +1,187 @@ +import { getDbClient } from '$lib/server/db'; +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const encoder = new TextEncoder(); +const activeControllers = new Set(); +let pollIntervalId: NodeJS.Timeout | null = null; +let lastPayload: any = null; + +type ImuRow = { + ts: number; + accelerometer_x: number | null; + accelerometer_y: number | null; + accelerometer_z: number | null; + gyroscope_x: number | null; + gyroscope_y: number | null; + gyroscope_z: number | null; + delta_velocity_x: number | null; + delta_velocity_y: number | null; + delta_velocity_z: number | null; + delta_angle_x: number | null; + delta_angle_y: number | null; + delta_angle_z: number | null; + temperature: number | null; +}; + +function magnitude(x: number, y: number, z: number): number { + return Math.sqrt(x * x + y * y + z * z); +} + +async function fetchImuSeries(minutes: number): Promise { + const db = getDbClient(); + const seconds = Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes * 60) : 600; + const res = await db.execute({ + sql: `SELECT rf.timestamp_epoch AS ts, + i.accelerometer_x, i.accelerometer_y, i.accelerometer_z, + i.gyroscope_x, i.gyroscope_y, i.gyroscope_z, + i.delta_velocity_x, i.delta_velocity_y, i.delta_velocity_z, + i.delta_angle_x, i.delta_angle_y, i.delta_angle_z, + i.temperature AS temperature + FROM RadioFrame rf + JOIN SbgImu i ON rf.data_type = 'SbgImu' AND rf.data_id = i.id + WHERE rf.timestamp_epoch >= strftime('%s','now') - ? + ORDER BY rf.timestamp_epoch ASC`, + args: [seconds] + }); + return res.rows as unknown as ImuRow[]; +} + +function buildMetrics(rows: ImuRow[]) { + const accelChart: Array<{ group: string; date: Date; value: number }> = []; + const gyroChart: Array<{ group: string; date: Date; value: number }> = []; + + let prev: ImuRow | null = null; + let prevAccelDvdt = 0; + let cumDeltaV = 0; + let cumRotation = 0; + + let peakAccel = 0; // m/s^2 from accelerometer + let peakAccelDvdt = 0; // m/s^2 from delta_v/dt + let peakGyro = 0; // rad/s from gyro + let peakOmegaDadt = 0; // rad/s from delta_angle/dt + let latestTemp: number | null = null; + + for (const r of rows) { + const date = new Date(r.ts * 1000); + const ax = Number(r.accelerometer_x ?? 0); + const ay = Number(r.accelerometer_y ?? 0); + const az = Number(r.accelerometer_z ?? 0); + const gx = Number(r.gyroscope_x ?? 0); + const gy = Number(r.gyroscope_y ?? 0); + const gz = Number(r.gyroscope_z ?? 0); + const dvx = Number(r.delta_velocity_x ?? 0); + const dvy = Number(r.delta_velocity_y ?? 0); + const dvz = Number(r.delta_velocity_z ?? 0); + const dax = Number(r.delta_angle_x ?? 0); + const day = Number(r.delta_angle_y ?? 0); + const daz = Number(r.delta_angle_z ?? 0); + + latestTemp = r.temperature ?? latestTemp; + + const accelMag = magnitude(ax, ay, az); + peakAccel = Math.max(peakAccel, accelMag); + accelChart.push({ group: 'Accel |a| (sensor)', date, value: accelMag }); + + const gyroMag = magnitude(gx, gy, gz); + peakGyro = Math.max(peakGyro, gyroMag); + gyroChart.push({ group: 'Gyro |ω| (sensor)', date, value: gyroMag }); + + if (prev && r.ts > prev.ts) { + const dt = Math.max(1e-6, r.ts - prev.ts); + const dvMag = magnitude(dvx, dvy, dvz); + const daMag = magnitude(dax, day, daz); + + const accelDvdt = dvMag / dt; + const omegaDadt = daMag / dt; + + peakAccelDvdt = Math.max(peakAccelDvdt, accelDvdt); + peakOmegaDadt = Math.max(peakOmegaDadt, omegaDadt); + accelChart.push({ group: 'Accel |Δv/Δt|', date, value: accelDvdt }); + gyroChart.push({ group: 'Rate |Δθ/Δt|', date, value: omegaDadt }); + + cumDeltaV += dvMag; + cumRotation += daMag; + + // Jerk could be added later if needed using prevAccelDvdt + prevAccelDvdt = accelDvdt; + } + + prev = r; + } + + const stats = { + peak_accel_mps2: peakAccel, + peak_accel_from_dvdt_mps2: peakAccelDvdt, + peak_gyro_rads: peakGyro, + peak_rate_from_dadt_rads: peakOmegaDadt, + cumulative_delta_v_mps: cumDeltaV, + cumulative_rotation_turns: cumRotation / (2 * Math.PI), + latest_temperature_c: latestTemp + }; + + return { accelChart, gyroChart, stats }; +} + +async function getPayload(minutes: number) { + const series = await fetchImuSeries(minutes); + const { accelChart, gyroChart, stats } = buildMetrics(series); + return { series: { accel: accelChart, gyro: gyroChart }, stats }; +} + +export const GET: RequestHandler = async ({ url, request }) => { + const minutes = Number(url.searchParams.get('minutes') || '999'); + const useSSE = url.searchParams.get('sse') === '1'; + + if (useSSE) { + const stream = new ReadableStream({ + start(controller) { + activeControllers.add(controller); + if (!pollIntervalId) { + const poll = async () => { + try { + const payload = await getPayload(minutes); + lastPayload = payload; + const chunk = encoder.encode(`event: imu\n` + `data: ${JSON.stringify(payload)}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(chunk); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + } catch (e) { + const err = encoder.encode(`event: error\n` + `data: {"message":"Failed to compute IMU metrics"}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(err); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + console.error('IMU central poll error', e); + } + }; + pollIntervalId = setInterval(poll, 2000); + poll(); + } else if (lastPayload) { + controller.enqueue(encoder.encode(`event: imu\n` + `data: ${JSON.stringify(lastPayload)}\n\n`)); + } + request.signal.addEventListener('abort', () => { + activeControllers.delete(controller); + try { controller.close(); } catch { } + if (activeControllers.size === 0 && pollIntervalId) { clearInterval(pollIntervalId); pollIntervalId = null; } + }); + }, + cancel() { } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); + } + + try { + const payload = await getPayload(minutes); + return json(payload); + } catch (e: any) { + console.error('IMU API error', e); + throw error(500, `Failed to fetch IMU metrics: ${e.message}`); + } +}; diff --git a/apps/ground-station-web/src/routes/map-downloader/+page.svelte b/apps/ground-station-web/src/routes/map-downloader/+page.svelte new file mode 100644 index 0000000..4a8a7ea --- /dev/null +++ b/apps/ground-station-web/src/routes/map-downloader/+page.svelte @@ -0,0 +1,481 @@ + + +
+
+ +
+ +

Download Settings

+ + +
+ + + + + + +
+ + {#if job?.status === 'processing'} + + {/if} +
+ + {#if job} +
+
+
0 ? (job.downloaded / job.total) * 100 : 0}%`} + >
+
+
{job.downloaded}/{job.total} tiles
+
+
Lat: {job.lat.toFixed(6)}
+
Lon: {job.lon.toFixed(6)}
+
Zoom: {job.minZoom}-{job.maxZoom}
+
Radius: {job.radius.toFixed(1)}km
+
+ {#if job.status === 'failed'} +
+ +
+ {/if} + {#if job.status !== 'processing'} +
+ +
+ {/if} +
+ {/if} + + {#if job?.status} + + {/if} +
+
+
+ + diff --git a/apps/ground-station-web/src/routes/mock/+page.svelte b/apps/ground-station-web/src/routes/mock/+page.svelte new file mode 100644 index 0000000..b246628 --- /dev/null +++ b/apps/ground-station-web/src/routes/mock/+page.svelte @@ -0,0 +1,356 @@ + + + + + +

Mock SBG IMU Data Inserter

+
+
+ + + + +

Device Sensors Control

+ {#if sensorError} + + {/if} + {#if !isListeningToSensors} + + {:else} + + {/if} +

+ Uses device orientation (alpha, beta, gamma) for gyroscope fields; may require + permissions. +

+
+
+
+ + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + > + + + + + + +

+ Accelerometers (m/s²) +

+ + + + + + +

+ Gyroscopes (α, β, γ) +

+ + + + + + +

+ Delta Velocity (m/s) +

+ + + + + + +

+ Delta Angle (rad) +

+ + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/apps/ground-station-web/src/routes/mock/api/+server.ts b/apps/ground-station-web/src/routes/mock/api/+server.ts new file mode 100644 index 0000000..802928f --- /dev/null +++ b/apps/ground-station-web/src/routes/mock/api/+server.ts @@ -0,0 +1,76 @@ +import { getDbClient } from '$lib/server/db'; // Assuming this path is correct +import { json, error as svelteKitError } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// Define the expected payload structure from the client +interface MockSbgImuPayload { + time_stamp: number; + status: string; + accelerometer_x?: number | null; + accelerometer_y?: number | null; + accelerometer_z?: number | null; + gyroscope_x?: number | null; + gyroscope_y?: number | null; + gyroscope_z?: number | null; + delta_velocity_x?: number | null; + delta_velocity_y?: number | null; + delta_velocity_z?: number | null; + delta_angle_x?: number | null; + delta_angle_y?: number | null; + delta_angle_z?: number | null; + temperature?: number | null; +} + +export const POST: RequestHandler = async ({ request }) => { + try { + const payload = await request.json() as MockSbgImuPayload; + + // Basic validation (can be more extensive) + if (!payload.time_stamp || !payload.status) { + return svelteKitError(400, 'Missing required fields: time_stamp and status'); + } + + const db = getDbClient(); + + const result = await db.execute({ + sql: `INSERT INTO SbgImu ( + time_stamp, status, + accelerometer_x, accelerometer_y, accelerometer_z, + gyroscope_x, gyroscope_y, gyroscope_z, + delta_velocity_x, delta_velocity_y, delta_velocity_z, + delta_angle_x, delta_angle_y, delta_angle_z, + temperature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, + args: [ + payload.time_stamp, + payload.status, + payload.accelerometer_x ?? null, + payload.accelerometer_y ?? null, + payload.accelerometer_z ?? null, + payload.gyroscope_x ?? null, + payload.gyroscope_y ?? null, + payload.gyroscope_z ?? null, + payload.delta_velocity_x ?? null, + payload.delta_velocity_y ?? null, + payload.delta_velocity_z ?? null, + payload.delta_angle_x ?? null, + payload.delta_angle_y ?? null, + payload.delta_angle_z ?? null, + payload.temperature ?? null, + ], + }); + + return json({ + message: 'Mock IMU data inserted successfully', + rowId: result.lastInsertRowid ? String(result.lastInsertRowid) : null + }, { status: 201 }); + + } catch (e: any) { + console.error("Error inserting mock IMU data:", e); + // Check for specific error types if needed, e.g., JSON parsing errors + if (e instanceof SyntaxError) { + return svelteKitError(400, 'Invalid JSON payload'); + } + return svelteKitError(500, e.message || 'Failed to insert mock IMU data'); + } +}; \ No newline at end of file diff --git a/apps/ground-station-web/src/routes/mock/types.ts b/apps/ground-station-web/src/routes/mock/types.ts new file mode 100644 index 0000000..87dd798 --- /dev/null +++ b/apps/ground-station-web/src/routes/mock/types.ts @@ -0,0 +1,40 @@ +// import { graphql } from 'gql.tada'; + +// export const InsertQuaternionDocument = graphql(` +// mutation InsertQuaternionMutation( +// $time_stamp: Int = 10 +// $time_stamp1: Int = 10 +// $w: Float = 1.5 +// $x: Float = 1.5 +// $y: Float = 1.5 +// $z: Float = 1.5 +// ) { +// insert_rocket_message( +// objects: { +// rocket_sensor_message: { +// data: { +// rocket_sensor_quat: { +// data: { +// status: 10 +// quat_w: $w +// quat_x: $x +// quat_y: $y +// quat_z: $z +// euler_std_dev_x: 0 +// euler_std_dev_y: 0 +// euler_std_dev_z: 0 +// time_stamp: $time_stamp1 +// } +// } +// component_id: 0 +// } +// } +// time_stamp: $time_stamp +// sender: "phone" +// message_type: "sensor" +// } +// ) { +// affected_rows +// } +// } +// `); diff --git a/apps/ground-station-web/src/routes/navball/+page.svelte b/apps/ground-station-web/src/routes/navball/+page.svelte new file mode 100644 index 0000000..d9ecb07 --- /dev/null +++ b/apps/ground-station-web/src/routes/navball/+page.svelte @@ -0,0 +1,103 @@ + + + + + + +
+ {#if error} + + {/if} +
+ +
+
+ +
+
+
+
+ + +

IMU Readout

+

Roll: {roll.toFixed(2)}°

+

Pitch: {pitch.toFixed(2)}°

+

+ Pointing: + 0 ? 'green' : 'red'}>{pitch > 0 ? 'Up' : 'Down'} +

+ {#if imuData} +

Status: {imuData.status}

+ {/if} +
+
+
+
diff --git a/apps/ground-station-web/src/routes/navball/api/+server.ts b/apps/ground-station-web/src/routes/navball/api/+server.ts new file mode 100644 index 0000000..f220454 --- /dev/null +++ b/apps/ground-station-web/src/routes/navball/api/+server.ts @@ -0,0 +1,129 @@ +import type { RequestHandler } from './$types'; +import { getDbClient } from '$lib/server/db'; + +// Module-scope state +let latestQuatData: any = { + time_stamp: Math.floor(Date.now() / 1000), + status: 'INITIALIZING', + quaternion_w: 1, + quaternion_x: 0, + quaternion_y: 0, + quaternion_z: 0 +}; +const activeControllers = new Set(); +const encoder = new TextEncoder(); +let dbPollingIntervalId: NodeJS.Timeout | null = null; +const DB_POLL_INTERVAL_MS = 500; + +async function fetchAndUpdateQuatData() { + try { + const db = getDbClient(); + const result = await db.execute({ + sql: `SELECT time_stamp, quaternion_w, quaternion_x, quaternion_y, quaternion_z, status FROM SbgEkfQuat ORDER BY id DESC LIMIT 1` + }); + latestQuatData = result.rows[0] ?? { + time_stamp: Math.floor(Date.now() / 1000), + status: 'NO_DATA', + quaternion_w: 1, + quaternion_x: 0, + quaternion_y: 0, + quaternion_z: 0 + }; + } catch (e: any) { + console.error('Error fetching EKF quaternion in central poller:', e); + latestQuatData = { ...latestQuatData, status: 'DB_ERROR', errorDetails: e.message }; + } + + // Broadcast + prune closed sockets + const message = `data: ${JSON.stringify(latestQuatData)}\n\n`; + for (const controller of Array.from(activeControllers)) { + try { + controller.enqueue(encoder.encode(message)); + } catch { + try { + controller.close(); + } catch {} + activeControllers.delete(controller); + } + } +} + +function ensurePoller() { + if (dbPollingIntervalId) return; + void fetchAndUpdateQuatData(); + dbPollingIntervalId = setInterval(fetchAndUpdateQuatData, DB_POLL_INTERVAL_MS); +} + +function stopPollerWhenIdle() { + if (activeControllers.size === 0 && dbPollingIntervalId) { + clearInterval(dbPollingIntervalId); + dbPollingIntervalId = null; + } +} + +export const GET: RequestHandler = (event) => { + let controllerForThisClient: ReadableStreamDefaultController; + + const stream = new ReadableStream({ + start(controller) { + controllerForThisClient = controller; + activeControllers.add(controllerForThisClient); + ensurePoller(); + console.log('SSE client connected. Total clients:', activeControllers.size); + + // Initial event flush + controller.enqueue(encoder.encode(`data: ${JSON.stringify(latestQuatData)}\n\n`)); + + // Robust cleanup with Node adapter if available + const node = (event as any)?.platform?.node as { req?: any; res?: any } | undefined; + if (node) { + const cleanup = () => { + if (controllerForThisClient) { + activeControllers.delete(controllerForThisClient); + stopPollerWhenIdle(); + try { + controllerForThisClient.close(); + } catch {} + node.req.off?.('close', cleanup); + node.res.off?.('close', cleanup); + node.req.off?.('aborted', cleanup); + console.log( + 'SSE client disconnected (socket close). Total clients:', + activeControllers.size + ); + } + }; + node.req.on?.('close', cleanup); + node.res.on?.('close', cleanup); + node.req.on?.('aborted', cleanup); + } + + // Fallback: abort signal + event.request.signal.addEventListener('abort', () => { + if (controllerForThisClient) { + activeControllers.delete(controllerForThisClient); + stopPollerWhenIdle(); + try { + controllerForThisClient.close(); + } catch {} + console.log('SSE client disconnected (abort). Total clients:', activeControllers.size); + } + }); + }, + cancel() { + if (controllerForThisClient) { + activeControllers.delete(controllerForThisClient); + stopPollerWhenIdle(); + console.log('SSE client disconnected (cancel). Total clients:', activeControllers.size); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); +}; diff --git a/apps/ground-station-web/src/routes/overview/api/+server.ts b/apps/ground-station-web/src/routes/overview/api/+server.ts new file mode 100644 index 0000000..80448f5 --- /dev/null +++ b/apps/ground-station-web/src/routes/overview/api/+server.ts @@ -0,0 +1,219 @@ +import { getDbClient } from '$lib/server/db'; +import { listServices } from '$lib/server/services'; +import type { FlightTransition, OverviewSnapshot } from '$lib/realtime/types'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const QNH_KPA = 102.5; + +function altitudeFromPressure(pressureKpa: number): number { + return 44330 * (1 - Math.pow(pressureKpa / QNH_KPA, 0.190263)); +} + +function numberOrNull(value: unknown): number | null { + if (value === null || value === undefined) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +async function getSnapshot(): Promise { + const db = getDbClient(); + const [ + stateRows, + eventRows, + baroRows, + gpsRows, + velocityRows, + imuRows, + radioRows, + frameRows, + lossRows, + transitionRows, + pathRows, + serviceResult + ] = await Promise.all([ + db.execute({ + sql: `SELECT ps.id, ps.state AS value, rf.timestamp_epoch AS ts FROM PhoenixState ps JOIN RadioFrame rf ON rf.data_type = 'PhoenixState' AND rf.data_id = ps.id ORDER BY rf.timestamp_epoch DESC LIMIT 1`, + args: [] + }), + db.execute({ + sql: `SELECT pe.id, pe.event AS value, rf.timestamp_epoch AS ts FROM PhoenixEvent pe JOIN RadioFrame rf ON rf.data_type = 'PhoenixEvent' AND rf.data_id = pe.id ORDER BY rf.timestamp_epoch DESC LIMIT 1`, + args: [] + }), + db.execute({ + sql: `SELECT b.pressure_kpa, rf.timestamp_epoch AS ts FROM Barometer b JOIN RadioFrame rf ON rf.data_type = 'Barometer' AND rf.data_id = b.id ORDER BY rf.timestamp_epoch DESC LIMIT 1`, + args: [] + }), + db.execute({ + sql: `SELECT p.altitude, p.latitude, p.longitude, p.num_sv_used, rf.timestamp_epoch AS ts FROM SbgGpsPos p JOIN RadioFrame rf ON rf.data_type = 'SbgGpsPos' AND rf.data_id = p.id WHERE p.latitude IS NOT NULL AND p.longitude IS NOT NULL ORDER BY rf.timestamp_epoch DESC LIMIT 1`, + args: [] + }), + db.execute({ + sql: `SELECT v.velocity_down, rf.timestamp_epoch AS ts FROM SbgGpsVel v JOIN RadioFrame rf ON rf.data_type = 'SbgGpsVel' AND rf.data_id = v.id ORDER BY rf.timestamp_epoch DESC LIMIT 1`, + args: [] + }), + db.execute({ + sql: `SELECT i.accelerometer_x, i.accelerometer_y, i.accelerometer_z, rf.timestamp_epoch AS ts FROM SbgImu i JOIN RadioFrame rf ON rf.data_type = 'SbgImu' AND rf.data_id = i.id ORDER BY rf.timestamp_epoch DESC LIMIT 1`, + args: [] + }), + db.execute({ + sql: `SELECT timestamp, rssi FROM RadioMetrics ORDER BY timestamp DESC LIMIT 1`, + args: [] + }), + db.execute({ + sql: `SELECT COUNT(*) AS total, SUM(CASE WHEN timestamp_epoch >= strftime('%s','now') - 10 THEN 1 ELSE 0 END) AS recent FROM RadioFrame`, + args: [] + }), + db.execute({ + sql: `SELECT COALESCE(SUM(packets_lost), 0) AS lost FROM RadioMetrics`, + args: [] + }), + db.execute({ + sql: `SELECT id, kind, value, ts FROM (SELECT ps.id, 'State' AS kind, ps.state AS value, rf.timestamp_epoch AS ts FROM PhoenixState ps JOIN RadioFrame rf ON rf.data_type = 'PhoenixState' AND rf.data_id = ps.id UNION ALL SELECT pe.id, 'Event' AS kind, pe.event AS value, rf.timestamp_epoch AS ts FROM PhoenixEvent pe JOIN RadioFrame rf ON rf.data_type = 'PhoenixEvent' AND rf.data_id = pe.id) ORDER BY ts DESC LIMIT 12`, + args: [] + }), + db.execute({ + sql: `SELECT rf.timestamp_epoch AS ts, p.latitude AS lat, p.longitude AS lon FROM SbgGpsPos p JOIN RadioFrame rf ON rf.data_type = 'SbgGpsPos' AND rf.data_id = p.id WHERE p.latitude IS NOT NULL AND p.longitude IS NOT NULL AND p.latitude != 0 AND p.longitude != 0 ORDER BY rf.timestamp_epoch DESC LIMIT 120`, + args: [] + }), + listServices() + .then((services) => ({ services, available: true })) + .catch(() => ({ services: [], available: false })) + ]); + + const state: any = stateRows.rows[0]; + const event: any = eventRows.rows[0]; + const baro: any = baroRows.rows[0]; + const gps: any = gpsRows.rows[0]; + const velocity: any = velocityRows.rows[0]; + const imu: any = imuRows.rows[0]; + const radio: any = radioRows.rows[0]; + const frames: any = frameRows.rows[0] ?? {}; + const losses: any = lossRows.rows[0] ?? {}; + const totalFrames = Number(frames.total ?? 0); + const totalLost = Number(losses.lost ?? 0); + const gpsTimestamp = numberOrNull(gps?.ts); + const imuValues = [imu?.accelerometer_x, imu?.accelerometer_y, imu?.accelerometer_z].map( + numberOrNull + ); + const acceleration = imuValues.every((value) => value !== null) + ? Math.hypot(...(imuValues as number[])) + : null; + + const transition = (row: any): FlightTransition => ({ + id: Number(row.id), + kind: row.kind as 'State' | 'Event', + value: String(row.value), + timestamp: numberOrNull(row.ts) + }); + + return { + generatedAt: Date.now(), + flight: { + state: state + ? { + id: Number(state.id), + kind: 'State', + value: String(state.value), + timestamp: numberOrNull(state.ts) + } + : null, + event: event + ? { + id: Number(event.id), + kind: 'Event', + value: String(event.value), + timestamp: numberOrNull(event.ts) + } + : null, + altitudeM: { + value: baro ? altitudeFromPressure(Number(baro.pressure_kpa)) : null, + updatedAt: numberOrNull(baro?.ts) + }, + gnssAltitudeM: { value: numberOrNull(gps?.altitude), updatedAt: gpsTimestamp }, + verticalVelocityMps: { + value: velocity?.velocity_down == null ? null : -Number(velocity.velocity_down), + updatedAt: numberOrNull(velocity?.ts) + }, + accelerationMps2: { value: acceleration, updatedAt: numberOrNull(imu?.ts) }, + position: { + value: gps ? { lat: Number(gps.latitude), lon: Number(gps.longitude) } : null, + updatedAt: gpsTimestamp + }, + satellites: { value: numberOrNull(gps?.num_sv_used), updatedAt: gpsTimestamp } + }, + radio: { + updatedAt: numberOrNull(radio?.timestamp), + rssiDbm: numberOrNull(radio?.rssi), + framesPerSecond: Number(frames.recent ?? 0) / 10, + packetLossPercent: totalFrames > 0 ? (totalLost / totalFrames) * 100 : 0, + totalFrames + }, + services: { + updatedAt: Date.now(), + running: serviceResult.services.filter((service) => service.status === 'running').length, + total: serviceResult.services.length, + degraded: serviceResult.services + .filter((service) => service.status !== 'running') + .map((service) => service.name), + available: serviceResult.available + }, + recentTransitions: transitionRows.rows.map(transition), + trajectory: [...pathRows.rows] + .reverse() + .map((row: any) => ({ + timestamp: Number(row.ts), + lat: Number(row.lat), + lon: Number(row.lon) + })) + }; +} + +export const GET: RequestHandler = async ({ url, request }) => { + if (url.searchParams.get('sse') !== '1') return json(await getSnapshot()); + + let interval: ReturnType; + let closed = false; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const push = async () => { + if (closed) return; + try { + const snapshot = await getSnapshot(); + controller.enqueue( + encoder.encode(`event: overview\ndata: ${JSON.stringify(snapshot)}\n\n`) + ); + } catch (error) { + console.error('Overview update failed', error); + controller.enqueue( + encoder.encode(`event: error\ndata: {"message":"Overview unavailable"}\n\n`) + ); + } + }; + void push(); + interval = setInterval(push, 1_000); + request.signal.addEventListener('abort', () => { + closed = true; + clearInterval(interval); + try { + controller.close(); + } catch { + /* already closed */ + } + }); + }, + cancel() { + closed = true; + clearInterval(interval); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); +}; diff --git a/apps/ground-station-web/src/routes/radio/+page.svelte b/apps/ground-station-web/src/routes/radio/+page.svelte new file mode 100644 index 0000000..2c3178b --- /dev/null +++ b/apps/ground-station-web/src/routes/radio/+page.svelte @@ -0,0 +1,390 @@ + + + + + + +
+

Radio Metrics

+
+ + {#if connected} + + {:else} + + {/if} +
+
+ + {#if error} + + {/if} + + {#if metrics.length === 0 && !loading} + + {/if} + + {#if metrics.length > 0} + + + + +

Latest RSSI

+ {#if metrics[0]?.rssi !== null} +
+ {metrics[0].rssi} dBm +
+ + {getRssiStatus(metrics[0].rssi).status} + + {:else} +
N/A
+ {/if} +
+
+ + +

Frames per second

+ {#if totals?.frames_per_second != null} +
+ {totals.frames_per_second.toFixed(2)} fps +
+ 10 + ? 'green' + : totals.frames_per_second > 3 + ? 'blue' + : 'red'} + size="sm" + > + {totals.frames_per_second > 10 + ? 'High' + : totals.frames_per_second > 3 + ? 'Moderate' + : 'Low'} + + {:else} +
N/A
+ {/if} +
+
+ + +

Total Packet Loss

+ {#if totals} +
+ {totals.total_packets_lost} +
+ + {totals.packet_loss_percentage.toFixed(2)}% + + + {:else} +
N/A
+ {/if} +
+
+ + +

Connection Status

+
+ {connected ? 'Live' : 'Static'} +
+ + {connected ? 'Real-time' : 'Manual Refresh'} + +
+
+
+ + + + + + Timestamp + RSSI + RSSI Status + Packets Lost + Packet Loss Status + + + + {#each metrics as metric (metric.timestamp)} + + {formatTimestamp(metric.timestamp)} + + {metric.rssi !== null ? `${metric.rssi} dBm` : 'N/A'} + + + + {getRssiStatus(metric.rssi).status} + + + {metric.packets_lost} + + + {getPacketLossStatus(metric.packets_lost).status} + + + + {/each} + +
+ {/if} +
+
+
+
diff --git a/apps/ground-station-web/src/routes/radio/api/+server.ts b/apps/ground-station-web/src/routes/radio/api/+server.ts new file mode 100644 index 0000000..fe3903c --- /dev/null +++ b/apps/ground-station-web/src/routes/radio/api/+server.ts @@ -0,0 +1,139 @@ +import { getDbClient } from '$lib/server/db'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const encoder = new TextEncoder(); +const activeControllers = new Set(); +let pollIntervalId: NodeJS.Timeout | null = null; + +async function pollAndBroadcast() { + const db = getDbClient(); + try { + const metricsResult = await db.execute({ + sql: `SELECT timestamp, rssi, packets_lost FROM RadioMetrics ORDER BY timestamp DESC LIMIT 20`, + args: [] + }); + // Throughput: frames per second over last 10 seconds + const fpsRes = await db.execute({ + sql: `SELECT COUNT(*) AS cnt FROM RadioFrame WHERE timestamp_epoch >= strftime('%s','now') - 10`, + args: [] + }); + const framesIn10s = Number((fpsRes.rows[0] as any)?.cnt ?? 0); + const frames_per_second = framesIn10s / 10.0; + + let total_packets_lost = 0; + let total_radio_frames = 0; + try { + const summary = await db.execute({ + sql: `SELECT total_packets_lost, total_radio_frames FROM MetricsSummary LIMIT 1`, + args: [] + }); + if (summary.rows.length > 0) { + const row: any = summary.rows[0]; + total_packets_lost = Number(row.total_packets_lost ?? 0); + total_radio_frames = Number(row.total_radio_frames ?? 0); + } + } catch (_) { + const [lostAgg, frameAgg] = await Promise.all([ + db.execute({ sql: `SELECT COALESCE(SUM(packets_lost), 0) AS total FROM RadioMetrics`, args: [] }), + db.execute({ sql: `SELECT COUNT(*) AS total FROM RadioFrame`, args: [] }) + ]); + total_packets_lost = Number((lostAgg.rows[0] as any).total ?? 0); + total_radio_frames = Number((frameAgg.rows[0] as any).total ?? 0); + } + + const packet_loss_ratio = total_radio_frames > 0 ? total_packets_lost / total_radio_frames : 0; + const packet_loss_percentage = packet_loss_ratio * 100; + + const payload = { + metrics: metricsResult.rows, + totals: { total_packets_lost, total_radio_frames, packet_loss_ratio, packet_loss_percentage, frames_per_second } + }; + const chunk = encoder.encode(`event: metrics\n` + `data: ${JSON.stringify(payload)}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(chunk); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + } catch (e) { + const errChunk = encoder.encode(`event: error\n` + `data: {"message":"Failed to fetch radio metrics"}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(errChunk); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + console.error('Radio central poll error:', e); + } +} + +function ensurePoller() { + if (!pollIntervalId) { + pollIntervalId = setInterval(pollAndBroadcast, 5000); + // kick immediately + pollAndBroadcast(); + } +} + +export const GET: RequestHandler = async ({ url, request }) => { + const useSSE = url.searchParams.get('sse') === '1'; + + if (useSSE) { + const stream = new ReadableStream({ + start(controller) { + activeControllers.add(controller); + ensurePoller(); + request.signal.addEventListener('abort', () => { + activeControllers.delete(controller); + try { controller.close(); } catch { } + }); + }, + cancel() { } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive' + } + }); + } + + // Regular GET request + const db = getDbClient(); + const metricsResult = await db.execute({ + sql: `SELECT timestamp, rssi, packets_lost FROM RadioMetrics ORDER BY timestamp DESC LIMIT 20`, + args: [] + }); + + // Summary (prefer summary table, fallback to aggregates) + let total_packets_lost = 0; + let total_radio_frames = 0; + try { + const summary = await db.execute({ + sql: `SELECT total_packets_lost, total_radio_frames FROM MetricsSummary LIMIT 1`, + args: [] + }); + if (summary.rows.length > 0) { + const row: any = summary.rows[0]; + total_packets_lost = Number(row.total_packets_lost ?? 0); + total_radio_frames = Number(row.total_radio_frames ?? 0); + } + } catch (_) { + const [lostAgg, frameAgg] = await Promise.all([ + db.execute({ sql: `SELECT COALESCE(SUM(packets_lost), 0) AS total FROM RadioMetrics`, args: [] }), + db.execute({ sql: `SELECT COUNT(*) AS total FROM RadioFrame`, args: [] }) + ]); + total_packets_lost = Number((lostAgg.rows[0] as any).total ?? 0); + total_radio_frames = Number((frameAgg.rows[0] as any).total ?? 0); + } + + const packet_loss_ratio = total_radio_frames > 0 ? total_packets_lost / total_radio_frames : 0; + const packet_loss_percentage = packet_loss_ratio * 100; + + return json({ + metrics: metricsResult.rows, + totals: { + total_packets_lost, + total_radio_frames, + packet_loss_ratio, + packet_loss_percentage + } + }); +}; diff --git a/apps/ground-station-web/src/routes/sbg/+page.server.ts b/apps/ground-station-web/src/routes/sbg/+page.server.ts new file mode 100644 index 0000000..d5a2495 --- /dev/null +++ b/apps/ground-station-web/src/routes/sbg/+page.server.ts @@ -0,0 +1,58 @@ +import { getDbClient } from '$lib/server/db'; +import type { Row } from '@libsql/client'; +import type { PageServerLoad } from './$types'; + +// Helper function to get the latest row from a table +async function getLatestRow(tableName: string): Promise { + const db = getDbClient(); + try { + const result = await db.execute({ + sql: `SELECT * FROM ${tableName} ORDER BY time_stamp DESC LIMIT 1`, + args: [] + }); + return result.rows.length > 0 ? result.rows[0] : null; + } catch (e: any) { + console.error(`Error fetching latest row from ${tableName}:`, e); + // Depending on requirements, you might want to throw, return null, or a specific error object + return null; // Return null on error for graceful handling + } +} + +export const load: PageServerLoad = async () => { + console.log("Loading initial SBG data for /sbg page..."); + + // Fetch latest data from relevant tables in parallel + const [ + latestUtcTime, + latestAir, + latestEkfQuat, + latestEkfNav, + latestImu, + latestGpsVel, + latestGpsPos + ] = await Promise.all([ + getLatestRow('SbgUtcTime'), + getLatestRow('SbgAir'), + getLatestRow('SbgEkfQuat'), + getLatestRow('SbgEkfNav'), + getLatestRow('SbgImu'), + getLatestRow('SbgGpsVel'), + getLatestRow('SbgGpsPos') + ]); + + console.log("Finished loading initial SBG data."); + + // Return the data in a structured way + // Handle potential nulls if a table is empty or an error occurred + return { + sbgData: { + utcTime: latestUtcTime ?? {}, + air: latestAir ?? {}, + ekfQuat: latestEkfQuat ?? {}, + ekfNav: latestEkfNav ?? {}, + imu: latestImu ?? {}, + gpsVel: latestGpsVel ?? {}, + gpsPos: latestGpsPos ?? {}, + } + }; +}; \ No newline at end of file diff --git a/apps/ground-station-web/src/routes/sbg/+page.svelte b/apps/ground-station-web/src/routes/sbg/+page.svelte new file mode 100644 index 0000000..4c85a87 --- /dev/null +++ b/apps/ground-station-web/src/routes/sbg/+page.svelte @@ -0,0 +1,306 @@ + + + + SBG Sensor Status + + + + + + + + + + Last updated: {lastUpdated.toLocaleString()} + {#if isLoading} + + {/if} + + +

+ EKF Navigation + {sbgData.ekfNav?.status ?? 'N/A'} +

+ + + + Field + Value + + + + + Timestamp + {formatTimestamp(sbgData.ekfNav?.time_stamp)} + + + Latitude + {formatNum(sbgData.ekfNav?.position_latitude, 7)}° (±{formatNum( + sbgData.ekfNav?.position_std_dev_latitude, + 2 + )}m) + + + Longitude + {formatNum(sbgData.ekfNav?.position_longitude, 7)}° (±{formatNum( + sbgData.ekfNav?.position_std_dev_longitude, + 2 + )}m) + + + Altitude + {formatNum(sbgData.ekfNav?.position_altitude, 2)} m (±{formatNum( + sbgData.ekfNav?.position_std_dev_altitude, + 2 + )}m) + + + Vel N/E/D + {formatNum(sbgData.ekfNav?.velocity_north)} / {formatNum( + sbgData.ekfNav?.velocity_east + )} / {formatNum(sbgData.ekfNav?.velocity_down)} m/s + + + + +

+ Air Data + {sbgData.air?.status ?? 'N/A'} +

+ + + + Field + Value + + + + + Timestamp + {formatTimestamp(sbgData.air?.time_stamp)} + + + Abs Pressure + {formatNum(sbgData.air?.pressure_abs, 2)} Pa + + + Altitude + {formatNum(sbgData.air?.altitude, 2)} m + + + Diff Pressure + {formatNum(sbgData.air?.pressure_diff, 2)} Pa + + + True Airspeed + {formatNum(sbgData.air?.true_airspeed, 2)} m/s + + + Temperature + {formatNum(sbgData.air?.air_temperature, 1)} °C + + + + +

+ IMU Data + {sbgData.imu?.status ?? 'N/A'} +

+ + + + Field + Value + + + + + Timestamp + {formatTimestamp(sbgData.imu?.time_stamp)} + + + Accel X/Y/Z + {formatNum(sbgData.imu?.accelerometer_x)} / {formatNum( + sbgData.imu?.accelerometer_y + )} / {formatNum(sbgData.imu?.accelerometer_z)} m/s² + + + Gyro X/Y/Z + {formatNum(sbgData.imu?.gyroscope_x)} / {formatNum(sbgData.imu?.gyroscope_y)} / {formatNum( + sbgData.imu?.gyroscope_z + )} rad/s + + + Temperature + {formatNum(sbgData.imu?.temperature, 1)} °C + + + +
+
+ + + + i?.text ?? ''} + on:select={(e) => { + selectedMetric = e.detail.selectedItem; + connectSSE(); + }} + size="sm" + label="Metric" + /> + + + +
+
diff --git a/apps/ground-station-web/src/routes/sbg/api/+server.ts b/apps/ground-station-web/src/routes/sbg/api/+server.ts new file mode 100644 index 0000000..bcabc02 --- /dev/null +++ b/apps/ground-station-web/src/routes/sbg/api/+server.ts @@ -0,0 +1,220 @@ +import { getDbClient } from '$lib/server/db'; +import { json, error } from '@sveltejs/kit'; +import type { Row } from '@libsql/client'; +import type { RequestHandler } from './$types'; + +// Re-use the helper function (could be moved to a shared lib if used elsewhere too) +async function getLatestRow(tableName: string): Promise { + const db = getDbClient(); + try { + const result = await db.execute({ + sql: `SELECT * FROM ${tableName} ORDER BY time_stamp DESC LIMIT 1`, + args: [] + }); + return result.rows.length > 0 ? result.rows[0] : null; + } catch (e: any) { + console.error(`Error fetching latest row from ${tableName} for API:`, e); + return null; + } +} + +// Map of supported metrics to their safe table/column definitions +// This prevents SQL injection since table/columns are not taken directly from user input +const METRICS: Record = { + air_altitude: { table: 'SbgAir', columns: [{ key: 'altitude', label: 'Altitude (m)' }] }, + air_pressure: { + table: 'SbgAir', + columns: [ + { key: 'pressure_abs', label: 'Abs Pressure (Pa)' }, + { key: 'pressure_diff', label: 'Diff Pressure (Pa)' } + ] + }, + air_true_airspeed: { table: 'SbgAir', columns: [{ key: 'true_airspeed', label: 'True Airspeed (m/s)' }] }, + ekf_altitude: { table: 'SbgEkfNav', columns: [{ key: 'position_altitude', label: 'Altitude (m)' }] }, + gpsvel_velocity: { + table: 'SbgGpsVel', + columns: [ + { key: 'velocity_north', label: 'Vel North (m/s)' }, + { key: 'velocity_east', label: 'Vel East (m/s)' }, + { key: 'velocity_down', label: 'Vel Down (m/s)' } + ] + }, + gpsvel_course: { table: 'SbgGpsVel', columns: [{ key: 'course', label: 'Course (deg)' }] }, + imu_temperature: { table: 'SbgImu', columns: [{ key: 'temperature', label: 'IMU Temp (°C)' }] }, + imu_accel: { + table: 'SbgImu', + columns: [ + { key: 'accelerometer_x', label: 'Accel X (m/s²)' }, + { key: 'accelerometer_y', label: 'Accel Y (m/s²)' }, + { key: 'accelerometer_z', label: 'Accel Z (m/s²)' } + ] + }, + imu_gyro: { + table: 'SbgImu', + columns: [ + { key: 'gyroscope_x', label: 'Gyro X (rad/s)' }, + { key: 'gyroscope_y', label: 'Gyro Y (rad/s)' }, + { key: 'gyroscope_z', label: 'Gyro Z (rad/s)' } + ] + } +}; + +async function getMetricSeries(metric: string, minutes: number) { + const def = METRICS[metric]; + if (!def) { + throw error(400, `Unsupported metric: ${metric}`); + } + const db = getDbClient(); + const seconds = Math.max(1, Math.floor(minutes * 60)); + + // Build a SELECT that returns ts and one column per requested series + const selectCols = ['time_stamp AS ts', ...def.columns.map((c) => `${c.key} AS ${c.key}`)].join(', '); + const sql = `SELECT ${selectCols} FROM ${def.table} WHERE time_stamp >= strftime('%s','now') - ? ORDER BY time_stamp ASC`; + + const result = await db.execute({ sql, args: [seconds] }); + + // Convert rows into Carbon Charts data format: { group, date, value } + const data: Array<{ group: string; date: Date; value: number }> = []; + for (const row of result.rows) { + const ts = Number(row.ts); + for (const col of def.columns) { + const val = row[col.key] as number | null | undefined; + if (typeof val === 'number') { + data.push({ group: col.label, date: new Date(ts * 1000), value: val }); + } + } + } + + return { + metric, + table: def.table, + seriesLabels: def.columns.map((c) => c.label), + data + }; +} + +const encoder = new TextEncoder(); +const activeControllers = new Set(); +let pollIntervalId: NodeJS.Timeout | null = null; +let lastMetricPayload: any = null; +let lastSnapshotPayload: any = null; + +async function centralPoll(metric: string | null, minutes: number) { + // If metric is provided, poll metric + snapshot; otherwise only snapshot + try { + const series = metric ? await getMetricSeries(metric, minutes) : null; + const snapshot = await Promise.all([ + getLatestRow('SbgUtcTime'), + getLatestRow('SbgAir'), + getLatestRow('SbgEkfQuat'), + getLatestRow('SbgEkfNav'), + getLatestRow('SbgImu'), + getLatestRow('SbgGpsVel'), + getLatestRow('SbgGpsPos') + ]).then(([utc, air, quat, nav, imu, vel, pos]) => ({ + utcTime: utc ?? {}, + air: air ?? {}, + ekfQuat: quat ?? {}, + ekfNav: nav ?? {}, + imu: imu ?? {}, + gpsVel: vel ?? {}, + gpsPos: pos ?? {} + })); + if (series) lastMetricPayload = series; + lastSnapshotPayload = snapshot; + const chunks: Uint8Array[] = []; + if (series) chunks.push(encoder.encode(`event: metric\n` + `data: ${JSON.stringify(series)}\n\n`)); + chunks.push(encoder.encode(`event: snapshot\n` + `data: ${JSON.stringify(snapshot)}\n\n`)); + for (const c of Array.from(activeControllers)) { + try { for (const ch of chunks) c.enqueue(ch); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + } catch (e) { + const err = encoder.encode(`event: error\n` + `data: {"message":"Failed to fetch data"}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(err); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + console.error('SBG central poll error:', e); + } +} + +export const GET: RequestHandler = async ({ url, request }) => { + const metric = url.searchParams.get('metric'); + const minutes = Number(url.searchParams.get('minutes') || '10'); + const useSSE = url.searchParams.get('sse') === '1'; + + if (metric && useSSE) { + const stream = new ReadableStream({ + start(controller) { + activeControllers.add(controller); + if (!pollIntervalId) { + pollIntervalId = setInterval(() => centralPoll(metric, minutes), 5000); + centralPoll(metric, minutes); + } else { + if (lastMetricPayload) controller.enqueue(encoder.encode(`event: metric\n` + `data: ${JSON.stringify(lastMetricPayload)}\n\n`)); + if (lastSnapshotPayload) controller.enqueue(encoder.encode(`event: snapshot\n` + `data: ${JSON.stringify(lastSnapshotPayload)}\n\n`)); + } + request.signal.addEventListener('abort', () => { + activeControllers.delete(controller); + try { controller.close(); } catch { } + if (activeControllers.size === 0 && pollIntervalId) { clearInterval(pollIntervalId); pollIntervalId = null; } + }); + }, + cancel() { } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); + } + + if (metric) { + try { + const series = await getMetricSeries(metric, minutes); + return json(series); + } catch (e: any) { + if (e?.status && e?.body) throw e; // forward handled errors + console.error('Error fetching metric series:', e); + throw error(500, `Failed to fetch metric series: ${e.message}`); + } + } + + // Fallback: latest snapshot, preserving existing behavior + try { + const [ + latestUtcTime, + latestAir, + latestEkfQuat, + latestEkfNav, + latestImu, + latestGpsVel, + latestGpsPos + ] = await Promise.all([ + getLatestRow('SbgUtcTime'), + getLatestRow('SbgAir'), + getLatestRow('SbgEkfQuat'), + getLatestRow('SbgEkfNav'), + getLatestRow('SbgImu'), + getLatestRow('SbgGpsVel'), + getLatestRow('SbgGpsPos') + ]); + + const responseData = { + utcTime: latestUtcTime ?? {}, + air: latestAir ?? {}, + ekfQuat: latestEkfQuat ?? {}, + ekfNav: latestEkfNav ?? {}, + imu: latestImu ?? {}, + gpsVel: latestGpsVel ?? {}, + gpsPos: latestGpsPos ?? {} + }; + + return json(responseData); + } catch (e: any) { + console.error('Error in SBG API GET handler:', e); + throw error(500, `Failed to fetch SBG data: ${e.message}`); + } +}; \ No newline at end of file diff --git a/apps/ground-station-web/src/routes/services/+page.svelte b/apps/ground-station-web/src/routes/services/+page.svelte new file mode 100644 index 0000000..0c4286b --- /dev/null +++ b/apps/ground-station-web/src/routes/services/+page.svelte @@ -0,0 +1,500 @@ + + + + + +
+

Service Manager

+ View Service Logs → +
+
+
+ + + + + + + + Service + Status + Actions + + + + {#if !loading && services.length === 0} + + No services found + + + + {:else} + {#each services as s} + + {s.name} + + {#if s.status === 'running'} + running + {:else if s.status === 'exited'} + stopped + {:else if s.status === 'restarting'} + restarting + {:else} + unknown + {/if} + + +
+ {#if s.status === 'running'} + + {:else} + + {/if} + + +
+
+
+ {/each} + {/if} +
+
+ +
+
+ + + + + + i?.text ?? ''} + on:select={(e) => { + selectedService = e.detail.selectedItem?.id ?? null; + }} + size="sm" + /> + + + + + + + + + + + + + + + + + + + + + +
+ {logText} +
+
+
+
+
+
+
+ + + + + + diff --git a/apps/ground-station-web/src/routes/services/api/+server.ts b/apps/ground-station-web/src/routes/services/api/+server.ts new file mode 100644 index 0000000..81845ac --- /dev/null +++ b/apps/ground-station-web/src/routes/services/api/+server.ts @@ -0,0 +1,32 @@ +import { error, json } from '@sveltejs/kit'; +import { dockerRequest, listServices } from '$lib/server/services'; + +async function containerAction(service: string, action: 'start' | 'stop' | 'restart') { + let p = `/containers/${encodeURIComponent(service)}/${action}`; + if (action === 'restart') p += '?t=5'; + const res = await dockerRequest(p, 'POST'); + if (!(res.status === 204 || res.status === 304)) { + throw new Error(`Docker ${action} failed with status ${res.status}`); + } +} + +export async function GET() { + try { + const services = await listServices(); + return json(services); + } catch (e: any) { + throw error(500, e.message ?? 'Failed to list services'); + } +} + +export async function POST({ url }) { + const action = (url.searchParams.get('action') as 'start' | 'stop' | 'restart') ?? 'start'; + const service = url.searchParams.get('service'); + if (!service) throw error(400, 'service is required'); + try { + await containerAction(service, action); + return new Response(null, { status: 204 }); + } catch (e: any) { + throw error(500, e.message ?? 'Operation failed'); + } +} diff --git a/apps/ground-station-web/src/routes/services/logs/+page.svelte b/apps/ground-station-web/src/routes/services/logs/+page.svelte new file mode 100644 index 0000000..275df85 --- /dev/null +++ b/apps/ground-station-web/src/routes/services/logs/+page.svelte @@ -0,0 +1,343 @@ + + + + + +

Service Logs

+

View and stream logs from Docker services

+
+
+ + {#if errorMsg} + + + + + + {/if} + + + + +

Configuration

+ + i?.text ?? ''} + on:select={(e) => onServiceChange(e.detail.selectedItem?.id ?? '')} + size="sm" + disabled={loading} + /> + + + + + +
+ + + +
+ +
+ + + +
+ + {#if selectedService} +
+ + Service: {selectedService} + + {#if streaming} + Streaming + {/if} +
+ {/if} +
+
+ + + +
+

Logs

+ {#if streaming} + + {/if} +
+ + {#if !selectedService} +
+ Select a service to view logs +
+ {:else if !logText && !streaming} +
+ Click "Get Logs" to fetch logs or "Start Stream" for real-time logs +
+ {:else} +
+ + {logText || 'Connecting to stream...'} + +
+ {/if} +
+
+
+
+ + + + diff --git a/apps/ground-station-web/src/routes/services/logs/api/+server.ts b/apps/ground-station-web/src/routes/services/logs/api/+server.ts new file mode 100644 index 0000000..421b0f7 --- /dev/null +++ b/apps/ground-station-web/src/routes/services/logs/api/+server.ts @@ -0,0 +1,93 @@ +import { error } from '@sveltejs/kit'; +import http from 'node:http'; + +const DOCKER_SOCKET = '/var/run/docker.sock'; + +function dockerRequest(path: string): Promise<{ status: number; text: () => Promise; ok: boolean; }> { + return new Promise((resolve, reject) => { + const req = http.request({ + socketPath: DOCKER_SOCKET, + path, + method: 'GET' + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on('end', () => { + const buf = Buffer.concat(chunks); + const status = res.statusCode ?? 500; + resolve({ + status, + ok: status >= 200 && status < 300, + text: async () => buf.toString('utf8') + }); + }); + }); + req.on('error', reject); + req.end(); + }); +} + +async function getContainerLogs(containerName: string, tail: string, grep?: string): Promise { + try { + // First, get container info to find the container ID + const containersRes = await dockerRequest('/containers/json?all=true'); + if (!containersRes.ok) { + throw new Error(`Failed to list containers: ${containersRes.status}`); + } + + const containers = JSON.parse(await containersRes.text()); + const container = containers.find((c: any) => + c.Names?.some((name: string) => name.includes(containerName)) || + c.Labels?.['com.docker.compose.service'] === containerName + ); + + if (!container) { + throw new Error(`Container not found: ${containerName}`); + } + + // Get logs from the container + const logsRes = await dockerRequest(`/containers/${container.Id}/logs?stdout=1&stderr=1&tail=${tail}×tamps=0`); + if (!logsRes.ok) { + throw new Error(`Failed to get logs: ${logsRes.status}`); + } + + let logs = await logsRes.text(); + + // Apply grep filter if specified + if (grep) { + try { + const grepRegex = new RegExp(grep, 'i'); + logs = logs.split('\n').filter(line => grepRegex.test(line)).join('\n'); + } catch (e) { + throw new Error('Invalid regex pattern'); + } + } + + return logs; + } catch (e: any) { + throw new Error(`Failed to get container logs: ${e.message}`); + } +} + +export async function GET({ url }) { + const service = url.searchParams.get('service'); + const tail = url.searchParams.get('tail') || '50'; + const grep = url.searchParams.get('grep'); + + if (!service) { + throw error(400, 'service parameter is required'); + } + + try { + const logs = await getContainerLogs(service, tail, grep || undefined); + + return new Response(logs, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8' + } + }); + } catch (e: any) { + console.error('Error fetching logs:', e); + throw error(500, e.message || 'Failed to fetch logs'); + } +} diff --git a/apps/ground-station-web/src/routes/services/logs/stream/+server.ts b/apps/ground-station-web/src/routes/services/logs/stream/+server.ts new file mode 100644 index 0000000..a58b976 --- /dev/null +++ b/apps/ground-station-web/src/routes/services/logs/stream/+server.ts @@ -0,0 +1,235 @@ +// src/routes/logs/+server.ts +import { error } from '@sveltejs/kit'; +import http from 'node:http'; + +const DOCKER_SOCKET = '/var/run/docker.sock'; + +function dockerRequestStream(path: string): Promise<{ status: number; stream: NodeJS.ReadableStream; ok: boolean; }> { + return new Promise((resolve, reject) => { + const req = http.request({ + socketPath: DOCKER_SOCKET, + path, + method: 'GET' + }, (res) => { + const status = res.statusCode ?? 500; + resolve({ + status, + ok: status >= 200 && status < 300, + stream: res + }); + }); + req.on('error', reject); + req.end(); + }); +} + +async function findContainer(containerName: string): Promise { + // First, get container info to find the container ID + const containersRes = await new Promise<{ status: number; text: () => Promise; ok: boolean; }>((resolve, reject) => { + const req = http.request({ + socketPath: DOCKER_SOCKET, + path: '/containers/json?all=true', + method: 'GET' + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on('end', () => { + const buf = Buffer.concat(chunks); + const status = res.statusCode ?? 500; + resolve({ + status, + ok: status >= 200 && status < 300, + text: async () => buf.toString('utf8') + }); + }); + }); + req.on('error', reject); + req.end(); + }); + + if (!containersRes.ok) { + throw new Error(`Failed to list containers: ${containersRes.status}`); + } + + const containers = JSON.parse(await containersRes.text()); + const container = containers.find((c: any) => + c.Names?.some((name: string) => name.includes(containerName)) || + c.Labels?.['com.docker.compose.service'] === containerName + ); + + if (!container) { + throw new Error(`Container not found: ${containerName}`); + } + + return container.Id; +} + +export async function GET({ url }) { + const service = url.searchParams.get('service'); + const tail = url.searchParams.get('tail') || '50'; + const grep = url.searchParams.get('grep'); + + if (!service) { + throw error(400, 'service parameter is required'); + } + + // Set up Server-Sent Events with true real-time streaming + const stream = new ReadableStream({ + start(controller) { + let isControllerClosed = false; + let dockerStream: any = null; + + // Safe function to enqueue data + const safeEnqueue = (data: string) => { + if (!isControllerClosed && controller.desiredSize !== null) { + try { + controller.enqueue(new TextEncoder().encode(data)); + } catch (e) { + console.error('Error enqueueing data:', e); + isControllerClosed = true; + } + } + }; + + // Safe function to close controller + const safeClose = () => { + if (!isControllerClosed) { + try { + isControllerClosed = true; + controller.close(); + } catch (e) { + console.error('Error closing controller:', e); + } + } + }; + + // Set a timeout to prevent hanging streams + const timeoutId = setTimeout(() => { + if (!isControllerClosed) { + console.log('Log stream timeout reached, closing'); + safeEnqueue(`data: ${JSON.stringify({ type: 'error', line: 'Stream timeout reached' })}\n\n`); + safeClose(); + } + }, 300000); // 5 minutes timeout + + // Send heartbeat every 30 seconds to keep connection alive + const heartbeatId = setInterval(() => { + if (!isControllerClosed) { + safeEnqueue(`data: ${JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })}\n\n`); + } + }, 30000); + + // Cleanup function + const cleanup = () => { + // Clear timeout + if (timeoutId) { + clearTimeout(timeoutId); + } + + // Clear heartbeat + if (heartbeatId) { + clearInterval(heartbeatId); + } + + // Close Docker stream if open + if (dockerStream && !dockerStream.destroyed) { + try { + dockerStream.destroy(); + } catch (e) { + console.error('Error destroying Docker stream:', e); + } + } + + safeClose(); + }; + + // Start streaming process + (async () => { + try { + // Send initial connection message + safeEnqueue(`data: ${JSON.stringify({ type: 'connected', message: 'Connected to log stream' })}\n\n`); + + // Find the container + const containerId = await findContainer(service); + safeEnqueue(`data: ${JSON.stringify({ type: 'info', line: `Found container: ${containerId}` })}\n\n`); + + // Get logs stream from the container with follow=1 for real-time streaming + const logsRes = await dockerRequestStream(`/containers/${containerId}/logs?stdout=1&stderr=1&tail=${tail}×tamps=0&follow=1`); + + if (!logsRes.ok) { + throw new Error(`Failed to get logs: ${logsRes.status}`); + } + + dockerStream = logsRes.stream; + + // Create regex filter if specified + let filter: RegExp | null = null; + if (grep) { + try { + filter = new RegExp(grep, 'i'); + } catch (e) { + safeEnqueue(`data: ${JSON.stringify({ type: 'error', line: 'Invalid regex pattern, using literal search' })}\n\n`); + // Fallback to literal search + const needle = grep.toLowerCase(); + filter = new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + } + } + + // Stream logs in real-time + dockerStream.on('data', (chunk: Buffer) => { + if (isControllerClosed) return; + + const lines = chunk.toString().split('\n').filter(line => line.trim()); + + for (const line of lines) { + if (isControllerClosed) break; + + // Apply filter if specified + if (filter && !filter.test(line)) continue; + + // Send the log line as an SSE event + safeEnqueue(`data: ${JSON.stringify({ type: 'log', line })}\n\n`); + } + }); + + // Handle stream end + dockerStream.on('end', () => { + if (!isControllerClosed) { + safeEnqueue(`data: ${JSON.stringify({ type: 'closed', code: 0 })}\n\n`); + cleanup(); + } + }); + + // Handle stream errors + dockerStream.on('error', (err: Error) => { + if (!isControllerClosed) { + console.error('Docker stream error:', err); + safeEnqueue(`data: ${JSON.stringify({ type: 'error', line: err.message })}\n\n`); + cleanup(); + } + }); + + } catch (e: any) { + if (!isControllerClosed) { + console.error('Error setting up log stream:', e); + safeEnqueue(`data: ${JSON.stringify({ type: 'error', line: e.message })}\n\n`); + cleanup(); + } + } + })(); + + // Handle client disconnect + url.searchParams.get('_disconnect') && cleanup(); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + } + }); +} diff --git a/apps/ground-station-web/src/routes/settings/+page.svelte b/apps/ground-station-web/src/routes/settings/+page.svelte new file mode 100644 index 0000000..2ade3b3 --- /dev/null +++ b/apps/ground-station-web/src/routes/settings/+page.svelte @@ -0,0 +1,103 @@ + + +Settings | RGS + +
+
+
+

Settings

+
LOCAL OPERATOR PREFERENCES
+
+ +
+ + {#each settings as group} +
+

{format(group.name)}

+
+ {#each group.settings as setting} + {@const Component = componentFor(setting.valueDescription)} +
+
+ {format(setting.name)} +

{setting.description ?? 'No description available'}

+
+
+
+ {/each} +
+
+ {/each} +
+ + diff --git a/apps/ground-station-web/src/routes/settings/components/BooleanSetting.svelte b/apps/ground-station-web/src/routes/settings/components/BooleanSetting.svelte new file mode 100644 index 0000000..e182218 --- /dev/null +++ b/apps/ground-station-web/src/routes/settings/components/BooleanSetting.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/ground-station-web/src/routes/settings/components/EnumSetting.svelte b/apps/ground-station-web/src/routes/settings/components/EnumSetting.svelte new file mode 100644 index 0000000..f4d6edd --- /dev/null +++ b/apps/ground-station-web/src/routes/settings/components/EnumSetting.svelte @@ -0,0 +1,25 @@ + + + diff --git a/apps/ground-station-web/src/routes/settings/components/NumberSetting.svelte b/apps/ground-station-web/src/routes/settings/components/NumberSetting.svelte new file mode 100644 index 0000000..b3a8216 --- /dev/null +++ b/apps/ground-station-web/src/routes/settings/components/NumberSetting.svelte @@ -0,0 +1,26 @@ + + + diff --git a/apps/ground-station-web/src/routes/settings/components/StringSetting.svelte b/apps/ground-station-web/src/routes/settings/components/StringSetting.svelte new file mode 100644 index 0000000..502b925 --- /dev/null +++ b/apps/ground-station-web/src/routes/settings/components/StringSetting.svelte @@ -0,0 +1,23 @@ + + + diff --git a/apps/ground-station-web/src/routes/settings/components/UnsupportedSetting.svelte b/apps/ground-station-web/src/routes/settings/components/UnsupportedSetting.svelte new file mode 100644 index 0000000..13332df --- /dev/null +++ b/apps/ground-station-web/src/routes/settings/components/UnsupportedSetting.svelte @@ -0,0 +1,14 @@ + + +
+ {setting.valueDescription.toUpperCase()} + {setting.description} (Not Implemented) +
diff --git a/apps/ground-station-web/src/routes/state/+page.svelte b/apps/ground-station-web/src/routes/state/+page.svelte new file mode 100644 index 0000000..9e50908 --- /dev/null +++ b/apps/ground-station-web/src/routes/state/+page.svelte @@ -0,0 +1,276 @@ + + + + Flight State + + +
+
+
+

Flight State

+
STATE MACHINE AND EVENT TRANSITIONS
+
+
+ {connected ? 'LIVE' : (error ?? 'CONNECTING')} +
+
+ +
+
+ CURRENT{items.find((item) => item.kind === 'State')?.value ?? 'NO STATE'} +
+
+ LATEST EVENT{items.find((item) => item.kind === 'Event')?.value ?? 'NO EVENT'} +
+
+ LAST UPDATE{items[0] ? formatTs(items[0].ts) : 'WAITING'} +
+
+ +
+
+

Transition Timeline

+
+ {#each ['All', 'State', 'Event'] as option} + + {/each} +
+
+ {#if visibleItems.length} +
    + {#each visibleItems as item, index (getUniqueKey(item, index))} +
  1. + + {item.kind} + {item.value} + + {formatDuration(item.duration_s)} +
  2. + {/each} +
+ {:else}
NO TRANSITIONS RECEIVED
{/if} +
+
+ + diff --git a/apps/ground-station-web/src/routes/state/api/+server.ts b/apps/ground-station-web/src/routes/state/api/+server.ts new file mode 100644 index 0000000..f08444a --- /dev/null +++ b/apps/ground-station-web/src/routes/state/api/+server.ts @@ -0,0 +1,160 @@ +import { getDbClient } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +type Row = { id: number; kind: 'State' | 'Event'; value: string; ts: number | null; duration_s: number | null }; + +// Central SSE state +const encoder = new TextEncoder(); +const activeControllers: Set = new Set(); +let pollIntervalId: NodeJS.Timeout | null = null; +let lastBroadcastStateId: number | null = null; +let lastBroadcastEventId: number | null = null; + +async function getLatest(kind: 'State' | 'Event'): Promise { + const db = getDbClient(); + // Use Phoenix* tables + const table = kind === 'State' ? 'PhoenixState' : 'PhoenixEvent'; + const column = kind === 'State' ? 'state' : 'event'; + const dataType = kind === 'State' ? 'PhoenixState' : 'PhoenixEvent'; + try { + const res = await db.execute({ + sql: `SELECT t.id AS id, t.${column} AS value, rf.timestamp_epoch AS ts + FROM ${table} t + JOIN RadioFrame rf ON rf.data_id = t.id AND rf.data_type = '${dataType}' + ORDER BY rf.timestamp_epoch DESC + LIMIT 1`, + args: [] + }); + if (res.rows.length) { + const r = res.rows[0]; + const tsNum = r.ts ? Number(r.ts) : null; + const now = Math.floor(Date.now() / 1000); + const duration = tsNum != null ? Math.max(0, now - tsNum) : null; + return { id: Number(r.id), kind, value: String(r.value), ts: tsNum, duration_s: duration }; + } + } catch (e) { + console.error(`${kind} API: join query failed, falling back`, e); + } + try { + const res2 = await db.execute({ + sql: `SELECT id, ${column} AS value FROM ${table} ORDER BY id DESC LIMIT 1`, + args: [] + }); + if (res2.rows.length) { + const r = res2.rows[0]; + return { id: Number(r.id), kind, value: String(r.value), ts: null, duration_s: null }; + } + } catch (e) { + console.error(`${kind} API: fallback latest query failed`, e); + } + return null; +} + +async function getSnapshot(limit = 50): Promise { + const db = getDbClient(); + try { + // Only transitions (changes) for state and event using window LAG over time + const stateRes = await db.execute({ + sql: `WITH ordered AS ( + SELECT ps.id AS id, + ps.state AS value, + rf.timestamp_epoch AS ts, + LAG(ps.state) OVER (ORDER BY rf.timestamp_epoch ASC) AS prev_value, + LEAD(rf.timestamp_epoch) OVER (ORDER BY rf.timestamp_epoch ASC) AS next_ts + FROM PhoenixState ps + JOIN RadioFrame rf ON rf.data_id = ps.id AND rf.data_type = 'PhoenixState' + ) + SELECT id, value, ts, + COALESCE(next_ts, strftime('%s','now')) - ts AS duration_s, + 'State' AS kind + FROM ordered + WHERE prev_value IS NULL OR value <> prev_value + ORDER BY ts DESC + LIMIT ?`, + args: [limit] + }); + const eventRes = await db.execute({ + sql: `WITH ordered AS ( + SELECT pe.id AS id, + pe.event AS value, + rf.timestamp_epoch AS ts, + LAG(pe.event) OVER (ORDER BY rf.timestamp_epoch ASC) AS prev_value, + LEAD(rf.timestamp_epoch) OVER (ORDER BY rf.timestamp_epoch ASC) AS next_ts + FROM PhoenixEvent pe + JOIN RadioFrame rf ON rf.data_id = pe.id AND rf.data_type = 'PhoenixEvent' + ) + SELECT id, value, ts, + COALESCE(next_ts, strftime('%s','now')) - ts AS duration_s, + 'Event' AS kind + FROM ordered + WHERE prev_value IS NULL OR value <> prev_value + ORDER BY ts DESC + LIMIT ?`, + args: [limit] + }); + const toRow = (r: any) => ({ id: Number(r.id), kind: r.kind as 'State' | 'Event', value: String(r.value), ts: r.ts ? Number(r.ts) : null, duration_s: r.duration_s != null ? Number(r.duration_s) : null }); + return [...stateRes.rows.map(toRow), ...eventRes.rows.map(toRow)] + .sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0)) + .slice(0, limit); + } catch (e) { + console.error('State API snapshot failed', e); + return []; + } +} + +function ensurePoller() { + if (pollIntervalId) return; + pollIntervalId = setInterval(async () => { + try { + const [s, e] = await Promise.all([getLatest('State'), getLatest('Event')]); + const newRows: Row[] = []; + if (s && (lastBroadcastStateId === null || s.id !== lastBroadcastStateId)) { + lastBroadcastStateId = s.id; + newRows.push(s); + } + if (e && (lastBroadcastEventId === null || e.id !== lastBroadcastEventId)) { + lastBroadcastEventId = e.id; + newRows.push(e); + } + if (newRows.length) { + newRows.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0)); + const payload = JSON.stringify(newRows); + const chunk = encoder.encode(`event: append\n` + `data: ${payload}\n\n`); + for (const c of Array.from(activeControllers)) { + try { c.enqueue(chunk); } catch { try { c.close(); } catch { } activeControllers.delete(c); } + } + } + } catch (e) { + // ignore single tick failures + } + }, 1000); +} + +export const GET: RequestHandler = async ({ request }) => { + const stream = new ReadableStream({ + start(controller) { + activeControllers.add(controller); + ensurePoller(); + // Initial snapshot for quick render + getSnapshot(50) + .then((rows) => controller.enqueue(encoder.encode(`event: snapshot\n` + `data: ${JSON.stringify(rows)}\n\n`))) + .catch(() => { }); + request.signal.addEventListener('abort', () => { + activeControllers.delete(controller); + try { controller.close(); } catch { } + if (activeControllers.size === 0 && pollIntervalId) { clearInterval(pollIntervalId); pollIntervalId = null; } + }); + }, + cancel() { + // best-effort cleanup + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); +}; diff --git a/apps/ground-station-web/src/routes/trajectory/+page.svelte b/apps/ground-station-web/src/routes/trajectory/+page.svelte new file mode 100644 index 0000000..b347dad --- /dev/null +++ b/apps/ground-station-web/src/routes/trajectory/+page.svelte @@ -0,0 +1,374 @@ + + +
+ +
+
+

Trajectory

+
+ + + {#if latest} + Lat: {latest.lat.toFixed(6)} + Lon: {latest.lon.toFixed(6)} + {#if latest.lat_acc != null}Lat σ: {latest.lat_acc.toFixed(2)}°{/if} + {#if latest.lon_acc != null}Lon σ: {latest.lon_acc.toFixed(2)}°{/if} + {#if latest.num_sv_used != null} + SV used: {latest.num_sv_used} + {/if} + {/if} +
+
+
+ +
+
+ + {#if connected} + + {:else} + + {/if} +
+
+ {#if error} + + {/if} +
+ +
+
+ + diff --git a/apps/ground-station-web/src/routes/trajectory/api/+server.ts b/apps/ground-station-web/src/routes/trajectory/api/+server.ts new file mode 100644 index 0000000..055d31e --- /dev/null +++ b/apps/ground-station-web/src/routes/trajectory/api/+server.ts @@ -0,0 +1,140 @@ +import { getDbClient } from '$lib/server/db'; +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const DEFAULT_CENTER = { lat: 47.986877, lon: -81.848765 }; +const DEFAULT_RADIUS_KM = 50; + +function computeBoundingBox(lat: number, lon: number, radiusKm: number) { + const latDelta = radiusKm / 111.32; // degrees + const cosLat = Math.cos((lat * Math.PI) / 180); + const lonDelta = radiusKm / (111.32 * Math.max(0.0001, cosLat)); + return { + minLat: lat - latDelta, + maxLat: lat + latDelta, + minLon: lon - lonDelta, + maxLon: lon + lonDelta + }; +} + +async function getLatest(lat: number, lon: number, radiusKm: number) { + const db = getDbClient(); + const { minLat, maxLat, minLon, maxLon } = computeBoundingBox(lat, lon, radiusKm); + const res = await db.execute({ + sql: + "SELECT rf.timestamp_epoch AS ts, p.latitude AS lat, p.longitude AS lon, p.latitude_accuracy AS lat_acc, p.longitude_accuracy AS lon_acc, p.num_sv_used AS num_sv_used FROM RadioFrame rf JOIN SbgGpsPos p ON rf.data_type = 'SbgGpsPos' AND rf.data_id = p.id WHERE p.latitude IS NOT NULL AND p.longitude IS NOT NULL AND p.latitude != 0 AND p.longitude != 0 AND p.latitude BETWEEN ? AND ? AND p.longitude BETWEEN ? AND ? ORDER BY rf.timestamp_epoch DESC LIMIT 1", + args: [minLat, maxLat, minLon, maxLon] + }); + const row: any = res.rows[0] ?? null; + if (!row) return null; + return { + ts: Number(row.ts), + lat: Number(row.lat), + lon: Number(row.lon), + lat_acc: row.lat_acc != null ? Number(row.lat_acc) : null, + lon_acc: row.lon_acc != null ? Number(row.lon_acc) : null, + num_sv_used: row.num_sv_used != null ? Number(row.num_sv_used) : null + }; +} + +async function getSeries(minutes: number, maxPoints: number, lat: number, lon: number, radiusKm: number) { + const db = getDbClient(); + const seconds = Math.max(1, Math.floor(minutes * 60)); + const targetPoints = Math.max(50, Math.floor(maxPoints)); + const bucketSizeSec = Math.max(1, Math.ceil(seconds / targetPoints)); + const { minLat, maxLat, minLon, maxLon } = computeBoundingBox(lat, lon, radiusKm); + + // Less distracting decimation: choose the last point within each time bucket + const res = await db.execute({ + sql: + "WITH cte AS (" + + " SELECT CAST(rf.timestamp_epoch / ? AS INTEGER) * ? AS bucket_ts, rf.timestamp_epoch AS ts, p.latitude AS lat, p.longitude AS lon" + + " FROM RadioFrame rf JOIN SbgGpsPos p ON rf.data_type = 'SbgGpsPos' AND rf.data_id = p.id" + + " WHERE rf.timestamp_epoch >= strftime('%s','now') - ?" + + " AND p.latitude IS NOT NULL AND p.longitude IS NOT NULL AND p.latitude != 0 AND p.longitude != 0" + + " AND p.latitude BETWEEN ? AND ? AND p.longitude BETWEEN ? AND ?" + + ") SELECT c1.bucket_ts AS bucket_ts, c1.lat AS lat, c1.lon AS lon" + + " FROM cte c1 JOIN (SELECT bucket_ts, MAX(ts) AS mts FROM cte GROUP BY bucket_ts) c2" + + " ON c1.bucket_ts = c2.bucket_ts AND c1.ts = c2.mts" + + " ORDER BY c1.bucket_ts ASC", + args: [bucketSizeSec, bucketSizeSec, seconds, minLat, maxLat, minLon, maxLon] + }); + + const points = (res.rows as any[]).map((r) => ({ + ts: Number((r as any).bucket_ts), + lat: Number((r as any).lat), + lon: Number((r as any).lon) + })); + return { points }; +} + +export const GET: RequestHandler = async ({ url, request }) => { + const minutes = Number(url.searchParams.get('minutes') || '240'); + const maxPoints = Number(url.searchParams.get('max_points') || '3000'); + const lat = Number(url.searchParams.get('lat') || String(DEFAULT_CENTER.lat)); + const lon = Number(url.searchParams.get('lon') || String(DEFAULT_CENTER.lon)); + const radiusKm = Number(url.searchParams.get('radius_km') || String(DEFAULT_RADIUS_KM)); + const useSSE = url.searchParams.get('sse') === '1'; + + if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radiusKm)) { + throw error(400, 'Invalid lat/lon/radius'); + } + + if (useSSE) { + let cancelled = false; + let intervalId: any; + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + function sendEvent(event: string, payload: unknown) { + if (cancelled) return; + const data = typeof payload === 'string' ? payload : JSON.stringify(payload); + const chunk = `event: ${event}\n` + `data: ${data}\n\n`; + controller.enqueue(encoder.encode(chunk)); + } + const push = async () => { + if (cancelled) return; + try { + const [latest, series] = await Promise.all([ + getLatest(lat, lon, radiusKm), + getSeries(minutes, maxPoints, lat, lon, radiusKm) + ]); + sendEvent('trajectory', { latest, series, center: { lat, lon }, radius_km: radiusKm }); + } catch (e) { + console.error('Trajectory SSE push error:', e); + sendEvent('error', { message: 'Failed to fetch trajectory data' }); + } + }; + push(); + intervalId = setInterval(push, 5000); + request.signal.addEventListener('abort', () => { + cancelled = true; + clearInterval(intervalId); + }); + }, + cancel() { + cancelled = true; + if (intervalId) clearInterval(intervalId); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + } + }); + } + + try { + const [latest, series] = await Promise.all([ + getLatest(lat, lon, radiusKm), + getSeries(minutes, maxPoints, lat, lon, radiusKm) + ]); + return json({ latest, series, center: { lat, lon }, radius_km: radiusKm }); + } catch (e: any) { + console.error('Trajectory API GET error:', e); + throw error(500, `Failed to fetch trajectory data: ${e.message}`); + } +}; diff --git a/apps/ground-station-web/static/.well-known/appspecific/com.chrome.devtools.json b/apps/ground-station-web/static/.well-known/appspecific/com.chrome.devtools.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/apps/ground-station-web/static/.well-known/appspecific/com.chrome.devtools.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/apps/ground-station-web/static/android-chrome-192x192.png b/apps/ground-station-web/static/android-chrome-192x192.png new file mode 100644 index 0000000..b819147 Binary files /dev/null and b/apps/ground-station-web/static/android-chrome-192x192.png differ diff --git a/apps/ground-station-web/static/android-chrome-512x512.png b/apps/ground-station-web/static/android-chrome-512x512.png new file mode 100644 index 0000000..88e032c Binary files /dev/null and b/apps/ground-station-web/static/android-chrome-512x512.png differ diff --git a/apps/ground-station-web/static/apple-touch-icon.png b/apps/ground-station-web/static/apple-touch-icon.png new file mode 100644 index 0000000..167754d Binary files /dev/null and b/apps/ground-station-web/static/apple-touch-icon.png differ diff --git a/apps/ground-station-web/static/favicon-16x16.png b/apps/ground-station-web/static/favicon-16x16.png new file mode 100644 index 0000000..9c1f475 Binary files /dev/null and b/apps/ground-station-web/static/favicon-16x16.png differ diff --git a/apps/ground-station-web/static/favicon-32x32.png b/apps/ground-station-web/static/favicon-32x32.png new file mode 100644 index 0000000..b7d7a9a Binary files /dev/null and b/apps/ground-station-web/static/favicon-32x32.png differ diff --git a/apps/ground-station-web/static/favicon.ico b/apps/ground-station-web/static/favicon.ico new file mode 100644 index 0000000..a558da0 Binary files /dev/null and b/apps/ground-station-web/static/favicon.ico differ diff --git a/apps/ground-station-web/static/icon_bg.png b/apps/ground-station-web/static/icon_bg.png new file mode 100644 index 0000000..bdaf015 Binary files /dev/null and b/apps/ground-station-web/static/icon_bg.png differ diff --git a/apps/ground-station-web/static/models/rocket.glb b/apps/ground-station-web/static/models/rocket.glb new file mode 100644 index 0000000..537f77a Binary files /dev/null and b/apps/ground-station-web/static/models/rocket.glb differ diff --git a/apps/ground-station-web/static/site.webmanifest b/apps/ground-station-web/static/site.webmanifest new file mode 100644 index 0000000..52a2fe3 --- /dev/null +++ b/apps/ground-station-web/static/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/apps/ground-station-web/static/textures/cubemap/road.hdr b/apps/ground-station-web/static/textures/cubemap/road.hdr new file mode 100644 index 0000000..22f7127 Binary files /dev/null and b/apps/ground-station-web/static/textures/cubemap/road.hdr differ diff --git a/apps/ground-station-web/static/textures/navball.png b/apps/ground-station-web/static/textures/navball.png new file mode 100644 index 0000000..cde44ed Binary files /dev/null and b/apps/ground-station-web/static/textures/navball.png differ diff --git a/apps/ground-station-web/svelte.config.js b/apps/ground-station-web/svelte.config.js new file mode 100644 index 0000000..014d36e --- /dev/null +++ b/apps/ground-station-web/svelte.config.js @@ -0,0 +1,15 @@ +import adapter from 'svelte-adapter-bun'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + }, + kit: { + adapter: adapter(), + csrf: { + trustedOrigins: ['http://localhost:5173', 'http://localhost:3000', 'wails://gui'] + } + } +}; + +export default config; diff --git a/apps/ground-station-web/tsconfig.json b/apps/ground-station-web/tsconfig.json new file mode 100644 index 0000000..e5e7927 --- /dev/null +++ b/apps/ground-station-web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + } +} \ No newline at end of file diff --git a/apps/ground-station-web/vite.config.ts b/apps/ground-station-web/vite.config.ts new file mode 100644 index 0000000..c6ac539 --- /dev/null +++ b/apps/ground-station-web/vite.config.ts @@ -0,0 +1,43 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { loadEnv } from 'vite'; +// import { viteStaticCopy } from 'vite-plugin-static-copy'; +import { defineConfig } from 'vitest/config'; +import tailwindcss from '@tailwindcss/vite'; +// const cesiumSource = './node_modules/cesium/Build/Cesium'; +// const cesiumBaseUrl = 'cesium'; + +const config = (mode: string) => { + // Make environment variables available from .env available + process.env = { ...process.env, ...loadEnv(mode, '.', '') }; + return defineConfig({ + test: { + dir: './src', + include: ['**/*.test.ts'] + }, + + server: { + port: parseInt(process.env['WEB_SERVER_PORT'] ?? '') || 3000, + host: '0.0.0.0', + strictPort: false, + allowedHosts: true + }, + + plugins: [ + sveltekit(), + tailwindcss() + // viteStaticCopy({ + // targets: [ + // { src: `${cesiumSource}/ThirdParty`, dest: cesiumBaseUrl }, + // { src: `${cesiumSource}/Workers`, dest: cesiumBaseUrl }, + // { src: `${cesiumSource}/Assets`, dest: cesiumBaseUrl }, + // { src: `${cesiumSource}/Widgets`, dest: cesiumBaseUrl } + // ] + // }) + ], + ssr: { + noExternal: ['three'] + } + }); +}; + +export default (mode: string) => config(mode); diff --git a/apps/sergw/Cargo.toml b/apps/sergw/Cargo.toml index 250cf93..d109c11 100644 --- a/apps/sergw/Cargo.toml +++ b/apps/sergw/Cargo.toml @@ -7,6 +7,6 @@ publish.workspace = true license.workspace = true [dependencies] -clap = { version = "4.5.16", features = ["derive"] } +clap = { workspace = true } ctrlc = "3.4" serialport = "4" diff --git a/apps/telemetry-ingestor/Cargo.toml b/apps/telemetry-ingestor/Cargo.toml new file mode 100644 index 0000000..b8d0c74 --- /dev/null +++ b/apps/telemetry-ingestor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "telemetry-ingestor" +version.workspace = true +authors.workspace = true +publish.workspace = true +license.workspace = true +edition = "2021" + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true, features = ["std"] } +serde_json = { workspace = true } +messages-prost = { workspace = true } +mavlink = { workspace = true } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +prost = { workspace = true } +libsql = { workspace = true } +hostname = { workspace = true } +chrono = { workspace = true, features = ["clock", "serde", "std"] } diff --git a/apps/telemetry-ingestor/README.md b/apps/telemetry-ingestor/README.md new file mode 100644 index 0000000..c855d8c --- /dev/null +++ b/apps/telemetry-ingestor/README.md @@ -0,0 +1,50 @@ +# Telemetry Ingestor + +A service that ingests telemetry data (MAVLink messages, specifically `POSTCARD_MESSAGE` and `RADIO_STATUS`), processes it, and stores it in a LibSQL database. It includes features like batch processing for efficiency and a heartbeat mechanism for service monitoring. + +## Features + +- Decodes MAVLink messages (specifically `POSTCARD_MESSAGE` and `RADIO_STATUS`) +- Stores telemetry data in a LibSQL database. +- Batch processes messages for improved database write performance. +- Monitors service health with an automatic heartbeat to the database. + +## Prerequisites + +- LibSQL server up and running +- Network access to the MAVLink message source (e.g., SerGW) + +## Usage + +### Running the Ingestor + +```sh +cargo run -- [options] +# Or directly if built: +# ./target/debug/telemetry-ingestor [options] +``` + +### Parameters + +| Parameter | Description | Default | +|-----------------------------|--------------------------------------------------------------------------|----------------------------| +| `--libsql-url` | LibSQL server URL | http://localhost:8080 | +| `--gateway-connection-string` | Gateway MAVLink connection string (e.g., `tcpout:localhost:5656`) | `tcpout:127.0.0.1:5656` | + +## Operational Details + +- Messages are batched for database insertion, with a batch size of 100 messages or a 500ms timeout. +- A heartbeat is sent to the database every 30 seconds. +- Currently processes `POSTCARD_MESSAGE` (saving content) and logs `RADIO_STATUS` (saving not yet implemented). +- Tracks MAVLink packet sequence numbers and logs detected packet loss. + +## Troubleshooting + +1. **Database Issues** + - Verify LibSQL server is running and accessible. + - Check that the `--libsql-url` parameter is correct. + +2. **Connection Issues** + - Verify the MAVLink source (e.g., SerGW) is running and accessible. + - Check that `--gateway-connection-string` parameter is correct. + - Ensure network connectivity between the ingestor and the MAVLink source. diff --git a/apps/telemetry-ingestor/src/connection_manager.rs b/apps/telemetry-ingestor/src/connection_manager.rs new file mode 100644 index 0000000..e14b7ba --- /dev/null +++ b/apps/telemetry-ingestor/src/connection_manager.rs @@ -0,0 +1,34 @@ +use libsql::{Builder, Connection}; +use mavlink::uorocketry::MavMessage; +use mavlink::MavConnection; +use tracing::{error, info}; + +pub async fn connect_to_database(db_url: String) -> Result> { + info!("Attempting to connect to database: {}", db_url); + let db = Builder::new_remote(db_url.clone(), "".to_string()).build().await?; + + match db.connect() { + Ok(connection) => { + info!("Database connection established successfully"); + Ok(connection) + } + Err(e) => { + error!("Failed to connect to database, is it running? REASON: {:?}", e); + Err(e.into()) + } + } +} + +pub async fn connect_to_mavlink(connection_string: &str) -> Result + Send + Sync>, Box> { + info!("Attempting to connect to MAVLink using string: {}", connection_string); + match mavlink::connect::(connection_string) { + Ok(connection) => { + info!("Successfully connected to MAVLink"); + Ok(connection) + } + Err(error) => { + error!("Failed to connect to MAVLink: {}", error); + Err(error.into()) + } + } +} diff --git a/apps/telemetry-ingestor/src/heartbeat.rs b/apps/telemetry-ingestor/src/heartbeat.rs new file mode 100644 index 0000000..1420845 --- /dev/null +++ b/apps/telemetry-ingestor/src/heartbeat.rs @@ -0,0 +1,41 @@ +use std::time::{Duration, SystemTime}; + +use libsql::Builder; +use tracing::{info, warn}; + +pub async fn run_heartbeat_task(db_url: String) -> Result<(), Box> { + let hostname = hostname::get() + .map_err(|e| Box::new(e) as Box)? + .into_string() + .map_err(|_| Box::from("Failed to convert OsString to String") as Box)?; + + info!("Heartbeat task started. Will ping DB at {} every 30s. Hostname: {}", db_url, hostname); + + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + + let db_result = async { + let builder = Builder::new_remote(db_url.clone(), "".to_string()); + let db = builder.build().await?; + let conn = db.connect()?; + + let app_timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + + let owned_hostname = hostname.clone(); + + info!("Sending heartbeat ping for service 'telemetry-ingestor'"); + conn.execute( + "INSERT INTO ServicePing (service_id, hostname, app_timestamp) VALUES (?1, ?2, ?3)", + libsql::params!["telemetry-ingestor", owned_hostname, app_timestamp as i64], + ) + .await?; + + Ok::<(), Box>(()) + } + .await; + + if let Err(e) = db_result { + warn!("Failed to send heartbeat ping: {}", e); + } + } +} diff --git a/apps/telemetry-ingestor/src/main.rs b/apps/telemetry-ingestor/src/main.rs new file mode 100644 index 0000000..ff9ac00 --- /dev/null +++ b/apps/telemetry-ingestor/src/main.rs @@ -0,0 +1,62 @@ +mod connection_manager; +mod heartbeat; +mod message_handler; +mod savers; + +use std::time::Duration; + +use clap::Parser; +use tracing::error; +use tracing::Level; + +#[derive(Parser, Debug, Clone)] +#[command(version, about, long_about = None)] +struct Args { + // database url + #[arg(long, default_value = "http://localhost:8080")] + libsql_url: String, + + // Renamed mavlink_connection_string to gateway_connection_string + #[arg( + long, + default_value = "tcpout:127.0.0.1:5656", + // Renamed parameter in help text + help = "Gateway MAVLink connection string (e.g., tcpout:localhost:5656, udpin:0.0.0.0:14550)" + )] + gateway_connection_string: String, // Renamed field +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt().with_max_level(Level::INFO).init(); + + let args = Args::parse(); + + // Start heartbeat task - Pass needed args (libsql_url is needed, maybe others) + // Cloning args here if heartbeat needs more than just libsql_url in the future + let args_for_heartbeat = args.clone(); + tokio::spawn(async move { + if let Err(e) = heartbeat::run_heartbeat_task(args_for_heartbeat.libsql_url).await { + error!("Heartbeat task failed: {:?}", e); + } + }); + + // Establish database connection (retry handled internally by libsql if needed) + let db_connection = connection_manager::connect_to_database(args.libsql_url).await?; + + // Keep running even if the gateway (e.g., `sergw`) is absent. Retry connect and, if the + // message loop exits, attempt to reconnect after a short delay. + loop { + match connection_manager::connect_to_mavlink(&args.gateway_connection_string).await { + Ok(mavlink_connection) => { + if let Err(e) = message_handler::handle_messages(mavlink_connection, db_connection.clone()).await { + error!("Message handler exited: {:?}", e); + } + } + Err(e) => { + error!("Failed to connect to MAVLink: {:?}", e); + } + } + tokio::time::sleep(Duration::from_secs(5)).await; + } +} diff --git a/apps/telemetry-ingestor/src/message_handler.rs b/apps/telemetry-ingestor/src/message_handler.rs new file mode 100644 index 0000000..184be95 --- /dev/null +++ b/apps/telemetry-ingestor/src/message_handler.rs @@ -0,0 +1,108 @@ +// Messages are decoded in batch; no need to parse them here +use std::time::{Duration, Instant}; + +use chrono::Utc; +use libsql::Connection; +use mavlink::uorocketry::MavMessage; +use mavlink::MavConnection; +use tracing::{error, info, warn}; + +use crate::savers; + +const BATCH_SIZE: usize = 100; +const BATCH_TIMEOUT: Duration = Duration::from_millis(500); + +pub async fn handle_messages( + mavlink_connection: Box + Send + Sync>, + db_connection: Connection, +) -> Result<(), Box> { + info!("Getting Messages..."); + let mut last_seq_num = 0; + let mut message_buffer: Vec> = Vec::with_capacity(BATCH_SIZE); + let mut last_batch_time = Instant::now(); + + loop { + let db_conn_for_batch = db_connection.clone(); + let recv_result = mavlink_connection.recv(); + + // info!("recv_result: {:?}", recv_result); + + match recv_result { + Ok((header, message)) => { + // info!("Received message: {:?}", header.sequence); + let packets_lost = ((header.sequence as i32) - (last_seq_num as i32) - 1).rem_euclid(256); + last_seq_num = header.sequence; + + if packets_lost > 0 { + warn!("Packets Lost: {}", packets_lost); + let ts = Utc::now().timestamp(); + let conn_clone = db_connection.clone(); + tokio::spawn(async move { + if let Err(e) = savers::radio::save_radio_metrics(&conn_clone, ts, None, Some(packets_lost as i64)).await { + error!("Failed to save packet loss metric: {:?}", e); + } + }); + } + + match message { + MavMessage::POSTCARD_MESSAGE(data) => { + // Buffer POSTCARD messages for decoding in batch + message_buffer.push(data.message.to_vec()); + } + MavMessage::RADIO_STATUS(data) => { + info!("Received radio status: {:?}", data); + let rssi_val = data.rssi as i64; + let ts = Utc::now().timestamp(); + let conn_clone = db_connection.clone(); + tokio::spawn(async move { + if let Err(e) = savers::radio::save_radio_metrics(&conn_clone, ts, Some(rssi_val), None).await { + error!("Failed to save radio status: {:?}", e); + } + }); + } + other => { + error!("Received an unexpected message type {:?}", other); + continue; + } + }; + } + Err(e) => match e { + mavlink::error::MessageReadError::Io(io_err) => match io_err.kind() { + std::io::ErrorKind::WouldBlock => {} + std::io::ErrorKind::UnexpectedEof => { + error!("Connection closed unexpectedly (EOF). Shutting down receiver loop."); + break; + } + _ => { + error!("Mavlink IO Error receiving message: {:?}", io_err); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + }, + e => { + error!("Mavlink Non-IO Error receiving message: {:?}", e); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + }, + } + + let should_save_batch = !message_buffer.is_empty() && (message_buffer.len() >= BATCH_SIZE || last_batch_time.elapsed() >= BATCH_TIMEOUT); + + if should_save_batch { + info!("Saving batch"); + let messages_to_save = std::mem::take(&mut message_buffer); + last_batch_time = Instant::now(); + + tokio::spawn(async move { + match savers::message::save_messages_batch(&db_conn_for_batch, messages_to_save).await { + Ok(_) => info!("Batch saved successfully."), + Err(e) => error!("Failed to save batch: {:?}", e), + } + }); + message_buffer.reserve(BATCH_SIZE); + } + } + + Ok(()) +} diff --git a/apps/telemetry-ingestor/src/savers/argus.rs b/apps/telemetry-ingestor/src/savers/argus.rs new file mode 100644 index 0000000..e512a09 --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/argus.rs @@ -0,0 +1,41 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::argus::{Pressure, Strain, Temperature}; + +pub async fn save_argus_pressure( + transaction: &Transaction, + m: &Pressure, +) -> Result { + transaction + .execute( + "INSERT INTO ArgusPressure (pressure, sensor_id) VALUES (?, ?)", + params![m.pressure, m.sensor_id], + ) + .await?; + Ok(transaction.last_insert_rowid()) +} + +pub async fn save_argus_temperature( + transaction: &Transaction, + m: &Temperature, +) -> Result { + transaction + .execute( + "INSERT INTO ArgusTemperature (temperature, sensor_id) VALUES (?, ?)", + params![m.temperature, m.sensor_id], + ) + .await?; + Ok(transaction.last_insert_rowid()) +} + +pub async fn save_argus_strain( + transaction: &Transaction, + m: &Strain, +) -> Result { + transaction + .execute( + "INSERT INTO ArgusStrain (strain, sensor_id) VALUES (?, ?)", + params![m.strain, m.sensor_id], + ) + .await?; + Ok(transaction.last_insert_rowid()) +} diff --git a/apps/telemetry-ingestor/src/savers/barometer.rs b/apps/telemetry-ingestor/src/savers/barometer.rs new file mode 100644 index 0000000..b2107a0 --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/barometer.rs @@ -0,0 +1,15 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::sensor::ms5611::Barometer as Ms5611Barometer; + +pub async fn save_barometer( + transaction: &Transaction, + baro: &Ms5611Barometer, +) -> Result { + transaction + .execute( + "INSERT INTO Barometer (pressure_kpa, temperature_celsius) VALUES (?, ?)", + params![baro.pressure_kpa, baro.temperature_celsius], + ) + .await?; + Ok(transaction.last_insert_rowid()) +} diff --git a/apps/telemetry-ingestor/src/savers/command.rs b/apps/telemetry-ingestor/src/savers/command.rs new file mode 100644 index 0000000..0d4e691 --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/command.rs @@ -0,0 +1,107 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::command::{self as cmd}; + +fn node_to_string(node: i32) -> Result { + let name = messages_prost::common::Node::try_from(node) + .map(|n| format!("{:?}", n)) + .unwrap_or_else(|_| "Unspecified".to_string()); + Ok(name) +} + +fn radio_rate_to_string(rate: i32) -> &'static str { + if rate == cmd::RadioRate::RateLow as i32 { + "RateLow" + } else if rate == cmd::RadioRate::RateMedium as i32 { + "RateMedium" + } else if rate == cmd::RadioRate::RateHigh as i32 { + "RateHigh" + } else { + "RateLow" + } +} + +pub async fn save_command( + transaction: &Transaction, + command: &cmd::Command, +) -> Result { + let data_type = match command.data.as_ref() { + Some(cmd::command::Data::DeployDrogue(_)) => "DeployDrogue", + Some(cmd::command::Data::DeployMain(_)) => "DeployMain", + Some(cmd::command::Data::PowerDown(_)) => "PowerDown", + Some(cmd::command::Data::PowerUpCamera(_)) => "PowerUpCamera", + Some(cmd::command::Data::PowerDownCamera(_)) => "PowerDownCamera", + Some(cmd::command::Data::RadioRateChange(_)) => "RadioRateChange", + Some(cmd::command::Data::Online(_)) => "Online", + Some(cmd::command::Data::Ping(_)) => "Ping", + Some(cmd::command::Data::Pong(_)) => "Pong", + None => "Unknown", + }; + + transaction + .execute( + "INSERT INTO Command (data_type, data_id) VALUES (?, ?)", + params![data_type, 0], // Placeholder for data_id + ) + .await?; + let command_id = transaction.last_insert_rowid(); + + // Save the specific Command subtype and get its ID + let data_id: i64 = match command.data.as_ref().unwrap() { + cmd::command::Data::DeployDrogue(deploy) => { + transaction + .execute("INSERT INTO DeployDrogue (val) VALUES (?)", params![deploy.val]) + .await?; + transaction.last_insert_rowid() + } + cmd::command::Data::DeployMain(deploy) => { + transaction + .execute("INSERT INTO DeployMain (val) VALUES (?)", params![deploy.val]) + .await?; + transaction.last_insert_rowid() + } + cmd::command::Data::PowerDown(power_down) => { + transaction + .execute("INSERT INTO PowerDown (board) VALUES (?)", params![node_to_string(power_down.board)?]) + .await?; + transaction.last_insert_rowid() + } + cmd::command::Data::PowerUpCamera(_) => { + transaction.execute("INSERT INTO PowerUpCamera DEFAULT VALUES", params![]).await?; + transaction.last_insert_rowid() + } + cmd::command::Data::PowerDownCamera(_) => { + transaction.execute("INSERT INTO PowerDownCamera DEFAULT VALUES", params![]).await?; + transaction.last_insert_rowid() + } + cmd::command::Data::RadioRateChange(rate_change) => { + transaction + .execute( + "INSERT INTO RadioRateChange (rate) VALUES (?)", + params![radio_rate_to_string(rate_change.rate)], + ) + .await?; + transaction.last_insert_rowid() + } + cmd::command::Data::Online(online) => { + transaction + .execute("INSERT INTO Online (online) VALUES (?)", params![online.online]) + .await?; + transaction.last_insert_rowid() + } + cmd::command::Data::Ping(ping) => { + transaction.execute("INSERT INTO Ping (ping_id) VALUES (?)", params![ping.id]).await?; + transaction.last_insert_rowid() + } + cmd::command::Data::Pong(pong) => { + transaction.execute("INSERT INTO Pong (pong_id) VALUES (?)", params![pong.id]).await?; + transaction.last_insert_rowid() + } + }; + + // Update Command with the actual data_id + transaction + .execute("UPDATE Command SET data_id = ? WHERE id = ?", params![data_id, command_id]) + .await?; + + Ok(command_id) +} diff --git a/apps/telemetry-ingestor/src/savers/event.rs b/apps/telemetry-ingestor/src/savers/event.rs new file mode 100644 index 0000000..8531c8c --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/event.rs @@ -0,0 +1,28 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::{argus_state, phoenix_state}; + +pub async fn save_phoenix_event( + transaction: &Transaction, + event_value: i32, +) -> Result { + let event_str = phoenix_state::Event::try_from(event_value) + .map(|e| format!("{:?}", e)) + .unwrap_or_else(|_| "UNKNOWN".to_string()); + transaction + .execute("INSERT INTO PhoenixEvent (event) VALUES (?)", params![event_str]) + .await?; + Ok(transaction.last_insert_rowid()) +} + +pub async fn save_argus_event( + transaction: &Transaction, + event_value: i32, +) -> Result { + let event_str = argus_state::Event::try_from(event_value) + .map(|e| format!("{:?}", e)) + .unwrap_or_else(|_| "UNKNOWN".to_string()); + transaction + .execute("INSERT INTO ArgusEvent (event) VALUES (?)", params![event_str]) + .await?; + Ok(transaction.last_insert_rowid()) +} diff --git a/apps/telemetry-ingestor/src/savers/imu.rs b/apps/telemetry-ingestor/src/savers/imu.rs new file mode 100644 index 0000000..83bfacf --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/imu.rs @@ -0,0 +1,44 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::iim20670::Imu; + +pub async fn save_imu( + transaction: &Transaction, + imu: &Imu, +) -> Result { + if let Some(data) = &imu.data { + transaction + .execute( + "INSERT INTO Iim20670Imu ( + timestamp_us, + gyro_x, gyro_y, gyro_z, + accel_x, accel_y, accel_z, + temperature_1, temperature_2, + accel_fs, gyro_fs, spi_status, + low_res_accel_x, low_res_accel_y, low_res_accel_z + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + data.timestamp_us as i64, + data.gyroscope.as_ref().map(|v| v.x), + data.gyroscope.as_ref().map(|v| v.y), + data.gyroscope.as_ref().map(|v| v.z), + data.accelerometer.as_ref().map(|v| v.x), + data.accelerometer.as_ref().map(|v| v.y), + data.accelerometer.as_ref().map(|v| v.z), + data.temperature_1, + data.temperature_2, + data.accelerometer_fs, + data.gyroscope_fs, + data.spi_status, + data.low_resolution_accelerometer.as_ref().map(|v| v.x), + data.low_resolution_accelerometer.as_ref().map(|v| v.y), + data.low_resolution_accelerometer.as_ref().map(|v| v.z), + ], + ) + .await?; + } else { + transaction + .execute("INSERT INTO Iim20670Imu (timestamp_us) VALUES (?)", params![0]) + .await?; + } + Ok(transaction.last_insert_rowid()) +} diff --git a/apps/telemetry-ingestor/src/savers/log.rs b/apps/telemetry-ingestor/src/savers/log.rs new file mode 100644 index 0000000..ba02d0e --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/log.rs @@ -0,0 +1,17 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::log::Log; + +pub async fn save_log( + transaction: &Transaction, + log_msg: &Log, +) -> Result { + let level = format!("{:?}", log_msg.level); + let event = format!("{:?}", log_msg.event); + transaction + .execute( + "INSERT INTO Log (level, event, message) VALUES (?, ?, ?)", + params![level, event, log_msg.message.as_slice()], + ) + .await?; + Ok(transaction.last_insert_rowid()) +} diff --git a/apps/telemetry-ingestor/src/savers/madgwick.rs b/apps/telemetry-ingestor/src/savers/madgwick.rs new file mode 100644 index 0000000..b89afb8 --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/madgwick.rs @@ -0,0 +1,24 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::madgwick::Madgwick; + +pub async fn save_madgwick( + transaction: &Transaction, + madgwick: &Madgwick, +) -> Result { + if let Some(quat) = &madgwick.data { + transaction + .execute( + "INSERT INTO Madgwick (quat_w, quat_x, quat_y, quat_z) VALUES (?, ?, ?, ?)", + params![quat.w, quat.x, quat.y, quat.z], + ) + .await?; + } else { + transaction + .execute( + "INSERT INTO Madgwick (quat_w, quat_x, quat_y, quat_z) VALUES (NULL, NULL, NULL, NULL)", + params![], + ) + .await?; + } + Ok(transaction.last_insert_rowid()) +} diff --git a/apps/telemetry-ingestor/src/savers/message.rs b/apps/telemetry-ingestor/src/savers/message.rs new file mode 100644 index 0000000..94d7ecb --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/message.rs @@ -0,0 +1,142 @@ +use chrono::Utc; +use libsql::{params, Connection, Result, Transaction}; +use messages_prost::{ + common::Node, + radio::{self, RadioFrame}, + sbg::sbg_data, +}; +use prost::Message as _; + +use super::{ + argus::{save_argus_pressure, save_argus_strain, save_argus_temperature}, + barometer::save_barometer, + command::save_command, + event::{save_argus_event, save_phoenix_event}, + imu::save_imu, + log::save_log, + madgwick::save_madgwick, + sbg::save_sbg, + state::{save_argus_state, save_phoenix_state}, +}; + +async fn insert_radio_message( + transaction: &Transaction, + node: i32, + data_type: &str, + data_id: i64, + millis_since_start: Option, +) -> Result { + let time_str = Utc::now().to_rfc3339(); + let time_epoch = Utc::now().timestamp(); + let node_name = Node::try_from(node) + .map(|n| format!("{:?}", n)) + .unwrap_or_else(|_| "Unspecified".to_string()); + + transaction + .execute( + "INSERT INTO RadioFrame (timestamp, timestamp_epoch, node, data_type, data_id, millis_since_start) VALUES (?, ?, ?, ?, ?, ?)", + params![time_str, time_epoch, node_name, data_type, data_id, millis_since_start], + ) + .await?; + Ok(transaction.last_insert_rowid()) +} + +pub async fn save_messages_batch( + db_connection: &Connection, + message_bytes_list: Vec>, +) -> Result<()> { + if message_bytes_list.is_empty() { + return Ok(()); + } + + let transaction = db_connection.transaction().await?; + tracing::info!("Starting batch save for {} messages", message_bytes_list.len()); + + for message_bytes in message_bytes_list.iter() { + match RadioFrame::decode_length_delimited(&message_bytes[..]) { + Ok(frame) => { + let node = frame.node; + let millis = Some(frame.millis_since_start as i64); + match frame.payload { + Some(radio::radio_frame::Payload::Barometer(m)) => { + let data_id = save_barometer(&transaction, &m).await?; + insert_radio_message(&transaction, node, "Barometer", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::Sbg(sbg)) => { + if let Some(inner) = &sbg.data { + let data_type = match inner { + sbg_data::Data::GpsPos(_) => "SbgGpsPos", + sbg_data::Data::UtcTime(_) => "SbgUtcTime", + sbg_data::Data::Imu(_) => "SbgImu", + sbg_data::Data::EkfQuat(_) => "SbgEkfQuat", + sbg_data::Data::EkfNav(_) => "SbgEkfNav", + sbg_data::Data::GpsVel(_) => "SbgGpsVel", + sbg_data::Data::Air(_) => "SbgAir", + }; + let data_id = save_sbg(&transaction, &sbg).await?; + insert_radio_message(&transaction, node, data_type, data_id, millis).await?; + } + } + // Intentionally ignored. Prefer SBG GpsPos/GpsVel. + Some(radio::radio_frame::Payload::Gps(_gps_data)) => { + tracing::warn!("Generic GPS payload handler removed. Prefer SBG GpsPos/GpsVel."); + } + Some(radio::radio_frame::Payload::Madgwick(m)) => { + let data_id = save_madgwick(&transaction, &m).await?; + insert_radio_message(&transaction, node, "Madgwick", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::Iim20670(m)) => { + let data_id = save_imu(&transaction, &m).await?; + insert_radio_message(&transaction, node, "Imu", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::Log(m)) => { + let data_id = save_log(&transaction, &m).await?; + insert_radio_message(&transaction, node, "Log", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::PhoenixState(s)) => { + let data_id = save_phoenix_state(&transaction, s).await?; + insert_radio_message(&transaction, node, "PhoenixState", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::PhoenixEvent(e)) => { + let data_id = save_phoenix_event(&transaction, e).await?; + insert_radio_message(&transaction, node, "PhoenixEvent", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::ArgusState(s)) => { + let data_id = save_argus_state(&transaction, s).await?; + insert_radio_message(&transaction, node, "ArgusState", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::ArgusEvent(e)) => { + let data_id = save_argus_event(&transaction, e).await?; + insert_radio_message(&transaction, node, "ArgusEvent", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::ArgusPressure(m)) => { + let data_id = save_argus_pressure(&transaction, &m).await?; + insert_radio_message(&transaction, node, "ArgusPressure", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::ArgusTemperature(m)) => { + let data_id = save_argus_temperature(&transaction, &m).await?; + insert_radio_message(&transaction, node, "ArgusTemperature", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::ArgusStrain(m)) => { + let data_id = save_argus_strain(&transaction, &m).await?; + insert_radio_message(&transaction, node, "ArgusStrain", data_id, millis).await?; + } + Some(radio::radio_frame::Payload::Command(m)) => { + let data_id = save_command(&transaction, &m).await?; + insert_radio_message(&transaction, node, "Command", data_id, millis).await?; + } + None => { + tracing::error!("RadioFrame had no payload. Skipping."); + } + } + } + Err(_) => { + tracing::error!("Failed to decode RadioFrame. Skipping buffer."); + } + } + } + + tracing::info!("Committing batch save"); + transaction.commit().await?; + Ok(()) +} diff --git a/apps/telemetry-ingestor/src/savers/mod.rs b/apps/telemetry-ingestor/src/savers/mod.rs new file mode 100644 index 0000000..9396b41 --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/mod.rs @@ -0,0 +1,11 @@ +pub mod argus; +pub mod barometer; +pub mod command; +pub mod event; +pub mod imu; +pub mod log; +pub mod madgwick; +pub mod message; +pub mod radio; +pub mod sbg; +pub mod state; diff --git a/apps/telemetry-ingestor/src/savers/radio.rs b/apps/telemetry-ingestor/src/savers/radio.rs new file mode 100644 index 0000000..8701190 --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/radio.rs @@ -0,0 +1,15 @@ +use libsql::{params, Connection, Result}; + +pub async fn save_radio_metrics( + conn: &Connection, + timestamp: i64, + rssi: Option, + packets_lost: Option, +) -> Result<()> { + conn.execute( + "INSERT INTO RadioMetrics (timestamp, rssi, packets_lost) VALUES (?, ?, ?)", + params![timestamp, rssi, packets_lost], + ) + .await?; + Ok(()) +} diff --git a/apps/telemetry-ingestor/src/savers/sbg.rs b/apps/telemetry-ingestor/src/savers/sbg.rs new file mode 100644 index 0000000..ccfd29d --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/sbg.rs @@ -0,0 +1,182 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::sbg::{sbg_data, SbgData}; + +pub async fn save_sbg( + transaction: &Transaction, + sbg: &SbgData, +) -> Result { + let data_id: i64 = match sbg.data.as_ref().unwrap() { + sbg_data::Data::UtcTime(utc_time) => { + let status = utc_time.status.as_ref().map(|s| format!("{:?}", s)).unwrap_or_default(); + let data = utc_time.data.as_ref(); + transaction + .execute( + "INSERT INTO SbgUtcTime (time_stamp, status, year, month, day, hour, minute, second, nano_second, gps_time_of_week) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + utc_time.time_stamp, + status, + data.map(|d| d.year), + data.map(|d| d.month), + data.map(|d| d.day), + data.map(|d| d.hour), + data.map(|d| d.minute), + data.map(|d| d.second), + data.map(|d| d.nano_second), + data.map(|d| d.gps_time_of_week), + ], + ) + .await?; + transaction.last_insert_rowid() + } + sbg_data::Data::Air(air) => { + let status = air.status.as_ref().map(|s| format!("{:?}", s)).unwrap_or_default(); + let data = air.data.as_ref(); + transaction + .execute( + "INSERT INTO SbgAir (time_stamp, status, pressure_abs, altitude, pressure_diff, true_airspeed, air_temperature) VALUES (?, ?, ?, ?, ?, ?, ?)", + params![ + air.time_stamp, + status, + data.map(|d| d.pressure_abs), + data.map(|d| d.altitude), + data.map(|d| d.pressure_diff), + data.map(|d| d.true_airspeed), + data.map(|d| d.air_temperature), + ], + ) + .await?; + transaction.last_insert_rowid() + } + sbg_data::Data::EkfQuat(ekf_quat) => { + let status = ekf_quat.status.as_ref().map(|s| format!("{:?}", s)).unwrap_or_default(); + let data = ekf_quat.data.as_ref(); + transaction + .execute( + "INSERT INTO SbgEkfQuat (time_stamp, quaternion_w, quaternion_x, quaternion_y, quaternion_z, euler_std_dev_roll, euler_std_dev_pitch, euler_std_dev_yaw, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + ekf_quat.time_stamp, + data.and_then(|d| d.quaternion).map(|q| q.w), + data.and_then(|d| d.quaternion).map(|q| q.x), + data.and_then(|d| d.quaternion).map(|q| q.y), + data.and_then(|d| d.quaternion).map(|q| q.z), + data.and_then(|d| d.euler_std_dev).map(|e| e.x), + data.and_then(|d| d.euler_std_dev).map(|e| e.y), + data.and_then(|d| d.euler_std_dev).map(|e| e.z), + status, + ], + ) + .await?; + transaction.last_insert_rowid() + } + sbg_data::Data::EkfNav(ekf_nav) => { + let status = ekf_nav.status.as_ref().map(|s| format!("{:?}", s)).unwrap_or_default(); + let vel = ekf_nav.velocity.as_ref(); + let pos = ekf_nav.position.as_ref(); + transaction + .execute( + "INSERT INTO SbgEkfNav (time_stamp, status, velocity_north, velocity_east, velocity_down, velocity_std_dev_north, velocity_std_dev_east, velocity_std_dev_down, position_latitude, position_longitude, position_altitude, position_std_dev_latitude, position_std_dev_longitude, position_std_dev_altitude, undulation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + ekf_nav.time_stamp, + status, + vel.and_then(|v| v.velocity).map(|v| v.x), + vel.and_then(|v| v.velocity).map(|v| v.y), + vel.and_then(|v| v.velocity).map(|v| v.z), + vel.and_then(|v| v.velocity_std_dev).map(|v| v.x), + vel.and_then(|v| v.velocity_std_dev).map(|v| v.y), + vel.and_then(|v| v.velocity_std_dev).map(|v| v.z), + pos.and_then(|p| p.position).map(|p| p.x), + pos.and_then(|p| p.position).map(|p| p.y), + pos.and_then(|p| p.position).map(|p| p.z), + pos.and_then(|p| p.position_std_dev).map(|p| p.x), + pos.and_then(|p| p.position_std_dev).map(|p| p.y), + pos.and_then(|p| p.position_std_dev).map(|p| p.z), + ekf_nav.undulation, + ], + ) + .await?; + transaction.last_insert_rowid() + } + sbg_data::Data::Imu(imu) => { + let status = imu.status.as_ref().map(|s| format!("{:?}", s)).unwrap_or_default(); + transaction + .execute( + "INSERT INTO SbgImu (time_stamp, status, accelerometer_x, accelerometer_y, accelerometer_z, gyroscope_x, gyroscope_y, gyroscope_z, delta_velocity_x, delta_velocity_y, delta_velocity_z, delta_angle_x, delta_angle_y, delta_angle_z, temperature) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + imu.time_stamp, + status, + imu.accelerometers.as_ref().and_then(|v| v.accelerometers).map(|v| v.x), + imu.accelerometers.as_ref().and_then(|v| v.accelerometers).map(|v| v.y), + imu.accelerometers.as_ref().and_then(|v| v.accelerometers).map(|v| v.z), + imu.gyroscopes.as_ref().and_then(|v| v.gyroscopes).map(|v| v.x), + imu.gyroscopes.as_ref().and_then(|v| v.gyroscopes).map(|v| v.y), + imu.gyroscopes.as_ref().and_then(|v| v.gyroscopes).map(|v| v.z), + imu.accelerometers.as_ref().and_then(|v| v.delta_velocity).map(|v| v.x), + imu.accelerometers.as_ref().and_then(|v| v.delta_velocity).map(|v| v.y), + imu.accelerometers.as_ref().and_then(|v| v.delta_velocity).map(|v| v.z), + imu.gyroscopes.as_ref().and_then(|v| v.delta_angle).map(|v| v.x), + imu.gyroscopes.as_ref().and_then(|v| v.delta_angle).map(|v| v.y), + imu.gyroscopes.as_ref().and_then(|v| v.delta_angle).map(|v| v.z), + imu.temperature, + ], + ) + .await?; + transaction.last_insert_rowid() + } + sbg_data::Data::GpsVel(gps_vel) => { + let status = gps_vel.status.as_ref().map(|s| format!("{:?}", s)).unwrap_or_default(); + let data = gps_vel.data.as_ref(); + transaction + .execute( + "INSERT INTO SbgGpsVel (time_stamp, status, velocity_north, velocity_east, velocity_down, velocity_acc_north, velocity_acc_east, velocity_acc_down, course, course_acc, time_of_week) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + gps_vel.time_stamp, + status, + data.and_then(|d| d.velocity).map(|v| v.x), + data.and_then(|d| d.velocity).map(|v| v.y), + data.and_then(|d| d.velocity).map(|v| v.z), + data.and_then(|d| d.velocity_acc).map(|v| v.x), + data.and_then(|d| d.velocity_acc).map(|v| v.y), + data.and_then(|d| d.velocity_acc).map(|v| v.z), + data.map(|d| d.course), + data.map(|d| d.course_acc), + data.map(|d| d.time_of_week), + ], + ) + .await?; + transaction.last_insert_rowid() + } + sbg_data::Data::GpsPos(gps_pos) => { + // // Print gps_pos as JSON + // match serde_json::to_string_pretty(&gps_pos) { + // Ok(json) => println!("{}", json), + // Err(e) => eprintln!("Failed to serialize gps_pos to JSON: {:?}", e), + // } + + let status = gps_pos.status.as_ref().map(|s| format!("{:?}", s)).unwrap_or_default(); + let data = gps_pos.data.as_ref(); + transaction + .execute( + "INSERT INTO SbgGpsPos (time_stamp, status, latitude, latitude_accuracy, longitude, longitude_accuracy, altitude, altitude_accuracy, undulation, num_sv_used, base_station_id, differential_age, time_of_week) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + gps_pos.time_stamp, + status, + data.map(|d| d.latitude), + data.map(|d| d.latitude_accuracy), + data.map(|d| d.longitude), + data.map(|d| d.longitude_accuracy), + data.map(|d| d.altitude), + data.map(|d| d.altitude_accuracy), + data.map(|d| d.undulation), + data.map(|d| d.num_sv_used), + data.map(|d| d.base_station_id), + data.map(|d| d.differential_age), + data.map(|d| d.time_of_week), + ], + ) + .await?; + transaction.last_insert_rowid() + } + }; + + Ok(data_id) +} diff --git a/apps/telemetry-ingestor/src/savers/state.rs b/apps/telemetry-ingestor/src/savers/state.rs new file mode 100644 index 0000000..a69431a --- /dev/null +++ b/apps/telemetry-ingestor/src/savers/state.rs @@ -0,0 +1,28 @@ +use libsql::{params, Result, Transaction}; +use messages_prost::{argus_state, phoenix_state}; + +pub async fn save_phoenix_state( + transaction: &Transaction, + state_value: i32, +) -> Result { + let state_str = phoenix_state::State::try_from(state_value) + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|_| "UNKNOWN".to_string()); + transaction + .execute("INSERT INTO PhoenixState (state) VALUES (?)", params![state_str]) + .await?; + Ok(transaction.last_insert_rowid()) +} + +pub async fn save_argus_state( + transaction: &Transaction, + state_value: i32, +) -> Result { + let state_str = argus_state::State::try_from(state_value) + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|_| "UNKNOWN".to_string()); + transaction + .execute("INSERT INTO ArgusState (state) VALUES (?)", params![state_str]) + .await?; + Ok(transaction.last_insert_rowid()) +} diff --git a/apps/tile-provider/.dockerignore b/apps/tile-provider/.dockerignore new file mode 100644 index 0000000..ff7ec48 --- /dev/null +++ b/apps/tile-provider/.dockerignore @@ -0,0 +1,11 @@ +# Exclude tiles data folder +tiles_data/ + +# Exclude tiles database files +tiles.db +tiles.db-journal +tiles.db-wal +tiles.db-shm + +# Exclude any other tiles.* files +tiles.* \ No newline at end of file diff --git a/apps/tile-provider/.gitignore b/apps/tile-provider/.gitignore new file mode 100644 index 0000000..31c9fb6 --- /dev/null +++ b/apps/tile-provider/.gitignore @@ -0,0 +1,6 @@ +node_modules + +# Database +tiles.db +tiles.db-shm +tiles.db-wal diff --git a/apps/tile-provider/Dockerfile b/apps/tile-provider/Dockerfile new file mode 100644 index 0000000..9c799d8 --- /dev/null +++ b/apps/tile-provider/Dockerfile @@ -0,0 +1,23 @@ +FROM oven/bun AS base +WORKDIR /usr/src/app + +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lockb /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +RUN mkdir -p /temp/prod +COPY package.json bun.lockb /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +FROM base AS prerelease +COPY --from=install /temp/prod/node_modules node_modules +COPY . . + +RUN bun test + +FROM base AS release +COPY --from=prerelease . . + +EXPOSE 6565/tcp +ENTRYPOINT [ "bun", "run", "/usr/src/app/index.ts", "--database", "/var/lib/tiles/tiles.db", "--port", "6565" ] \ No newline at end of file diff --git a/apps/tile-provider/README.md b/apps/tile-provider/README.md new file mode 100644 index 0000000..1a8afd5 --- /dev/null +++ b/apps/tile-provider/README.md @@ -0,0 +1,17 @@ +# Tile Provider + +Offline-first map tile cache and regional download service used by the ground-station web application. + +To install dependencies: + +```bash +bun install --frozen-lockfile +``` + +To run: + +```bash +bun run index.ts +``` + +Use `deployments/ground-station/compose.yaml` to run it with the complete ground-station stack. diff --git a/apps/tile-provider/bun.lockb b/apps/tile-provider/bun.lockb new file mode 100644 index 0000000..601c0a9 Binary files /dev/null and b/apps/tile-provider/bun.lockb differ diff --git a/apps/tile-provider/client/index.ts b/apps/tile-provider/client/index.ts new file mode 100644 index 0000000..c10e18c --- /dev/null +++ b/apps/tile-provider/client/index.ts @@ -0,0 +1,931 @@ +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { html, render } from 'lit-html'; + +// Create and append the map container +const rootContainer = document.createElement('div'); +rootContainer.id = 'map-container'; +document.body.appendChild(rootContainer); + + +interface DownloadState { + id: string; + layer: string; + lat: number; + lon: number; + minZoom: number; + maxZoom: number; + radius: number; + downloaded: number; + total: number; + status: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'; + error?: string; +} + +let container: HTMLElement | null = null; +let map: L.Map | null = null; +let tileLayerInstance: L.TileLayer | null = null; +let currentLayer = 'satellite'; +let minZoom = 1; +let maxZoom = 19; +let zIndexMin = 2; +let zIndexMax = 3; +let radius = 5; +let downloadCircle: L.Circle | null = null; +let currentDownloadState: DownloadState | null = null; +let statusPollInterval: number | null = null; + +// State persistence +function saveState() { + if (!map) return; + const center = map.getCenter(); + const state = { + center: { + lat: center.lat, + lng: center.lng + }, + zoom: map.getZoom(), + minZoom, + maxZoom, + radius, + currentLayer, + downloadState: currentDownloadState + }; + localStorage.setItem('mapState', JSON.stringify(state)); +} + +function restoreState() { + const savedState = localStorage.getItem('mapState'); + if (!savedState) return; + + try { + const state = JSON.parse(savedState); + + // Validate and restore active layer + currentLayer = typeof state.currentLayer === 'string' ? state.currentLayer : 'satellite'; + if (tileLayerInstance) { + tileLayerInstance.setUrl(`/tiles/${currentLayer}/{z}/{x}/{y}`); + } + + // Validate and set zoom levels with defaults + minZoom = Number.isInteger(state.minZoom) ? Math.max(0, Math.min(19, state.minZoom)) : 1; + maxZoom = Number.isInteger(state.maxZoom) ? Math.max(0, Math.min(19, state.maxZoom)) : 19; + if (minZoom > maxZoom) { + minZoom = 1; + maxZoom = 19; + } + + // Validate and set radius with default + radius = Number.isFinite(state.radius) ? Math.max(0.1, Math.min(25, state.radius)) : 5; + + // Validate and set center coordinates + if (state.center && + typeof state.center.lat === 'number' && + typeof state.center.lng === 'number' && + !isNaN(state.center.lat) && + !isNaN(state.center.lng)) { + if (map) { + map.setView([state.center.lat, state.center.lng], state.zoom || 2); + } + } + + // Validate and restore download state + if (state.downloadState && + typeof state.downloadState.lat === 'number' && + typeof state.downloadState.lon === 'number' && + !isNaN(state.downloadState.lat) && + !isNaN(state.downloadState.lon)) { + const validatedState: DownloadState = { + ...state.downloadState, + lat: state.downloadState.lat, + lon: state.downloadState.lon, + radius: Number.isFinite(state.downloadState.radius) ? + Math.max(0.1, Math.min(25, state.downloadState.radius)) : 5 + }; + currentDownloadState = validatedState; + + if (map) { + const center = L.latLng(validatedState.lat, validatedState.lon); + downloadCircle = L.circle(center, { + radius: validatedState.radius * 1000, + color: '#3388ff', + fillColor: '#3388ff', + fillOpacity: 0.2, + weight: 2, + dashArray: '5, 10', + }).addTo(map); + + if (validatedState.status === 'processing') { + startStatusPolling(validatedState.id); + } + } + } + } catch (error) { + console.error('Error restoring state:', error); + // Clear invalid state + localStorage.removeItem('mapState'); + } +} + +function clearDownloadState() { + if (statusPollInterval) { + clearInterval(statusPollInterval); + statusPollInterval = null; + } + currentDownloadState = null; + saveState(); + renderControlContent(); +} + +function startStatusPolling(jobId: string) { + if (statusPollInterval) { + clearInterval(statusPollInterval); + } + + statusPollInterval = window.setInterval(async () => { + try { + const response = await fetch(`/api/download/${jobId}/status`); + if (!response.ok) { + throw new Error('Failed to fetch job status'); + } + + const job = await response.json(); + if (!job) { + throw new Error('No job data received'); + } + + // Update the current download state with the job status + if (currentDownloadState) { + currentDownloadState = { + ...currentDownloadState, + ...job, + downloaded: job.downloaded || 0, + total: job.total || 0, + status: job.status || 'failed', + error: job.error + }; + saveState(); + renderControlContent(); + + // Stop polling if the job is no longer processing + if (job.status !== 'processing') { + if (statusPollInterval) { + clearInterval(statusPollInterval); + statusPollInterval = null; + } + } + } + } catch (error) { + console.error('Error polling job status:', error); + if (currentDownloadState) { + currentDownloadState.status = 'failed'; + currentDownloadState.error = error instanceof Error ? error.message : 'Failed to fetch job status'; + saveState(); + renderControlContent(); + } + if (statusPollInterval) { + clearInterval(statusPollInterval); + statusPollInterval = null; + } + } + }, 1000); +} + +function renderControlContent() { + if (!container) return; + + const content = html` +

Layer Settings

+ + + +

Download Settings

+ +
+
+ + +
+ +
+ + ${radius.toFixed(1)}km +
+
+ + ${currentDownloadState?.status === 'processing' ? html` + + ` : ''} +
+ ${currentDownloadState ? html` +
+
+
+
+
+ ${currentDownloadState.downloaded}/${currentDownloadState.total} tiles +
+
+
Lat: ${currentDownloadState.lat.toFixed(6)}
+
Lon: ${currentDownloadState.lon.toFixed(6)}
+
Zoom: ${currentDownloadState.minZoom}-${currentDownloadState.maxZoom}
+
Radius: ${currentDownloadState.radius.toFixed(1)}km
+
Layer: ${currentDownloadState.layer}
+
+ ${currentDownloadState.status === 'failed' ? html` +
+ +
+ ` : ''} + ${currentDownloadState.status !== 'processing' ? html` +
+ +
+ ` : ''} +
+ ` : ''} + ${currentDownloadState?.status ? html` +
+ ${currentDownloadState.status} +
+ ` : ''} + `; + + render(content, container); +} + +function updateTemplate() { + const template = html` + +
+
+
+ `; + return template; +} + +async function handleRetryDownload() { + if (!currentDownloadState || !map) return; + + // Restore the previous download configuration + const center = L.latLng(currentDownloadState.lat, currentDownloadState.lon); + if (downloadCircle) { + map.removeLayer(downloadCircle); + } + downloadCircle = L.circle(center, { + radius: currentDownloadState.radius * 1000, + color: '#3388ff', + fillColor: '#3388ff', + fillOpacity: 0.2, + weight: 2, + dashArray: '5, 10', + }).addTo(map); + + // Start the download + await handleDownload(); +} + +async function handleDownload() { + if (!map || !downloadCircle) return; + + const center = downloadCircle.getLatLng(); + const radius = downloadCircle.getRadius(); + + // Initialize download state immediately + currentDownloadState = { + id: '', // Will be set when we get the response + layer: currentLayer, + lat: center.lat, + lon: center.lng, + minZoom, + maxZoom, + radius: radius / 1000, + downloaded: 0, + total: 0, + status: 'pending' + }; + saveState(); + renderControlContent(); + + try { + const response = await fetch('/api/download', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + layer: currentLayer, + lat: center.lat, + lon: center.lng, + radiusKm: radius / 1000, + minZoom, + maxZoom, + }), + }); + + if (!response.ok) { + throw new Error('Download request failed'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Download failed'); + } + + if (result.jobId && currentDownloadState) { + currentDownloadState.id = result.jobId; + currentDownloadState.status = 'processing'; + saveState(); + renderControlContent(); + startStatusPolling(result.jobId); + } + } catch (error) { + console.error('Error starting download:', error); + if (currentDownloadState) { + currentDownloadState.status = 'failed'; + currentDownloadState.error = error instanceof Error ? error.message : 'Unknown error'; + saveState(); + renderControlContent(); + } + } +} + +async function handleCancelDownload() { + if (!currentDownloadState) return; + + try { + const response = await fetch(`/api/download/${currentDownloadState.id}/cancel`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error('Failed to cancel download'); + } + + const result = await response.json(); + if (result.success) { + if (statusPollInterval) { + clearInterval(statusPollInterval); + statusPollInterval = null; + } + currentDownloadState.status = 'cancelled'; + saveState(); + renderControlContent(); + } + } catch (error) { + console.error('Error cancelling download:', error); + } +} + +function handleSliderPointerMove(e: PointerEvent) { + // If the user is currently dragging/pressing, do not swap z-index to avoid interrupting active gesture + if (e.buttons > 0) return; + + const containerEl = e.currentTarget as HTMLElement; + const rect = containerEl.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const pct = Math.max(0, Math.min(1, clickX / rect.width)); + const clickVal = pct * 19; + const mid = (minZoom + maxZoom) / 2; + let newMinZ = 2; + let newMaxZ = 2; + + if (clickVal < mid) { + newMinZ = 3; + newMaxZ = 2; + } else { + newMinZ = 2; + newMaxZ = 3; + } + + if (zIndexMin !== newMinZ || zIndexMax !== newMaxZ) { + zIndexMin = newMinZ; + zIndexMax = newMaxZ; + renderControlContent(); + } +} + +function handleSliderPointerDown(e: PointerEvent) { + const containerEl = e.currentTarget as HTMLElement; + const rect = containerEl.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const pct = Math.max(0, Math.min(1, clickX / rect.width)); + const clickVal = pct * 19; + const mid = (minZoom + maxZoom) / 2; + let newMinZ = 2; + let newMaxZ = 2; + + if (clickVal < mid) { + newMinZ = 3; + newMaxZ = 2; + } else { + newMinZ = 2; + newMaxZ = 3; + } + + if (zIndexMin !== newMinZ || zIndexMax !== newMaxZ) { + zIndexMin = newMinZ; + zIndexMax = newMaxZ; + renderControlContent(); + } +} + +function handleMinSliderInput(e: Event) { + const input = e.target as HTMLInputElement; + const val = parseInt(input.value) || 0; + if (val > maxZoom) { + input.value = maxZoom.toString(); + minZoom = maxZoom; + } else { + minZoom = val; + } + saveState(); + renderControlContent(); +} + +function handleMaxSliderInput(e: Event) { + const input = e.target as HTMLInputElement; + const val = parseInt(input.value) || 0; + if (val < minZoom) { + input.value = minZoom.toString(); + maxZoom = minZoom; + } else { + maxZoom = val; + } + saveState(); + renderControlContent(); +} + +function handleRadiusChange(e: Event) { + const input = e.target as HTMLInputElement; + radius = parseFloat(input.value); + if (downloadCircle) { + downloadCircle.setRadius(radius * 1000); + renderControlContent(); + } +} + +function handleMapClick(e: L.LeafletMouseEvent) { + if (!map) return; + + // Don't create a new circle if we're currently downloading + if (currentDownloadState?.status === 'processing') { + return; + } + + if (downloadCircle) { + map.removeLayer(downloadCircle); + } + + downloadCircle = L.circle(e.latlng, { + radius: radius * 1000, + color: '#3388ff', + fillColor: '#3388ff', + fillOpacity: 0.2, + weight: 2, + dashArray: '5, 10', + }).addTo(map); + + renderControlContent(); +} + +const DownloadControl = L.Control.extend({ + options: { + position: 'topright' + }, + + onAdd: function (map: L.Map) { + container = L.DomUtil.create('div', 'leaflet-bar leaflet-control download-control'); + + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + + renderControlContent(); + return container; + } +}); + +function handleLayerChange(e: Event) { + const select = e.target as HTMLSelectElement; + currentLayer = select.value; + if (tileLayerInstance) { + tileLayerInstance.setUrl(`/tiles/${currentLayer}/{z}/{x}/{y}`); + } + saveState(); + renderControlContent(); +} + +export function render_map(element: HTMLElement) { + const template = updateTemplate(); + render(template, element); + + if (!map) { + map = L.map(element, { + minZoom, + maxZoom, + }).setView([0, 0], 2); + + tileLayerInstance = L.tileLayer(`/tiles/${currentLayer}/{z}/{x}/{y}`, { + maxZoom, + tileSize: 256 + }).addTo(map); + + map.on('click', handleMapClick); + map.on('moveend', saveState); + new DownloadControl().addTo(map); + + restoreState(); + } +} + + +render_map(rootContainer); diff --git a/apps/tile-provider/index.ts b/apps/tile-provider/index.ts new file mode 100644 index 0000000..84c69bc --- /dev/null +++ b/apps/tile-provider/index.ts @@ -0,0 +1,208 @@ +import { Database } from "bun:sqlite"; +import { config } from "./lib/config"; +import { TileDatabase } from "./lib/database"; +import { DownloadQueue } from "./lib/downloadQueue"; +import homepage from "./public/index.html"; + +// ----------------------- +// Setup SQLite database +// ----------------------- +console.log("Opening DB at:", config.database); +console.log("Using address:", config.address); +console.log("Using port:", config.port); + +const db = new Database(config.database); +db.exec("PRAGMA journal_mode = WAL;"); + +// Create the tiles table if needed. +db.exec(` + CREATE TABLE IF NOT EXISTS tiles ( + zoom INTEGER, + x INTEGER, + y INTEGER, + blob BLOB, + PRIMARY KEY (zoom, x, y) + ); +`); + +// Initialize database and download queue +const tileDb = new TileDatabase(db); +const downloadQueue = new DownloadQueue(db); + +// ----------------------- +// CORS helper function +// ----------------------- +function corsHeaders(): Record { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }; +} + +// ----------------------- +// HTTP handler +// ----------------------- +async function handleTileRequest( + layer: string, + zoom: string, + x: string, + y: string +): Promise { + const baseUrl = config.tileSources[layer as keyof typeof config.tileSources]; + if (!baseUrl) { + return new Response(`Unknown layer: ${layer}`, { + status: 400, + headers: corsHeaders(), + }); + } + + // Check the database. + const dbBlob = tileDb.getTile(layer, Number(zoom), Number(x), Number(y)); + if (dbBlob) { + return new Response(dbBlob, { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000", + ...corsHeaders(), + }, + }); + } + + // Fetch from the remote source. + const url = `${baseUrl}&x=${x}&y=${y}&z=${zoom}`; + const response = await fetch(url); + + if (!response.ok) { + return new Response(response.statusText, { + status: 500, + headers: { + "Content-Type": "text/plain", + ...corsHeaders(), + }, + }); + } + + const buffer = new Uint8Array(await response.arrayBuffer()); + tileDb.saveTile(layer, Number(zoom), Number(x), Number(y), buffer); + + return new Response(buffer, { + status: 200, + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000", + ...corsHeaders(), + }, + }); +} + +// ----------------------- +// Main HTTP server route handling +// ----------------------- +const server = Bun.serve({ + development: process.env.NODE_ENV !== "production", + routes: { + "/health": () => new Response("OK", { headers: corsHeaders() }), + "/tiles/:zoom/:x/:y": async (req) => { + const { zoom, x, y } = req.params; + return handleTileRequest("satellite", zoom, x, y); + }, + "/tiles/:layer/:zoom/:x/:y": async (req) => { + const { layer, zoom, x, y } = req.params; + return handleTileRequest(layer, zoom, x, y); + }, + "/api/download": async (req) => { + // Handle OPTIONS preflight request + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders(), + }); + } + + const body = await req.json(); + const result = await downloadQueue.queueDownload(body); + return new Response(JSON.stringify(result), { + status: result.success ? 200 : 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders(), + }, + }); + }, + "/api/download/:jobId/status": async (req) => { + // Handle OPTIONS preflight request + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders(), + }); + } + + const { jobId } = req.params; + const job = await downloadQueue.getJobStatus(jobId); + if (!job) { + return new Response(JSON.stringify({ error: 'Job not found' }), { + status: 404, + headers: { + "Content-Type": "application/json", + ...corsHeaders(), + }, + }); + } + return new Response(JSON.stringify(job), { + headers: { + "Content-Type": "application/json", + ...corsHeaders(), + }, + }); + }, + "/api/download/:jobId/cancel": async (req) => { + // Handle OPTIONS preflight request + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders(), + }); + } + + const { jobId } = req.params; + const success = await downloadQueue.cancelDownload(jobId); + return new Response(JSON.stringify({ success }), { + status: success ? 200 : 404, + headers: { + "Content-Type": "application/json", + ...corsHeaders(), + }, + }); + }, + "/": homepage, + }, + hostname: config.address, + port: config.port, +}); + +console.log(`Starting server at ${config.address}:${config.port}`); + +// ----------------------- +// Graceful shutdown +// ----------------------- +function shutdown(signal: string) { + try { + console.log(`[tile_provider] Received ${signal}, shutting down...`); + // Stop accepting new requests + try { server.stop(); } catch { } + // Stop background work + try { downloadQueue.shutdown(); } catch { } + } finally { + // Force exit if cleanup hangs + setTimeout(() => process.exit(0), 2000).unref(); + } +} + +try { + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} catch { } diff --git a/apps/tile-provider/lib/config.test.ts b/apps/tile-provider/lib/config.test.ts new file mode 100644 index 0000000..5baf4fd --- /dev/null +++ b/apps/tile-provider/lib/config.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; + +test("config defaults are sane", async () => { + // Dynamically import to avoid capturing env from test runner incorrectly + const mod = await import("./config"); + const cfg = mod.config; + + expect(cfg.address.length).toBeGreaterThan(0); + expect(Number(cfg.port)).toBeGreaterThan(0); + expect(cfg.database).toBeDefined(); + expect(cfg.tileSources.satellite).toContain("http"); + expect(cfg.tileSources.terrain).toContain("http"); +}); diff --git a/apps/tile-provider/lib/config.ts b/apps/tile-provider/lib/config.ts new file mode 100644 index 0000000..b97019d --- /dev/null +++ b/apps/tile-provider/lib/config.ts @@ -0,0 +1,36 @@ +import process from "process"; +import { parseArgs } from "util"; + + +const { values } = parseArgs({ + args: Bun.argv, + options: { + address: { type: "string", default: process.env.ADDRESS ?? "0.0.0.0" }, + port: { type: "string", default: process.env.PORT ?? "6565" }, + database: { + type: "string", + default: process.env.DB_FILENAME ?? "tiles.db", + }, + }, + strict: true, + allowPositionals: true, +}) + +export const config = { + address: values.address ?? "127.0.0.1", + port: values.port ?? "6565", + database: values.database ?? "tiles.db", + tileSources: { + satellite: "http://mt0.google.com/vt/lyrs=s,h", + terrain: "http://mt0.google.com/vt/lyrs=p", + }, + cors: { + allowedOrigins: ["*"], + allowedMethods: ["GET", "POST", "OPTIONS"], + allowedHeaders: ["Content-Type"] + }, + download: { + batchSize: 100, + delayMs: 100 + } +} as const; \ No newline at end of file diff --git a/apps/tile-provider/lib/database.test.ts b/apps/tile-provider/lib/database.test.ts new file mode 100644 index 0000000..3bd8eab --- /dev/null +++ b/apps/tile-provider/lib/database.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { TileDatabase } from "./database"; + +function createMemoryDb() { + return new Database(":memory:"); +} + +test("TileDatabase: save/get/has tile roundtrip", () => { + const db = createMemoryDb(); + const tiles = new TileDatabase(db); + + const blob = new Uint8Array([1, 2, 3, 4]); + const saved = tiles.saveTile("satellite", 5, 10, 20, blob); + expect(saved).toBe(true); + + expect(tiles.hasTile("satellite", 5, 10, 20)).toBe(true); + + const read = tiles.getTile("satellite", 5, 10, 20); + expect(read).not.toBeNull(); + expect(Array.from(read!)).toEqual(Array.from(blob)); +}); + +test("TileDatabase: batchCheckTiles finds existing", () => { + const db = createMemoryDb(); + const tiles = new TileDatabase(db); + + const existing = new Uint8Array([9]); + tiles.saveTile("satellite", 3, 4, 5, existing); + + const set = tiles.batchCheckTiles("satellite", [ + { zoom: 3, x: 4, y: 5, blob: new Uint8Array() }, + { zoom: 7, x: 8, y: 9, blob: new Uint8Array() } + ]); + + expect(set.has("3/4/5")).toBe(true); + expect(set.has("7/8/9")).toBe(false); +}); + +test("TileDatabase: cleanupOldTiles removes outdated rows", () => { + const db = createMemoryDb(); + const tiles = new TileDatabase(db); + + // Insert a tile and mark it as very old + tiles.saveTile("satellite", 1, 1, 1, new Uint8Array([7])); + db.exec("UPDATE tiles SET created_at = datetime('now', '-100 days') WHERE layer=0 AND zoom=1 AND x=1 AND y=1"); + + const removed = tiles.cleanupOldTiles(30); + expect(removed).toBeGreaterThan(0); + expect(tiles.hasTile("satellite", 1, 1, 1)).toBe(false); +}); diff --git a/apps/tile-provider/lib/database.ts b/apps/tile-provider/lib/database.ts new file mode 100644 index 0000000..bb8b538 --- /dev/null +++ b/apps/tile-provider/lib/database.ts @@ -0,0 +1,158 @@ +import { Database } from "bun:sqlite"; + +export interface Tile { + zoom: number; + x: number; + y: number; + blob: Uint8Array; +} + +export interface TileQuery { + get: (...args: any[]) => { blob: Uint8Array } | null | undefined; + run: (...args: any[]) => void; + all: (...args: any[]) => any[]; +} + +const LAYER_MAP: Record = { + satellite: 0, + terrain: 1, +}; + +function getLayerId(layer: string): number { + if (layer in LAYER_MAP) { + return LAYER_MAP[layer]; + } + return 0; // Default to satellite (0) +} + +export class TileDatabase { + private db: Database; + private getTileQuery!: TileQuery; + private saveTileQuery!: TileQuery; + + constructor(db: Database) { + this.db = db; + // Ensure schema exists before preparing queries that depend on it + this.initializeSchema(); + this.initializeQueries(); + } + + private initializeSchema() { + // Check if table exists and if it has the 'layer' column with INTEGER type + let dropTable = false; + try { + const info = this.db.prepare("PRAGMA table_info(tiles)").all() as any[]; + const layerCol = info.find((col: any) => col.name === 'layer'); + if (!layerCol || layerCol.type !== 'INTEGER') { + dropTable = true; + } + } catch (e) { + // Table doesn't exist yet, which is fine + } + + if (dropTable) { + // If table exists but has incorrect type (like TEXT), drop it to migrate primary key and type cleanly + try { + this.db.exec("DROP TABLE IF EXISTS tiles;"); + } catch (e) { + console.warn('Error dropping old tiles table:', e); + } + } + + // Create the schema with layer as INTEGER and part of the primary key + this.db.exec(` + CREATE TABLE IF NOT EXISTS tiles ( + layer INTEGER, + zoom INTEGER, + x INTEGER, + y INTEGER, + blob BLOB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (layer, zoom, x, y) + ); + `); + + // Create the index if it doesn't exist + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_tiles_created_at ON tiles(created_at); + `); + } + + private initializeQueries() { + this.getTileQuery = this.db.query( + "SELECT blob FROM tiles WHERE layer = ? AND zoom = ? AND x = ? AND y = ?" + ) as unknown as TileQuery; + + this.saveTileQuery = this.db.prepare( + "INSERT OR REPLACE INTO tiles (layer, zoom, x, y, blob) VALUES (?, ?, ?, ?, ?)" + ) as unknown as TileQuery; + } + + getTile(layer: string, zoom: number, x: number, y: number): Uint8Array | null { + try { + const result = this.getTileQuery.get(getLayerId(layer), zoom, x, y); + return result ? result.blob : null; + } catch (error) { + console.error(`Error getting tile ${layer}/${zoom}/${x}/${y}:`, error); + return null; + } + } + + saveTile(layer: string, zoom: number, x: number, y: number, blob: Uint8Array): boolean { + try { + this.saveTileQuery.run(getLayerId(layer), zoom, x, y, blob); + return true; + } catch (error) { + console.error(`Error saving tile ${layer}/${zoom}/${x}/${y}:`, error); + return false; + } + } + + hasTile(layer: string, zoom: number, x: number, y: number): boolean { + return this.getTile(layer, zoom, x, y) !== null; + } + + batchCheckTiles(layer: string, tiles: Tile[]): Set { + const existingTiles = new Set(); + const layerId = getLayerId(layer); + + // Process tiles in batches + for (let i = 0; i < tiles.length; i += 100) { + const batch = tiles.slice(i, i + 100); + const placeholders = batch.map(() => '(?, ?, ?, ?)').join(','); + + try { + const query = this.db.prepare(` + SELECT zoom, x, y + FROM tiles + WHERE (layer, zoom, x, y) IN (${placeholders}) + `); + + const params = batch.flatMap(tile => [layerId, tile.zoom, tile.x, tile.y]); + const results = query.all(...params) as Tile[]; + + results.forEach(tile => { + existingTiles.add(`${tile.zoom}/${tile.x}/${tile.y}`); + }); + } catch (error) { + console.error('Error in batch check:', error); + } + } + + return existingTiles; + } + + cleanupOldTiles(daysOld: number = 30): number { + try { + const result = this.db.prepare(` + DELETE FROM tiles + WHERE created_at < datetime('now', '-' || ? || ' days') + `).run(daysOld); + + return result.changes; + } catch (error) { + console.error('Error cleaning up old tiles:', error); + return 0; + } + } +} \ No newline at end of file diff --git a/apps/tile-provider/lib/downloadQueue.test.ts b/apps/tile-provider/lib/downloadQueue.test.ts new file mode 100644 index 0000000..10b2c71 --- /dev/null +++ b/apps/tile-provider/lib/downloadQueue.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { DownloadQueue } from "./downloadQueue"; + +// Avoid hitting the network in tests +global.fetch = (async (url: string) => { + // Return a tiny PNG header-like buffer to simulate a tile + const data = new Uint8Array([137,80,78,71]); + return new Response(data, { status: 200, headers: { 'content-type': 'image/png' } }); +}) as any; + +test("DownloadQueue enqueues and completes a tiny job", async () => { + const db = new Database(":memory:"); + const q = new DownloadQueue(db); + + const res = await q.queueDownload({ + layer: "satellite", + lat: 0, + lon: 0, + minZoom: 1, + maxZoom: 1, + radiusKm: 0.1 + }); + + expect(res.success).toBe(true); + expect(res.jobId).toBeDefined(); + + // Wait briefly for processing + await new Promise((r) => setTimeout(r, 200)); + + const job = await q.getJobStatus(res.jobId!); + expect(job).not.toBeNull(); + expect(["completed","failed","processing","cancelled","pending"]).toContain(job!.status); +}); diff --git a/apps/tile-provider/lib/downloadQueue.ts b/apps/tile-provider/lib/downloadQueue.ts new file mode 100644 index 0000000..5326f03 --- /dev/null +++ b/apps/tile-provider/lib/downloadQueue.ts @@ -0,0 +1,273 @@ +import { Database } from "bun:sqlite"; +import { config } from "./config"; +import { TileDatabase } from "./database"; + +export interface DownloadJob { + id: string; + layer: string; + lat: number; + lon: number; + minZoom: number; + maxZoom: number; + radiusKm: number; + status: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'; + downloaded: number; + total: number; + error?: string; +} + +interface TileCoordinates { + zoom: number; + x: number; + y: number; +} + +interface DownloadResult { + success: boolean; + error?: string; + jobId?: string; + downloaded: number; + total: number; +} + +function deg2tile(lat: number, lon: number, zoom: number): [number, number] { + const n = 2 ** zoom; + const xTile = Math.floor(((lon + 180) / 360) * n); + const yTile = Math.floor( + ((1 - + Math.log( + Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180) + ) / + Math.PI) / + 2) * + n + ); + return [xTile, yTile]; +} + +function generateTileUrls(job: DownloadJob): TileCoordinates[] { + const urls: TileCoordinates[] = []; + + for (let zoom = job.minZoom; zoom <= job.maxZoom; zoom++) { + const [xCenter, yCenter] = deg2tile(job.lat, job.lon, zoom); + const n = 2 ** zoom; + + // Calculate radius in tiles (approximate) + const earthRadius = 6371; // km + const tileSize = (2 * Math.PI * earthRadius) / n; + const radiusTiles = Math.ceil(job.radiusKm / tileSize); + + // Calculate bounds + const xMin = Math.max(0, xCenter - radiusTiles); + const xMax = Math.min(n - 1, xCenter + radiusTiles); + const yMin = Math.max(0, yCenter - radiusTiles); + const yMax = Math.min(n - 1, yCenter + radiusTiles); + + // Generate URLs + for (let x = xMin; x <= xMax; x++) { + for (let y = yMin; y <= yMax; y++) { + urls.push({ zoom, x, y }); + } + } + } + + return urls; +} + +export class DownloadQueue { + private jobs: Map = new Map(); + private isProcessing = false; + private db: TileDatabase; + private currentJobId: string | null = null; + private shuttingDown = false; + private inflightAbort: AbortController | null = null; + + constructor(db: Database) { + this.db = new TileDatabase(db); + } + + async queueDownload(job: Omit): Promise { + try { + // Backend input validation and sanitization + if (typeof job.minZoom !== 'number' || typeof job.maxZoom !== 'number') { + return { success: false, error: "Zoom levels must be numbers", downloaded: 0, total: 0 }; + } + + const minZoom = Math.max(0, Math.min(19, Math.floor(job.minZoom))); + const maxZoom = Math.max(0, Math.min(19, Math.floor(job.maxZoom))); + + if (minZoom > maxZoom) { + return { + success: false, + error: "minZoom cannot be greater than maxZoom", + downloaded: 0, + total: 0 + }; + } + + if (typeof job.lat !== 'number' || isNaN(job.lat) || job.lat < -90 || job.lat > 90) { + return { success: false, error: "Invalid latitude (-90 to 90)", downloaded: 0, total: 0 }; + } + if (typeof job.lon !== 'number' || isNaN(job.lon) || job.lon < -180 || job.lon > 180) { + return { success: false, error: "Invalid longitude (-180 to 180)", downloaded: 0, total: 0 }; + } + if (typeof job.radiusKm !== 'number' || isNaN(job.radiusKm) || job.radiusKm <= 0 || job.radiusKm > 50) { + return { success: false, error: "Invalid radius (must be between 0 and 50 km)", downloaded: 0, total: 0 }; + } + + const id = crypto.randomUUID(); + const newJob: DownloadJob = { + ...job, + minZoom, + maxZoom, + id, + layer: "satellite & terrain", + status: 'pending', + downloaded: 0, + total: 0 + }; + + this.jobs.set(id, newJob); + + if (!this.isProcessing) { + this.processQueue(); + } + + return { + success: true, + jobId: id, + downloaded: 0, + total: 0 + }; + } catch (error) { + console.error('Error queueing download:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + downloaded: 0, + total: 0 + }; + } + } + + async cancelDownload(jobId: string): Promise { + const job = this.jobs.get(jobId); + if (!job) return false; + + if (job.status === 'processing' && this.currentJobId === jobId) { + job.status = 'cancelled'; + return true; + } + + return false; + } + + shutdown(): void { + this.shuttingDown = true; + if (this.inflightAbort) { + try { this.inflightAbort.abort(); } catch {} + } + } + + async getJobStatus(jobId: string): Promise { + return this.jobs.get(jobId) || null; + } + + private async processQueue(): Promise { + if (this.jobs.size === 0 || this.shuttingDown) { + this.isProcessing = false; + return; + } + + this.isProcessing = true; + const job = Array.from(this.jobs.values()).find(j => j.status === 'pending'); + + if (!job) { + this.isProcessing = false; + return; + } + + this.currentJobId = job.id; + job.status = 'processing'; + + try { + const urls = generateTileUrls(job); + const targetLayers = ["satellite", "terrain"]; + const tasks: { layer: string; zoom: number; x: number; y: number }[] = []; + + // Generate task list for both layers + for (const layer of targetLayers) { + const existingTiles = this.db.batchCheckTiles(layer, urls.map(url => ({ ...url, blob: new Uint8Array() }))); + for (const url of urls) { + if (!existingTiles.has(`${url.zoom}/${url.x}/${url.y}`)) { + tasks.push({ layer, ...url }); + } + } + } + + job.total = tasks.length; + + console.log(`Processing download job ${job.id}: ${tasks.length} tiles to download across both layers`); + + for (const task of tasks) { + if (job.status !== 'processing' || this.shuttingDown) { + break; + } + + try { + const result = await this.fetchTileFromSource(task.layer, task.zoom, task.x, task.y); + if (result.ok && result.buffer) { + if (this.db.saveTile(task.layer, task.zoom, task.x, task.y, result.buffer)) { + job.downloaded++; + } + } + // Add small delay to avoid overwhelming the source; skip if shutting down + if (this.shuttingDown) break; + await new Promise(resolve => setTimeout(resolve, config.download.delayMs)); + } catch (error) { + if ((error as any)?.name === 'AbortError') { + // Graceful shutdown; mark as cancelled + job.status = 'cancelled'; + break; + } + console.error(`Failed to download tile ${task.layer}/${task.zoom}/${task.x}/${task.y}:`, error); + job.status = 'failed'; + job.error = error instanceof Error ? error.message : 'Unknown error'; + break; + } finally { + this.inflightAbort = null; + } + } + + if (job.status === 'processing') { + job.status = 'completed'; + } + } catch (error) { + console.error('Error processing download job:', error); + job.status = 'failed'; + job.error = error instanceof Error ? error.message : 'Unknown error'; + } finally { + this.currentJobId = null; + // Process next job + if (!this.shuttingDown) this.processQueue(); + } + } + + private async fetchTileFromSource(layer: string, zoom: number, x: number, y: number) { + const baseUrl = config.tileSources[layer as keyof typeof config.tileSources]; + if (!baseUrl) { + return { ok: false, error: `Unknown layer: ${layer}` }; + } + const url = `${baseUrl}&x=${x}&y=${y}&z=${zoom}`; + const controller = new AbortController(); + this.inflightAbort = controller; + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + return { ok: false, error: response.statusText }; + } + + const buffer = new Uint8Array(await response.arrayBuffer()); + return { ok: true, buffer }; + } +} diff --git a/apps/tile-provider/package.json b/apps/tile-provider/package.json new file mode 100644 index 0000000..e3c3466 --- /dev/null +++ b/apps/tile-provider/package.json @@ -0,0 +1,21 @@ +{ + "name": "tile_provider", + "module": "index.ts", + "type": "module", + "scripts": { + "start": "bun run index.ts", + "dev": "bun --watch run index.ts" + }, + "dependencies": { + "lit-html": "^2.1.0", + "leaflet": "^1.9.4" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/leaflet": "^1.9.8", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/apps/tile-provider/public/index.html b/apps/tile-provider/public/index.html new file mode 100644 index 0000000..2988627 --- /dev/null +++ b/apps/tile-provider/public/index.html @@ -0,0 +1,27 @@ + + + + + + + RGS Offline Map Center & Tile Provider + + + + + + + + + + \ No newline at end of file diff --git a/apps/tile-provider/tsconfig.json b/apps/tile-provider/tsconfig.json new file mode 100644 index 0000000..93896c3 --- /dev/null +++ b/apps/tile-provider/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowJs": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["client/*"] + } + }, + "include": ["client/**/*", "lib/**/*", "index.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/common/drivers/src/lib.rs b/common/drivers/src/lib.rs index 7e62e24..f3ea5a4 100644 --- a/common/drivers/src/lib.rs +++ b/common/drivers/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] #![feature(impl_trait_in_assoc_type)] pub mod ejection_channel; +pub mod iim20670; pub mod ms561101; pub mod rfd900x; -pub mod iim20670; \ No newline at end of file diff --git a/deployments/ground-station/README.md b/deployments/ground-station/README.md new file mode 100644 index 0000000..c1ad98c --- /dev/null +++ b/deployments/ground-station/README.md @@ -0,0 +1,29 @@ +# Ground Station Deployment + +This deployment composes the independently maintained applications under `apps/` with LibSQL and Traefik. + +## Run + +From the repository root: + +```bash +docker compose -f deployments/ground-station/compose.yaml up --build +``` + +By default, telemetry services connect to `tcpout:sergw:5656`. To run the repository's Rust serial gateway with a local radio: + +```bash +SERIAL_DEVICE=/dev/ttyUSB0 \ +docker compose -f deployments/ground-station/compose.yaml --profile radio up --build +``` + +To use a gateway on another machine, leave the `radio` profile disabled and provide its MAVLink connection string: + +```bash +GATEWAY_CONNECTION_STRING=tcpout:192.168.8.178:5656 \ +docker compose -f deployments/ground-station/compose.yaml up --build +``` + +Runtime database and tile data are stored in ignored local state or Docker volumes. The `migration` service applies `db/seed.sql` when the stack starts. + +Published ports can be overridden with `GROUND_STATION_HTTP_PORT`, `TRAEFIK_DASHBOARD_PORT`, `LIBSQL_PORT`, `TILES_PORT`, and `SERGW_PORT` when the defaults conflict with local services. diff --git a/deployments/ground-station/compose.yaml b/deployments/ground-station/compose.yaml new file mode 100644 index 0000000..825743a --- /dev/null +++ b/deployments/ground-station/compose.yaml @@ -0,0 +1,167 @@ +name: ground-station + +x-rust-service: &rust-service + build: + context: ../.. + dockerfile: deployments/ground-station/docker/Dockerfile.rust-service + restart: unless-stopped + networks: + - ground-station + +services: + traefik: + image: traefik:v3.7 + command: + - --api.insecure=true + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --log.level=INFO + ports: + - "${GROUND_STATION_HTTP_PORT:-80}:80" + - "${TRAEFIK_DASHBOARD_PORT:-8080}:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - ground-station + labels: + - traefik.enable=true + - traefik.http.routers.deny-all.rule=HostRegexp(`.+`) + - traefik.http.routers.deny-all.entrypoints=web + - traefik.http.routers.deny-all.priority=1 + - traefik.http.routers.deny-all.service=noop@internal + + db: + image: ghcr.io/tursodatabase/libsql-server:latest + environment: + SQLD_NODE: standalone + volumes: + - ./db/data:/var/lib/sqld + ports: + - "${LIBSQL_PORT:-5001}:8080" + networks: + - ground-station + + migration: + image: docker.io/oven/bun:1 + working_dir: /app + command: ["sh", "-c", "bun install --frozen-lockfile && bun index.ts"] + environment: + DATABASE_URL: http://db:8080 + volumes: + - ./db:/app + depends_on: + - db + restart: on-failure + networks: + - ground-station + + web: + build: + context: ../../apps/ground-station-web + dockerfile: Dockerfile + environment: + NODE_ENV: production + PORT: "3000" + DB_URL: http://db:8080 + COMPOSE_PROJECT_NAME: ground-station + VITE_ALLOWED_HOSTS: uorocketry.ca,localhost,127.0.0.1,*.local + SVELTEKIT_ALLOWED_HOSTS: uorocketry.ca,localhost,127.0.0.1,*.local + ALLOWED_SUBNETS: 192.168.8.0/24,127.0.0.1/32,172.18.0.0/16 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + user: "0:0" + depends_on: + migration: + condition: service_completed_successfully + restart: unless-stopped + networks: + - ground-station + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=Host(`uorocketry.ca`) || HostRegexp(`.+\.local`) + - traefik.http.routers.web.entrypoints=web + - traefik.http.routers.web.service=web + - traefik.http.routers.web.priority=100 + - traefik.http.services.web.loadbalancer.server.port=3000 + + tiles: + build: + context: ../../apps/tile-provider + dockerfile: Dockerfile + volumes: + - tile-data:/var/lib/tiles + ports: + - "${TILES_PORT:-6565}:6565" + restart: unless-stopped + networks: + - ground-station + labels: + - traefik.enable=true + - traefik.http.routers.tiles.rule=Host(`tiles.uorocketry.ca`) + - traefik.http.routers.tiles.entrypoints=web + - traefik.http.routers.tiles.priority=100 + - traefik.http.services.tiles.loadbalancer.server.port=6565 + + telemetry-ingestor: + <<: *rust-service + build: + context: ../.. + dockerfile: deployments/ground-station/docker/Dockerfile.rust-service + args: + CRATE: telemetry-ingestor + command: + - --libsql-url + - http://db:8080 + - --gateway-connection-string + - ${GATEWAY_CONNECTION_STRING:-tcpout:sergw:5656} + depends_on: + migration: + condition: service_completed_successfully + + command-dispatcher: + <<: *rust-service + build: + context: ../.. + dockerfile: deployments/ground-station/docker/Dockerfile.rust-service + args: + CRATE: command-dispatcher + command: + - --libsql-url + - http://db:8080 + - --gateway-connection-string + - ${GATEWAY_CONNECTION_STRING:-tcpout:sergw:5656} + - --keepalive-interval-secs + - "30" + depends_on: + migration: + condition: service_completed_successfully + + sergw: + <<: *rust-service + profiles: + - radio + build: + context: ../.. + dockerfile: deployments/ground-station/docker/Dockerfile.rust-service + args: + CRATE: sergw + command: + - listen + - --serial + - ${SERIAL_DEVICE:-/dev/ttyUSB0} + - --baud + - ${SERIAL_BAUD:-57600} + - --host + - 0.0.0.0:5656 + devices: + - ${SERIAL_DEVICE:-/dev/ttyUSB0}:${SERIAL_DEVICE:-/dev/ttyUSB0} + ports: + - "${SERGW_PORT:-5656}:5656" + +networks: + ground-station: + driver: bridge + +volumes: + tile-data: diff --git a/deployments/ground-station/db/.gitignore b/deployments/ground-station/db/.gitignore new file mode 100644 index 0000000..6fe8114 --- /dev/null +++ b/deployments/ground-station/db/.gitignore @@ -0,0 +1,174 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/deployments/ground-station/db/README.md b/deployments/ground-station/db/README.md new file mode 100644 index 0000000..d695134 --- /dev/null +++ b/deployments/ground-station/db/README.md @@ -0,0 +1,15 @@ +# @rgs/db + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/deployments/ground-station/db/bun.lockb b/deployments/ground-station/db/bun.lockb new file mode 100644 index 0000000..6bba375 Binary files /dev/null and b/deployments/ground-station/db/bun.lockb differ diff --git a/deployments/ground-station/db/index.test.ts b/deployments/ground-station/db/index.test.ts new file mode 100644 index 0000000..e7cb557 --- /dev/null +++ b/deployments/ground-station/db/index.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +const seed = await Bun.file(new URL("./seed.sql", import.meta.url)).text(); +import { parseSqlStatements } from "./sql"; + +test("seed.sql applies cleanly to in-memory sqlite", () => { + const db = new Database(":memory:"); + const stmts = parseSqlStatements(seed); + let threwError = false; + for (const s of stmts) { + try { + // Using .run for DDL/DML; will throw on error + db.run(s); + } catch (e) { + threwError = true; + } + } + expect(threwError).toBe(false); +}); diff --git a/deployments/ground-station/db/index.ts b/deployments/ground-station/db/index.ts new file mode 100644 index 0000000..20d63fa --- /dev/null +++ b/deployments/ground-station/db/index.ts @@ -0,0 +1,60 @@ +import { createClient } from "@libsql/client"; +import { parseSqlStatements } from "./sql"; + +const DATABASE_URL = process.env.DATABASE_URL || "http://0.0.0.0:8080"; + +// Function to wait for database to be connectable +async function waitForDatabase(url: string, maxRetries = 30, baseDelay = 1000): Promise { + console.log(`Waiting for database to be available at ${url}...`); + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const testClient = createClient({ url }); + // Try to execute a simple query to test connection + await testClient.execute("SELECT 1"); + console.log(`Database is available after ${attempt} attempts`); + return; + } catch (error) { + if (attempt === maxRetries) { + console.error(`Failed to connect to database after ${maxRetries} attempts`); + throw error; + } + + const delay = baseDelay * Math.pow(2, attempt - 1); + console.log(`Attempt ${attempt}/${maxRetries}: Database not ready, retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } +} + +// Wait for database before proceeding +await waitForDatabase(DATABASE_URL); + +const client = createClient({ url: DATABASE_URL }); + +const seed = await Bun.file(new URL("./seed.sql", import.meta.url)).text(); +const statements = parseSqlStatements(seed); + +let nErrors = 0; +for (const statement of statements) { + try { + await client.execute(statement); + const firstLine = statement.split("\n")[0]; + console.log(`${firstLine} ...OK`); + } catch (e) { + nErrors++; + console.error("Statement failed:"); + console.log("--------------------------------"); + console.log(statement); + console.log("--------------------------------"); + console.error(e); + console.log("--------------------------------"); + } +} + +if (nErrors > 0) { + console.error(`Failed to execute ${nErrors} statements`); + process.exit(1); +} + +console.log(`Successfully executed ${statements.length} statements`); diff --git a/deployments/ground-station/db/package.json b/deployments/ground-station/db/package.json new file mode 100644 index 0000000..c561aef --- /dev/null +++ b/deployments/ground-station/db/package.json @@ -0,0 +1,15 @@ +{ + "name": "@rgs/db", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@libsql/client": "^0.14.0", + "libsql": "^0.5.0-pre.5" + } +} \ No newline at end of file diff --git a/deployments/ground-station/db/seed.sql b/deployments/ground-station/db/seed.sql new file mode 100644 index 0000000..7bb02fb --- /dev/null +++ b/deployments/ground-station/db/seed.sql @@ -0,0 +1,366 @@ +--#region RGS tables + +-- Table to store commands requested by the ground station to be sent to the rocket. +CREATE TABLE IF NOT EXISTS OutgoingCommand ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command_type TEXT NOT NULL, -- e.g., 'DeployDrogue', 'Online', 'Ping' + parameters TEXT, -- JSON string or simple value representing command arguments. Can be NULL if no params. + status TEXT NOT NULL, -- e.g., 'Pending', 'Queued', 'Sending', 'Sent', 'Failed', 'Cancelled' + created_at INTEGER NOT NULL, -- UNIX epoch timestamp when the command was requested + queued_at INTEGER, -- UNIX epoch timestamp when the dispatcher picked it up (optional) + sent_at INTEGER, -- UNIX epoch timestamp when the dispatcher attempted to send it (optional) + attempts INTEGER NOT NULL DEFAULT 0, -- Number of send attempts + error_message TEXT, -- Details if sending failed (optional) + source_service TEXT NOT NULL -- e.g., 'rgs-web', 'command-dispatcher-keepalive' +); + +-- Index for efficient retrieval of pending commands by the dispatcher +CREATE INDEX IF NOT EXISTS idx_outgoingcommand_status_created ON OutgoingCommand (status, created_at); + +-- Table to store system-wide configuration settings. +CREATE TABLE IF NOT EXISTS SystemConfig ( + key TEXT PRIMARY KEY, -- Unique configuration key, e.g., 'gateway_mode', 'serial_port_path' + value TEXT NOT NULL, -- The configuration value (stored as text) + description TEXT, -- Optional description of the setting + updated_at INTEGER NOT NULL -- UNIX epoch timestamp when the setting was last updated +); + + +-- Table to store the current status of each running service instance. +CREATE TABLE IF NOT EXISTS ServiceStatus ( + service_instance_id TEXT PRIMARY KEY, -- Unique ID for a service instance (e.g., 'rgs-telemetry-ingestor@hostname-pid') + service_name TEXT NOT NULL, -- e.g., 'rgs-telemetry-ingestor', 'rgs-web' + hostname TEXT, -- Hostname where the service is running (optional) + status TEXT NOT NULL, -- e.g., 'Running', 'Degraded', 'Error', 'Stopped' + status_message TEXT, -- Short message detailing current state (optional) + last_heartbeat_at INTEGER NOT NULL, -- UNIX epoch timestamp of the last status update + start_time INTEGER -- UNIX epoch timestamp when the service started (optional) +); + +-- Index for efficient retrieval of service statuses by name +CREATE INDEX IF NOT EXISTS idx_servicestatus_name ON ServiceStatus (service_name); + +CREATE TABLE IF NOT EXISTS ServicePing ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id TEXT NOT NULL, + hostname TEXT, + app_timestamp INTEGER NOT NULL, + db_timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_serviceping_db_timestamp ON ServicePing (db_timestamp); +CREATE INDEX IF NOT EXISTS idx_serviceping_service_db_timestamp ON ServicePing (service_id, db_timestamp); + +--#endregion + +--#region Incoming telemetry tables +CREATE TABLE IF NOT EXISTS RadioFrame ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, -- ISO 8601 format + timestamp_epoch INTEGER NOT NULL, -- UNIX epoch time for range queries + node TEXT NOT NULL, -- Enum Node type (PressureBoard, StrainBoard, etc) + data_type TEXT NOT NULL, -- Payload type: "Command" | "Log" | "PhoenixState" | "PhoenixEvent" | "ArgusState" | "ArgusEvent" | "SbgGpsPos" | "SbgUtcTime" | "SbgImu" | "SbgEkfQuat" | "SbgEkfNav" | "SbgGpsVel" | "SbgAir" | "Gps" | "Imu" | "Madgwick" | "Barometer" | "ArgusPressure" | "ArgusTemperature" | "ArgusStrain" + data_id INTEGER NOT NULL, -- Foreign key to specific data table + millis_since_start INTEGER -- Milliseconds since node start, if provided +); + + +CREATE TABLE IF NOT EXISTS Command ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + data_type TEXT NOT NULL, -- Enum Command subtype (e.g., "DeployDrogue", "DeployMain", "PowerDown", "RadioRateChange", "Online") + data_id INTEGER -- Foreign key to subtype-specific table +); + + +--#region Command subtypes +CREATE TABLE IF NOT EXISTS Online ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + online BOOLEAN NOT NULL +); + +CREATE TABLE IF NOT EXISTS DeployDrogue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + val BOOLEAN NOT NULL +); + +CREATE TABLE IF NOT EXISTS DeployMain ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + val BOOLEAN NOT NULL +); + +CREATE TABLE IF NOT EXISTS PowerDown ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + board TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS RadioRateChange ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rate TEXT NOT NULL +); + +-- Camera power control commands (no additional fields for now) +CREATE TABLE IF NOT EXISTS PowerUpCamera ( + id INTEGER PRIMARY KEY AUTOINCREMENT +); + +CREATE TABLE IF NOT EXISTS PowerDownCamera ( + id INTEGER PRIMARY KEY AUTOINCREMENT +); + +CREATE TABLE IF NOT EXISTS Ping ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ping_id INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS Pong ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pong_id INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS Log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + event TEXT NOT NULL, + message BLOB +); + +CREATE TABLE IF NOT EXISTS State ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS Event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL +); + + +--#endregion + +--#region Sbg + +CREATE TABLE IF NOT EXISTS SbgUtcTime ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time_stamp INTEGER NOT NULL, + status TEXT NOT NULL, + year INTEGER, + month INTEGER, + day INTEGER, + hour INTEGER, + minute INTEGER, + second INTEGER, + nano_second INTEGER, + gps_time_of_week INTEGER +); + +CREATE TABLE IF NOT EXISTS SbgAir ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time_stamp INTEGER NOT NULL, + status TEXT NOT NULL, + pressure_abs REAL, + altitude REAL, + pressure_diff REAL, + true_airspeed REAL, + air_temperature REAL +); + +CREATE TABLE IF NOT EXISTS SbgEkfQuat ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time_stamp INTEGER NOT NULL, + quaternion_w REAL, + quaternion_x REAL, + quaternion_y REAL, + quaternion_z REAL, + euler_std_dev_roll REAL, + euler_std_dev_pitch REAL, + euler_std_dev_yaw REAL, + status TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS SbgEkfNav ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL, + velocity_north REAL, + velocity_east REAL, + velocity_down REAL, + velocity_std_dev_north REAL, + velocity_std_dev_east REAL, + velocity_std_dev_down REAL, + position_latitude REAL, + position_longitude REAL, + position_altitude REAL, + position_std_dev_latitude REAL, + position_std_dev_longitude REAL, + position_std_dev_altitude REAL, + time_stamp INTEGER NOT NULL, + undulation REAL +); + +CREATE TABLE IF NOT EXISTS SbgImu ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time_stamp INTEGER NOT NULL, + status TEXT NOT NULL, + accelerometer_x REAL, + accelerometer_y REAL, + accelerometer_z REAL, + gyroscope_x REAL, + gyroscope_y REAL, + gyroscope_z REAL, + delta_velocity_x REAL, + delta_velocity_y REAL, + delta_velocity_z REAL, + delta_angle_x REAL, + delta_angle_y REAL, + delta_angle_z REAL, + temperature REAL +); + +CREATE TABLE IF NOT EXISTS SbgGpsVel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time_of_week INTEGER, + time_stamp INTEGER NOT NULL, + status TEXT NOT NULL, + velocity_north REAL, + velocity_east REAL, + velocity_down REAL, + velocity_acc_north REAL, + velocity_acc_east REAL, + velocity_acc_down REAL, + course REAL, + course_acc REAL +); + +CREATE TABLE IF NOT EXISTS SbgGpsPos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + latitude REAL, + longitude REAL, + time_of_week INTEGER, + undulation REAL, + altitude REAL, + time_stamp INTEGER NOT NULL, + status TEXT NOT NULL, + latitude_accuracy REAL, + longitude_accuracy REAL, + altitude_accuracy REAL, + num_sv_used INTEGER, + base_station_id INTEGER, + differential_age INTEGER +); + +-- Table to store radio metrics such as RSSI and packet loss statistics +CREATE TABLE IF NOT EXISTS RadioMetrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + rssi INTEGER, + packets_lost INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_radiometrics_timestamp ON RadioMetrics (timestamp); + +-- Summary table to maintain aggregates for fast queries +CREATE TABLE IF NOT EXISTS MetricsSummary ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total_packets_lost INTEGER NOT NULL DEFAULT 0, + total_radio_frames INTEGER NOT NULL DEFAULT 0 +); + +-- Ensure a single row exists +INSERT INTO MetricsSummary (id, total_packets_lost, total_radio_frames) + SELECT 1, 0, 0 + WHERE NOT EXISTS (SELECT 1 FROM MetricsSummary WHERE id = 1); + +-- Triggers to update summary on inserts +CREATE TRIGGER IF NOT EXISTS trg_radiometrics_insert +AFTER INSERT ON RadioMetrics +BEGIN + UPDATE MetricsSummary + SET total_packets_lost = total_packets_lost + COALESCE(NEW.packets_lost, 0); +END; + +CREATE TRIGGER IF NOT EXISTS trg_radioframe_insert +AFTER INSERT ON RadioFrame +BEGIN + UPDATE MetricsSummary + SET total_radio_frames = total_radio_frames + 1; +END; + +-- Sensor tables for protobuf messages +CREATE TABLE IF NOT EXISTS Iim20670Imu ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp_us INTEGER NOT NULL, + gyro_x REAL, + gyro_y REAL, + gyro_z REAL, + accel_x REAL, + accel_y REAL, + accel_z REAL, + temperature_1 INTEGER, + temperature_2 INTEGER, + accel_fs INTEGER, + gyro_fs INTEGER, + spi_status INTEGER, + low_res_accel_x REAL, + low_res_accel_y REAL, + low_res_accel_z REAL +); + +CREATE TABLE IF NOT EXISTS Madgwick ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quat_w REAL, + quat_x REAL, + quat_y REAL, + quat_z REAL +); + +-- Barometer message table (MS5611) +CREATE TABLE IF NOT EXISTS Barometer ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pressure_kpa REAL, + temperature_celsius REAL +); + +-- Phoenix state/event tables +CREATE TABLE IF NOT EXISTS PhoenixState ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS PhoenixEvent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL +); + +-- Argus state/event tables +CREATE TABLE IF NOT EXISTS ArgusState ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS ArgusEvent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL +); + +-- Argus sensor tables +CREATE TABLE IF NOT EXISTS ArgusPressure ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pressure REAL, + sensor_id INTEGER +); + +CREATE TABLE IF NOT EXISTS ArgusTemperature ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + temperature REAL, + sensor_id INTEGER +); + +CREATE TABLE IF NOT EXISTS ArgusStrain ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strain REAL, + sensor_id INTEGER +); + + +--#endregion + +--#endregion diff --git a/deployments/ground-station/db/sql.ts b/deployments/ground-station/db/sql.ts new file mode 100644 index 0000000..5badd36 --- /dev/null +++ b/deployments/ground-station/db/sql.ts @@ -0,0 +1,64 @@ +/** + * Remove line comments (--) and block comments (/* *\/) from SQL, + * collapse empty lines, and split into statements by semicolon. + * This is a simplistic splitter and will not handle semicolons inside strings. + */ +export function parseSqlStatements(sql: string): string[] { + // Remove block comments + const noBlock = sql.replace(/\/\*[\s\S]*?\*\//g, ""); + // Remove line comments + const noLine = noBlock.replace(/^\s*--.*$/gm, ""); + + const lines = noLine.split(/\r?\n/); + const statements: string[] = []; + let buffer: string[] = []; + let inTrigger = false; + + const pushBuffer = () => { + const stmt = buffer.join("\n").trim(); + if (stmt.length > 0) { + statements.push(stmt); + } + buffer = []; + }; + + for (const originalLine of lines) { + const line = originalLine; // preserve indentation + const trimmed = line.trim(); + + if (!inTrigger && /^CREATE\s+TRIGGER/i.test(trimmed)) { + inTrigger = true; + buffer.push(line); + continue; + } + + if (inTrigger) { + buffer.push(line); + if (/\bEND\s*;\s*$/i.test(trimmed)) { + inTrigger = false; + pushBuffer(); + } + continue; + } + + if (trimmed.length === 0) { + // Keep blank lines within a statement for readability + if (buffer.length > 0) buffer.push(line); + continue; + } + + buffer.push(line); + if (trimmed.endsWith(";")) { + pushBuffer(); + } + } + + // Flush any remaining buffer + pushBuffer(); + + return statements; +} + +export interface StatementExecutor { + execute(sql: string): Promise; +} diff --git a/deployments/ground-station/db/tsconfig.json b/deployments/ground-station/db/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/deployments/ground-station/db/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/deployments/ground-station/docker/Dockerfile.rust-service b/deployments/ground-station/docker/Dockerfile.rust-service new file mode 100644 index 0000000..f19fa00 --- /dev/null +++ b/deployments/ground-station/docker/Dockerfile.rust-service @@ -0,0 +1,32 @@ +FROM rust:bookworm AS builder + +ARG CRATE + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + clang \ + cmake \ + git \ + libclang-dev \ + libssl-dev \ + libudev-dev \ + llvm-dev \ + pkg-config \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +COPY . . +RUN cargo build --locked --release -p "${CRATE}" \ + && install -Dm755 "target/release/${CRATE}" /out/service + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libudev1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=builder /out/service /app/bin/service +ENTRYPOINT ["/app/bin/service"] diff --git a/docs/ground-station/architecture.md b/docs/ground-station/architecture.md new file mode 100644 index 0000000..88047a7 --- /dev/null +++ b/docs/ground-station/architecture.md @@ -0,0 +1,29 @@ +# Architecture + +Ground-station processes are separate where hardware ownership, safety, persistence, or resource boundaries require it. Docker Compose owns process supervision. + +## Data Flow + +```text +radio <-> sergw <-> telemetry-ingestor -> LibSQL <- ground-station-web <-> operator + ^ ^ + | | + +--- command-dispatcher --+ + +ground-station-web <-> tile-provider +``` + +## Applications + +- **sergw** (`apps/sergw`, Rust) owns the physical serial device and exposes a raw TCP bridge. Its Compose service is optional when the radio is attached to another host. +- **telemetry-ingestor** (`apps/telemetry-ingestor`, Rust) decodes MAVLink and protobuf telemetry, writes LibSQL records, and reports service health. +- **command-dispatcher** (`apps/command-dispatcher`, Rust) consumes queued commands, sends them through `sergw`, updates delivery state, and emits bounded Ping keepalives. +- **ground-station-web** (`apps/ground-station-web`, SvelteKit/Bun) provides the operator UI and server-side APIs over LibSQL and local service controls. +- **tile-provider** (`apps/tile-provider`, Bun) owns map tile caching and offline download jobs. +- **antenna-tracker** (`apps/antenna-tracker`, Rust/Python) is an optional field application for GPS extraction, prediction, and antenna control. + +LibSQL, schema migration, Traefik routing, and application wiring are defined in `deployments/ground-station/compose.yaml`. Diagnostic and setup programs under `tools/ground-station` are not deployed by default. + +## Protocol Ownership + +The ground station currently consumes `messages-prost` and the uORocketry MAVLink dialect from their existing Git repositories. Moving those generated protocol artifacts into `common/` is intentionally separate from this repository migration. diff --git a/docs/ground-station/hands-on/modifying-the-database.md b/docs/ground-station/hands-on/modifying-the-database.md new file mode 100644 index 0000000..dfb7aaa --- /dev/null +++ b/docs/ground-station/hands-on/modifying-the-database.md @@ -0,0 +1,16 @@ +# Modifying the Database + +Schema is defined in `deployments/ground-station/db/seed.sql` and applied by its Bun migration script. + +**Steps:** + +1. Edit `deployments/ground-station/db/seed.sql`. +2. Start LibSQL instance (see [Docker requirements](../requirements/docker.md)) +3. Apply schema: + +```sh +cd deployments/ground-station/db +bun run index.ts +``` + +On failure, the script prints the failed SQL and exits non-zero. diff --git a/docs/ground-station/index.md b/docs/ground-station/index.md new file mode 100644 index 0000000..41eb852 --- /dev/null +++ b/docs/ground-station/index.md @@ -0,0 +1,12 @@ +# Ground Station + +The uORocketry ground station receives MAVLink telemetry, persists decoded protobuf messages, presents a compact operator interface, and dispatches commands back to the vehicle. + +## Repository Locations + +- Runtime applications: `apps/sergw`, `apps/telemetry-ingestor`, `apps/command-dispatcher`, `apps/ground-station-web`, and `apps/tile-provider` +- Optional antenna application: `apps/antenna-tracker` +- Deployment configuration and LibSQL schema: `deployments/ground-station` +- Development and field utilities: `tools/ground-station` + +Start with [Architecture](architecture.md), then review the [requirements](requirements/index.md) and deployment README. diff --git a/docs/ground-station/next-steps.md b/docs/ground-station/next-steps.md new file mode 100644 index 0000000..058e9d2 --- /dev/null +++ b/docs/ground-station/next-steps.md @@ -0,0 +1,3 @@ +# Next Steps + +You should now be able to run the ground station locally. The following hands-on projects will help you become familiar with the system. diff --git a/docs/ground-station/requirements/bun.md b/docs/ground-station/requirements/bun.md new file mode 100644 index 0000000..73a6b4f --- /dev/null +++ b/docs/ground-station/requirements/bun.md @@ -0,0 +1,25 @@ +# Bun + +Bun is installed via `devbox`. For Bun applications, install dependencies and run: + +```sh +cd +bun install +bun run dev +``` + +Test with the `web` folder. Server runs at . + +> Tip: Browser warnings about untrusted certificates in local dev are safe to ignore. + +## Troubleshooting + +**Common mistakes:** +- Running `npm install` → Use `bun install` instead +- Running as `sudo` → Never run package managers as sudo + +**ENOENT errors:** Verify you're in the correct directory. + +**Config/module errors:** Run `bun install` then `bun run dev` again. + +**Connection errors:** Ensure the dev server is running and check the URL. diff --git a/docs/ground-station/requirements/development-environment.md b/docs/ground-station/requirements/development-environment.md new file mode 100644 index 0000000..3682515 --- /dev/null +++ b/docs/ground-station/requirements/development-environment.md @@ -0,0 +1,12 @@ +# Development Environment + +Linux is the primary development and deployment environment. Windows contributors should use WSL. + +```bash +git clone https://github.com/uorocketry/avionics-software +cd avionics-software +sudo apt update +sudo apt install -y git pkg-config libudev-dev +``` + +Install Docker, Bun, and UV using the linked requirement pages or their official installers. Open the repository root in your editor so Rust Analyzer can discover the shared Cargo workspace. diff --git a/docs/ground-station/requirements/docker.md b/docs/ground-station/requirements/docker.md new file mode 100644 index 0000000..0e92091 --- /dev/null +++ b/docs/ground-station/requirements/docker.md @@ -0,0 +1,20 @@ +# Docker + +[Docker](https://docs.docker.com/) is required for services and database. Install [Docker Desktop](https://www.docker.com/products/docker-desktop). + +## Start Services + +```sh +docker compose up -d +``` + +## Apply Database Schema + +Services connect to LibSQL at `http://db:8080`. Apply schema from host: + +```sh +cd db +bun run index.ts +``` + +Schema (`db/seed.sql`) creates tables: `RadioFrame`, `Command`, SBG sensor tables. Inspect data with LibSQL/Turso-compatible clients or service logs. \ No newline at end of file diff --git a/docs/ground-station/requirements/index.md b/docs/ground-station/requirements/index.md new file mode 100644 index 0000000..11a7bb6 --- /dev/null +++ b/docs/ground-station/requirements/index.md @@ -0,0 +1,10 @@ +# Requirements + +The monorepo pins Rust through `rust-toolchain.toml`. Ground-station development additionally requires: + +- Docker with Compose for the integrated deployment +- Bun for the web, database migration, and tile-provider applications +- UV and Python 3.12+ for antenna and hardware utilities +- `pkg-config` and `libudev-dev` for host Rust applications that access serial devices + +See [Development Environment](development-environment.md), [Docker](docker.md), [Bun](bun.md), and [Rust](rust.md). diff --git a/docs/ground-station/requirements/rust.md b/docs/ground-station/requirements/rust.md new file mode 100644 index 0000000..7dbc733 --- /dev/null +++ b/docs/ground-station/requirements/rust.md @@ -0,0 +1,8 @@ +# Rust + +Rust is installed via `devbox`. Build and run Rust services with: + +```sh +cargo build +cargo run +``` diff --git a/docs/ground-station/web/index.md b/docs/ground-station/web/index.md new file mode 100644 index 0000000..514e8de --- /dev/null +++ b/docs/ground-station/web/index.md @@ -0,0 +1,13 @@ +# Web + +SvelteKit dashboard frontend for the RGS ground station. + +## Quick Start + +```bash +cd web +bun install +bun run dev +``` + +Development server runs at . Configure port via `WEB_SERVER_PORT` environment variable. diff --git a/tools/ground-station/README.md b/tools/ground-station/README.md new file mode 100644 index 0000000..794a61f --- /dev/null +++ b/tools/ground-station/README.md @@ -0,0 +1,11 @@ +# Ground Station Tools + +Development, setup, and field-diagnostics utilities that are not part of the default deployment: + +- `manual-command-dispatcher`: direct MAVLink command TUI +- `virtual-serial`: pseudo-terminal serial simulator +- `remote-test`: gamepad input diagnostics +- `sbg-test`: SBG device readers and build helpers +- `ssh-setup`: local-network SSH provisioning +- `tenna-manual` and `tenna-setup`: antenna hardware utilities +- `scripts`: workstation setup and port inspection helpers diff --git a/tools/ground-station/manual-command-dispatcher/Cargo.toml b/tools/ground-station/manual-command-dispatcher/Cargo.toml new file mode 100644 index 0000000..306b3ec --- /dev/null +++ b/tools/ground-station/manual-command-dispatcher/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "manual-command-dispatcher" +version.workspace = true +authors.workspace = true +publish.workspace = true +license.workspace = true +edition = "2021" + +[dependencies] +tokio = { workspace = true } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +mavlink = { workspace = true } +messages-prost = { workspace = true } +crossterm = "0.27" +ratatui = { version = "0.26", default-features = false, features = ["crossterm"] } +futures = "0.3" +prost = { workspace = true } diff --git a/tools/ground-station/manual-command-dispatcher/src/args.rs b/tools/ground-station/manual-command-dispatcher/src/args.rs new file mode 100644 index 0000000..4175753 --- /dev/null +++ b/tools/ground-station/manual-command-dispatcher/src/args.rs @@ -0,0 +1,19 @@ +use clap::Parser; +use messages_prost::common::Node; + +#[derive(Parser, Debug, Clone)] +#[command(version, about = "Manual protobuf command dispatcher (TUI)", long_about = None)] +pub struct Args { + #[arg( + long, + default_value = "tcpout:127.0.0.1:5656", + help = "Gateway MAVLink connection string (e.g., tcpout:localhost:5656)" + )] + pub gateway_connection_string: String, + + #[arg(long, default_value_t = Node::GroundStation as i32, help = "Default command origin node as numeric enum value")] + pub origin_node: i32, + + #[arg(long, default_value_t = Node::Phoenix as i32, help = "Default command target node as numeric enum value")] + pub target_node: i32, +} diff --git a/tools/ground-station/manual-command-dispatcher/src/commands.rs b/tools/ground-station/manual-command-dispatcher/src/commands.rs new file mode 100644 index 0000000..f1a7831 --- /dev/null +++ b/tools/ground-station/manual-command-dispatcher/src/commands.rs @@ -0,0 +1,109 @@ +use messages_prost::command as cmd; + +#[derive(Copy, Clone, Debug)] +pub enum MenuItem { + Online, + DeployDrogue, + DeployMain, + PowerDown, + PowerUpCamera, + PowerDownCamera, + RadioRateLow, + RadioRateMedium, + RadioRateHigh, + Ping, +} + +impl MenuItem { + pub fn all() -> &'static [MenuItem] { + use MenuItem::*; + const ITEMS: &[MenuItem] = &[ + Online, + DeployDrogue, + DeployMain, + PowerDown, + PowerUpCamera, + PowerDownCamera, + RadioRateLow, + RadioRateMedium, + RadioRateHigh, + Ping, + ]; + ITEMS + } + + pub fn label(&self) -> &'static str { + match self { + MenuItem::Online => "Online { online: true }", + MenuItem::DeployDrogue => "DeployDrogue { val: true }", + MenuItem::DeployMain => "DeployMain { val: true }", + MenuItem::PowerDown => "PowerDown { board: }", + MenuItem::PowerUpCamera => "PowerUpCamera", + MenuItem::PowerDownCamera => "PowerDownCamera", + MenuItem::RadioRateLow => "RadioRateChange { rate: LOW }", + MenuItem::RadioRateMedium => "RadioRateChange { rate: MEDIUM }", + MenuItem::RadioRateHigh => "RadioRateChange { rate: HIGH }", + MenuItem::Ping => "Ping { id: }", + } + } +} + +pub fn build_command( + item: MenuItem, + _origin_node: i32, + target_node: i32, + ping_counter: &mut u32, +) -> cmd::Command { + match item { + MenuItem::Online => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::Online(cmd::Online { online: true })), + }, + MenuItem::DeployDrogue => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::DeployDrogue(cmd::DeployDrogue { val: true })), + }, + MenuItem::DeployMain => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::DeployMain(cmd::DeployMain { val: true })), + }, + MenuItem::PowerDown => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::PowerDown(cmd::PowerDown { board: target_node })), + }, + MenuItem::PowerUpCamera => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::PowerUpCamera(cmd::PowerUpCamera {})), + }, + MenuItem::PowerDownCamera => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::PowerDownCamera(cmd::PowerDownCamera {})), + }, + MenuItem::RadioRateLow => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::RadioRateChange(cmd::RadioRateChange { + rate: cmd::RadioRate::RateLow as i32, + })), + }, + MenuItem::RadioRateMedium => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::RadioRateChange(cmd::RadioRateChange { + rate: cmd::RadioRate::RateMedium as i32, + })), + }, + MenuItem::RadioRateHigh => cmd::Command { + node: target_node, + data: Some(cmd::command::Data::RadioRateChange(cmd::RadioRateChange { + rate: cmd::RadioRate::RateHigh as i32, + })), + }, + MenuItem::Ping => { + let id = ping_counter.wrapping_add(1); + *ping_counter = id; + cmd::Command { + node: target_node, + data: Some(cmd::command::Data::Ping(cmd::Ping { id })), + } + } + } +} diff --git a/tools/ground-station/manual-command-dispatcher/src/main.rs b/tools/ground-station/manual-command-dispatcher/src/main.rs new file mode 100644 index 0000000..10695b9 --- /dev/null +++ b/tools/ground-station/manual-command-dispatcher/src/main.rs @@ -0,0 +1,196 @@ +mod args; +mod commands; +mod messages { + pub mod display; +} +mod transport; +mod ui; + +use std::error::Error; +use std::fs::OpenOptions; +use std::io; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use args::Args; +use clap::Parser; +use commands::{build_command, MenuItem}; +use crossterm::event::{self, Event as CEvent, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use messages_prost::common::Node; +use messages_prost::radio::radio_frame::Payload; +use messages_prost::radio::RadioFrame; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use tracing::{error, info}; +use transport::{spawn_receiver, spawn_sender}; + +// state, args, and menu items moved to modules + +// build_command now lives in commands.rs + +fn encode_message(msg: &M) -> Vec { + prost::Message::encode_length_delimited_to_vec(msg) +} + +// drawing moved to ui::draw + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + let start_instant = Instant::now(); + // Initialize logging to a file relative to current working directory + let log_path = std::env::current_dir()?.join("manual-command-dispatcher.log"); + let log_file = OpenOptions::new().create(true).append(true).open(&log_path)?; + let file_arc = Arc::new(Mutex::new(log_file)); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_writer({ + let file_arc = Arc::clone(&file_arc); + move || file_arc.lock().unwrap().try_clone().unwrap() + }) + .init(); + info!("Starting manual-command-dispatcher. Logging to {:?}", log_path); + + // Spawn IO threads so UI is not blocked + let (tx_out, _send_handle) = spawn_sender(args.gateway_connection_string.clone()); + let (rx_in, _recv_handle) = spawn_receiver(args.gateway_connection_string.clone()); + + // Setup TUI + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut state = ui::AppState::new(args.origin_node, args.target_node); + let mut last_tick = Instant::now(); + let tick_rate = Duration::from_millis(200); + + loop { + terminal.draw(|f| ui::draw(f, &state))?; + + let timeout = tick_rate.checked_sub(last_tick.elapsed()).unwrap_or(Duration::from_secs(0)); + + if crossterm::event::poll(timeout)? { + if let CEvent::Key(KeyEvent { code, modifiers, .. }) = event::read()? { + match code { + KeyCode::Char('q') => break, + KeyCode::Up => { + if state.selected == 0 { + state.selected = MenuItem::all().len() - 1; + } else { + state.selected -= 1; + } + } + KeyCode::Down => { + state.selected = (state.selected + 1) % MenuItem::all().len(); + } + KeyCode::Enter => { + let item = MenuItem::all()[state.selected]; + info!( + "Enter pressed -> preparing command: {:?}, origin={:?}, target={:?}", + item, + Node::try_from(state.origin_node).unwrap_or(Node::Unspecified), + Node::try_from(state.target_node).unwrap_or(Node::Unspecified) + ); + let command = build_command(item, state.origin_node, state.target_node, &mut state.ping_counter); + + // Radio frame + let frame = RadioFrame { + payload: Some(Payload::Command(command)), + node: state.origin_node, + millis_since_start: start_instant.elapsed().as_millis() as u64, + }; + + let bytes = encode_message(&frame); + info!("Encoded command {:?} into {} bytes", item, bytes.len()); + info!("Queueing frame to sender thread..."); + let send_len = bytes.len(); + match tx_out.send(bytes) { + Ok(_) => { + info!("Send success: {:?} ({} bytes)", item, send_len); + state.left_log_lines.push(format!("Sent {:?} ({} bytes)", item, send_len)); + let sm = crate::messages::display::summarize_command(state.origin_node, &command); + state.right_sent_messages.push(sm); + } + Err(e) => { + error!("Send failed for {:?}: {}", item, e); + state.left_log_lines.push(format!("Send error: {}", e)); + } + } + } + KeyCode::Char('n') => { + // cycle target node + state.target_node = match Node::try_from(state.target_node).unwrap_or(Node::Unspecified) { + Node::Unspecified => Node::PressureBoard as i32, + Node::PressureBoard => Node::TemperatureBoard as i32, + Node::TemperatureBoard => Node::StrainBoard as i32, + Node::StrainBoard => Node::GroundStation as i32, + Node::GroundStation => Node::Phoenix as i32, + Node::Phoenix => Node::Unspecified as i32, + }; + } + KeyCode::Char('p') => { + // cycle origin node + state.origin_node = match Node::try_from(state.origin_node).unwrap_or(Node::GroundStation) { + Node::Unspecified => Node::PressureBoard as i32, + Node::PressureBoard => Node::TemperatureBoard as i32, + Node::TemperatureBoard => Node::StrainBoard as i32, + Node::StrainBoard => Node::GroundStation as i32, + Node::GroundStation => Node::Phoenix as i32, + Node::Phoenix => Node::Unspecified as i32, + }; + } + KeyCode::Char('1') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 0; + } + KeyCode::Char('2') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 1; + } + KeyCode::Char('3') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 2; + } + KeyCode::Char('4') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 3; + } + KeyCode::Char('5') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 4; + } + KeyCode::Char('6') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 5; + } + KeyCode::Char('7') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 6; + } + KeyCode::Char('8') if modifiers.contains(KeyModifiers::CONTROL) => { + state.selected = 7; + } + _ => {} + } + } + } + + // background receive: drain a few frames per tick without blocking + for _ in 0..16 { + match rx_in.try_recv() { + Ok(bytes) => { + let summary = crate::messages::display::summarize_received_bytes(&bytes); + state.right_received_messages.push(summary); + } + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => break, + } + } + + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } + } + + // teardown + disable_raw_mode()?; + crossterm::execute!(terminal.backend_mut(), crossterm::terminal::LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/tools/ground-station/manual-command-dispatcher/src/messages/display.rs b/tools/ground-station/manual-command-dispatcher/src/messages/display.rs new file mode 100644 index 0000000..cd57308 --- /dev/null +++ b/tools/ground-station/manual-command-dispatcher/src/messages/display.rs @@ -0,0 +1,173 @@ +use messages_prost::command as cmd; +use messages_prost::common::Node; +use messages_prost::radio::{self, RadioFrame}; +use messages_prost::sbg::sbg_data; +use prost::Message as _; +use tracing::{debug, warn}; + +#[derive(Clone, Debug)] +pub struct SentMessage { + pub summary: String, +} + +pub fn summarize_command( + node_from: i32, + command: &cmd::Command, +) -> SentMessage { + let origin = Node::try_from(node_from).unwrap_or(Node::Unspecified); + let target = Node::try_from(command.node).unwrap_or(Node::Unspecified); + let details = match command.data.as_ref() { + Some(cmd::command::Data::Online(v)) => format!("Online online={} ", v.online), + Some(cmd::command::Data::DeployDrogue(v)) => format!("DeployDrogue val={} ", v.val), + Some(cmd::command::Data::DeployMain(v)) => format!("DeployMain val={} ", v.val), + Some(cmd::command::Data::PowerDown(v)) => format!("PowerDown board={:?} ", Node::try_from(v.board).unwrap_or(Node::Unspecified)), + Some(cmd::command::Data::PowerUpCamera(_)) => "PowerUpCamera".to_string(), + Some(cmd::command::Data::PowerDownCamera(_)) => "PowerDownCamera".to_string(), + Some(cmd::command::Data::RadioRateChange(v)) => format!("RadioRateChange rate={} ", v.rate), + Some(cmd::command::Data::Ping(v)) => format!("Ping id={} ", v.id), + Some(cmd::command::Data::Pong(v)) => format!("Pong id={} ", v.id), + None => "".to_string(), + }; + SentMessage { + summary: format!("{:?} -> {:?}: {}", origin, target, details), + } +} + +pub fn summarize_received_bytes(bytes: &[u8]) -> SentMessage { + // Only decode as RadioFrame, do not attempt legacy message handling + match RadioFrame::decode_length_delimited(bytes) { + Ok(frame) => { + let origin = Node::try_from(frame.node).unwrap_or(Node::Unspecified); + debug!("Successfully decoded RadioFrame from node={:?}", origin); + match frame.payload { + Some(radio::radio_frame::Payload::Sbg(sbg)) => { + if let Some(inner) = &sbg.data { + let kind = match inner { + sbg_data::Data::GpsPos(v) => match &v.data { + Some(gps_data) => format!("SbgGpsPos lat: {:?}, lon: {:?}", gps_data.latitude, gps_data.longitude), + None => match &v.status { + Some(status) => { + format!("SbgGpsPos status: {:?}", status) + } + None => "SbgGpsPos ".to_string(), + }, + }, + sbg_data::Data::UtcTime(_) => "SbgUtcTime".to_string(), + sbg_data::Data::Imu(_) => "SbgImu".to_string(), + sbg_data::Data::EkfQuat(_) => "SbgEkfQuat".to_string(), + sbg_data::Data::EkfNav(_) => "SbgEkfNav".to_string(), + sbg_data::Data::GpsVel(_) => "SbgGpsVel".to_string(), + sbg_data::Data::Air(_) => "SbgAir".to_string(), + }; + return SentMessage { + summary: format!("RadioFrame Sbg {} from node={:?}", kind, origin), + }; + } + return SentMessage { + summary: format!("RadioFrame Sbg from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::Gps(gps)) => { + let len = gps.data.len(); + return SentMessage { + summary: format!("RadioFrame Gps ({} bytes) from node={:?}", len, origin), + }; + } + Some(radio::radio_frame::Payload::Madgwick(_m)) => { + return SentMessage { + summary: format!("RadioFrame Madgwick from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::Iim20670(_m)) => { + return SentMessage { + summary: format!("RadioFrame Imu(IIM20670) from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::Barometer(_m)) => { + return SentMessage { + summary: format!("RadioFrame Baro from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::Log(_m)) => { + return SentMessage { + summary: format!("RadioFrame Log from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::PhoenixState(_s)) => { + return SentMessage { + summary: format!("RadioFrame PhoenixState from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::PhoenixEvent(_e)) => { + return SentMessage { + summary: format!("RadioFrame PhoenixEvent from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::ArgusState(_s)) => { + return SentMessage { + summary: format!("RadioFrame ArgusState from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::ArgusEvent(_e)) => { + return SentMessage { + summary: format!("RadioFrame ArgusEvent from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::Command(m)) => { + let detail = match m.data.as_ref() { + Some(cmd::command::Data::Ping(v)) => format!("Ping id={}", v.id), + Some(cmd::command::Data::Pong(v)) => format!("Pong id={}", v.id), + Some(cmd::command::Data::Online(v)) => { + format!("Online online={}", v.online) + } + Some(cmd::command::Data::DeployDrogue(v)) => { + format!("DeployDrogue val={}", v.val) + } + Some(cmd::command::Data::DeployMain(v)) => { + format!("DeployMain val={}", v.val) + } + Some(cmd::command::Data::PowerDown(v)) => { + format!("PowerDown board={:?}", Node::try_from(v.board).unwrap_or(Node::Unspecified)) + } + Some(cmd::command::Data::PowerUpCamera(_)) => "PowerUpCamera".to_string(), + Some(cmd::command::Data::PowerDownCamera(_)) => "PowerDownCamera".to_string(), + Some(cmd::command::Data::RadioRateChange(v)) => { + format!("RadioRateChange rate={}", v.rate) + } + None => "".to_string(), + }; + return SentMessage { + summary: format!("RadioFrame Command from node={:?}: {}", origin, detail), + }; + } + Some(radio::radio_frame::Payload::ArgusPressure(_)) => { + return SentMessage { + summary: format!("RadioFrame ArgusPressure from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::ArgusTemperature(_)) => { + return SentMessage { + summary: format!("RadioFrame ArgusTemperature from node={:?}", origin), + }; + } + Some(radio::radio_frame::Payload::ArgusStrain(_)) => { + return SentMessage { + summary: format!("RadioFrame ArgusStrain from node={:?}", origin), + }; + } + None => { + return SentMessage { + summary: format!("RadioFrame from node={:?}", origin), + }; + } + } + } + Err(radio_err) => { + warn!("RadioFrame decode failed: {}. Buffer: {:02x?}", radio_err, bytes); + } + } + + SentMessage { + summary: format!("Unknown/Failed decode ({} bytes)", bytes.len()), + } +} diff --git a/tools/ground-station/manual-command-dispatcher/src/transport.rs b/tools/ground-station/manual-command-dispatcher/src/transport.rs new file mode 100644 index 0000000..4d2ccee --- /dev/null +++ b/tools/ground-station/manual-command-dispatcher/src/transport.rs @@ -0,0 +1,100 @@ +use std::error::Error; +use std::sync::mpsc; +use std::thread; + +use mavlink::uorocketry::MavMessage; +use mavlink::{connect, MavConnection, MavHeader}; +use tracing::{debug, error, info, warn}; + +pub fn send_over_mavlink( + conn: &mut Box + Send + Sync>, + bytes: &[u8], +) -> Result<(), Box> { + let mut fixed = [0u8; 255]; + let len = bytes.len().min(255); + fixed[..len].copy_from_slice(&bytes[..len]); + let msg = MavMessage::POSTCARD_MESSAGE(mavlink::uorocketry::POSTCARD_MESSAGE_DATA { message: fixed }); + conn.send(&MavHeader::default(), &msg).map(|_| ()).map_err(|e| e.into()) +} + +pub fn spawn_sender(connection_string: String) -> (mpsc::Sender>, thread::JoinHandle<()>) { + let (tx, rx) = mpsc::channel::>(); + let handle = thread::spawn(move || { + loop { + info!("Sender thread: connecting to {}", connection_string); + let mut conn = match connect::(&connection_string) { + Ok(c) => c, + Err(e) => { + warn!("Sender connect failed: {}. Retrying...", e); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + info!("Sender connected. Waiting for outbound frames..."); + // Drain and send until error or channel closed + loop { + match rx.recv() { + Ok(bytes) => { + if let Err(e) = send_over_mavlink(&mut conn, &bytes) { + error!("Sender send error: {}. Reconnecting...", e); + break; // reconnect + } + } + Err(_) => { + info!("Sender channel closed. Exiting sender thread."); + return; + } + } + } + } + }); + (tx, handle) +} + +pub fn spawn_receiver(connection_string: String) -> (mpsc::Receiver>, thread::JoinHandle<()>) { + let (tx, rx) = mpsc::channel::>(); + let handle = thread::spawn(move || { + loop { + info!("Receiver thread: connecting to {}", connection_string); + let conn = match connect::(&connection_string) { + Ok(c) => c, + Err(e) => { + warn!("Receiver connect failed: {}. Retrying...", e); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + info!("Receiver connected. Listening for incoming frames..."); + loop { + match conn.recv() { + Ok((_header, message)) => match message { + MavMessage::POSTCARD_MESSAGE(data) => { + let _ = tx.send(data.message.to_vec()); + } + MavMessage::RADIO_STATUS(_) => { + // ignore here + } + other => { + debug!("Receiver: ignoring unexpected message: {:?}", other); + } + }, + Err(mavlink::error::MessageReadError::Io(io_err)) if io_err.kind() == std::io::ErrorKind::WouldBlock => { + // spin + continue; + } + Err(mavlink::error::MessageReadError::Io(io_err)) if io_err.kind() == std::io::ErrorKind::UnexpectedEof => { + warn!("Receiver: connection closed (EOF). Reconnecting..."); + break; // reconnect + } + Err(e) => { + warn!("Receiver: error: {}. Reconnecting...", e); + break; // reconnect + } + } + } + } + }); + (rx, handle) +} diff --git a/tools/ground-station/manual-command-dispatcher/src/ui/mod.rs b/tools/ground-station/manual-command-dispatcher/src/ui/mod.rs new file mode 100644 index 0000000..bacded5 --- /dev/null +++ b/tools/ground-station/manual-command-dispatcher/src/ui/mod.rs @@ -0,0 +1,141 @@ +use messages_prost::common::Node; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +use crate::commands::MenuItem; +use crate::messages::display::SentMessage; + +pub struct AppState { + pub selected: usize, + pub left_log_lines: Vec, + pub right_sent_messages: Vec, + pub right_received_messages: Vec, + pub ping_counter: u32, + pub origin_node: i32, + pub target_node: i32, +} + +impl AppState { + pub fn new( + origin_node: i32, + target_node: i32, + ) -> Self { + Self { + selected: 0, + left_log_lines: Vec::new(), + right_sent_messages: Vec::new(), + right_received_messages: Vec::new(), + ping_counter: 0, + origin_node, + target_node, + } + } +} + +pub fn draw( + f: &mut ratatui::Frame, + state: &AppState, +) { + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(8), Constraint::Length(3)].as_ref()) + .split(f.size()); + + let help = Paragraph::new("↑/↓ select, Enter send, q quit | n/p change target/origin node").block( + Block::default() + .title(Span::styled( + "manual-command-dispatcher", + Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL), + ); + f.render_widget(help, root[0]); + + let halves = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(root[1]); + + // Left: command list + let items: Vec = MenuItem::all() + .iter() + .enumerate() + .map(|(i, item)| { + let sel = i == state.selected; + let label = if sel { format!("» {}", item.label()) } else { item.label().to_string() }; + let content = if sel { + Span::styled(label, Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD)) + } else { + Span::styled(item.label(), Style::default().fg(Color::Gray)) + }; + ListItem::new(Line::from(content)) + }) + .collect(); + let list = List::new(items).block( + Block::default() + .title(Span::styled("Commands", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))) + .borders(Borders::ALL), + ); + f.render_widget(list, halves[0]); + + // Right: latest sent messages + // Right is vertically split into Sent (top) and Received (bottom) + let right_split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(halves[1]); + + let right_sent_lines: Vec = state + .right_sent_messages + .iter() + .rev() + .take(10) + .rev() + .map(|m| Line::from(Span::raw(m.summary.clone()))) + .collect(); + let right_sent = Paragraph::new(right_sent_lines).block( + Block::default() + .title(Span::styled( + "Latest Sent", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL), + ); + f.render_widget(right_sent, right_split[0]); + + let right_recv_lines: Vec = state + .right_received_messages + .iter() + .rev() + .take(10) + .rev() + .map(|m| Line::from(Span::raw(m.summary.clone()))) + .collect(); + let right_recv = Paragraph::new(right_recv_lines).block( + Block::default() + .title(Span::styled( + "Latest Received", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL), + ); + f.render_widget(right_recv, right_split[1]); + + // Footer: status + let mut footer: Vec = Vec::new(); + footer.push(Line::from(Span::raw(format!( + "Origin: {:?} Target: {:?}", + Node::try_from(state.origin_node).unwrap_or(Node::Unspecified), + Node::try_from(state.target_node).unwrap_or(Node::Unspecified) + )))); + let log_text: String = state.left_log_lines.iter().rev().take(2).rev().cloned().collect::>().join("\n"); + footer.push(Line::from(Span::raw(log_text))); + let footer = Paragraph::new(footer).block( + Block::default() + .title(Span::styled("Status", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))) + .borders(Borders::ALL), + ); + f.render_widget(footer, root[2]); +} diff --git a/tools/ground-station/remote-test/README.md b/tools/ground-station/remote-test/README.md new file mode 100644 index 0000000..d3c5f8c --- /dev/null +++ b/tools/ground-station/remote-test/README.md @@ -0,0 +1,37 @@ +# Remote Test Utility + +A simple joystick/gamepad input testing tool that reads and displays events from `/dev/input/js0`. + +## What It Does + +- Monitors joystick input events in real-time +- Displays button presses/releases and axis movements +- Automatically reconnects if the device is disconnected +- Normalizes axis values to -1.0 to 1.0 range + +## Usage + +```bash +python3 remote.py +``` + +## Output Example + +``` +[14:30:15] Listening on /dev/input/js0 (Ctrl-C to exit)… +[1234567 ms] Button A DOWN +[1234568 ms] Button A UP +[1234569 ms] Axis LX → 0.500 +[1234570 ms] Axis LY → -0.250 +``` + +## Requirements + +- Linux system with joystick/gamepad connected +- Python 3 +- Access to `/dev/input/js0` + +## Supported Inputs + +**Buttons**: A, B, X, Y, LB, RB, Back, Start, Guide, LThumb, RThumb +**Axes**: LX, LY, LT, RX, RY, RT diff --git a/tools/ground-station/remote-test/remote.py b/tools/ground-station/remote-test/remote.py new file mode 100755 index 0000000..2ac5ae7 --- /dev/null +++ b/tools/ground-station/remote-test/remote.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +import errno +import fcntl +import os +import struct +import threading +import time +from typing import Dict, Optional + + +EVENT_FMT = 'IhBB' +EVENT_SIZE = struct.calcsize(EVENT_FMT) + +JS_EVENT_BUTTON = 0x01 +JS_EVENT_AXIS = 0x02 +JS_EVENT_INIT = 0x80 + + +BUTTON_INDEX_TO_NAME: Dict[int, str] = { + 0: 'A', 1: 'B', 2: 'X', 3: 'Y', + 4: 'LB', 5: 'RB', 6: 'Back', 7: 'Start', + 8: 'Guide', 9: 'LThumb', 10: 'RThumb', +} + +AXIS_INDEX_TO_NAME: Dict[int, str] = { + 0: 'LX', 1: 'LY', 2: 'LT', + 3: 'RX', 4: 'RY', 5: 'RT', +} + +BUTTON_NAME_TO_INDEX: Dict[str, int] = {v: k for k, v in BUTTON_INDEX_TO_NAME.items()} +AXIS_NAME_TO_INDEX: Dict[str, int] = {v: k for k, v in AXIS_INDEX_TO_NAME.items()} + + +def _normalize_axis(value: int) -> float: + if value <= -32767: + return -1.0 + if value >= 32767: + return 1.0 + return value / 32767.0 + + +class XboxController: + """Simple Linux joystick reader for Xbox controllers using /dev/input/js*. + + Exposes a minimal API expected by manual.py: + - start() -> bool + - stop() -> None + - is_connected() -> bool + - get_button(name: str) -> bool + - get_axis(name: str) -> float in [-1.0, 1.0] + """ + + def __init__(self, deadzone: float = 0.1, device_path: str = '/dev/input/js0') -> None: + self.device_path: str = device_path + self.deadzone: float = max(0.0, min(0.9, deadzone)) + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._connected: bool = False + self._fd: Optional[int] = None + self._lock = threading.Lock() + self._buttons: Dict[str, bool] = {name: False for name in BUTTON_NAME_TO_INDEX.keys()} + self._axes: Dict[str, float] = {name: 0.0 for name in AXIS_NAME_TO_INDEX.keys()} + + def start(self) -> bool: + if self._thread and self._thread.is_alive(): + return True + self._stop_event.clear() + self._thread = threading.Thread(target=self._reader_loop, name='XboxControllerReader', daemon=True) + self._thread.start() + # Give the thread a short time to attempt initial open + time.sleep(0.1) + return True + + def stop(self) -> None: + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.5) + self._close_device() + + def is_connected(self) -> bool: + return self._connected + + def get_button(self, name: str) -> bool: + with self._lock: + return bool(self._buttons.get(name, False)) + + def get_axis(self, name: str) -> float: + with self._lock: + value = float(self._axes.get(name, 0.0)) + if abs(value) < self.deadzone: + return 0.0 + return value + + # Internal + def _open_device(self) -> None: + try: + fd = os.open(self.device_path, os.O_RDONLY | os.O_NONBLOCK) + # Ensure non-blocking + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + self._fd = fd + self._connected = True + except FileNotFoundError: + self._connected = False + except PermissionError: + self._connected = False + + def _close_device(self) -> None: + if self._fd is not None: + try: + os.close(self._fd) + except OSError: + pass + finally: + self._fd = None + self._connected = False + + def _reader_loop(self) -> None: + last_attempt = 0.0 + while not self._stop_event.is_set(): + # Ensure device is open + if self._fd is None: + now = time.time() + if now - last_attempt < 0.5: + time.sleep(0.05) + continue + last_attempt = now + self._open_device() + if self._fd is None: + time.sleep(0.3) + continue + + # Read any available events + try: + data = os.read(self._fd, EVENT_SIZE) + if not data: + time.sleep(0.01) + continue + if len(data) < EVENT_SIZE: + continue + timestamp, value, ev_type, number = struct.unpack(EVENT_FMT, data) + if ev_type & JS_EVENT_INIT: + continue + if ev_type & JS_EVENT_BUTTON: + name = BUTTON_INDEX_TO_NAME.get(number) + if name is not None: + with self._lock: + self._buttons[name] = (value != 0) + elif ev_type & JS_EVENT_AXIS: + name = AXIS_INDEX_TO_NAME.get(number) + if name is not None: + norm = _normalize_axis(value) + with self._lock: + self._axes[name] = norm + # Gentle yield + time.sleep(0.001) + except BlockingIOError: + time.sleep(0.01) + continue + except OSError as e: + if e.errno == errno.ENODEV: + # Device disconnected + self._close_device() + time.sleep(0.3) + continue + # Other I/O error: reset and retry + self._close_device() + time.sleep(0.2) + continue + + +# This module is intended to be imported by manual.py; no CLI entry point. diff --git a/tools/ground-station/sbg-test/.gitignore b/tools/ground-station/sbg-test/.gitignore new file mode 100644 index 0000000..80a76ee --- /dev/null +++ b/tools/ground-station/sbg-test/.gitignore @@ -0,0 +1,4 @@ +sbgBasicLogger +sbgEComApi +sbgECom/ +__pycache__/ \ No newline at end of file diff --git a/tools/ground-station/sbg-test/.python-version b/tools/ground-station/sbg-test/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/tools/ground-station/sbg-test/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/tools/ground-station/sbg-test/README.md b/tools/ground-station/sbg-test/README.md new file mode 100644 index 0000000..094a690 --- /dev/null +++ b/tools/ground-station/sbg-test/README.md @@ -0,0 +1,49 @@ +## SBG Test Utilities + +Tools and scripts to work with SBG Systems devices. This folder provides: +- Two native tools from the SBG ECom library (`sbgBasicLogger`, `sbgEComApi`) +- Small Python helpers for reading and viewing data + +### Requirements +- CMake and a C/C++ toolchain +- Git +- Python 3.10+ (optional, for the helper scripts) + +### Serial/USB access (run without root) +Most systems restrict access to `/dev/ttyUSB*` or `/dev/ttyACM*` devices to specific groups. +If you can’t open the device, run the helper which will suggest and add the right groups: + +```bash +cd utils/sbg-test +python3 fix_perms.py +# Log out/in (or reboot) for group changes to apply. +``` + +### Build the SBG tools +This compiles the SBG ECom tools and places the binaries in this folder. + +```bash +cd utils/sbg-test +python3 build_sbg_utils.py +``` +- The script clones/updates `sbgECom`, configures CMake with tools enabled, builds, and copies `sbgBasicLogger` and `sbgEComApi` here. +- Works with CMake’s native generator on your platform (Make/Ninja/Visual Studio/MSBuild, etc.). + +### Quick usage +- Log to console from a serial device: + ```bash +./sbgBasicLogger -s /dev/ttyUSB0 -r 115200 -p + ``` +- Inspect the REST-style API client: + ```bash +./sbgEComApi --help + ``` + +### Python helpers +- `sbg_reader.py`: Typed, high-level reader around `sbgBasicLogger` output. Provides structured callbacks/messages for EKF, GNSS, IMU, air data, etc. +- `main.py`: Simple terminal UI (Textual) to stream, tab, and view SBG data. +- `latest_measurement.py`: One-shot, labeled readout of the latest values across key data types. +- `gps_orientation.py`: Minimal GPS + orientation viewer using the reader. +- `fix_perms.py`: Adds your user to groups that own serial devices so you don’t need root. + +That’s it—ensure serial permissions, build the tools, and you’re ready to log or script against your SBG device. diff --git a/tools/ground-station/sbg-test/build_sbg_utils.py b/tools/ground-station/sbg-test/build_sbg_utils.py new file mode 100644 index 0000000..e626e8b --- /dev/null +++ b/tools/ground-station/sbg-test/build_sbg_utils.py @@ -0,0 +1,175 @@ +import os +import subprocess +import shutil +import sys +import platform +import glob + + +def clone_sbg_ecom_repo(): + """Clone the sbgECom repository if it doesn't exist.""" + repo_url = "https://github.com/SBG-Systems/sbgECom" + repo_dir = os.path.join(os.path.dirname(__file__), "sbgECom") + if not os.path.exists(repo_dir): + print(f"Cloning {repo_url} into {repo_dir}...") + subprocess.check_call(["git", "clone", repo_url, repo_dir]) + else: + print(f"Repository already cloned at {repo_dir}.") + return repo_dir + + +def check_dependencies(): + """Check if required build dependencies are available.""" + required_tools = ["cmake", "git"] + + # On POSIX, ensure compilers exist (Windows handled by generator toolchains) + if os.name == "posix": + required_tools.extend(["gcc", "g++"]) + + missing_tools = [] + for tool in required_tools: + try: + subprocess.run([tool, "--version"], capture_output=True, check=True) + print(f"✓ {tool} found") + except (subprocess.CalledProcessError, FileNotFoundError): + missing_tools.append(tool) + print(f"✗ {tool} not found") + + if missing_tools: + print(f"\nMissing required tools: {', '.join(missing_tools)}") + print("Please install the missing tools before running this script.") + sys.exit(1) + + +def configure_project(repo_dir: str, build_dir: str) -> None: + """Run CMake configuration step.""" + print("Configuring build with CMake...") + cmake_cmd = [ + "cmake", + "..", + "-DBUILD_TOOLS=ON", + "-DBUILD_EXAMPLES=OFF", + "-DCMAKE_BUILD_TYPE=Release", + "-DBUILD_SHARED_LIBS=OFF", # Build static libraries for sbgECom + "-DCMAKE_POLICY_VERSION_MINIMUM=3.5", + ] + subprocess.check_call(cmake_cmd, cwd=build_dir) + + +def build_project(build_dir: str) -> None: + """Build using CMake's cross-platform build command.""" + print("Building binaries...") + # Use cmake --build for portability (handles make/ninja/msbuild) + # Use Release config for multi-config generators (e.g., Visual Studio) + build_cmd = ["cmake", "--build", ".", "--config", "Release", "--parallel"] + subprocess.check_call(build_cmd, cwd=build_dir) + + +def find_binary(build_dir: str, base_name: str) -> str | None: + """Locate a built binary, accounting for generator layouts and platforms.""" + exe_suffix = ".exe" if platform.system() == "Windows" else "" + candidates = [ + os.path.join(build_dir, f"{base_name}{exe_suffix}"), + os.path.join(build_dir, "Release", f"{base_name}{exe_suffix}"), + ] + + for path in candidates: + if os.path.exists(path): + return path + + # Fallback: search within build dir but avoid CMakeFiles artifacts + for root, dirs, files in os.walk(build_dir): + # Skip internal build directories + if "CMakeFiles" in root: + continue + for fname in files: + if fname == f"{base_name}{exe_suffix}": + return os.path.join(root, fname) + return None + + +def build_binaries(repo_dir: str) -> str: + """Configure and build the sbgBasicLogger and sbgEComApi binaries.""" + build_dir = os.path.join(repo_dir, "build") + + # Create fresh build directory + if os.path.exists(build_dir): + print(f"Cleaning existing build directory: {build_dir}") + shutil.rmtree(build_dir) + os.makedirs(build_dir, exist_ok=True) + + configure_project(repo_dir, build_dir) + build_project(build_dir) + return build_dir + + +def copy_binaries(build_dir: str, target_dir: str) -> list[str]: + """Copy the built binaries to the target directory.""" + print(f"Copying binaries to {target_dir}...") + + binary_names = ["sbgBasicLogger", "sbgEComApi"] + copied_binaries: list[str] = [] + + for name in binary_names: + src_path = find_binary(build_dir, name) + if src_path and os.path.exists(src_path): + # Ensure executable bit on POSIX + if os.name == "posix": + os.chmod(src_path, 0o755) + dst_basename = os.path.basename(src_path) + dst_path = os.path.join(target_dir, dst_basename) + shutil.copy2(src_path, dst_path) + print(f"✓ Copied {dst_basename} to {dst_path}") + copied_binaries.append(dst_path) + else: + print(f"✗ Binary not found: {name}") + + return copied_binaries + + +def verify_binaries(binaries: list[str]) -> None: + """Verify that the binaries exist and are runnable.""" + print("\nVerifying binaries...") + for binary_path in binaries: + if not os.path.exists(binary_path): + print(f"✗ {os.path.basename(binary_path)} not found") + continue + if os.name == "posix" and not os.access(binary_path, os.X_OK): + print(f"✗ {os.path.basename(binary_path)} is not executable") + else: + print(f"✓ {os.path.basename(binary_path)} is ready") + + +def main(): + """Main function to orchestrate the build process.""" + print("=== SBG ECom Tools Build Automation ===\n") + + # Check dependencies + print("1. Checking build dependencies...") + check_dependencies() + + # Clone repository + print("\n2. Setting up repository...") + repo_dir = clone_sbg_ecom_repo() + + # Build binaries + print("\n3. Building binaries...") + build_dir = build_binaries(repo_dir) + + # Copy binaries to current directory + print("\n4. Copying binaries...") + target_dir = os.path.dirname(__file__) + copied_binaries = copy_binaries(build_dir, target_dir) + + # Verify binaries + print("\n5. Verifying binaries...") + verify_binaries(copied_binaries) + + print(f"\n=== Build Complete ===") + print(f"Binaries available in: {target_dir}") + for binary in copied_binaries: + print(f" - {os.path.basename(binary)}") + + +if __name__ == "__main__": + main() diff --git a/tools/ground-station/sbg-test/fix_perms.py b/tools/ground-station/sbg-test/fix_perms.py new file mode 100755 index 0000000..a94a952 --- /dev/null +++ b/tools/ground-station/sbg-test/fix_perms.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +USB/Serial Terminal Permissions Fixer + +Detects serial device files under /dev and adds the current user +to the owning groups (e.g., uucp, dialout) for serial/USB serial access. +""" + +import os +import sys +import subprocess +import argparse +import glob +import grp + +def get_serial_groups() -> list[str]: + """ + Inspect common serial device file patterns and return the set of + non-root groups owning those devices. + """ + patterns = ['/dev/ttyS*', '/dev/ttyUSB*', '/dev/ttyACM*'] + groups = set() + for pat in patterns: + for dev in glob.glob(pat): + try: + st = os.stat(dev) + group = grp.getgrgid(st.st_gid).gr_name + if group != 'root': + groups.add(group) + except FileNotFoundError: + continue + return sorted(groups) + +def run_command(cmd: list[str], dry_run: bool) -> int: + """Run a system command, optionally in dry-run mode.""" + if dry_run: + print(f"DRY RUN: {' '.join(cmd)}") + return 0 + return subprocess.run(cmd, check=False).returncode + +def add_user_to_group(user: str, group: str, dry_run: bool=False) -> bool: + """ + Add the specified user to the given group. + """ + # Check current groups + out = subprocess.run(['groups', user], capture_output=True, text=True) + if group in out.stdout.split(): + print(f"User {user} already in group {group}") + return True + # Add to group + cmd = ['sudo', 'usermod', '-aG', group, user] + rc = run_command(cmd, dry_run) + if rc == 0: + print(f"Added {user} to {group}") + return True + else: + print(f"Failed to add {user} to {group}", file=sys.stderr) + return False + +def main(): + parser = argparse.ArgumentParser( + description="Fix permissions for USB/serial devices" + ) + parser.add_argument('--dry-run', action='store_true', + help='Show what would be done without making changes') + args = parser.parse_args() + + current_user = os.getenv('USER') or os.getenv('LOGNAME') or 'unknown' + groups = get_serial_groups() + + if not groups: + print("No serial devices detected.") + sys.exit(1) + + print(f"Detected serial device groups: {', '.join(groups)}") + success = True + for g in groups: + if not add_user_to_group(current_user, g, args.dry_run): + success = False + + if not args.dry_run: + print("→ Remember to reboot for changes to take effect.") + + sys.exit(0 if success else 1) + +if __name__ == "__main__": + main() diff --git a/tools/ground-station/sbg-test/foo.py b/tools/ground-station/sbg-test/foo.py new file mode 100644 index 0000000..517fe53 --- /dev/null +++ b/tools/ground-station/sbg-test/foo.py @@ -0,0 +1,83 @@ +from typing import Tuple +import numpy as np +import math + +_WGS84_A = 6378137.0 +_WGS84_F = 1.0 / 298.257223563 +_WGS84_E2 = _WGS84_F * (2.0 - _WGS84_F) + +def _geodetic_to_ecef(lat_deg: float, lon_deg: float, h_m: float) -> Tuple[float, float, float]: + lat = math.radians(lat_deg) + lon = math.radians(lon_deg) + sin_lat = math.sin(lat) + cos_lat = math.cos(lat) + sin_lon = math.sin(lon) + cos_lon = math.cos(lon) + N = _WGS84_A / math.sqrt(1.0 - _WGS84_E2 * sin_lat * sin_lat) + x = (N + h_m) * cos_lat * cos_lon + y = (N + h_m) * cos_lat * sin_lon + z = (N * (1.0 - _WGS84_E2) + h_m) * sin_lat + return x, y, z + +def _ecef_to_enu(dx: Tuple[float, float, float], lat0_deg: float, lon0_deg: float) -> Tuple[float, float, float]: + lat0 = math.radians(lat0_deg) + lon0 = math.radians(lon0_deg) + sin_lat = math.sin(lat0) + cos_lat = math.cos(lat0) + sin_lon = math.sin(lon0) + cos_lon = math.cos(lon0) + dx_x, dx_y, dx_z = dx + e = -sin_lon * dx_x + cos_lon * dx_y + n = -sin_lat * cos_lon * dx_x - sin_lat * sin_lon * dx_y + cos_lat * dx_z + u = cos_lat * cos_lon * dx_x + cos_lat * sin_lon * dx_y + sin_lat * dx_z + return e, n, u + + +def compute_az_el(tgt_lat: float, tgt_lon: float, tgt_alt: float) -> Tuple[float, float]: + x_o, y_o, z_o = _geodetic_to_ecef( + antenna_lat_deg, antenna_lon_deg, antenna_alt_m + ) + x_t, y_t, z_t = _geodetic_to_ecef(tgt_lat, tgt_lon, tgt_alt) + e, n, u = _ecef_to_enu((x_t - x_o, y_t - y_o, z_t - z_o), antenna_lat_deg, antenna_lon_deg) + az = math.degrees(math.atan2(e, n)) + if az < 0: + az += 360.0 + el = math.degrees(math.atan2(u, math.hypot(e, n))) + return az, el + +def _normalize_0_360(deg: float) -> float: + return (deg % 360.0 + 360.0) % 360.0 + +def _rotation_from_yaw_zero_basic(bearing_deg: float) -> float: + """Yaw relative to yaw-zero (single-turn), honoring config.yaw_normalization (0to360 or pm180).""" + raw = bearing_deg - antenna_yaw_zero_deg + return _normalize_0_360(raw) + +def yaw_pitch_from_target(tgt_lat: float, tgt_lon: float, tgt_alt: float) -> Tuple[float, float]: + print("Calculating Yaw pitch from target") + az, el = compute_az_el(tgt_lat, tgt_lon, tgt_alt) # az from North + yaw_rel = _rotation_from_yaw_zero_basic(az) + return yaw_rel, el + + + +antenna_yaw_zero_deg = 195 + +antenna_lat_deg = 47.990527 +antenna_lon_deg = -81.851502 +antenna_alt_m = 373.0 + +target_lat_deg = 47.991 +target_lon_deg = -81.851502 +target_alt_m = 1000.0 + + +x_o, y_o, z_o = _geodetic_to_ecef( + antenna_lat_deg, antenna_lon_deg, antenna_alt_m +) +x_t, y_t, z_t = _geodetic_to_ecef(target_lat_deg, target_lon_deg, target_alt_m) +e, n, u = _ecef_to_enu((x_t - x_o, y_t - y_o, z_t - z_o), antenna_lat_deg, antenna_lon_deg) + +yaw, pitch = yaw_pitch_from_target(target_lat_deg, target_lon_deg, target_alt_m) +print("Yaw:", yaw) +print("Pitch:", pitch) \ No newline at end of file diff --git a/tools/ground-station/sbg-test/gps_orientation.py b/tools/ground-station/sbg-test/gps_orientation.py new file mode 100755 index 0000000..d37e985 --- /dev/null +++ b/tools/ground-station/sbg-test/gps_orientation.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +A Textual application to display live GPS and Orientation data from an SBG device. +""" + +import os +import glob +import sys +import threading +import math +from typing import Optional + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Static +from textual.reactive import reactive +from sbg_reader import SBGReader, SbgEkfNavData, SbgEkfEulerData, SbgEkfQuatData + + +def find_ftdi_device(vendor_id="0403", product_id="6001") -> Optional[str]: + """Find FTDI device by vendor and product ID.""" + import glob + + usb_devs = glob.glob("/dev/ttyUSB*") + for dev in usb_devs: + try: + tty_name = os.path.basename(dev) + sys_tty_path = f"/sys/class/tty/{tty_name}/device" + sys_usb_path = os.path.realpath(sys_tty_path) + for _ in range(3): + id_vendor = os.path.join(sys_usb_path, "idVendor") + id_product = os.path.join(sys_usb_path, "idProduct") + if os.path.exists(id_vendor) and os.path.exists(id_product): + with open(id_vendor) as f: + vid = f.read().strip().lower() + with open(id_product) as f: + pid = f.read().strip().lower() + if vid == vendor_id.lower() and pid == product_id.lower(): + return dev + sys_usb_path = os.path.dirname(sys_usb_path) + except Exception: + continue + return None + + +def quaternion_to_euler(qw: float, qx: float, qy: float, qz: float) -> tuple[float, float, float]: + """ + Convert quaternion to euler angles (roll, pitch, yaw) in degrees. + + Args: + qw, qx, qy, qz: Quaternion components + + Returns: + tuple: (roll, pitch, yaw) in degrees + """ + # Normalize quaternion + norm = math.sqrt(qw*qw + qx*qx + qy*qy + qz*qz) + if norm == 0: + return 0.0, 0.0, 0.0 + + qw /= norm + qx /= norm + qy /= norm + qz /= norm + + # Convert to euler angles (roll, pitch, yaw) + # Roll (x-axis rotation) + sinr_cosp = 2 * (qw * qx + qy * qz) + cosr_cosp = 1 - 2 * (qx * qx + qy * qy) + roll = math.atan2(sinr_cosp, cosr_cosp) + + # Pitch (y-axis rotation) + sinp = 2 * (qw * qy - qz * qx) + if abs(sinp) >= 1: + pitch = math.copysign(math.pi / 2, sinp) # use 90 degrees if out of range + else: + pitch = math.asin(sinp) + + # Yaw (z-axis rotation) + siny_cosp = 2 * (qw * qz + qx * qy) + cosy_cosp = 1 - 2 * (qy * qy + qz * qz) + yaw = math.atan2(siny_cosp, cosy_cosp) + + # Convert to degrees + roll_deg = math.degrees(roll) + pitch_deg = math.degrees(pitch) + yaw_deg = math.degrees(yaw) + + return roll_deg, pitch_deg, yaw_deg + + +class GPSDisplay(Static): + """A widget to display GPS data.""" + lat = reactive(0.0) + lon = reactive(0.0) + + def update_data(self, data: SbgEkfNavData): + self.lat = data.latitude + self.lon = data.longitude + + def render(self) -> Text: + return Text.from_markup(f"[b]Latitude:[/b] {self.lat:9.6f}°\n[b]Longitude:[/b] {self.lon:9.6f}°") + + +class OrientationDisplay(Static): + """A widget to display orientation data.""" + roll = reactive(0.0) + pitch = reactive(0.0) + yaw = reactive(0.0) + + def update_data(self, data: SbgEkfQuatData): + # Convert quaternion to euler angles + self.roll, self.pitch, self.yaw = quaternion_to_euler(data.qW, data.qX, data.qY, data.qZ) + + def render(self) -> Text: + return Text.from_markup( + f"[b]Roll:[/b] {self.roll:8.3f}°\n[b]Pitch:[/b] {self.pitch:8.3f}°\n[b]Yaw:[/b] {self.yaw:8.3f}°" + ) + + +class HeadingDisplay(Static): + """A widget to display heading data.""" + ekf_heading = reactive(0.0) + gnss_heading = reactive(0.0) + gnss_heading_available = reactive(False) + + def update_ekf_data(self, data: SbgEkfQuatData): + # Convert quaternion to euler and get yaw as heading + _, _, yaw = quaternion_to_euler(data.qW, data.qX, data.qY, data.qZ) + self.ekf_heading = yaw + + def update_gnss_data(self, data): + # This method is kept for compatibility but GNSS heading is not available + pass + + def render(self) -> Text: + if self.gnss_heading_available: + return Text.from_markup( + f"[b]EKF Heading:[/b] {self.ekf_heading:8.3f}°\n[b]GNSS Heading:[/b] {self.gnss_heading:8.3f}°" + ) + else: + return Text.from_markup( + f"[b]EKF Heading:[/b] {self.ekf_heading:8.3f}°\n[b]GNSS Heading:[/b] [dim]Not available[/dim]" + ) + + +class GPSOrientationApp(App): + """A Textual app to display SBG GPS and orientation data.""" + + TITLE = "SBG GPS + Orientation + Heading" + CSS_PATH = None + BINDINGS = [("q", "quit", "Quit")] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._sbg_reader = SBGReader() + self._reader_thread: Optional[threading.Thread] = None + + def compose(self) -> ComposeResult: + yield Header() + yield Static("Initializing...", id="status") + yield GPSDisplay(id="gps") + yield OrientationDisplay(id="orientation") + yield HeadingDisplay(id="heading") + yield Footer() + + def on_mount(self) -> None: + """Called when the app is mounted.""" + # Start the reader thread after the app is fully mounted + self.call_after_refresh(self._start_reader_thread) + + def _start_reader_thread(self) -> None: + """Start the reader thread after the UI is fully ready.""" + self._reader_thread = threading.Thread(target=self._run_reader, daemon=True) + self._reader_thread.start() + + def _run_reader(self) -> None: + """The target for the reader thread, responsible for finding and starting the device reader.""" + status_widget = self.query_one("#status", Static) + self.call_from_thread(status_widget.update, "Searching for FTDI device...") + device = find_ftdi_device() + + if not device: + status_widget = self.query_one("#status", Static) + self.call_from_thread(status_widget.update, Text("Error: FTDI device not found.", style="bold red")) + return + + status_widget = self.query_one("#status", Static) + self.call_from_thread(status_widget.update, f"Device found at [bold green]{device}[/]. Starting reader...") + + try: + self._sbg_reader.subscribe('nav', self.on_nav_data) + self._sbg_reader.subscribe('quat', self.on_quat_data) + self._sbg_reader.start(device_path=device) + + status_widget = self.query_one("#status", Static) + self.call_from_thread(status_widget.update, "Reader started. Waiting for data...") + except (FileNotFoundError, RuntimeError) as e: + status_widget = self.query_one("#status", Static) + self.call_from_thread(status_widget.update, Text(f"Error: {e}", style="bold red")) + + def on_unmount(self) -> None: + """Called when the app is unmounted.""" + self._sbg_reader.stop() + + def on_nav_data(self, data: SbgEkfNavData) -> None: + """Callback for new navigation data.""" + gps_widget = self.query_one("#gps") + self.call_from_thread(gps_widget.update_data, data) + + def on_quat_data(self, data: SbgEkfQuatData) -> None: + """Callback for new quaternion data.""" + orientation_widget = self.query_one("#orientation") + heading_widget = self.query_one("#heading") + self.call_from_thread(orientation_widget.update_data, data) + self.call_from_thread(heading_widget.update_ekf_data, data) + + def action_quit(self) -> None: + """An action to quit the app.""" + self.exit() + + +if __name__ == "__main__": + app = GPSOrientationApp() + app.run() diff --git a/tools/ground-station/sbg-test/latest_measurement.py b/tools/ground-station/sbg-test/latest_measurement.py new file mode 100755 index 0000000..6b915f2 --- /dev/null +++ b/tools/ground-station/sbg-test/latest_measurement.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +Standalone script to get the latest measurement from SBG device with labels. + +This script connects to the SBG device using the sbgBasicLogger binary and +displays the latest measurement for each data type with proper labels. +""" + +import os +import subprocess +import time +import threading +import signal +import sys +from typing import List + + +def find_ftdi_device(vendor_id="0403", product_id="6001"): + """Find FTDI device by vendor and product ID.""" + import glob + + usb_devs = glob.glob("/dev/ttyUSB*") + for dev in usb_devs: + try: + tty_name = os.path.basename(dev) + sys_tty_path = f"/sys/class/tty/{tty_name}/device" + sys_usb_path = os.path.realpath(sys_tty_path) + for _ in range(3): + id_vendor = os.path.join(sys_usb_path, "idVendor") + id_product = os.path.join(sys_usb_path, "idProduct") + if os.path.exists(id_vendor) and os.path.exists(id_product): + with open(id_vendor) as f: + vid = f.read().strip().lower() + with open(id_product) as f: + pid = f.read().strip().lower() + if vid == vendor_id.lower() and pid == product_id.lower(): + return dev + sys_usb_path = os.path.dirname(sys_usb_path) + except Exception: + continue + return None + + +class SBGMeasurementReader: + """Class to read and parse SBG measurements. + + The data types and field order are based on the sbgBasicLogger source code: + - Main registration order: utils/sbg-test/sbgECom/tools/sbgBasicLogger/src/loggerApp.cpp (lines 50-90) + - GNSS field definitions: utils/sbg-test/sbgECom/tools/sbgBasicLogger/src/loggerEntry/loggerEntryGnss.cpp (lines 100-115) + - EKF field definitions: utils/sbg-test/sbgECom/tools/sbgBasicLogger/src/loggerEntry/loggerEntryEkf.cpp + - IMU field definitions: utils/sbg-test/sbgECom/tools/sbgBasicLogger/src/loggerEntry/loggerEntryImu.cpp + """ + + # Define the labels for each data type based on sbgBasicLogger source code + DATA_LABELS = { + 'euler': [ + 'Status', 'Roll (deg)', 'Pitch (deg)', 'Yaw (deg)', + 'Roll Std (deg)', 'Pitch Std (deg)', 'Yaw Std (deg)', + 'Mag Heading (deg)', 'Mag Decl (deg)', 'Mag Incl (deg)' + ], + 'quat': [ + 'Status', 'qW', 'qX', 'qY', 'qZ', + 'Roll Std (deg)', 'Pitch Std (deg)', 'Yaw Std (deg)', + 'Mag Decl (deg)', 'Mag Incl (deg)' + ], + 'nav': [ + 'Status', 'Vel N (m/s)', 'Vel E (m/s)', 'Vel D (m/s)', + 'Vel Std N (m/s)', 'Vel Std E (m/s)', 'Vel Std D (m/s)', + 'Latitude (deg)', 'Longitude (deg)', 'Altitude (m)', + 'Lat Std (m)', 'Lon Std (m)', 'Alt Std (m)', 'Undulation (m)' + ], + 'airData': [ + 'Status', 'Pressure (hPa)', 'Altitude (m)', + 'Temperature (C)', 'Air Speed (m/s)', 'Air Speed Std (m/s)' + ], + 'imuData': [ + 'Status', 'Accel X (m/s²)', 'Accel Y (m/s²)', 'Accel Z (m/s²)', + 'Gyro X (deg/s)', 'Gyro Y (deg/s)', 'Gyro Z (deg/s)', 'Temperature (C)' + ], + 'gnss1Vel': [ + 'Status', 'GPS Time (ms)', 'Vel N (m/s)', 'Vel E (m/s)', 'Vel D (m/s)', + 'Vel Std N (m/s)', 'Vel Std E (m/s)', 'Vel Std D (m/s)', + 'Course (deg)', 'Course Std (deg)' + ], + 'gnss1Pos': [ + 'Status', 'Status Ext', 'GPS Time (ms)', 'Latitude (deg)', 'Longitude (deg)', 'Altitude (m)', + 'Undulation (m)', 'Lat Std (m)', 'Lon Std (m)', 'Alt Std (m)', + 'Satellites Tracked', 'Satellites Used', 'Base Station ID', 'Differential Age' + ], + 'status': [ + 'Status', 'Main Power', 'IMU Power', 'GPS Power', 'Settings' + ], + 'utcTime': [ + 'Status', 'Year', 'Month', 'Day', 'Hour', 'Minute', 'Second', + 'Nanosecond', 'GpsTimeOfWeek (s)', 'Flags', 'Valid', 'Time (s)' + ], + 'gnss1Hdt': [ + 'Status', 'GPS Time (ms)', 'Heading (deg)', 'Heading Std (deg)', + 'Pitch (deg)', 'Pitch Std (deg)', 'Baseline (m)', 'Satellites Tracked', 'Satellites Used' + ], + 'gnss1Sat': [ + 'Satellites Count' + ], + 'imuShort': [ + 'Status', 'Delta Vel X (m/s)', 'Delta Vel Y (m/s)', 'Delta Vel Z (m/s)', + 'Delta Angle X (deg)', 'Delta Angle Y (deg)', 'Delta Angle Z (deg)', 'Temperature (C)' + ], + 'imuFast': [ + 'Status', 'Delta Vel X (m/s)', 'Delta Vel Y (m/s)', 'Delta Vel Z (m/s)', + 'Delta Angle X (deg)', 'Delta Angle Y (deg)', 'Delta Angle Z (deg)', 'Temperature (C)' + ], + 'mag': [ + 'Status', 'Mag X (uT)', 'Mag Y (uT)', 'Mag Z (uT)', 'Temperature (C)' + ], + 'diag': [ + 'Status', 'Main Loop Time (us)', 'Computation Load (%)', 'CPU Temperature (C)' + ], + 'velBody': [ + 'Status', 'Vel X (m/s)', 'Vel Y (m/s)', 'Vel Z (m/s)', + 'Vel Std X (m/s)', 'Vel Std Y (m/s)', 'Vel Std Z (m/s)' + ] + } + + def __init__(self): + self.latest_measurements = {} + self.running = False + self.process = None + + def format_number(self, value_str: str) -> str: + """Format a number string - round floats to 3 decimals, keep integers as-is.""" + try: + # First try to parse as int + int_val = int(value_str) + return str(int_val) + except ValueError: + try: + # If that fails, try as float + float_val = float(value_str) + return f"{float_val:.3f}" + except ValueError: + # If both fail, return original string + return value_str + + def parse_line(self, line: str) -> None: + """Parse a line from sbgBasicLogger output.""" + if not line or line.startswith("*"): + return + + parts = line.strip().split() + if not parts: + return + + message_type = parts[0].rstrip(":") + data = parts[1:] + + # Format the data nicely + formatted_data = [] + for item in data: + formatted_data.append(self.format_number(item)) + + # Store the latest measurement + self.latest_measurements[message_type] = formatted_data + + def read_from_device(self) -> None: + """Read data from the SBG device.""" + try: + print("Looking for FTDI device...") + dev_path = find_ftdi_device("0403", "6001") + if not dev_path: + print("Error: FTDI USB device not found") + return + + print(f"Found FTDI device at: {dev_path}") + + if not os.path.exists("./sbgBasicLogger"): + print("Error: sbgBasicLogger executable not found") + return + + print("Starting sbgBasicLogger...") + cmd = ["./sbgBasicLogger", "-s", dev_path, "-r", "115200", "-p"] + print(f"Command: {' '.join(cmd)}") + + self.process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1 + ) + + time.sleep(1) + if self.process.poll() is not None: + err = self.process.stderr.read().strip() + print(f"sbgBasicLogger failed with: {err}") + return + + print("sbgBasicLogger started successfully, reading data...") + print("Press Ctrl+C to stop and display latest measurements\n") + + if self.process.stdout: + for line in iter(self.process.stdout.readline, ""): + if not self.running: + break + if line.strip(): + self.parse_line(line) + + except FileNotFoundError as e: + print(f"FileNotFoundError: {e}") + except Exception as e: + print(f"Unexpected error: {e}") + + def print_message(self, msg_type: str, data: List[str]) -> None: + """Prints a single formatted message.""" + labels = self.DATA_LABELS.get(msg_type, []) + + print(f"\n{msg_type.upper()} DATA:") + print("-" * 40) + + # Display data with labels + for i, (label, value) in enumerate(zip(labels, data)): + if i < len(data): + print(f" {label:<20}: {value}") + else: + print(f" {label:<20}: N/A") + + # If we have more data than labels, show the extra data + for i in range(len(labels), len(data)): + print(f" Data[{i}]: {data[i]}") + + def display_latest_measurements(self) -> None: + """Display the latest measurements with labels and a summary.""" + if not self.latest_measurements: + print("No measurements received yet.") + return + + print("\n" + "="*80) + print("LATEST SBG MEASUREMENTS") + print("="*80) + + # Display important measurements first, then any others. + message_order = ['status', 'diag', 'imuData', 'euler', 'nav', 'airData', 'gnss1Vel', 'gnss1Pos'] + + displayed_messages = set() + + for msg_type in message_order: + if msg_type in self.latest_measurements: + self.print_message(msg_type, self.latest_measurements[msg_type]) + displayed_messages.add(msg_type) + + for msg_type, data in self.latest_measurements.items(): + if msg_type not in displayed_messages: + self.print_message(msg_type, data) + displayed_messages.add(msg_type) + + print("\n" + "="*80) + print("SUMMARY") + print("="*80) + + # Check for unknown message types that were received + unknown_messages = set(self.latest_measurements.keys()) - set(self.DATA_LABELS.keys()) + if unknown_messages: + print("\nUnknown message types received (no labels defined):") + for msg_type in unknown_messages: + print(f" - {msg_type}") + else: + print("\nAll received message types are known.") + + # Check for defined labels for which no data was received + missing_messages = set(self.DATA_LABELS.keys()) - set(self.latest_measurements.keys()) + if missing_messages: + print("\nNo data received for the following message types:") + for msg_type in sorted(list(missing_messages)): + print(f" - {msg_type}") + else: + print("\nData received for all known message types.") + + def start(self) -> None: + """Start reading from the device.""" + self.running = True + + # Start reading thread + reader_thread = threading.Thread(target=self.read_from_device, daemon=True) + reader_thread.start() + + try: + # Keep the main thread alive + while self.running: + time.sleep(0.1) + except KeyboardInterrupt: + print("\nStopping...") + self.stop() + + def stop(self) -> None: + """Stop reading from the device.""" + self.running = False + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + self.process.kill() + + # Display the latest measurements + self.display_latest_measurements() + + +def main(): + """Main function.""" + print("SBG Latest Measurement Reader") + print("=" * 40) + + reader = SBGMeasurementReader() + + # Set up signal handler for graceful shutdown + def signal_handler(signum, frame): + print("\nReceived signal to stop...") + reader.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + reader.start() + except Exception as e: + print(f"Error: {e}") + reader.stop() + + +if __name__ == "__main__": + main() diff --git a/tools/ground-station/sbg-test/main.py b/tools/ground-station/sbg-test/main.py new file mode 100644 index 0000000..c98fee5 --- /dev/null +++ b/tools/ground-station/sbg-test/main.py @@ -0,0 +1,305 @@ +import os +import subprocess +import time +import threading +from collections import deque +from typing import Dict, Deque, List, Optional + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.css.query import NoMatches +from textual.widgets import Header, Footer, TabbedContent, TabPane, RichLog, Label +from textual.reactive import reactive +from textual import work, log +from textual.message import Message + + +def debug_log(message: str): + """Write debug messages to log file""" + with open("./log.txt", "a") as f: + timestamp = time.strftime("%H:%M:%S") + f.write(f"[{timestamp}] {message}\n") + f.flush() + + +def find_ftdi_device(vendor_id="0403", product_id="6001") -> Optional[str]: + import glob + + usb_devs = glob.glob("/dev/ttyUSB*") + for dev in usb_devs: + try: + tty_name = os.path.basename(dev) + sys_tty_path = f"/sys/class/tty/{tty_name}/device" + sys_usb_path = os.path.realpath(sys_tty_path) + for _ in range(3): + id_vendor = os.path.join(sys_usb_path, "idVendor") + id_product = os.path.join(sys_usb_path, "idProduct") + if os.path.exists(id_vendor) and os.path.exists(id_product): + with open(id_vendor) as f: + vid = f.read().strip().lower() + with open(id_product) as f: + pid = f.read().strip().lower() + if vid == vendor_id.lower() and pid == product_id.lower(): + return dev + sys_usb_path = os.path.dirname(sys_usb_path) + except Exception: + continue + return None + + +class UpdateMessage(Message): + def __init__(self, message_type: str, data: List[str], timestamp: str, log_text: str): + super().__init__() + self.message_type = message_type + self.data = data + self.timestamp = timestamp + self.log_text = log_text + + +class EllipseReaderApp(App): + CSS = """ + Vertical#main-container { + height: 100%; + } + + Label#status { + height: 1; + width: 100%; + content-align: left middle; + } + + Label#utc { + height: 1; + width: 100%; + content-align: left middle; + } + + TabbedContent#message-tabs { + height: 1fr; + margin-top: 1; + } + """ + + BINDINGS = [ + ("d", "clear_active_data", "Clear Data"), + ("q", "quit", "Quit"), + ] + + connection_status = reactive("Awaiting Data...") + last_utc_time = reactive("N/A") + + def __init__(self): + super().__init__() + # Clean up log file at startup + if os.path.exists("./log.txt"): + os.remove("./log.txt") + debug_log("=== EllipseReader Starting ===") + + self.running = threading.Event() + self.max_messages = 10 + self.message_queues: Dict[str, Deque[str]] = {} + self.reader_thread = None + + def compose(self) -> ComposeResult: + with Vertical(id="main-container"): + yield Label(f"Status: {self.connection_status}", id="status") + yield Label(f"UTC: {self.last_utc_time}", id="utc") + yield TabbedContent(id="message-tabs") + + def action_clear_active_data(self) -> None: + try: + tabs = self.query_one("#message-tabs", TabbedContent) + if tabs.active: + pane = tabs.get_pane(tabs.active) + if pane: + rich_log = pane.query_one(RichLog) + rich_log.clear() + except (NoMatches, ValueError): + pass + + def watch_connection_status(self, value: str) -> None: + """Update the status label whenever the reactive changes.""" + try: + self.query_one("#status", Label).update(f"Status: {value}") + except NoMatches: + # ignore until the label actually exists + pass + + def watch_last_utc_time(self, value: str) -> None: + """Update the UTC label whenever the reactive changes.""" + try: + self.query_one("#utc", Label).update(f"UTC: {value}") + except NoMatches: + pass + + def on_update_message(self, message: UpdateMessage) -> None: + """Handle UI updates, capping per-type logs and auto-creating tabs.""" + debug_log(f"Received UpdateMessage for type: {message.message_type}") + + queue = self.message_queues.setdefault( + message.message_type, deque(maxlen=self.max_messages) + ) + queue.append(message.log_text) + + # set reactive to 'Connected' once we get any data + if self.connection_status != "Connected": + self.connection_status = "Connected" + + try: + tabs = self.query_one("#message-tabs", TabbedContent) + + # Check if tab already exists by looking at all tab panes + existing_pane = None + for pane in tabs.query(TabPane): + if pane.id == message.message_type: + existing_pane = pane + debug_log(f"Tab '{message.message_type}' already exists") + break + + if not existing_pane: + # Tab doesn't exist, create it + debug_log(f"Creating new tab: {message.message_type}") + pane = TabPane(message.message_type, id=message.message_type) + rich_log = RichLog(wrap=True, highlight=False, auto_scroll=True) + pane.compose_add_child(rich_log) + tabs.add_pane(pane) + + # Set as active if it's the first tab + if not tabs.active: + tabs.active = message.message_type + debug_log(f"Set {message.message_type} as active tab") + existing_pane = pane + debug_log(f"Successfully created tab: {message.message_type}") + + # refresh just this tab + if existing_pane: + try: + log_widget = existing_pane.query_one(RichLog) + log_widget.clear() + for entry in queue: + log_widget.write(entry) + debug_log(f"Updated tab '{message.message_type}' with {len(queue)} entries") + except NoMatches as e: + debug_log(f"Could not find RichLog in pane {message.message_type}: {e}") + + except NoMatches as e: + debug_log(f"NoMatches error in on_update_message: {e}") + except Exception as e: + debug_log(f"Error in on_update_message: {e}") + import traceback + debug_log(f"Traceback: {traceback.format_exc()}") + + def read_from_device_thread(self) -> None: + """Thread function to read from device""" + debug_log("Starting device reader thread") + try: + debug_log("Looking for FTDI device...") + dev_path = find_ftdi_device("0403", "6001") + if not dev_path: + debug_log("FTDI device not found") + self.connection_status = "Error: FTDI USB device not found" + return + + debug_log(f"Found FTDI device at: {dev_path}") + + if not os.path.exists("./sbgBasicLogger"): + debug_log("sbgBasicLogger executable not found") + self.connection_status = "Error: sbgBasicLogger missing" + return + + debug_log("Starting sbgBasicLogger...") + cmd = ["./sbgBasicLogger", "-s", dev_path, "-r", "115200", "-p"] + debug_log(f"Command: {' '.join(cmd)}") + + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1 + ) + time.sleep(1) + if proc.poll() is not None: + err = proc.stderr.read().strip() + debug_log(f"sbgBasicLogger failed with: {err}") + self.connection_status = f"sbgBasicLogger failed: {err}" + return + + debug_log("sbgBasicLogger started successfully, reading lines...") + self.connection_status = "Connected" + + line_count = 0 + if proc.stdout: + for line in iter(proc.stdout.readline, ""): + if not self.running.is_set(): + debug_log("Thread stopped by running flag") + break + if line.strip(): + line_count += 1 + if line_count <= 5: # Log first 5 lines for debugging + debug_log(f"Raw line {line_count}: {repr(line.strip())}") + self.parse_line(line) + + except FileNotFoundError as e: + debug_log(f"FileNotFoundError: {e}") + self.connection_status = "Error: sbgBasicLogger not found" + except Exception as e: + debug_log(f"Unexpected error: {e}") + self.connection_status = f"Error: {e}" + + def format_number(self, value_str: str) -> str: + """Format a number string - round floats to 2 decimals, keep integers as-is""" + try: + # First try to parse as int + int_val = int(value_str) + return str(int_val) + except ValueError: + try: + # If that fails, try as float + float_val = float(value_str) + return f"{float_val:.2f}" + except ValueError: + # If both fail, return original string + return value_str + + def parse_line(self, line: str) -> None: + if not line or line.startswith("*"): + return + parts = line.strip().split() + if not parts: + return + + message_type = parts[0].rstrip(":") + data = parts[1:] + + # Format the data nicely - remove the header and format numbers + formatted_data = [] + for item in data: + formatted_data.append(self.format_number(item)) + + # Create a clean log text without the header + clean_log_text = " ".join(formatted_data) + + debug_log(f"Parsed - Type: '{message_type}', Data count: {len(data)}") + + # Use current time as timestamp + timestamp = time.strftime("%H:%M:%S") + + # Post the message to create tabs with clean data + debug_log(f"Posting UpdateMessage for type: {message_type}") + self.call_from_thread(self.post_message, UpdateMessage(message_type, data, timestamp, clean_log_text)) + + def on_mount(self) -> None: + debug_log("App mounted, starting reader thread") + self.running.set() + # Start the background thread to read from device + self.reader_thread = threading.Thread(target=self.read_from_device_thread, daemon=True) + self.reader_thread.start() + + def on_unmount(self) -> None: + debug_log("App unmounting, stopping threads") + self.running.clear() + if self.reader_thread and self.reader_thread.is_alive(): + self.reader_thread.join(timeout=1.0) + + +if __name__ == "__main__": + app = EllipseReaderApp() + app.run() \ No newline at end of file diff --git a/tools/ground-station/sbg-test/pyproject.toml b/tools/ground-station/sbg-test/pyproject.toml new file mode 100644 index 0000000..04a6e08 --- /dev/null +++ b/tools/ground-station/sbg-test/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "sbg-test" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "textual>=5.2.0", +] diff --git a/tools/ground-station/sbg-test/sbg_reader.py b/tools/ground-station/sbg-test/sbg_reader.py new file mode 100755 index 0000000..97bd24a --- /dev/null +++ b/tools/ground-station/sbg-test/sbg_reader.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +""" +A typed, callback-based reader for SBG devices that uses the sbgBasicLogger +executable to stream data. + +This module provides a clean, modern, and easy-to-use Python interface for an +SBG device, abstracting away the subprocess and data parsing complexities. +""" + +import os +import subprocess +import threading +import time +from dataclasses import dataclass +from typing import Optional, Callable, Dict, List, Literal, overload, Any, Set + +# --- Data Models --- + +@dataclass +class SbgEkfEulerData: + """ + Represents the EKF Euler Angles data log. + This log provides the roll, pitch, and yaw angles, along with their standard deviations. + """ + status: int # EKF status information. + roll: float # Roll angle. Unit: deg + pitch: float # Pitch angle. Unit: deg + yaw: float # Yaw angle (heading). Unit: deg + roll_std: float # Standard deviation of the roll angle. Unit: deg + pitch_std: float # Standard deviation of the pitch angle. Unit: deg + yaw_std: float # Standard deviation of the yaw angle. Unit: deg + mag_heading: float # The magnetic heading. Unit: deg + mag_declination: float # Magnetic declination. Unit: deg + mag_inclination: float # Magnetic inclination. Unit: deg + +@dataclass +class SbgEkfQuatData: + """ + Represents the EKF Quaternion data log. + This log provides the orientation as a quaternion, which avoids gimbal lock issues. + """ + status: int # EKF status information. + qW: float # Quaternion W component. + qX: float # Quaternion X component. + qY: float # Quaternion Y component. + qZ: float # Quaternion Z component. + roll_std: float # Standard deviation of the roll angle. Unit: deg + pitch_std: float # Standard deviation of the pitch angle. Unit: deg + yaw_std: float # Standard deviation of the yaw angle. Unit: deg + mag_declination: float # Magnetic declination. Unit: deg + mag_inclination: float # Magnetic inclination. Unit: deg + +@dataclass +class SbgEkfNavData: + """ + Represents the EKF Navigation data log. + This log provides the device's position (latitude, longitude, altitude) and + velocities in the North-East-Down (NED) frame. + """ + status: int # EKF status information. + vel_n: float # Velocity in the North direction. Unit: m/s + vel_e: float # Velocity in the East direction. Unit: m/s + vel_d: float # Velocity in the Down direction. Unit: m/s + vel_n_std: float # Standard deviation of the North velocity. Unit: m/s + vel_e_std: float # Standard deviation of the East velocity. Unit: m/s + vel_d_std: float # Standard deviation of the Down velocity. Unit: m/s + latitude: float # Latitude position. Unit: deg + longitude: float # Longitude position. Unit: deg + altitude: float # Altitude above mean sea level. Unit: m + lat_std: float # Standard deviation of the latitude. Unit: m + lon_std: float # Standard deviation of the longitude. Unit: m + alt_std: float # Standard deviation of the altitude. Unit: m + undulation: float # Geoid undulation. Unit: m + +@dataclass +class SbgAirData: + """ + Represents the Air Data log (e.g., from a Pitot tube or barometer). + Contains barometric pressure, altitude, and airspeed information. + """ + status: int # Air data sensor status. + pressure: float # Barometric pressure. Unit: hPa + altitude: float # Barometric altitude. Unit: m + temperature: float # Ambient temperature. Unit: °C + air_speed: float # True airspeed. Unit: m/s + air_speed_std: float # Standard deviation of the true airspeed. Unit: m/s + +@dataclass +class SbgImuData: + """ + Represents the IMU (Inertial Measurement Unit) data log. + This log contains calibrated sensor data from the accelerometers, gyroscopes, + and the device's internal temperature sensor. + """ + status: int # IMU status information. + accel_x: float # Calibrated accelerometer X-axis. Unit: m/s^2 + accel_y: float # Calibrated accelerometer Y-axis. Unit: m/s^2 + accel_z: float # Calibrated accelerometer Z-axis. Unit: m/s^2 + gyro_x: float # Calibrated gyroscope X-axis. Unit: deg/s + gyro_y: float # Calibrated gyroscope Y-axis. Unit: deg/s + gyro_z: float # Calibrated gyroscope Z-axis. Unit: deg/s + temperature: float # Internal IMU temperature. Unit: °C + +@dataclass +class SbgGnssVelData: + """ + Represents the GNSS Velocity data log. + Contains velocity information calculated from the GNSS receiver. + """ + status: int # GNSS velocity status. + gps_time_ms: int # GPS time of week. Unit: ms + vel_n: float # Velocity in the North direction. Unit: m/s + vel_e: float # Velocity in the East direction. Unit: m/s + vel_d: float # Velocity in the Down direction. Unit: m/s + vel_std_n: float # Standard deviation of the North velocity. Unit: m/s + vel_std_e: float # Standard deviation of the East velocity. Unit: m/s + vel_std_d: float # Standard deviation of the Down velocity. Unit: m/s + course: float # True heading from the GNSS. Unit: deg + course_std: float # Standard deviation of the course. Unit: deg + +@dataclass +class SbgGnssPosData: + """ + Represents the GNSS Position data log. + Contains position information from the GNSS receiver. + """ + status: int # GNSS position status. + status_ext: int # Extended GNSS position status. + gps_time_ms: int # GPS time of week. Unit: ms + latitude: float # Latitude position. Unit: deg + longitude: float # Longitude position. Unit: deg + altitude: float # Altitude above mean sea level. Unit: m + undulation: float # Geoid undulation. Unit: m + lat_std: float # Standard deviation of the latitude. Unit: m + lon_std: float # Standard deviation of the longitude. Unit: m + alt_std: float # Standard deviation of the altitude. Unit: m + satellites_tracked: int # Number of satellites tracked. + satellites_used: int # Number of satellites used in the computation. + base_station_id: int # ID of the DGPS/RTK base station. + differential_age: int # Age of the differential corrections. Unit: 0.01s + +@dataclass +class SbgStatusData: + """ + Represents the main device status log. + Provides high-level status information about the device's main components. + """ + status: int # General status summary. + main_power: int # Main power supply status. + imu_power: int # IMU power status. + gps_power: int # GNSS receiver power status. + settings: int # Status of the device settings. + +@dataclass +class SbgUtcTimeData: + """ + Represents the UTC Time data log. + Provides date and time information synchronized with UTC. + """ + status: int # UTC time synchronization status. + year: int # Year. + month: int # Month. + day: int # Day. + hour: int # Hour. + minute: int # Minute. + second: int # Second. + nanosecond: int # Nanosecond part of the time. + gps_time_of_week_s: int # GPS time of week. Unit: s + flags: int # UTC data flags. + valid: int # Validity status. + time_s: int # Time in seconds. + +@dataclass +class SbgGnssHdtData: + """ + Represents the GNSS True Heading data log. + Provides heading information from a dual-antenna GNSS setup. + """ + status: int # GNSS heading status. + gps_time_ms: int # GPS time of week. Unit: ms + heading: float # True heading. Unit: deg + heading_std: float # Standard deviation of the heading. Unit: deg + pitch: float # Pitch angle from dual-antenna GNSS. Unit: deg + pitch_std: float # Standard deviation of the pitch. Unit: deg + baseline: float # Length of the baseline between the two GNSS antennas. Unit: m + satellites_tracked: int # Number of satellites tracked. + satellites_used: int # Number of satellites used in the heading computation. + +@dataclass +class SbgGnssSatData: + """ + Represents the GNSS Satellite data log. + Provides a simple count of the visible satellites. + """ + satellites_count: int # The number of visible satellites. + +@dataclass +class SbgImuShortData: + """ + Represents the Short IMU data log. + Provides integrated delta velocity and delta angle values over a short period. + """ + status: int # IMU status. + delta_vel_x: float # Integrated delta velocity X-axis. Unit: m/s + delta_vel_y: float # Integrated delta velocity Y-axis. Unit: m/s + delta_vel_z: float # Integrated delta velocity Z-axis. Unit: m/s + delta_angle_x: float # Integrated delta angle X-axis. Unit: deg + delta_angle_y: float # Integrated delta angle Y-axis. Unit: deg + delta_angle_z: float # Integrated delta angle Z-axis. Unit: deg + temperature: float # Internal IMU temperature. Unit: °C + +@dataclass +class SbgImuFastData: + """ + Represents the Fast IMU data log. + Similar to the short IMU log but intended for higher frequency output. + """ + status: int # IMU status. + delta_vel_x: float # Integrated delta velocity X-axis. Unit: m/s + delta_vel_y: float # Integrated delta velocity Y-axis. Unit: m/s + delta_vel_z: float # Integrated delta velocity Z-axis. Unit: m/s + delta_angle_x: float # Integrated delta angle X-axis. Unit: deg + delta_angle_y: float # Integrated delta angle Y-axis. Unit: deg + delta_angle_z: float # Integrated delta angle Z-axis. Unit: deg + temperature: float # Internal IMU temperature. Unit: °C + +@dataclass +class SbgMagData: + """ + Represents the Magnetometer data log. + Contains calibrated data from the magnetometers. + """ + status: int # Magnetometer status. + mag_x: float # Calibrated magnetometer X-axis. Unit: uT + mag_y: float # Calibrated magnetometer Y-axis. Unit: uT + mag_z: float # Calibrated magnetometer Z-axis. Unit: uT + temperature: float # Internal magnetometer temperature. Unit: °C + +@dataclass +class SbgDiagData: + """ + Represents the device's diagnostics log. + Contains information about the device's internal health and performance. + """ + status: int # General diagnostics status. + main_loop_time: int # Execution time of the main loop. Unit: us + computation_load: float # CPU computation load. Unit: % + cpu_temperature: float # CPU temperature. Unit: °C + +@dataclass +class SbgVelBodyData: + """ + Represents the Body Velocity data log. + Contains velocities expressed in the vehicle's body frame (X, Y, Z). + """ + status: int # Velocity status. + vel_x: float # Velocity along the X-axis of the body frame. Unit: m/s + vel_y: float # Velocity along the Y-axis of the body frame. Unit: m/s + vel_z: float # Velocity along the Z-axis of the body frame. Unit: m/s + vel_std_x: float # Standard deviation of the X-axis velocity. Unit: m/s + vel_std_y: float # Standard deviation of the Y-axis velocity. Unit: m/s + vel_std_z: float # Standard deviation of the Z-axis velocity. Unit: m/s + +# --- Main Reader Class --- +class SBGReader: + """ + Manages the sbgBasicLogger process and provides a typed API to access + and subscribe to SBG device data streams. + """ + + def __init__(self) -> None: + """ + Initializes the SBGReader, setting up internal state for managing + the subprocess, subscriptions, and latest measurements. + """ + self._process: Optional[subprocess.Popen] = None + self._reader_thread: Optional[threading.Thread] = None + self._running = False + self._subscriptions: Dict[str, Set[Callable]] = {} + self._latest_measurements: Dict[str, Any] = {} + + def start(self, device_path: str, baudrate: int = 115200) -> None: + """ + Starts the sbgBasicLogger process and begins reading data. + """ + if self._running: + raise RuntimeError("SBGReader is already running.") + + # logger_path = os.path.join(os.path.dirname(__file__), "sbgECom", "tools", "sbgBasicLogger", "sbgBasicLogger") + logger_path = "./sbgBasicLogger" + if not os.path.exists(logger_path): + raise FileNotFoundError(f"sbgBasicLogger not found at {logger_path}") + + cmd = [logger_path, "-s", device_path, "-r", str(baudrate), "-p", "-f", "hexadecimal"] + + self._process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1 + ) + + time.sleep(1) + if self._process.poll() is not None: + err_output = self._process.stderr.read().strip() if self._process.stderr else "" + raise RuntimeError(f"sbgBasicLogger failed to start: {err_output}") + + self._running = True + self._reader_thread = threading.Thread(target=self._read_loop, daemon=True) + self._reader_thread.start() + + def stop(self) -> None: + """ + Stops the sbgBasicLogger process and the reader thread gracefully. + """ + if not self._running: + return + + self._running = False + if self._process: + self._process.terminate() + try: + self._process.wait(timeout=2) + except subprocess.TimeoutExpired: + self._process.kill() + + if self._reader_thread: + self._reader_thread.join() + + @overload + def get_latest(self, message_type: Literal['euler']) -> Optional[SbgEkfEulerData]: ... + @overload + def get_latest(self, message_type: Literal['quat']) -> Optional[SbgEkfQuatData]: ... + @overload + def get_latest(self, message_type: Literal['nav']) -> Optional[SbgEkfNavData]: ... + @overload + def get_latest(self, message_type: Literal['airData']) -> Optional[SbgAirData]: ... + @overload + def get_latest(self, message_type: Literal['imuData']) -> Optional[SbgImuData]: ... + @overload + def get_latest(self, message_type: Literal['gnss1Vel']) -> Optional[SbgGnssVelData]: ... + @overload + def get_latest(self, message_type: Literal['gnss1Pos']) -> Optional[SbgGnssPosData]: ... + @overload + def get_latest(self, message_type: Literal['status']) -> Optional[SbgStatusData]: ... + @overload + def get_latest(self, message_type: Literal['utcTime']) -> Optional[SbgUtcTimeData]: ... + @overload + def get_latest(self, message_type: Literal['gnss1Hdt']) -> Optional[SbgGnssHdtData]: ... + @overload + def get_latest(self, message_type: Literal['gnss1Sat']) -> Optional[SbgGnssSatData]: ... + @overload + def get_latest(self, message_type: Literal['imuShort']) -> Optional[SbgImuShortData]: ... + @overload + def get_latest(self, message_type: Literal['imuFast']) -> Optional[SbgImuFastData]: ... + @overload + def get_latest(self, message_type: Literal['mag']) -> Optional[SbgMagData]: ... + @overload + def get_latest(self, message_type: Literal['diag']) -> Optional[SbgDiagData]: ... + @overload + def get_latest(self, message_type: Literal['velBody']) -> Optional[SbgVelBodyData]: ... + + def get_latest(self, message_type: str) -> Optional[Any]: + return self._latest_measurements.get(message_type) + + @overload + def subscribe(self, message_type: Literal['euler'], callback: Callable[[SbgEkfEulerData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['quat'], callback: Callable[[SbgEkfQuatData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['nav'], callback: Callable[[SbgEkfNavData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['airData'], callback: Callable[[SbgAirData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['imuData'], callback: Callable[[SbgImuData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['gnss1Vel'], callback: Callable[[SbgGnssVelData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['gnss1Pos'], callback: Callable[[SbgGnssPosData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['status'], callback: Callable[[SbgStatusData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['utcTime'], callback: Callable[[SbgUtcTimeData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['gnss1Hdt'], callback: Callable[[SbgGnssHdtData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['gnss1Sat'], callback: Callable[[SbgGnssSatData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['imuShort'], callback: Callable[[SbgImuShortData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['imuFast'], callback: Callable[[SbgImuFastData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['mag'], callback: Callable[[SbgMagData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['diag'], callback: Callable[[SbgDiagData], None]) -> None: ... + @overload + def subscribe(self, message_type: Literal['velBody'], callback: Callable[[SbgVelBodyData], None]) -> None: ... + + def subscribe(self, message_type: str, callback: Callable) -> None: + if message_type not in self._subscriptions: + self._subscriptions[message_type] = set() + self._subscriptions[message_type].add(callback) + + def unsubscribe(self, message_type: str, callback: Callable) -> None: + if message_type in self._subscriptions: + self._subscriptions[message_type].discard(callback) + + def _read_loop(self) -> None: + if not self._process or not self._process.stdout: + return + + for line in iter(self._process.stdout.readline, ""): + if not self._running: + break + if line.strip(): + self._parse_line(line) + + def _parse_value(self, value: str) -> Any: + try: + if value.lower().startswith('0x'): + return int(value, 16) + if value.lower() == 'nan': + return float('nan') + return int(value) + except ValueError: + try: + return float(value) + except ValueError: + return value + + def _parse_line(self, line: str) -> None: + if not line or line.startswith("*"): + return + + parts = line.strip().split() + if not parts: + return + + message_type = parts[0].rstrip(":") + data = [self._parse_value(p) for p in parts[1:]] + + instance = None + try: + if message_type == 'euler' and len(data) >= 10: + instance = SbgEkfEulerData(*data) + elif message_type == 'quat' and len(data) >= 10: + instance = SbgEkfQuatData(*data) + elif message_type == 'nav' and len(data) >= 14: + instance = SbgEkfNavData(*data) + elif message_type == 'airData' and len(data) >= 6: + instance = SbgAirData(*data) + elif message_type == 'imuData' and len(data) >= 8: + instance = SbgImuData(*data) + elif message_type == 'gnss1Vel' and len(data) >= 10: + instance = SbgGnssVelData(*data) + elif message_type == 'gnss1Pos' and len(data) >= 14: + instance = SbgGnssPosData(*data) + elif message_type == 'status' and len(data) >= 5: + instance = SbgStatusData(*data) + elif message_type == 'utcTime' and len(data) >= 12: + instance = SbgUtcTimeData(*data) + elif message_type == 'gnss1Hdt' and len(data) >= 9: + instance = SbgGnssHdtData(*data) + elif message_type == 'gnss1Sat' and len(data) >= 1: + instance = SbgGnssSatData(*data) + elif message_type == 'imuShort' and len(data) >= 8: + instance = SbgImuShortData(*data) + elif message_type == 'imuFast' and len(data) >= 8: + instance = SbgImuFastData(*data) + elif message_type == 'mag' and len(data) >= 5: + instance = SbgMagData(*data) + elif message_type == 'diag' and len(data) >= 4: + instance = SbgDiagData(*data) + elif message_type == 'velBody' and len(data) >= 7: + instance = SbgVelBodyData(*data) + except (TypeError, IndexError) as e: + # This can happen if the number of data fields is wrong or types are incorrect + # print(f"Warning: Could not parse data for {message_type} due to a {type(e).__name__}: {e}. Line: '{line.strip()}'") + return + + if instance: + self._latest_measurements[message_type] = instance + if message_type in self._subscriptions: + for callback in self._subscriptions[message_type]: + try: + callback(instance) + except Exception as e: + print(f"Error in callback for {message_type}: {e}") diff --git a/tools/ground-station/sbg-test/uv.lock b/tools/ground-station/sbg-test/uv.lock new file mode 100644 index 0000000..98f57b6 --- /dev/null +++ b/tools/ground-station/sbg-test/uv.lock @@ -0,0 +1,132 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "sbg-test" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "textual" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=5.2.0" }] + +[[package]] +name = "textual" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/55/2f7ede2f7a2aa852f062019ab96204dd311dda9103f0b73980e8aeb4e7cc/textual-5.2.0.tar.gz", hash = "sha256:6d4a77fddde7aa659acc25c3e14ed27ff75e125c6730c6f57263cf91b60e0d50", size = 1556452, upload-time = "2025-08-01T18:45:33.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/10/81b3c0cac7d5c6b0b32f38b663fdae3773fb2843623c1e0ff7626dfef7c9/textual-5.2.0-py3-none-any.whl", hash = "sha256:5df1ef970a88ac9a266cbcace098f595f22706990f2506f6e545d9bb55983f1d", size = 701823, upload-time = "2025-08-01T18:45:31.17Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] diff --git a/tools/ground-station/scripts/port_prog_finder.sh b/tools/ground-station/scripts/port_prog_finder.sh new file mode 100755 index 0000000..f8a92b9 --- /dev/null +++ b/tools/ground-station/scripts/port_prog_finder.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +# sudo lsof -n -i :5656 | grep LISTEN +sudo lsof -n -i :$1 | grep LISTEN \ No newline at end of file diff --git a/tools/ground-station/scripts/postcreate.sh b/tools/ground-station/scripts/postcreate.sh new file mode 100755 index 0000000..347e265 --- /dev/null +++ b/tools/ground-station/scripts/postcreate.sh @@ -0,0 +1,19 @@ +# bin/bash -i + +set -e + +# Get the directory of this script' +cd "$(dirname "$0")" + +DIR="$(pwd)" + +for script in $DIR/requirements/*.sh; do + sudo chmod +x $script + $script +done + +echo '' +echo '🚀 You are almost ready to go!' +echo 'Re-open your terminal to update your environment' +echo '' +echo '🦀 Running `cargo build` will install and compile all rust deps' diff --git a/tools/ground-station/scripts/requirements/0_dev_deps.sh b/tools/ground-station/scripts/requirements/0_dev_deps.sh new file mode 100755 index 0000000..442f736 --- /dev/null +++ b/tools/ground-station/scripts/requirements/0_dev_deps.sh @@ -0,0 +1,14 @@ +# bin/bash -i + +echo '📥 Updating system' +sudo apt -qq update && sudo apt -qq upgrade -y +echo '✅ System updated' + +echo '📥 Installing dev dependencies' +sudo apt -qq install -y \ + pkg-config \ + libudev-dev \ + inotify-tools \ + cmake \ + libusb-1.0-0-dev +echo '✅ Dev dependencies installed' diff --git a/tools/ground-station/ssh-setup/README.md b/tools/ground-station/ssh-setup/README.md new file mode 100644 index 0000000..3b91caf --- /dev/null +++ b/tools/ground-station/ssh-setup/README.md @@ -0,0 +1,130 @@ +# SSH Setup Utility + +A lightweight utility for automating SSH key distribution and server configuration across a local network. This tool consists of a master server that distributes SSH configuration to slave nodes, enabling secure one-way SSH access from the master to all configured slaves. + +## What It Does + +This utility automates the process of: +1. **SSH Key Distribution**: Automatically distributes the master's SSH public key to slave nodes +2. **SSH Server Setup**: Ensures SSH servers are installed and running on slave nodes +3. **Cross-Platform Support**: Works across multiple Linux distributions (Debian, Ubuntu, Fedora, CentOS, Arch, etc.) +4. **Network Discovery**: Automatically detects network interfaces and IP addresses +5. **Zero-Configuration**: Minimal setup required - just run the server and execute one command on each slave + +## Quick Start + +### 1. Start the Master Server + +On your master/control machine: + +```bash +cd utils/ssh-setup +python3 server.py +``` + +The server will: +- Auto-detect your primary network IP address +- Use existing SSH keys or generate new ones if needed +- Start an HTTP server on port 8000 +- Display the curl command to run on slave nodes + +### 2. Configure Slave Nodes + +On each slave node you want to configure, run: + +```bash +curl -sSL http://MASTER_IP:8000/install | python3 +``` + +Replace `MASTER_IP` with the actual IP address shown by the server. + +## How It Works + +### Master Server (`server.py`) + +1. **Network Detection**: Automatically finds the primary network interface and IP address +2. **SSH Key Management**: Uses existing SSH keys from `~/.ssh/id_rsa.pub` or generates new ones +3. **Template Processing**: Serves a customized `bootstrap.py` script with: + - Master's IP address + - Master's SSH public key + - Slave's IP address (detected from HTTP request) + +### Bootstrap Script (`bootstrap.py`) + +1. **OS Detection**: Identifies the Linux distribution and package manager +2. **SSH Server Installation**: Installs OpenSSH server if not present +3. **Service Configuration**: Ensures SSH service is enabled and running +4. **Key Distribution**: Adds master's public key to user's `~/.ssh/authorized_keys` +5. **mDNS Support**: Checks for Avahi service to provide hostname-based access + +## Supported Distributions + +The utility supports multiple Linux distributions through package manager detection: + +- **Debian-based**: Ubuntu, Debian, Pop!_OS, Linux Mint +- **Red Hat-based**: Fedora, CentOS, RHEL, Rocky Linux +- **Arch-based**: Arch Linux, Manjaro + +### Prerequisites + +- All nodes must be on the same local network + +**Master Server Requirements:** +- Python 3.6+ +- `ssh-keygen` command available +- Network access to slave nodes + +**Slave Node Requirements:** +- Python 3.6+ +- `sudo` privileges (for package installation and service management) +- `systemctl` command available +- Network connectivity to master server + +### Customization + +**Adding New Distributions:** +Edit the `DISTRO_CONFIG` dictionary in `bootstrap.py`: + +```python +DISTRO_CONFIG = { + "your_distro": { + "pkg_manager": "your_pkg_manager", + "update_cmd": ["update"], + "install_cmd": ["install", "-y"], + "ssh_pkg": "openssh-server", + "ssh_svc": "ssh", + } +} +``` + +**Changing Server Port:** +Modify the `SERVER_PORT` variable in `server.py`. + +**Custom SSH Configuration:** +Edit the `setup_user_ssh()` function in `bootstrap.py` to add additional SSH options. + +## Example Usage + +```bash +# On master (192.168.1.100) +$ cd utils/ssh-setup +$ python3 server.py +--- Bootstrap Server Running --- +Auto-detected Master IP: 192.168.1.100 +Server listening on http://0.0.0.0:8000 + +To install on a slave node, run: +curl -sSL http://192.168.1.100:8000/install | python3 + +# On slave (192.168.1.101) +$ curl -sSL http://192.168.1.100:8000/install | python3 +[bootstrap] Performing pre-flight checks... +[bootstrap] Detected distribution: ubuntu +[bootstrap] Setting up user-specific SSH configuration for user: ubuntu +[bootstrap] SSH server (sshd) is already installed. +[bootstrap] Bootstrap complete! Slave node is ready. +[bootstrap] Master can connect to this slave using: ssh ubuntu@192.168.1.101 + +# Test connection from master +$ ssh ubuntu@192.168.1.101 +``` diff --git a/tools/ground-station/ssh-setup/bootstrap.py b/tools/ground-station/ssh-setup/bootstrap.py new file mode 100755 index 0000000..f9b56ed --- /dev/null +++ b/tools/ground-station/ssh-setup/bootstrap.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 + +import os +import sys +import subprocess +import shutil +import tempfile + +# This script bootstraps a slave node for one-way SSH access from a master server. +# It must be run as the user you wish to configure (e.g., `pi`, `ubuntu`). +# It detects the OS, installs the SSH server if missing, configures SSH access +# for the user, and ensures the system-wide SSH daemon is running correctly. + +# --- TEMPLATE VARIABLES --- +# These would be replaced by your templating engine before the script is run. +MASTER_IP = "{{MASTER_IP}}" # e.g. "192.168.88.10" +MASTER_SSH_PUBKEY = "{{MASTER_SSH_PUBKEY}}" # e.g. "ssh-ed25519 AAAA... comment" +SLAVE_IP = "{{SLAVE_IP}}" # e.g. "192.168.88.11" + + +# --- DISTRIBUTION CONFIGURATION (EXTENSIBLE) --- +# To add support for a new distribution, add its ID (from /etc/os-release) +# to the appropriate family or create a new family archetype. + +# 1. Define the archetypes for distro families. +DEBIAN_LIKE = { + "pkg_manager": "apt-get", + "update_cmd": ["update"], + "install_cmd": ["install", "-y"], + "ssh_pkg": "openssh-server", + "ssh_svc": "ssh", +} +FEDORA_LIKE = { + "pkg_manager": "dnf", + "update_cmd": [], # dnf doesn't typically require a separate update command + "install_cmd": ["install", "-y"], + "ssh_pkg": "openssh-server", + "ssh_svc": "sshd", +} +ARCH_LIKE = { + "pkg_manager": "pacman", + "update_cmd": ["-Sy"], # Sync and update repositories + "install_cmd": ["-S", "--noconfirm"], + "ssh_pkg": "openssh", + "ssh_svc": "sshd", +} + +# 2. Map OS IDs to their family archetypes. +DISTRO_CONFIG = { + "debian": DEBIAN_LIKE, + "ubuntu": DEBIAN_LIKE, + "pop": DEBIAN_LIKE, # Pop!_OS + "linuxmint":DEBIAN_LIKE, # Linux Mint + "fedora": FEDORA_LIKE, + "centos": FEDORA_LIKE, + "rhel": FEDORA_LIKE, # Red Hat Enterprise Linux + "rocky": FEDORA_LIKE, # Rocky Linux + "arch": ARCH_LIKE, + "manjaro": ARCH_LIKE, +} + +# --- UTILS --- + +def log(message): + """Prints a standard log message.""" + print(f"[bootstrap] {message}") + +def err(message): + """Prints a colorized error message to stderr.""" + print(f"\033[0;31m[bootstrap][ERROR] {message}\033[0m", file=sys.stderr) + +def require_filled(name, value): + """Checks if a template variable has been filled.""" + if not value or "{{" in value or "}}" in value: + err(f"Template variable {name} is not set.") + sys.exit(1) + +def require_cmd(command): + """Checks if a command exists in the system's PATH.""" + if not shutil.which(command): + err(f"Required command '{command}' not found in PATH.") + sys.exit(1) + +def run_command(command_list, use_sudo=False, check=True): + """Runs a command, optionally with sudo, and handles errors.""" + if use_sudo: + require_cmd("sudo") + command_list.insert(0, "sudo") + + log(f"Running command: {' '.join(command_list)}") + try: + result = subprocess.run( + command_list, check=check, capture_output=True, text=True + ) + return result + except subprocess.CalledProcessError as e: + err(f"Command failed with exit code {e.returncode}: {' '.join(command_list)}") + err(f"STDOUT: {e.stdout.strip()}") + err(f"STDERR: {e.stderr.strip()}") + sys.exit(1) + except FileNotFoundError: + err(f"Command not found: {command_list[0]}") + sys.exit(1) + +def get_distro_info(): + """ + Detects the Linux distribution by reading /etc/os-release and returns + the corresponding configuration from DISTRO_CONFIG. + """ + if not os.path.exists("/etc/os-release"): + err("Cannot determine OS: /etc/os-release not found. This script requires a standard Linux system.") + return None + + with open("/etc/os-release") as f: + lines = f.readlines() + + os_info = {} + for line in lines: + if "=" in line: + key, value = line.strip().split("=", 1) + os_info[key] = value.strip('"') + + distro_id = os_info.get("ID") + id_like = os_info.get("ID_LIKE", "").split() + + # Check primary ID first, then fall back to ID_LIKE for derivatives. + if distro_id in DISTRO_CONFIG: + log(f"Detected distribution: {distro_id}") + return DISTRO_CONFIG[distro_id] + + for id_val in id_like: + if id_val in DISTRO_CONFIG: + log(f"Detected compatible distribution: {id_val} (via ID_LIKE)") + return DISTRO_CONFIG[id_val] + + err(f"Unsupported Linux distribution: '{distro_id}'. Supported: {list(DISTRO_CONFIG.keys())}") + return None + + +# --- SCRIPT LOGIC --- + +def setup_user_ssh(): + """Configures the user's ~/.ssh/authorized_keys file.""" + log(f"Setting up user-specific SSH configuration for user: {os.getlogin()}") + + home_dir = os.path.expanduser("~") + ssh_dir = os.path.join(home_dir, ".ssh") + auth_keys_path = os.path.join(ssh_dir, "authorized_keys") + + os.makedirs(ssh_dir, mode=0o700, exist_ok=True) + + if not os.path.exists(auth_keys_path): + open(auth_keys_path, 'a').close() + os.chmod(auth_keys_path, 0o600) + + try: + key_material = " ".join(MASTER_SSH_PUBKEY.split()[:2]) + except IndexError: + err("MASTER_SSH_PUBKEY appears to be malformed.") + sys.exit(1) + + key_line = MASTER_SSH_PUBKEY # No 'from=' IP restriction + + lines = [] + key_found = False + with open(auth_keys_path, "r") as f: + for line in f: + line = line.strip() + if not line: continue + if key_material in line: + lines.append(key_line) + key_found = True + else: + lines.append(line) + + if not key_found: + lines.append(key_line) + log("Adding master's key to user's authorized_keys.") + else: + log("Master's key material found. Ensured line is up-to-date.") + + with tempfile.NamedTemporaryFile("w", dir=ssh_dir, delete=False) as tmp: + tmp.write("\n".join(lines) + "\n") + temp_path = tmp.name + + os.replace(temp_path, auth_keys_path) + os.chmod(auth_keys_path, 0o600) + log("User-specific SSH setup complete.") + +def setup_system_ssh(distro_info): + """ + Ensures the SSH server package is installed, configured, and running. + """ + log("Checking system SSH service status.") + ssh_service = distro_info['ssh_svc'] + + # 1. Install SSH server if it's not present + if not shutil.which("sshd"): + log("SSH server (sshd) not found. Attempting installation...") + pkg_manager = distro_info['pkg_manager'] + + if distro_info['update_cmd']: + run_command([pkg_manager] + distro_info['update_cmd'], use_sudo=True) + + install_command = [pkg_manager] + distro_info['install_cmd'] + [distro_info['ssh_pkg']] + run_command(install_command, use_sudo=True) + + require_cmd("sshd") # Verify installation was successful + log(f"Successfully installed {distro_info['ssh_pkg']}.") + else: + log("SSH server (sshd) is already installed.") + + # 2. Configure and enable the service + host_keys_exist = any(f.startswith('ssh_host_') for f in os.listdir('/etc/ssh/')) + daemon_running = run_command(["pgrep", "-x", "sshd"], check=False).returncode == 0 + service_enabled = run_command(["systemctl", "is-enabled", "--quiet", ssh_service], check=False).returncode == 0 + + if not host_keys_exist: log("SSH host keys not found.") + if not daemon_running: log("SSH daemon is not running.") + if not service_enabled: log("SSH service is not enabled to start on boot.") + + if not all([host_keys_exist, daemon_running, service_enabled]): + log("System SSH setup/activation is required...") + if not host_keys_exist: + log("Generating SSH host keys...") + run_command(["ssh-keygen", "-A"], use_sudo=True) + + log(f"Enabling and starting the {ssh_service} service...") + run_command(["systemctl", "enable", "--now", ssh_service], use_sudo=True) + log("System SSH setup complete.") + else: + log("System SSH setup not needed - daemon is configured and running.") + +def get_connect_address(): + """ + Determines the best address (mDNS hostname or IP) for connecting to this node. + """ + connect_address = SLAVE_IP + log("Checking for mDNS (Avahi) service to provide a hostname...") + + try: + # Avahi may not be installed, so we don't treat failure as a critical error. + if shutil.which("systemctl"): + result = run_command(["systemctl", "is-active", "--quiet", "avahi-daemon"], check=False) + if result.returncode == 0: + log("Avahi daemon is active. Constructing mDNS hostname.") + hostname = run_command(["hostname"]).stdout.strip() + if hostname: + connect_address = f"{hostname}.local" + log(f"Node is discoverable at: {connect_address}") + else: + log("Avahi daemon not active or found. Node must be accessed via its IP address.") + except Exception as e: + err(f"A non-critical error occurred while checking for mDNS: {e}") + + return connect_address + +def main(): + """Main execution function.""" + # --- PRE-FLIGHT CHECKS --- + log("Performing pre-flight checks...") + require_filled("MASTER_IP", MASTER_IP) + require_filled("MASTER_SSH_PUBKEY", MASTER_SSH_PUBKEY) + require_filled("SLAVE_IP", SLAVE_IP) + + distro_info = get_distro_info() + if not distro_info: + sys.exit(1) + + required_commands = [ + "sudo", "systemctl", "pgrep", "hostname", "ssh-keygen", + distro_info['pkg_manager'] + ] + for cmd in required_commands: + require_cmd(cmd) + log("Pre-flight checks passed.") + + # --- MAIN LOGIC --- + setup_user_ssh() + setup_system_ssh(distro_info) + connect_address = get_connect_address() + + # --- FINAL MESSAGE --- + log("Bootstrap complete! Slave node is ready.") + log(f"Master can connect to this slave using: ssh {os.getlogin()}@{connect_address}") + sys.exit(0) + +if __name__ == "__main__": + try: + main() + except Exception as e: + err(f"An unexpected error occurred: {e}") + sys.exit(1) \ No newline at end of file diff --git a/tools/ground-station/ssh-setup/server.py b/tools/ground-station/ssh-setup/server.py new file mode 100755 index 0000000..942deb3 --- /dev/null +++ b/tools/ground-station/ssh-setup/server.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 + +import os +import sys +import re +import shutil +import socket +import subprocess +from http.server import BaseHTTPRequestHandler, HTTPServer + +# --- UTILITY FUNCTIONS --- + +def get_primary_ip(): + """ + Determines the primary IP for an offline LAN, ignoring virtual interfaces. + It tries the 'ip' command first, then falls back to 'ifconfig'. + """ + ignored_prefixes = ('lo', 'docker', 'virbr', 'veth', 'br-') + + # Method 1: Use the 'ip' command (Modern Linux) + if shutil.which("ip"): + try: + result = subprocess.run(["ip", "-o", "link", "show"], capture_output=True, text=True, check=True) + for line in result.stdout.strip().split('\n'): + match = re.search(r'^\d+:\s+([^:]+):', line) + if not match: continue + + iface_name = match.group(1).strip() + if any(iface_name.startswith(p) for p in ignored_prefixes): continue + + ip_result = subprocess.run(["ip", "-4", "addr", "show", "dev", iface_name], capture_output=True, text=True) + ip_match = re.search(r"inet\s+([\d.]+)/", ip_result.stdout) + if ip_match: + ip = ip_match.group(1) + print(f"[INFO] Found IP '{ip}' on interface '{iface_name}' via 'ip' command.") + return ip + except Exception as e: + print(f"[WARNING] 'ip' command method failed: {e}") + + # Method 2: Fallback to 'ifconfig' (Older systems) + if shutil.which("ifconfig"): + try: + result = subprocess.run(["ifconfig"], capture_output=True, text=True, check=True) + for block in result.stdout.strip().split('\n\n'): + iface_name = block.split()[0].rstrip(':') + if any(iface_name.startswith(p) for p in ignored_prefixes): continue + + ip_match = re.search(r"inet(?: addr:)?\s*([\d.]+)", block) + if ip_match and not ip_match.group(1).startswith("127."): + ip = ip_match.group(1) + print(f"[INFO] Found IP '{ip}' on interface '{iface_name}' via 'ifconfig'.") + return ip + except Exception as e: + print(f"[WARNING] 'ifconfig' command method failed: {e}") + + return None + +# --- CONFIGURATION --- + +# 1. Determine Master IP +MASTER_IP_VAL = get_primary_ip() +if not MASTER_IP_VAL: + print("[CRITICAL] Could not auto-detect a primary IP. Aborting.") + sys.exit(1) + +# 2. Load or Generate Master SSH Key from the standard user location +ssh_dir = os.path.expanduser("~/.ssh") +key_path = os.path.join(ssh_dir, "id_rsa") +pub_key_path = f"{key_path}.pub" + +if os.path.exists(pub_key_path): + print(f"[INFO] Using existing SSH key: {pub_key_path}") + with open(pub_key_path, "r") as f: + MASTER_SSH_PUBKEY_VAL = f.read().strip() +else: + print(f"[WARNING] SSH key '{pub_key_path}' not found. Generating a new one...") + if not shutil.which("ssh-keygen"): + print("[CRITICAL] 'ssh-keygen' command not found. Cannot generate key. Aborting.") + sys.exit(1) + + try: + # Ensure the .ssh directory exists with correct permissions (drwx------) + os.makedirs(ssh_dir, mode=0o700, exist_ok=True) + + hostname = socket.gethostname() + keygen_command = [ + "ssh-keygen", "-t", "rsa", "-b", "4096", # Strong RSA key + "-f", key_path, # Output file path (~/.ssh/id_rsa) + "-N", "", # Empty passphrase + ] + subprocess.run(keygen_command, check=True, capture_output=True, text=True) + print(f"[INFO] Successfully generated new SSH key pair in {ssh_dir}") + with open(pub_key_path, "r") as f: + MASTER_SSH_PUBKEY_VAL = f.read().strip() + except subprocess.CalledProcessError as e: + print("[CRITICAL] Failed to generate SSH key.") + print(f"STDERR: {e.stderr.strip()}") + sys.exit(1) + +SERVER_HOST = "0.0.0.0" +SERVER_PORT = 8000 +TEMPLATE_FILE = "bootstrap.py" + +# --- HTTP SERVER LOGIC --- + +class TemplatingRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/install": + self.serve_template() + else: + self.send_error(404, "Not Found") + self.end_headers() + self.wfile.write(b"Error: Endpoint not found. Use /install\n") + + def serve_template(self): + try: + slave_ip = self.client_address[0] + print(f"[INFO] Request for /install from slave at {slave_ip}") + + with open(TEMPLATE_FILE, "r") as f: + template_content = f.read() + + rendered_script = template_content.replace("{{MASTER_IP}}", MASTER_IP_VAL) + rendered_script = rendered_script.replace("{{MASTER_SSH_PUBKEY}}", MASTER_SSH_PUBKEY_VAL) + rendered_script = rendered_script.replace("{{SLAVE_IP}}", slave_ip) + + self.send_response(200) + self.send_header("Content-type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(rendered_script.encode("utf-8")) + except FileNotFoundError: + print(f"[ERROR] Template file '{TEMPLATE_FILE}' not found!") + self.send_error(500, "Internal Server Error") + except Exception as e: + print(f"[ERROR] An unexpected error occurred: {e}") + self.send_error(500, "Internal Server Error") + +def run_server(): + """Starts the HTTP server.""" + try: + server_address = (SERVER_HOST, SERVER_PORT) + httpd = HTTPServer(server_address, TemplatingRequestHandler) + print("\n--- Bootstrap Server Running ---") + print(f"Auto-detected Master IP: {MASTER_IP_VAL}") + print(f"Server listening on http://{SERVER_HOST}:{SERVER_PORT}") + print("\nTo install on a slave node, run:") + print(f"\033[0;32mcurl -sSL http://{MASTER_IP_VAL}:{SERVER_PORT}/install | python3\033[0m") + print("\n---------------------------------") + print("Press Ctrl+C to stop the server.") + httpd.serve_forever() + except KeyboardInterrupt: + print("\n[INFO] Server is shutting down.") + httpd.server_close() + except OSError as e: + print(f"[ERROR] Could not start server: {e}") + +if __name__ == "__main__": + if not os.path.exists(TEMPLATE_FILE): + print(f"[ERROR] Template file './{TEMPLATE_FILE}' not found. Aborting.") + else: + run_server() \ No newline at end of file diff --git a/tools/ground-station/tenna-manual/README.md b/tools/ground-station/tenna-manual/README.md new file mode 100644 index 0000000..e69de29 diff --git a/tools/ground-station/tenna-manual/manual.py b/tools/ground-station/tenna-manual/manual.py new file mode 100755 index 0000000..70745fd --- /dev/null +++ b/tools/ground-station/tenna-manual/manual.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Simplified Manual ODrive Control with Xbox Controller +""" +import time +import readline +import odrive +from odrive.enums import AxisState, ControlMode, InputMode +from odrive.utils import dump_errors +from remote import XboxController + +class ManualODriveControl: + """Manage manual control of an ODrive device, including Xbox controller support.""" + def __init__(self): + self.device = None + self.axes = {} # {'yaw': axis0, 'pitch': axis1} + self.offsets = {'yaw': 0.0, 'pitch': 0.0} + self.calibrated = False + self.debug = False + self.controller = None + self.controller_active = False + + self.cfg = { + 'gear_ratios': {'yaw': 30, 'pitch': 36}, + 'pole_pairs': 7, + 'torque_constant': 8.27 / 150, + 'current_limit': 30.0, + 'regen_current_limit': 4.0, # Amps + 'dc_max_negative_current': -6.0, # Amps + 'dc_max_positive_current': 60.0, # Amps + 'calibration_current': 5.0, # Amps + 'calibration_timeout': 30.0, + 'ctrl_speed_scale': 10, # How fast the controller affects the target position + 'speed_step': 1.5, + 'speed_mult_limits': (0.1, 2.0), + 'max_speeds_deg': { + 'yaw': 120.0, # output deg/s at full stick + 'pitch': 120.0, # can tune each axis separately + }, + 'ramp_deg_s2': { + 'yaw': 180.0, # decel/accel slope (deg/s²) - 0.25s stop time from full speed + 'pitch': 180.0, # stop time = max_speed_deg / ramp_deg_s2 = 90°/s / 360°/s² = 0.25s + }, + 'max_acceleration': 180.0, # deg/s² - maximum acceleration for safety + 'limits': { + 'yaw': (-370, 370), + # 'yaw': (-80, 80), + 'pitch': (-10, 90), + }, + 'gains': { + 'pos': 2.0, + 'vel': 0.10, + 'vel_int': 3.0, + } + } + + def connect(self): + print("Connecting to ODrive...") + try: + self.device = odrive.find_any() + if not self.device: + print("ERROR: No ODrive found.") + return False + self.axes = {'yaw': self.device.axis0, 'pitch': self.device.axis1} + self.device.clear_errors() + print(f"Connected: Serial {self.device.serial_number}") + return True + except Exception as e: + print("ERROR: Could not connect", e) + return False + + def configure(self): + print("Configuring motors...") + + + + self.device.config.enable_brake_resistor = True + self.device.config.brake_resistance = 2.0 + + self.device.config.dc_max_negative_current = self.cfg['dc_max_negative_current'] + self.device.config.dc_max_positive_current = self.cfg['dc_max_positive_current'] + self.device.config.max_regen_current = self.cfg['regen_current_limit'] + for name, axis in self.axes.items(): + axis.controller.config.vel_limit = float('inf') #revs/sec (max safe limit) + mcfg = axis.motor.config + ccfg = axis.controller.config + tcfg = axis.trap_traj.config + # Motor settings + mcfg.pole_pairs = self.cfg['pole_pairs'] + mcfg.torque_constant = self.cfg['torque_constant'] + mcfg.current_lim = self.cfg['current_limit'] + mcfg.calibration_current = self.cfg['calibration_current'] + # Controller settings + ccfg.input_mode = InputMode.TRAP_TRAJ + ccfg.control_mode = ControlMode.POSITION_CONTROL + ccfg.pos_gain = self.cfg['gains']['pos'] + ccfg.vel_gain = self.cfg['gains']['vel'] + ccfg.vel_integrator_gain = self.cfg['gains']['vel_int'] + # Set appropriate velocity limit for position control (not mirroring trap trajectory) + ccfg.vel_limit = float('inf') # Allow full speed for position control + # Disable circular setpoints for yaw to prevent wrapping + if name == 'yaw': + ccfg.circular_setpoints = False + print("Configuration done.") + return True + + def calibrate(self): + print("Clearing errors and calibrating axes...") + self.device.clear_errors() # Clear any existing errors before calibration + print(f"Calibrating both axes simultaneously (timeout: {self.cfg['calibration_timeout']}s)...") + # Start calibration on both axes + for name, ax in self.axes.items(): + print(f"Requesting calibration for {name} axis...") + ax.requested_state = AxisState.FULL_CALIBRATION_SEQUENCE + + start = time.time() + # Wait for both axes to finish calibration or timeout + while True: + all_idle = all(ax.current_state == AxisState.IDLE for ax in self.axes.values()) + if all_idle: + break + if time.time() - start > self.cfg['calibration_timeout']: + print("ERROR: Calibration timed out for one or more axes") + return False + time.sleep(0.2) + + # Set both axes to closed loop control + for name, ax in self.axes.items(): + ax.requested_state = AxisState.CLOSED_LOOP_CONTROL + time.sleep(0.5) + + # Check for errors on both axes + for name, ax in self.axes.items(): + if ax.error != 0: + print(f"ERROR: {name} axis has error after calibration") + dump_errors(self.device) + return False + print(f"{name.capitalize()} axis calibration complete.") + + self.calibrated = True + print("All axes calibrated.") + return not self.has_errors() + + def has_errors(self): + if any(ax.error for ax in self.axes.values()): + dump_errors(self.device) + return True + return False + + def get_pos(self): + pos = {} + for name, axis in self.axes.items(): + turns = axis.encoder.pos_estimate - self.offsets[name] + pos[name] = (turns / self.cfg['gear_ratios'][name]) * 360.0 + return pos + + def set_pos(self, pitch=None, yaw=None, verbose=True, bypass_limits=False): + for name, deg in [('pitch', pitch), ('yaw', yaw)]: + if deg is None: + continue + if bypass_limits: + clamped = deg + if verbose: + print(f"LIMITS BYPASSED: {name.capitalize()} set to {deg}°") + else: + lo, hi = self.cfg['limits'][name] + clamped = max(lo, min(hi, deg)) + if verbose and clamped != deg: + print(f"WARNING: {name.capitalize()} clamped to {clamped}°") + turns = (clamped / 360.0) * self.cfg['gear_ratios'][name] + self.offsets[name] + self.axes[name].controller.input_pos = turns + + print(f"Pitch Degrees: {pitch}, Yaw Degrees: {yaw}") + return not self.has_errors() + + def set_vel(self, pitch=None, yaw=None): + for name, dps in [('pitch', pitch), ('yaw', yaw)]: + if dps is None: + continue + turns_per_s = (dps / 360.0) * self.cfg['gear_ratios'][name] + self.axes[name].controller.input_vel = turns_per_s + + def calculate_max_permissible_velocity(self, axis_name, requested_velocity): + """ + Calculate maximum permissible velocity based on kinematic constraints. + + Args: + axis_name: 'pitch' or 'yaw' + requested_velocity: requested velocity in deg/s (can be negative) + + Returns: + float: maximum permissible velocity in deg/s + """ + if requested_velocity == 0: + return 0.0 + + # Get current position and limits + current_pos = self.get_pos()[axis_name] + limits = self.cfg['limits'][axis_name] + max_accel = self.cfg.get('max_acceleration', 20.0) # deg/s² + + # Determine direction and available travel distance + if requested_velocity > 0: # Moving in positive direction + available_distance = limits[1] - current_pos # Distance to upper limit + direction = 1 + else: # Moving in negative direction + available_distance = current_pos - limits[0] # Distance to lower limit + direction = -1 + + # If we're already at a limit, no movement allowed + if available_distance <= 0: + return 0.0 + + # Kinematic equation: v² = v₀² + 2aΔx + # For stopping from velocity v to 0: 0 = v² + 2(-a)Δx + # Therefore: v = √(2aΔx) + # Where: a = deceleration, Δx = available distance + + # Convert max_accel from deg/s² to deg/s² (already in correct units) + max_velocity = (2 * max_accel * available_distance) ** 0.5 + + # Apply direction + max_velocity *= direction + + # Limit to the requested velocity magnitude + if abs(requested_velocity) <= abs(max_velocity): + return requested_velocity + else: + return max_velocity + + def stop(self): + for ax in self.axes.values(): + ax.controller.input_vel = 0 + print("Motors stopped.") + + def zero(self): + for name, ax in self.axes.items(): + self.offsets[name] = ax.encoder.pos_estimate + print("Zero position set.") + + def status(self): + pos = self.get_pos() + print(f"Pitch: {pos['pitch']:.2f}°, Yaw: {pos['yaw']:.2f}°") + for name, ax in self.axes.items(): + print(f"{name.capitalize()} state: {AxisState(ax.current_state).name}") + print(f"Calibrated: {self.calibrated}") + self.has_errors() + + def disengage(self): + """Set both axes to IDLE state (disengaged)""" + print("Disengaging motors...") + for name, ax in self.axes.items(): + ax.requested_state = AxisState.IDLE + time.sleep(0.5) # Allow time for state change + print("Motors disengaged.") + return not self.has_errors() + + def engage(self): + """Set both axes to CLOSED_LOOP_CONTROL state (engaged)""" + if not self.calibrated: + print("ERROR: Cannot engage - motors not calibrated. Run calibration first.") + return False + print("Engaging motors...") + for name, ax in self.axes.items(): + ax.requested_state = AxisState.CLOSED_LOOP_CONTROL + time.sleep(0.5) # Allow time for state change + print("Motors engaged.") + return not self.has_errors() + + def toggle_debug(self, on): + self.debug = on + print("Debug", "ON" if on else "OFF") + + def _start_controller_services(self): + if self.controller_active: + return True + try: + self.controller = XboxController(deadzone=0.1) + if not self.controller.start(): + print("ERROR: Failed to start Xbox controller.") + return False + self.controller_active = True + return True + except Exception as e: + print("ERROR:", e) + return False + + def _stop_controller_services(self): + if not self.controller_active: + return True + print("Stopping controller...") + if self.controller: + self.controller.stop() + self.controller_active = False + self.stop() + print("Xbox controller stopped.") + return True + + def run_controller_loop(self): + if not self._start_controller_services(): + return + print("\nController mode (VELOCITY). Ctrl-C to exit.") + print("X=set zero here, Y=return to zero, B=emergency stop") + + # Put both axes in velocity control mode with ramped input + for name, ax in self.axes.items(): + ccfg = ax.controller.config + ccfg.control_mode = ControlMode.VELOCITY_CONTROL + ccfg.input_mode = InputMode.VEL_RAMP + + # Convert output ramp (deg/s²) -> motor turns/s² + ramp_turns_s2 = (self.cfg['ramp_deg_s2'][name] / 360.0) * self.cfg['gear_ratios'][name] + ccfg.vel_ramp_rate = ramp_turns_s2 + + # Compute expected motor turns/s at full stick for this axis + max_turns_per_s = (self.cfg['max_speeds_deg'][name] / 360.0) * self.cfg['gear_ratios'][name] + + # Give some headroom (50%) to avoid nuisance trips from measurement spikes + ccfg.vel_limit = max(1.0, max_turns_per_s * 1.5) + + # Optional: relax overspeed tolerance a bit (default is ~1.2) + try: + ccfg.vel_limit_tolerance = 1.5 + except AttributeError: + pass # older firmware may not expose this + + # Button state tracking + last_x = last_y = last_b = False + + + + try: + while self.controller_active: + if not self.controller.is_connected(): + print("Controller disconnected.") + break + + # Check buttons + xb = self.controller.get_button('X') + yb = self.controller.get_button('Y') + bb = self.controller.get_button('B') + + + + # X button: set zero at current position + if xb and not last_x: + self.zero() + print("Zero position set at current location") + + # Y button: return to zero position + if yb and not last_y: + print("Returning to zero position...") + # Switch to position control temporarily + for ax in self.axes.values(): + ax.controller.config.control_mode = ControlMode.POSITION_CONTROL + ax.controller.config.input_mode = InputMode.TRAP_TRAJ + + # Move to zero + self.set_pos(0.0, 0.0, bypass_limits=False) + + # Wait for movement to complete + time.sleep(2.0) + + # Switch back to velocity control with ramped input + for name, ax in self.axes.items(): + ccfg = ax.controller.config + ccfg.control_mode = ControlMode.VELOCITY_CONTROL + ccfg.input_mode = InputMode.VEL_RAMP + + # Recompute ramp rate and velocity limits for this axis + ramp_turns_s2 = (self.cfg['ramp_deg_s2'][name] / 360.0) * self.cfg['gear_ratios'][name] + ccfg.vel_ramp_rate = ramp_turns_s2 + + max_turns_per_s = (self.cfg['max_speeds_deg'][name] / 360.0) * self.cfg['gear_ratios'][name] + ccfg.vel_limit = max(1.0, max_turns_per_s * 1.5) + + print("Returned to zero position") + + # B button: Emergency stop - set to idle and stop controller mode + if bb and not last_b: + print("EMERGENCY STOP ACTIVATED!") + print("Setting motors to IDLE and stopping controller mode...") + + # Stop all motors immediately + self.set_vel(0.0, 0.0) + + # Set both axes to IDLE state (disengage) + for name, ax in self.axes.items(): + ax.requested_state = AxisState.IDLE + + # Stop controller services and exit loop + self._stop_controller_services() + return + + last_x, last_y, last_b = xb, yb, bb + + yaw_in = self.controller.get_axis('LX') + pitch_in = -self.controller.get_axis('RY') + + yaw_dps = yaw_in * self.cfg['max_speeds_deg']['yaw'] + pitch_dps = pitch_in * self.cfg['max_speeds_deg']['pitch'] + + # Apply kinematic velocity limits + yaw_dps_limited = self.calculate_max_permissible_velocity('yaw', yaw_dps) + pitch_dps_limited = self.calculate_max_permissible_velocity('pitch', pitch_dps) + if abs(yaw_in) > 0.05 or abs(pitch_in) > 0.05: # Only print when moving + print(f"Limited velocities: Pitch {pitch_dps_limited:.1f}°/s, Yaw {yaw_dps_limited:.1f}°/s") + + self.set_vel(pitch_dps_limited, yaw_dps_limited) + self.has_errors() + + # Print estimated position in degrees + if abs(yaw_in) > 0.05 or abs(pitch_in) > 0.05: # Only print when moving + pos_deg = self.get_pos() + print(f"Position: Pitch {pos_deg['pitch']:.2f}°, Yaw {pos_deg['yaw']:.2f}° | Velocity: Pitch {pitch_dps:.1f}°/s, Yaw {yaw_dps:.1f}°/s") + + time.sleep(0.02) + except KeyboardInterrupt: + print("\nCtrl-C detected. Setting motors to idle and stopping...") + # Stop all motors immediately + self.set_vel(0.0, 0.0) + + # Set both axes to IDLE state (disengage) + for name, ax in self.axes.items(): + ax.requested_state = AxisState.IDLE + + print("Motors set to IDLE state.") + finally: + self.set_vel(0.0, 0.0) + self._stop_controller_services() + + +def main(): + ctl = ManualODriveControl() + if not (ctl.connect() and ctl.configure() and ctl.calibrate()): + return + cmds = ['help','status','zero','stop','pos','debug','controller','engage','disengage','calibrate','quit'] + readline.parse_and_bind("tab: complete") + readline.set_completer(lambda t,s: [c for c in cmds if c.startswith(t)][s] if s < len(cmds) else None) + print("Ready. Type 'help'.") + while True: + try: + inp = input('> ').split() + if not inp: continue + cmd, *args = inp + if cmd in ('quit','exit'): + break + if cmd == 'help': + print("Commands: status, zero, stop, pos , debug , controller, engage, disengage, calibrate, quit") + print("Controller: X=zero here, Y=go to zero, B=emergency stop") + elif cmd == 'status': ctl.status() + elif cmd == 'zero': ctl.zero() + elif cmd == 'stop': ctl.stop() + elif cmd == 'pos' and len(args)==2: ctl.set_pos(float(args[0]), float(args[1]), bypass_limits=False) + elif cmd == 'debug' and args: ctl.toggle_debug(args[0]=='on') + elif cmd == 'controller': ctl.run_controller_loop() + elif cmd == 'engage': ctl.engage() + elif cmd == 'disengage': ctl.disengage() + elif cmd == 'calibrate': ctl.calibrate() + else: + print("Unknown command") + except KeyboardInterrupt: + break + +if __name__ == '__main__': + main() diff --git a/tools/ground-station/tenna-manual/pyproject.toml b/tools/ground-station/tenna-manual/pyproject.toml new file mode 100644 index 0000000..20af3e8 --- /dev/null +++ b/tools/ground-station/tenna-manual/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "tenna-manual" +version = "0.1.0" +description = "Manual antenna tracker demo" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "odrive>=0.6.10.post0" +] diff --git a/tools/ground-station/tenna-manual/remote.py b/tools/ground-station/tenna-manual/remote.py new file mode 100755 index 0000000..3887537 --- /dev/null +++ b/tools/ground-station/tenna-manual/remote.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +import errno +import fcntl +import os +import struct +import threading +import time +from typing import Dict, Optional + + +EVENT_FMT = 'IhBB' +EVENT_SIZE = struct.calcsize(EVENT_FMT) + +JS_EVENT_BUTTON = 0x01 +JS_EVENT_AXIS = 0x02 +JS_EVENT_INIT = 0x80 + + +BUTTON_INDEX_TO_NAME: Dict[int, str] = { + 0: 'A', 1: 'B', 2: 'X', 3: 'Y', + 4: 'LB', 5: 'RB', 6: 'Back', 7: 'Start', + 8: 'Guide', 9: 'LThumb', 10: 'RThumb', +} + +AXIS_INDEX_TO_NAME: Dict[int, str] = { + 0: 'LX', 1: 'LY', 2: 'LT', + 3: 'RX', 4: 'RY', 5: 'RT', +} + +BUTTON_NAME_TO_INDEX: Dict[str, int] = {v: k for k, v in BUTTON_INDEX_TO_NAME.items()} +AXIS_NAME_TO_INDEX: Dict[str, int] = {v: k for k, v in AXIS_INDEX_TO_NAME.items()} + + +def _normalize_axis(value: int) -> float: + if value <= -32767: + return -1.0 + if value >= 32767: + return 1.0 + return value / 32767.0 + + +class XboxController: + """Simple Linux joystick reader for Xbox controllers using /dev/input/js*. + + Exposes a minimal API expected by manual.py: + - start() -> bool + - stop() -> None + - is_connected() -> bool + - get_button(name: str) -> bool + - get_axis(name: str) -> float in [-1.0, 1.0] + """ + + def __init__(self, deadzone: float = 0.1, device_path: str = '/dev/input/js0') -> None: + self.device_path: str = device_path + self.deadzone: float = max(0.0, min(0.9, deadzone)) + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._connected: bool = False + self._fd: Optional[int] = None + self._lock = threading.Lock() + self._buttons: Dict[str, bool] = {name: False for name in BUTTON_NAME_TO_INDEX.keys()} + self._axes: Dict[str, float] = {name: 0.0 for name in AXIS_NAME_TO_INDEX.keys()} + + def start(self) -> bool: + if self._thread and self._thread.is_alive(): + return True + self._stop_event.clear() + self._thread = threading.Thread(target=self._reader_loop, name='XboxControllerReader', daemon=True) + self._thread.start() + # Give the thread a short time to attempt initial open + time.sleep(0.1) + return True + + def stop(self) -> None: + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.5) + self._close_device() + + def is_connected(self) -> bool: + return self._connected + + def get_button(self, name: str) -> bool: + with self._lock: + return bool(self._buttons.get(name, False)) + + def get_axis(self, name: str) -> float: + with self._lock: + value = float(self._axes.get(name, 0.0)) + if abs(value) < self.deadzone: + return 0.0 + return value + + # Internal + def _open_device(self) -> None: + try: + fd = os.open(self.device_path, os.O_RDONLY | os.O_NONBLOCK) + # Ensure non-blocking + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + self._fd = fd + self._connected = True + except FileNotFoundError: + self._connected = False + except PermissionError: + self._connected = False + + def _close_device(self) -> None: + if self._fd is not None: + try: + os.close(self._fd) + except OSError: + pass + finally: + self._fd = None + self._connected = False + + def _reader_loop(self) -> None: + last_attempt = 0.0 + while not self._stop_event.is_set(): + # Ensure device is open + if self._fd is None: + now = time.time() + if now - last_attempt < 0.5: + time.sleep(0.05) + continue + last_attempt = now + self._open_device() + if self._fd is None: + time.sleep(0.3) + continue + + # Read any available events + try: + data = os.read(self._fd, EVENT_SIZE) + if not data: + time.sleep(0.01) + continue + if len(data) < EVENT_SIZE: + continue + timestamp, value, ev_type, number = struct.unpack(EVENT_FMT, data) + if ev_type & JS_EVENT_INIT: + continue + if ev_type & JS_EVENT_BUTTON: + name = BUTTON_INDEX_TO_NAME.get(number) + if name is not None: + with self._lock: + self._buttons[name] = (value != 0) + elif ev_type & JS_EVENT_AXIS: + name = AXIS_INDEX_TO_NAME.get(number) + if name is not None: + norm = _normalize_axis(value) + with self._lock: + self._axes[name] = norm + # Gentle yield + time.sleep(0.001) + except BlockingIOError: + time.sleep(0.01) + continue + except OSError as e: + if e.errno == errno.ENODEV: + # Device disconnected + self._close_device() + time.sleep(0.3) + continue + # Other I/O error: reset and retry + self._close_device() + time.sleep(0.2) + continue diff --git a/tools/ground-station/tenna-setup/README.md b/tools/ground-station/tenna-setup/README.md new file mode 100644 index 0000000..9884a73 --- /dev/null +++ b/tools/ground-station/tenna-setup/README.md @@ -0,0 +1,3 @@ +# Antenna Tracker setup (WIP) + +TODO: Describe the required steps to get it up and running including setup scripts for one click deploys on clean images. \ No newline at end of file diff --git a/tools/ground-station/tenna-setup/bluetooth.sh b/tools/ground-station/tenna-setup/bluetooth.sh new file mode 100755 index 0000000..1998683 --- /dev/null +++ b/tools/ground-station/tenna-setup/bluetooth.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Script to keep trying to connect to an Xbox controller using bluetoothctl + +# Replace this with your Xbox controller's MAC address +XBOX_MAC="C8:3F:26:3D:28:D9" + +if [ -z "$XBOX_MAC" ] || [ "$XBOX_MAC" = "XX:XX:XX:XX:XX:XX" ]; then + echo "Please set your Xbox controller's MAC address in the script (XBOX_MAC variable)." + exit 1 +fi + +echo "Attempting to connect to Xbox controller at $XBOX_MAC..." + +while true; do + # Check if the controller is already connected + connected=$(bluetoothctl info "$XBOX_MAC" | grep "Connected: yes") + if [ -n "$connected" ]; then + echo "Controller is connected." + else + echo "Controller not connected. Trying to connect..." + bluetoothctl connect "$XBOX_MAC" + fi + # Wait 5 seconds before trying again + sleep 5 +done diff --git a/tools/ground-station/tenna-setup/setup.sh b/tools/ground-station/tenna-setup/setup.sh new file mode 100755 index 0000000..653f077 --- /dev/null +++ b/tools/ground-station/tenna-setup/setup.sh @@ -0,0 +1,36 @@ + + +# sudo date -s "2025-08-05 15:30:00" +# sudo timedatectl set-ntp true + +sudo systemctl restart systemd-timesyncd + + +sudo apt update + +sudo apt upgrade + +sudo apt install -y git curl + +# sbg e com compilation +sudo apt install -y cmake build-essential + +curl -LsSf https://astral.sh/uv/install.sh | sh + +source $HOME/.local/bin/env + +# set up odrive usb permissions + +sudo tee /etc/udev/rules.d/91-odrive.rules > /dev/null <<'EOF' +SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="0d3[0-9]", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1" +SUBSYSTEM=="usb", ATTR{idVendor}=="0483", ATTR{idProduct}=="df11", MODE="0666" +EOF + + +sudo tee /etc/udev/rules.d/99-sbg-ellipse.rules > /dev/null < anyhow::Result<(OwnedFd, OwnedFd, String)> { + let ws = Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let OpenptyResult { master, slave } = openpty(Some(&ws), None)?; + let slave_symlink = format!("/proc/self/fd/{}", slave.as_raw_fd()); + let slave_path = fs::read_link(&slave_symlink)?; + Ok((master, slave, slave_path.to_string_lossy().into_owned())) +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + let _args = Args::parse(); + let (master, _slave, slave_path) = create_pty_pair()?; + let alias = "/tmp/sergw-serial"; + let _ = fs::remove_file(alias); + let _ = std::os::unix::fs::symlink(&slave_path, alias); + info!("virtual serial at {} (alias {}): ready", slave_path, alias); + + // keep master open and echo periodic heartbeat so reader sees data + let mut master_file: std::fs::File = master.into(); + loop { + master_file.write_all(b"\n").ok(); + master_file.flush().ok(); + std::thread::sleep(Duration::from_secs(5)); + // Drain so we don't block writers + let mut buf = [0u8; 1024]; + if let Ok(n) = master_file.read(&mut buf) { + if n > 0 { /* ignore */ } + } + } +}