diff --git a/.github/workflows/merge-criteria.yml b/.github/workflows/merge-criteria.yml index dd0b3845..a9baf587 100644 --- a/.github/workflows/merge-criteria.yml +++ b/.github/workflows/merge-criteria.yml @@ -212,14 +212,43 @@ jobs: shell: pwsh run: platformio run -d ${{ matrix.project }} --target compiledb + - name: Package versioned firmware + id: package + shell: python + working-directory: ./${{ matrix.project }} + env: + PROJECT: ${{ matrix.project }} + run: | + import os + from pathlib import Path + import re + import shutil + from platformio.project.config import ProjectConfig + + project = os.environ["PROJECT"] + config = ProjectConfig("platformio.ini") + environment, = config.get("platformio", "default_envs") or list(config.envs()) + flags = " ".join(config.get(f"env:{environment}", "build_flags")) + match = re.search(r'''(?:^|\s)-D\s*FIRMWARE_VERSION=['"]*([0-9A-Za-z.+-]+)['"]*(?=\s|$)''', flags) + if not match: + raise RuntimeError(f"Missing FIRMWARE_VERSION for {project}/{environment}") + + name = f"{project}-{match.group(1)}" + build = Path(config.get("platformio", "build_dir")) / environment + destination = Path(os.environ["RUNNER_TEMP"]) / name + destination.mkdir(parents=True, exist_ok=True) + extensions = ["bin", "elf"] + (["UF2"] if project == "ground_station" else []) + for extension in extensions: + shutil.copy2(build / f"firmware.{extension}", destination / f"{name}.{extension}") + shutil.copy2(build / "compile_commands.json", destination / "compile_commands.json") + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"name={name}\npath={destination.as_posix()}\n") + - name: Upload Artifacts uses: actions/upload-artifact@v7 with: - name: artifacts_${{ matrix.project }} - path: | - ./${{ matrix.project }}/.pio/build/**/firmware.UF2 - ./${{ matrix.project }}/.pio/build/**/firmware.bin - ./${{ matrix.project }}/.pio/build/**/firmware.elf - ./${{ matrix.project }}/.pio/build/**/compile_commands.json + name: ${{ steps.package.outputs.name }} + path: ${{ steps.package.outputs.path }} if-no-files-found: error retention-days: 90 diff --git a/ground_station/AGENTS.md b/ground_station/AGENTS.md index f9daeb36..7c767775 100644 --- a/ground_station/AGENTS.md +++ b/ground_station/AGENTS.md @@ -65,8 +65,8 @@ The Sharp Memory LCD is 400x240 and a full refresh blocks the HMI task long enou the existing debounce logic to observe it rather than refreshing again immediately. - Keep the 15 ms button debounce unless hardware evidence justifies changing it. Add hold-to-repeat only where repeated navigation is useful, such as long log lists. -- Bind settings behavior through `settings_button_action_e`, never by a fragile row number. `Enter Bootloader` must remain - the final General settings item; `USB Drive` must invoke USB storage, not bootloader entry. +- Bind settings behavior through `settings_button_action_e`, never by a fragile row number. `Update Firmware` must remain + the final System settings item; `USB Drive` must invoke USB storage, not bootloader entry. - Keep simulator behavior aligned with production HMI behavior. The simulator is not permission to introduce a second, independent implementation of a screen. - Whenever making visual changes, run the relevant simulator scenarios and inspect the rendered screens for clipping, @@ -133,7 +133,7 @@ For USB, storage, startup, task-stack, display-driver, or toolchain changes, tes - immediate behavior after UF2 installation and behavior after a power cycle; - display output (no snow), buttons, and absence of restart loops; -- Settings > USB Drive, leaving that screen, and the final Enter Bootloader item; +- Settings > System > USB Drive, leaving that screen, and the final Update Firmware item; - entering the log list without a restart and seeing both current and legacy CSV names; - USB connect/eject/reconnect, recording reclaim, and retained configuration/logs; - both receiver modes, enabled-link LEDs, sensors, and recovery navigation when affected. diff --git a/ground_station/RADIO-UPDATE.md b/ground_station/RADIO-UPDATE.md new file mode 100644 index 00000000..7583347b --- /dev/null +++ b/ground_station/RADIO-UPDATE.md @@ -0,0 +1,43 @@ +# Ground Station receiver firmware update + +The Ground Station can install one production STM32G071 telemetry application on both internal receiver MCUs. It uses +the STM32 factory system-memory bootloader over each receiver's existing UART; the ordinary telemetry protocol and the +application flash address (`0x08000000`) are unchanged. + +## Prepare and run an update + +1. Build the production `telemetry` PlatformIO environment and take its `firmware.bin`. Keep its SHA-256 with the + release record as an informational artifact. +2. Connect the Ground Station USB drive, create `/telemetry_firmware`, and copy the raw `.bin` file into that directory. + Up to 64 `.bin` files are listed alphabetically; subdirectories are not scanned. +3. Safely eject the USB drive from the computer. Keep the Ground Station powered and connected to a stable supply. +4. On the Ground Station, open **Settings > System > Update Firmware > Radio Receivers**, select the file, review the + size and CRC32, and choose **Install**. +5. Do not disconnect power until both links report a verified application version. + +The displayed CRC32 detects a file changing between selection and programming. A raw binary has no product identity, +expected version, signature, or downgrade metadata. The operator is responsible for selecting production telemetry +firmware intended for the Ground Station. Reinstalling the same version is allowed. + +## Safety and recovery + +The update is refused while a log is active, a radio is in test mode, or a recent radio packet reports an airborne +state. USB mass-storage ownership, logging, and both UARTs are handed over before programming. Link 1 is fully written, +read back, started, and version-checked before Link 2 is touched. + +The updater checks the STM32 product ID, 128 KiB flash geometry, ROM revision B1-B4, and unprotected option bytes. It +erases individual 2 KiB pages, waits for the affected ROM revisions, writes in at most 256-byte aligned blocks, and +reads every byte back. The first eight vector-table bytes are written only after the rest of the image and its source +CRC are verified. It never changes option bytes or performs read/write unprotect. + +An interrupted factory-ROM update has no rollback slot. Most interruptions leave the vector table erased and permit a +new ROM session, but an interruption during final vector activation can require ST-Link recovery. A link whose entry or +programming result is ambiguous is quarantined until Ground Station restart or repair. This is the unavoidable safety +limitation of using the factory ROM instead of a resident custom bootloader. + +The bootloader-entry request is `CMD_BOOTLOADER`, a zero payload length, and the usual frame CRC8. The receiver rejects +requests with a nonzero payload length and acknowledges entry before jumping to the ROM bootloader. + +Receiver applications predating the `CMD_BOOTLOADER` implementation need a one-time ST-Link installation of a +ROM-capable production telemetry build. Units provisioned with the earlier protected custom-loader proof of concept need +a separate provisioning procedure. Older Vega firmware remains compatible with the ROM-capable telemetry application. diff --git a/ground_station/SELF-TEST.md b/ground_station/SELF-TEST.md new file mode 100644 index 00000000..06768286 --- /dev/null +++ b/ground_station/SELF-TEST.md @@ -0,0 +1,86 @@ +# Ground Station self-test + +Start on battery power with **USB disconnected**. Open **Settings > System > Self-Test**, +rest the Ground Station on the bench with access to the sky, and press **A: Start**. +Leave USB disconnected until the final USB drive check. Results stay on the screen; no reports +or CSV files are saved. Saved settings, flight logs and compass calibration are retained. + +## Step 0 - Gyroscope calibration + +Keep the Ground Station completely still on the bench. The test averages the three +raw gyro readings over at least five seconds and 200 fresh samples. Acceleration +must remain within 0.8-1.2 g in magnitude, and each raw gyro reading within +/-5 degrees/s. +An acceleration range above 0.1 g on any axis, a gyro range above 0.5 degrees/s, +invalid readings or a sampling gap over 100 ms restart the measurement. It fails +if a stable measurement cannot be collected within 30 seconds. + +Successful offsets are saved and read back in the ESP32's NVS under `cats-gyro/bias-v1`. +They are applied to the gyro before compass/orientation processing and the subsequent +IMU checks, and are loaded on future starts. Calibration always uses raw readings, +so rerunning it does not subtract the previous offsets twice. Failed or cancelled +measurements retain the previous offsets. This is a stationary zero-rate correction, +not a temperature compensation or magnetometer calibration. + +## Phase 1 - Ground Station + +Runs automatically without test Vegas: + +- GNSS: fresh positions and advancing time, with up to two minutes to acquire. +- IMU: plausible, stable acceleration and gyro readings while stationary. +- Magnetometer: detected sensor with valid raw readings. +- Battery: five seconds alongside the sensor checks, with every voltage reading + within 3.0-4.35 V and no active USB bus. No separate manual battery step is needed. +- Storage: write, reopen, verify and delete a 256-byte temporary file. USB storage + is reclaimed before accessing files; existing files are never overwritten. + +## Phase 2 - Telemetry + +Power two test Vegas with phrases **cats_test_1** and **cats_test_2**, then press +**A: Start**. The following checks run automatically: + +- Each telemetry chip returns a fresh version reply. Any version is accepted. +- Dual reception, dual reception with phrases swapped, single mode, receiver 1 + alone and receiver 2 alone. Each receiver is scored separately. +- Packet rate, link quality and reception gaps meet the bench limits. A failed + radio check retries once. Original receiver settings are restored afterward. +- Watch the receiver LEDs during reception; there is no separate manual LED step. + +The limits are at least 8 packets/s over the observation window, mean link quality +at least 80%, SNR strictly greater than -5 dB in every received quality report, and +no gap longer than 1 second. Each active receiver needs at least one quality report. +Dual, swapped and single reception are measured for 15 seconds each; each receiver +alone is measured for 5 seconds, after a 2.5-second settling period. +These limits need checking against +known-good units in the fixture, especially with antennas absent. They do not +measure range. No Vega testing or pyro commands are sent. + +## Phase 3 - Manual checks + +**Every numbered check waits for a fresh A: Start press.** Waiting has no timeout; +measurements, patterns and test timers begin only after confirmation. Completing +one check opens the next confirmation screen. Holding A cannot start the next check. + +| Check | What to do after pressing Start | +| --- | --- | +| 3.1 Sensor movement | Tilt and rotate around all three axes; completion is detected automatically. | +| 3.2 Buttons | Press and release all six buttons: Up, Down, Left, Right, A and B. The Start press does not count. | +| 3.3 Display | Inspect black, white and two checkerboard patterns, held for four seconds each. Then A = good, B = fault. | +| 3.4 USB drive | After Start, connect USB and open the CATS GS drive on the computer. The GS shares storage and requires a fresh successful host read within 30 seconds. Connection alone does not pass. | + +Receiver settings are restored after telemetry testing. USB storage is shared during +the USB drive check and remains shared afterward. The battery check assumes the +operator starts with USB physically disconnected; bus state alone cannot prove +removal of USB power. Normal storage ownership still treats host suspension +separately from disconnection. The power checks do not measure charge current or +battery capacity. + +Hold **B for two seconds** to cancel. Available Skip actions leave a check +**NOT TESTED**. The final screen shows **PASS** only if every check passed; +otherwise it shows **FAIL**, with individual results and reasons available using +up/down. Press A to rerun or B to return to Settings. + +The simulator uses the production state machine and display renderer with fixture +inputs. Automated tests cover all confirmation gates, held buttons, timeouts, +receiver faults, storage faults, cancellation and USB sequencing. Physical sensors, +radio quality, LCD/LED appearance, battery operation and USB/storage behavior still +require verification on a Ground Station. diff --git a/ground_station/bootloader/README.md b/ground_station/bootloader/README.md index 03f33b79..2050e12b 100644 --- a/ground_station/bootloader/README.md +++ b/ground_station/bootloader/README.md @@ -1,34 +1,41 @@ -# Ground Station TinyUF2 updater +# Ground Station WROVER bootloader ## Current release -The current bootloader artifacts come from Adafruit TinyUF2 `0.35.0`, tag -commit `8542b474ffa19c0dd66a8ccc99b1d6b8caff8d12`: +These artifacts are for the ESP32-S2 Saola WROVER module. They are unmodified +files from [Adafruit TinyUF2 0.35.0](https://github.com/adafruit/tinyuf2/releases/tag/0.35.0), +tag commit `8542b474ffa19c0dd66a8ccc99b1d6b8caff8d12`. -- `tinyuf2-espressif_saola_1_wroom-0.35.0.zip`, release SHA-256 - `f4fc81da5c1c8f4378e5c79dd6685c3d87ebccd701a7e1ecc930595568fa689e`; -- `tinyuf2-espressif_saola_1_wrover-0.35.0.zip`, release SHA-256 - `8df90daf8fca821f88304f6e9a94a61dec08b7468d923321668bffa679215d91`. - -The checked-in `update-tinyuf2-*.uf2` files are the normal update artifacts for -Ground Stations that already run TinyUF2. Complete factory images are not kept -in this repository because they are not needed for normal application or -bootloader updates. +Source: [tinyuf2-espressif_saola_1_wrover-0.35.0.zip](https://github.com/adafruit/tinyuf2/releases/download/0.35.0/tinyuf2-espressif_saola_1_wrover-0.35.0.zip). +The ZIP SHA-256 matches the official release digest: +`8df90daf8fca821f88304f6e9a94a61dec08b7468d923321668bffa679215d91`. ## Which artifact to use -- Copy the matching `update-tinyuf2-*.uf2` to an existing TinyUF2 drive. It - temporarily replaces the Ground Station application with the updater, - updates only the factory UF2 application, and then opens TinyUF2 again. -- Reinstall the Ground Station application UF2 immediately afterward. The - updater does not replace the second-stage ESP bootloader or partition table. - -Always select the WROOM or WROVER artifact that matches the hardware. - -## Wired recovery - -If a complete factory image is needed for wired recovery, download the matching -0.35.0 WROOM or WROVER release ZIP from -[Adafruit TinyUF2](https://github.com/adafruit/tinyuf2/releases/tag/0.35.0). -Use its `combined-ota.bin`; the plain `combined.bin` has a different partition -layout and is not compatible with the Ground Station application layout. +- `combined-ota-espressif_saola_1_wrover-0.35.0.bin` is the archive's + `combined-ota.bin`, renamed to identify the board and version. Flash it at + **`0x0`** through the ESP ROM bootloader for blank-board provisioning or wired + recovery. Then install the Ground Station application UF2. This factory image + replaces the bootloader, partition table and application regions, including + NVS settings; it is not a normal application update. +- `update-tinyuf2-espressif_saola_1_wrover-0.35.0.uf2` is the archive's + `update-tinyuf2.uf2`. Copy it to an existing TinyUF2 drive. It temporarily + replaces the Ground Station application with the updater, updates only the + factory UF2 application, and then opens TinyUF2 again. Reinstall the Ground + Station application UF2 immediately afterward. This updater does not replace + the second-stage ESP bootloader or partition table. + +## Verification + +- Factory BIN: 3,116,960 bytes; SHA-256 + `12f95fd2814589422b94d943d1a8218da1887fff2b74987d96143d6dd145e51a`. +- Updater UF2: 613,888 bytes; SHA-256 + `1bb601567b78adad890b68c4f99a6d572e57b3ada608988e11569798b6c0a9bb`. + +The factory image's OTA partition table matches `../partitions_custom.csv`: +`ota_0` at `0x010000`, `ota_1` at `0x170000`, `uf2` at `0x2d0000`, and `ffat` +at `0x310000`. The plain `combined.bin` has a different partition layout and is +not compatible with the Ground Station application layout. + +Both artifacts were verified against the release archive. Physical flashing +and bootloader recovery remain hardware checks. diff --git a/ground_station/bootloader/combined-ota-espressif_saola_1_wrover-0.35.0.bin b/ground_station/bootloader/combined-ota-espressif_saola_1_wrover-0.35.0.bin new file mode 100644 index 00000000..b1f1cb5d Binary files /dev/null and b/ground_station/bootloader/combined-ota-espressif_saola_1_wrover-0.35.0.bin differ diff --git a/ground_station/bootloader/update-tinyuf2-espressif_saola_1_wroom-0.35.0.uf2 b/ground_station/bootloader/update-tinyuf2-espressif_saola_1_wroom-0.35.0.uf2 deleted file mode 100644 index 1735d4f9..00000000 Binary files a/ground_station/bootloader/update-tinyuf2-espressif_saola_1_wroom-0.35.0.uf2 and /dev/null differ diff --git a/ground_station/gs-sim.ps1 b/ground_station/gs-sim.ps1 index 3a8ee21e..6584da18 100644 --- a/ground_station/gs-sim.ps1 +++ b/ground_station/gs-sim.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [Parameter(Position = 0, Mandatory = $true)] - [ValidateSet('setup', 'build', 'fatfs-test', 'serve', 'run', 'test')] + [ValidateSet('setup', 'build', 'browser-test', 'fatfs-test', 'fixture', 'serve', 'run', 'test')] [string]$Command, [Parameter(Position = 1)] [string]$Scenario, diff --git a/ground_station/gs-wsl-check.sh b/ground_station/gs-wsl-check.sh index da7cbfc1..8a571383 100644 --- a/ground_station/gs-wsl-check.sh +++ b/ground_station/gs-wsl-check.sh @@ -215,7 +215,27 @@ lint_project() { fi } +test_rom_bootloader() { + local test_binary="$build_dir/rom-bootloader-test" + mkdir -p "$build_dir" + g++ -std=c++20 -Wall -Wextra -Werror -Wno-sign-compare \ + -I"$project_root/src" \ + "$project_root/tests/rom_bootloader_test.cpp" \ + "$project_root/src/update/rom_bootloader.cpp" \ + "$project_root/src/telemetry/crc.cpp" \ + -o "$test_binary" + "$test_binary" +} + +test_self_test() { + local test_binary="$build_dir/self-test" + g++ -std=c++20 -Wall -Wextra -Werror -I"$project_root/src" \ + "$project_root/tests/self_test.cpp" "$project_root/src/self_test.cpp" -o "$test_binary" + "$test_binary" +} timed dependencies prepare_dependencies +timed rom-bootloader-test test_rom_bootloader +timed self-test test_self_test timed build build_project timed lint lint_project diff --git a/ground_station/platformio.ini b/ground_station/platformio.ini index f8d4556b..5a025280 100644 --- a/ground_station/platformio.ini +++ b/ground_station/platformio.ini @@ -28,7 +28,7 @@ lib_deps = build_flags = -fexceptions ; Required by the Nayuki C++ QR encoder - -DFIRMWARE_VERSION='"1.2.1"' ; Enter Firmware Version here + -DFIRMWARE_VERSION='"1.3.0"' ; Enter Firmware Version here -DUSB_MANUFACTURER='"CATS"' ; USB Manufacturer string -DUSB_PRODUCT='"CATS Ground Station"' ; USB Product String -D USB_SERIAL="0" ; Enter Device Serial Number here diff --git a/ground_station/simulator/API.md b/ground_station/simulator/API.md index 90d87001..43872c1d 100644 --- a/ground_station/simulator/API.md +++ b/ground_station/simulator/API.md @@ -25,7 +25,7 @@ keeps deterministic fixture setup fast by advancing directly to the menu. Telemetry injection accepts state, timestamp, coordinates, altitude, velocity, voltage, continuity, connection/update state, LQ, RSSI, and SNR for either link. Navigation injection accepts home/rocket coordinates, compass north, -azimuth, distance, and sensor/calibration values. Device injection accepts USB +pitch, roll, azimuth, distance, and sensor/calibration values. Device injection accepts USB state, `usbStorageState` (`firmware`, `preparing`, `host`, or `fault`), plus recorder write, finalize, and delete failure flags. @@ -36,3 +36,6 @@ They also expose `usbStorageState` and `usbStorageMessage` for the automatic idle-time USB Drive handoff flow. During startup they also expose `startupElapsedMs` and `startupPhase`. Configuration snapshots include `startupAnimation`, which defaults to `true`. +Browser snapshots also expose the injected home and rocket coordinates plus +`northRad`, `pitchRad`, and `rollRad` under `navigation`, so fixtures can verify +the location and orientation sent through the WebAssembly boundary. diff --git a/ground_station/simulator/COVERAGE.md b/ground_station/simulator/COVERAGE.md new file mode 100644 index 00000000..757dd123 --- /dev/null +++ b/ground_station/simulator/COVERAGE.md @@ -0,0 +1,46 @@ +# Ground Station simulator coverage + +This matrix is the checklist for simulator changes. “Golden” compares a stable +framebuffer SHA-256, “deterministic” compares two independent runs byte for +byte, and “browser golden” exercises the compiled WebAssembly module in a real +browser and compares its framebuffer. + +## Screens + +| GS screen or state | Deterministic scenario | Named snapshot | Framebuffer check | Browser/WASM check | +|---|---|---|---|---| +| Animated startup | `startup-intro.json` | `rocket-launch` through `logo-hold` | Golden | `startup-rocket` browser golden | +| Static startup | `startup-static.json` | `static-logo` | Golden | — | +| Main menu | `menu.json` | `menu` | Golden | `menu` browser golden | +| Live telemetry | `live.json` | `live-telemetry` | Deterministic | Physical-controls smoke | +| Recovery | `qr-recovery.json` | `recovery-link-2-qr` | Golden | Physical-controls smoke | +| Testing | `testing-timeout.json` | `timeout` | Golden | — | +| Data and flight statistics | `log-management.json` | `log-management` | Deterministic | Fixture smoke | +| Sensors, compass, and calibration | `sensors.json`, `sensors-orientation.json` | `sensors-calibration-complete`, `orientation-east` | Deterministic | `compass-orientation` browser golden | +| Settings | `settings.json` | `settings` | Golden | USB fixture flow | +| USB Drive | `usb-storage.json` | `usb-storage-controls`, `usb-storage-handoff` | Deterministic | `usb-storage` browser golden | +| Bootloader request | `bootloader.json` | `bootloader-requested` | Deterministic | — | + +## Data, recovery, and failure paths + +| Path | Deterministic scenario or shared fixture | Named snapshot | Framebuffer check | Browser/WASM check | +|---|---|---|---|---| +| Replay timing | `replay.json` | `replay` | Golden | — | +| Recorded light-mode flight timing and units | `light-demo` fixture | — | Deterministic fixture load | `light-demo` browser golden | +| Recovery QR, dual link | `qr-recovery.json` | `recovery-link-2-qr` | Golden | Physical-controls smoke | +| Data QR, dual link | `qr-data.json` | `data-link-2-qr` | Golden | — | +| Missing recovery fix | `qr-no-fix.json`, `missing-fix` fixture | `no-fix-statistics` | Golden | Fixture smoke | +| Equator or prime-meridian fix | `qr-zero-coordinate.json`, `zero-coordinate` fixture | `zero-coordinate-qr` | Golden | Fixture smoke | +| More than 11 logs | `log-management.json`, `many` fixture | `log-management` | Deterministic | Fixture smoke | +| Legacy CSV names | `legacy-log-names.json` | `legacy-log-names` | Deterministic | — | +| Recording independent of screen | `recording-independent.json` | `screen-independent-recording` | Deterministic | — | +| Automatic and Never-mode finalization | `recording-modes.json`, `never` fixture | `recording-modes` | Deterministic | Fixture smoke | +| Malformed or partial log | `demo` fixture | — | Deterministic fixture load | Fixture smoke | +| Deletion failure | `delete-failure` fixture | — | Deterministic fixture load | Fixture smoke | +| USB blocks deletion | `usb-delete` fixture | — | Deterministic fixture load | Fixture smoke | +| Finalization failure | `finalize-failure` fixture | — | Deterministic fixture load | Fixture smoke | +| Recorder write failure | `recorder-fault` fixture | — | Deterministic fixture load | `recorder-fault` browser golden | +| USB remount failure | — | — | Gap | Gap | + +The explicit gaps are intentional: they stay visible here until a scenario and +framebuffer assertion are added, instead of being implied by unrelated tests. diff --git a/ground_station/simulator/README.md b/ground_station/simulator/README.md index e9573d08..b002008a 100644 --- a/ground_station/simulator/README.md +++ b/ground_station/simulator/README.md @@ -16,17 +16,39 @@ Then run: ```powershell .\gs-sim.ps1 setup .\gs-sim.ps1 build +.\gs-sim.ps1 browser-test .\gs-sim.ps1 serve .\gs-sim.ps1 run .\simulator\scenarios\menu.json +.\gs-sim.ps1 fixture demo .\gs-sim.ps1 test .\gs-sim.ps1 fatfs-test ``` +For a compact public-facing version, append `?mode=light` to the simulator URL: + +```text +http://127.0.0.1:8787/index.html?mode=light +``` + +Light mode keeps the production-rendered display and physical controls. The +visitor starts one recorded demo flight with the Play button and can restart it +afterward. The replay preserves the recorded timing: CSV timestamps are +deciseconds, so each timestamp step is 100 ms and the 2700-decisecond span +lasts 270 seconds. Reboot plays the production startup animation and returns +to the main menu. +The full engineering toolbar, fixture selection, timing controls, USB controls, +keyboard help, raw state snapshot, and expandable test coverage matrix remain +available in the default mode. + The browser plays the four-second startup intro on initial load and whenever Reset is selected. Pause time and use the millisecond step control to inspect individual deterministic animation phases. -Settings > Other > Startup Animation defaults to ON. When switched OFF and +On the Sensors screen, press Right or Down to open the compass/orientation +view and Left or Up to return to the raw readings. Press A from either view to +use the existing compass calibration flow. + +Settings > Preferences > Startup Animation defaults to ON. When switched OFF and saved, Reset shows the legacy static CATS logo for two seconds instead. `fatfs-test` verifies that FatFs R0.16 can read and update an R0.13c storage @@ -47,10 +69,17 @@ catalogs, nominal dual recovery, a missing recovery fix, active Never-mode recording, zero-coordinate GNSS fixes, USB deletion blocking, write/delete/finalize failures, and manual finalization. These presets drive the compiled controller and production `Window` renderer rather than static -images. +images. Their data and operations live in `web/fixtures.json`; both the browser +and `gs_sim.py fixture` consume that same manifest. + +`browser-test` builds the WebAssembly module, launches a temporary headless +Edge or Chrome instance, presses controller inputs, inspects snapshots, applies +all shared fixtures, and compares stable framebuffer hashes. The full simulator +renders `web/coverage.json` as a collapsible coverage matrix, including explicit +gaps that still need scenarios. USB mass storage is shared automatically whenever USB is connected and the recorder is idle. The firmware reclaims the filesystem before writing the -first flight sample and shares it again after finalization. Settings > General +first flight sample and shares it again after finalization. Settings > System > USB Drive shows the current state and can disconnect the drive manually. Simulate eject by setting `device.usbStorageState` back to `firmware`. diff --git a/ground_station/simulator/compat/Arduino.h b/ground_station/simulator/compat/Arduino.h index a676abb6..467d555f 100644 --- a/ground_station/simulator/compat/Arduino.h +++ b/ground_station/simulator/compat/Arduino.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -22,7 +23,11 @@ class String { String(unsigned int value) : value_(std::to_string(value)) {} String(long value) : value_(std::to_string(value)) {} String(unsigned long value) : value_(std::to_string(value)) {} - String(float value) : value_(std::to_string(value)) {} + String(float value) { + char buffer[32]{}; + std::snprintf(buffer, sizeof(buffer), "%.2f", static_cast(value)); + value_ = buffer; + } [[nodiscard]] size_t length() const { return value_.size(); } [[nodiscard]] const char* c_str() const { return value_.c_str(); } diff --git a/ground_station/simulator/compat/Print.h b/ground_station/simulator/compat/Print.h index cda09396..d3315172 100644 --- a/ground_station/simulator/compat/Print.h +++ b/ground_station/simulator/compat/Print.h @@ -21,7 +21,7 @@ class Print { size_t print(unsigned int value) { return print(std::to_string(value)); } size_t print(long value) { return print(std::to_string(value)); } size_t print(unsigned long value) { return print(std::to_string(value)); } - size_t print(float value) { return print(std::to_string(value)); } + size_t print(float value) { return print(value, 2); } size_t print(float value, int digits) { char buffer[64] = {}; std::snprintf(buffer, sizeof(buffer), "%.*f", digits, static_cast(value)); diff --git a/ground_station/simulator/compat/navigation.hpp b/ground_station/simulator/compat/navigation.hpp index 613b6965..97f36671 100644 --- a/ground_station/simulator/compat/navigation.hpp +++ b/ground_station/simulator/compat/navigation.hpp @@ -17,6 +17,10 @@ class Navigation { azimuth_ = azimuth; elevation_ = elevation; } + void setAttitude(float pitch, float roll) { + pitch_ = pitch; + roll_ = roll; + } void setDistance(float distance) { distance_ = distance; } void setAcceleration(float x, float y, float z) { ax_ = x; ay_ = y; az_ = z; } void setGyroscope(float x, float y, float z) { gx_ = x; gy_ = y; gz_ = z; } @@ -26,6 +30,8 @@ class Navigation { [[nodiscard]] EarthPoint3D getPointA() const { return pointA_; } [[nodiscard]] EarthPoint3D getPointB() const { return pointB_; } float getNorth() { return north_; } + float getPitch() { return pitch_; } + float getRoll() { return roll_; } float getAzimuth() { return azimuth_; } float getElevation() { return elevation_; } float getDistance() { return distance_; } @@ -45,6 +51,8 @@ class Navigation { EarthPoint3D pointA_{}; EarthPoint3D pointB_{}; float north_ = 0.0F; + float pitch_ = 0.0F; + float roll_ = 0.0F; float azimuth_ = 0.0F; float elevation_ = 0.0F; float distance_ = 0.0F; diff --git a/ground_station/simulator/compat/telemetry/telemetryData.hpp b/ground_station/simulator/compat/telemetry/telemetryData.hpp index 62a80a55..6f83cda5 100644 --- a/ground_station/simulator/compat/telemetry/telemetryData.hpp +++ b/ground_station/simulator/compat/telemetry/telemetryData.hpp @@ -5,7 +5,7 @@ class TelemetryData { public: void set(uint8_t state, uint8_t errors, int32_t altitude, int16_t velocity, float latitude, float longitude, - float voltage, uint8_t continuity, bool testingMode) { + float voltage, uint8_t continuity, bool testingMode, uint32_t lastUpdateTime = 0) { state_ = state; errors_ = errors; altitude_ = altitude; @@ -15,6 +15,7 @@ class TelemetryData { voltage_ = voltage; continuity_ = continuity; testingMode_ = testingMode; + lastUpdateTime_ = lastUpdateTime; updated_ = true; } void clear() { updated_ = false; } @@ -28,6 +29,7 @@ class TelemetryData { float voltage() { updated_ = false; return voltage_; } uint8_t pyroContinuity() { updated_ = false; return continuity_; } bool testingMode() { updated_ = false; return testingMode_; } + [[nodiscard]] uint32_t getLastUpdateTime() const { return lastUpdateTime_; } private: uint8_t state_ = 2; @@ -40,6 +42,7 @@ class TelemetryData { uint8_t continuity_ = 0; bool testingMode_ = false; bool updated_ = false; + uint32_t lastUpdateTime_ = 0; }; class TelemetryInfo { diff --git a/ground_station/simulator/golden/snapshots.json b/ground_station/simulator/golden/snapshots.json index a23790f3..81608046 100644 --- a/ground_station/simulator/golden/snapshots.json +++ b/ground_station/simulator/golden/snapshots.json @@ -2,8 +2,8 @@ "startup-intro.json": { "final": "93ca1eaed823a5d0b5798a79a8b5c16cfe1dd61373bbf5f6510589a4c751a391", "snapshots": { - "rocket-launch": "2fb78bdc2cd56983938f805bfa48d3db503fba34271c70037954c5bc99b45eb8", - "rocket-through-clouds": "c3f51dd9f8f05ee34fa9e1cf0b92fde11c063fcdf89fee2e8cc841d74878e4c6", + "rocket-launch": "2f13e98f1d2199291ee19ee56acee2d79aa78b8b0ca6b9523df21b353e3ab914", + "rocket-through-clouds": "529656d22e6542ee68033f2bbb0fba4cedac51aaad032737aa0046ed6b88dae3", "rocket-exited": "3d6e0f34fd863e99c5e195ccf94c6c47dff1859f111f45334555ac33751b6cc8", "logo-descent": "fb416e6f6799be09247ede6f2f4af62947b3df1d55797ef0ccc81228a52a69e3", "logo-settle": "0f2ec313ce36907818711faf4ad24ed891303f226b7c42b31ed7f72c50a634b8", diff --git a/ground_station/simulator/gs-sim.ps1 b/ground_station/simulator/gs-sim.ps1 index f940a787..2da70ce9 100644 --- a/ground_station/simulator/gs-sim.ps1 +++ b/ground_station/simulator/gs-sim.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [Parameter(Position = 0, Mandatory = $true)] - [ValidateSet('setup', 'build', 'fatfs-test', 'serve', 'run', 'test')] + [ValidateSet('setup', 'build', 'browser-test', 'fatfs-test', 'fixture', 'serve', 'run', 'test')] [string]$Command, [Parameter(Position = 1)] @@ -149,12 +149,16 @@ function Build-WasmDirect { (Join-Path $GroundStationRoot 'lib\Adafruit_GFX_Library\Adafruit_GFX.cpp'), (Join-Path $GroundStationRoot 'lib\NayukiQRCode\src\qrcodegen.cpp'), (Join-Path $SimulatorRoot 'hmi_controller.cpp'), + (Join-Path $SimulatorRoot 'self_test_fixture.cpp'), + (Join-Path $GroundStationRoot 'src\self_test.cpp'), + (Join-Path $GroundStationRoot 'src\hmi\window_self_test.cpp'), (Join-Path $GroundStationRoot 'src\hmi\location_qr.cpp'), (Join-Path $GroundStationRoot 'src\hmi\settings.cpp'), (Join-Path $GroundStationRoot 'src\hmi\window.cpp'), '-o', (Join-Path $SimulatorRoot 'web\gs-sim.js'), '-sWASM=1', + '-sSTACK_SIZE=262144', '-sMODULARIZE=1', '-sEXPORT_ES6=1', '-sENVIRONMENT=web', @@ -181,6 +185,76 @@ function Build-WasmIfAvailable { Build-WasmDirect } +function Resolve-HeadlessBrowser { + $candidates = @( + (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'), + (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe'), + (Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\Application\msedge.exe'), + (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe') + ) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + throw 'Microsoft Edge or Google Chrome is required for browser-test.' +} + +function Get-FreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port } + finally { $listener.Stop() } +} + +function Invoke-BrowserRegressionTest { + $emcc = Get-EmscriptenExecutable -Name 'emcc' + if ($null -eq $emcc) { throw "Emscripten $PinnedEmscripten is required for browser-test." } + Initialize-EmscriptenEnvironment + Build-WasmDirect + + $python = Resolve-Python + $browser = Resolve-HeadlessBrowser + $webRoot = Join-Path $SimulatorRoot 'web' + $testPort = Get-FreeTcpPort + $profileRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('cats-gs-browser-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $profileRoot | Out-Null + $server = Start-Process -FilePath $python -ArgumentList '-m', 'http.server', $testPort, '--directory', $webRoot -PassThru -WindowStyle Hidden + try { + $url = "http://127.0.0.1:$testPort/browser-tests.html" + $ready = $false + foreach ($attempt in 1..50) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $url | Out-Null + $ready = $true + break + } catch { + Start-Sleep -Milliseconds 100 + } + } + if (!$ready) { throw 'The browser-test web server did not start.' } + + $browserArguments = @( + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + "--user-data-dir=$profileRoot", '--virtual-time-budget=10000', '--dump-dom', $url + ) + $standardOutput = Join-Path $profileRoot 'browser-test.html' + $standardError = Join-Path $profileRoot 'browser-test.stderr' + $browserProcess = Start-Process -FilePath $browser -ArgumentList $browserArguments -RedirectStandardOutput $standardOutput -RedirectStandardError $standardError -PassThru -Wait -WindowStyle Hidden + if ($browserProcess.ExitCode -ne 0) { throw "Headless browser exited with code $($browserProcess.ExitCode)." } + $dom = Get-Content -LiteralPath $standardOutput -Raw + if ($dom -notmatch 'data-status="passed"') { + $result = [regex]::Match($dom, '
]*>(.*?)
', 'Singleline').Groups[1].Value + throw "Browser/WASM regression test failed: $result" + } + $reportJson = [regex]::Match($dom, '
]*>(.*?)
', 'Singleline').Groups[1].Value + $report = [System.Net.WebUtility]::HtmlDecode($reportJson) | ConvertFrom-Json + Write-Host "browser/WASM: $($report.results.Count) regression checks passed" + } finally { + if (!$server.HasExited) { Stop-Process -Id $server.Id -Force } + Remove-Item -LiteralPath $profileRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Invoke-FatFsCompatibilityTest { $emcc = Get-EmscriptenExecutable -Name 'emcc' if ($null -eq $emcc) { throw "Emscripten $PinnedEmscripten is required for the FatFs compatibility test." } @@ -233,11 +307,16 @@ function Invoke-FatFsCompatibilityTest { switch ($Command) { 'setup' { Setup-Simulator } 'build' { Build-WasmIfAvailable } + 'browser-test' { Invoke-BrowserRegressionTest } 'fatfs-test' { Invoke-FatFsCompatibilityTest } 'run' { if ([string]::IsNullOrWhiteSpace($Scenario)) { throw 'run requires a scenario JSON path.' } Invoke-Headless @('run', (Resolve-Path -LiteralPath $Scenario).Path, '--write-snapshots') } + 'fixture' { + if ([string]::IsNullOrWhiteSpace($Scenario)) { throw 'fixture requires a fixture id.' } + Invoke-Headless @('fixture', $Scenario, '--manifest', (Join-Path $SimulatorRoot 'web\fixtures.json')) + } 'test' { Invoke-Headless @('test', '--root', $RepoRoot) } diff --git a/ground_station/simulator/gs_sim.py b/ground_station/simulator/gs_sim.py index ad77ab95..139fab6f 100644 --- a/ground_station/simulator/gs_sim.py +++ b/ground_station/simulator/gs_sim.py @@ -38,7 +38,8 @@ REPEAT_MS = 100 BUTTONS = ("up", "down", "left", "right", "center", "ok", "back") MENU_SCREENS = ("live", "recovery", "testing", "data", "sensors", "settings") -SETTING_COUNTS = (4, 4, 3) +SETTING_COUNTS = (4, 4, 4) +CSV_HEADER = "link,ts[deciseconds],state,errors,lat[deg/10000],lon[deg/10000],altitude[m],velocity[m/s],battery[decivolts],pyro1,pyro2\n" assert BOOT_MS <= MAXIMUM_BOOT_MS assert BOOT_MS % INTRO_FRAME_MS == 0 @@ -237,7 +238,8 @@ def defaults() -> dict[str, Any]: "info": {"linkQuality": 0, "rssi": 0, "snr": 0, "lastUpdateMs": 0, "updated": False}}, ], "navigation": {"homeLatitude": 0.0, "homeLongitude": 0.0, "rocketLatitude": 0.0, - "rocketLongitude": 0.0, "northRad": 0.0, "azimuthRad": 0.0, "distanceM": 0.0, + "rocketLongitude": 0.0, "northRad": 0.0, "pitchRad": 0.0, "rollRad": 0.0, + "azimuthRad": 0.0, "distanceM": 0.0, "elevationRad": 0.0, "ax": 0.0, "ay": 0.0, "az": 1.0, "gx": 0.0, "gy": 0.0, "gz": 0.0, "mx": 0.0, "my": 0.0, "mz": 0.0, "calibrationPercentage": 0.0, "calibrationState": 0, "updated": False}, @@ -288,15 +290,24 @@ def __init__(self, initial: dict[str, Any] | None = None, ready: bool = True, ba self.screen = "logo" self.testing_state = "disclaimer" self.calibration_state = "idle" + self.sensor_view = "readings" self.settings_state = "list" self.menu_selection = 0 self.settings_page = 0 self.settings_selection = -1 + self.version_last_request = -1000 + self.settings_versions = ["", ""] + self.version_replies = [None, None] + for link in self.state["links"]: + link.setdefault("firmwareVersion", "1.1.3") self.data_selection = 0 self.data_statistics = False self.data_subview = "list" self.usb_storage_session = False self.usb_storage_message = "" + self.firmware_update_selection = 0 + self.radio_update_state = "browse" + self.radio_update_started_ms = 0 self.usb_previously_connected = False self.automatic_usb_share_pending = False self.previous_recorder_state = "idle" @@ -368,6 +379,8 @@ def draw_intro_cloud(self, x: int, y: int, scale: int) -> None: def draw_intro_rocket(self, center_x: int, center_y: int, frame_number: int) -> None: def point(forward: int, sideways: int) -> tuple[int, int]: + forward = int(forward * 6 / 5) + sideways = int(sideways * 6 / 5) return (center_x + (5 * forward + 9 * sideways) // 10, center_y + (-9 * forward + 5 * sideways) // 10) @@ -383,9 +396,12 @@ def point(forward: int, sideways: int) -> tuple[int, int]: (point(-11, 7), point(-21, 16)), (point(-21, 16), point(-2, 8))): self.frame.line(*start, *end) wobble = 2 if frame_number % 2 == 0 else -2 - self.frame.line(*point(-16, 0), *point(-34, wobble)) - self.frame.line(*point(-16, 0), *point(-28, -5 - wobble)) - self.frame.line(*point(-16, 0), *point(-27, 5 + wobble)) + for sideways in range(-4, 5): + self.frame.line(*point(-15, sideways), *point(-38, wobble)) + self.frame.line(*point(-15, -4), *point(-29, -7 - wobble)) + self.frame.line(*point(-15, 4), *point(-28, 7 - wobble)) + for sideways in range(-1, 2): + self.frame.line(*point(-17, sideways), *point(-29, wobble // 2), black=False) def render_intro(self) -> None: if not self.config().get("startupAnimation", True): @@ -474,11 +490,18 @@ def render_screen(self) -> None: self.render("Data", "Flight logs") self.frame.text(12, 82, f"Selection: {self.data_selection}") elif self.screen == "sensors": - self.render("Sensors", self.calibration_state) + self.render("Sensors", self.calibration_state if self.calibration_state != "idle" else self.sensor_view) elif self.screen == "settings": title = f"Settings page {self.settings_page}" self.render(title, self.settings_state) self.frame.text(12, 82, f"Selection: {self.settings_selection}") + if self.settings_page == 2 and self.settings_selection == 0: + self.frame.text(12, 182, "Ground Station: simulator") + self.frame.text(12, 202, "Telemetry 1: " + self.settings_versions[0]) + self.frame.text(12, 222, "Telemetry 2: " + self.settings_versions[1]) + elif self.screen == "self_test": + self.render("Factory self-test", "Open the WebAssembly simulator to run this test.") + self.frame.text(12, 82, "Back (B)") elif self.screen == "bootloader": self.render("Bootloader", "Bootloader request emitted") elif self.screen == "usb_storage": @@ -489,6 +512,21 @@ def render_screen(self) -> None: self.render("USB Drive", "Logs available on PC. Back (B). Disconnect (A).") else: self.render("USB Drive", "Preparing USB drive...") + elif self.screen == "firmware_update": + target = "Ground Station" if self.firmware_update_selection == 0 else "Radio Receivers" + self.render("Update Firmware", target) + elif self.screen == "radio_update": + if self.radio_update_state == "browse": + self.render("Update GS Radios", "telemetry-1.2.0.bin") + elif self.radio_update_state == "confirm": + self.render("Confirm Radio Update", "Do not disconnect power") + elif self.radio_update_state == "complete": + self.render("Radio Update Complete", "Both radios verified. Back (B).") + else: + elapsed = self.now_ms - self.radio_update_started_ms + link = 1 if elapsed < 2000 else 2 + percent = min(99, (elapsed % 2000) * 100 // 2000) + self.render("Updating GS Radios", f"Link {link} Writing {percent}%") def press(self, button: str) -> None: button = self.normalize_button(button) @@ -531,6 +569,15 @@ def advance(self, milliseconds: int) -> None: self.tick(set()) def tick(self, explicit_pressed: set[str]) -> None: + timed_out = self.now_ms >= 8000 + request = not timed_out and self.now_ms - self.version_last_request >= 1000 + for index in range(2): + self.settings_versions[index] = self.version_replies[index] or ("No response" if timed_out else "Reading...") + if self.version_replies[index] is None and request: + self.emit("version_requested", link=index + 1) + self.version_replies[index] = self.link(index)["firmwareVersion"] or None + if request: + self.version_last_request = self.now_ms for index in range(2): telemetry = self.link(index)["telemetry"] location = (float(telemetry.get("latitude", 0.0)), float(telemetry.get("longitude", 0.0))) @@ -556,9 +603,16 @@ def tick(self, explicit_pressed: set[str]) -> None: elif self.screen == "recovery": self.recovery_step(explicit_pressed) elif self.screen == "bootloader" and "back" in explicit_pressed: + self.firmware_update_selection = 0 + self.screen = "firmware_update" + elif self.screen == "self_test" and "back" in explicit_pressed: self.screen = "settings" elif self.screen == "usb_storage": self.usb_storage_step(explicit_pressed) + elif self.screen == "firmware_update": + self.firmware_update_step(explicit_pressed) + elif self.screen == "radio_update": + self.radio_update_step(explicit_pressed) for name in explicit_pressed: if self.held.get(name): self.last_repeat[name] = self.now_ms @@ -671,6 +725,7 @@ def to_menu(self) -> None: self.screen = "menu" self.testing_state = "disclaimer" self.calibration_state = "idle" + self.sensor_view = "readings" self.data_selection = 0 self.data_statistics = False self.data_subview = "list" @@ -692,6 +747,8 @@ def menu_step(self, pressed: set[str]) -> None: self.emit("menu_selection", value=self.menu_selection) if "ok" in pressed or "center" in pressed: self.screen = MENU_SCREENS[self.menu_selection] + if self.screen == "sensors": + self.sensor_view = "readings" if self.screen == "data": self.automatic_usb_share_pending = False self.state["deviceStatus"]["usbStorageState"] = "firmware" @@ -893,6 +950,12 @@ def data_step(self, pressed: set[str]) -> None: def sensors_step(self, pressed: set[str]) -> None: if self.calibration_state == "idle": + if "right" in pressed or "down" in pressed: + self.sensor_view = "orientation" + self.emit("sensor_view", value=1, text="orientation") + if "left" in pressed or "up" in pressed: + self.sensor_view = "readings" + self.emit("sensor_view", value=0, text="readings") if "ok" in pressed: self.calibration_state = "prepare" if "back" in pressed: @@ -904,6 +967,7 @@ def sensors_step(self, pressed: set[str]) -> None: self.emit("calibration_started") if "back" in pressed: self.calibration_state = "idle" + self.sensor_view = "readings" elif self.calibration_state == "calibrating": if "back" in pressed: self.calibration_state = "idle" @@ -914,6 +978,7 @@ def sensors_step(self, pressed: set[str]) -> None: self.emit("calibration_completed") elif self.calibration_state == "concluded" and ({"ok", "back"} & pressed): self.calibration_state = "idle" + self.sensor_view = "readings" self.state["deviceStatus"]["usbStorageState"] = "firmware" self.automatic_usb_share_pending = bool(self.state["deviceStatus"].get("usb", False)) self.emit("configuration_saved") @@ -936,17 +1001,17 @@ def settings_step(self, pressed: set[str]) -> None: if self.settings_selection < 0: if "right" in pressed: - self.settings_page = min(2, self.settings_page + 1) + self.settings_page = min(len(SETTING_COUNTS) - 1, self.settings_page + 1) if "left" in pressed: self.settings_page = max(0, self.settings_page - 1) else: config = self.config() inc = "right" in pressed dec = "left" in pressed - if self.settings_page == 0 and self.settings_selection == 0: + if self.settings_page == 1 and self.settings_selection == 0: if inc: config["neverStopLogging"] = True if dec: config["neverStopLogging"] = False - elif self.settings_page == 0 and self.settings_selection == 2 and "ok" in pressed: + elif self.settings_page == 2 and self.settings_selection == 1 and "ok" in pressed: self.usb_storage_message = "" device = self.state["deviceStatus"] if not device.get("usb", False): @@ -963,23 +1028,24 @@ def settings_step(self, pressed: set[str]) -> None: self.emit("usb_storage_shared") self.screen = "usb_storage" return - elif self.settings_page == 0 and self.settings_selection == 3 and "ok" in pressed: - self.screen = "bootloader" - self.emit("bootloader_requested") - elif self.settings_page == 1 and self.settings_selection == 0: + elif self.settings_page == 2 and self.settings_selection == 3 and "ok" in pressed: + self.firmware_update_selection = 0 + self.screen = "firmware_update" + elif self.settings_page == 0 and self.settings_selection == 0: if inc: config["dualReceiver"] = True if dec: config["dualReceiver"] = False - elif self.settings_page == 1 and 1 <= self.settings_selection <= 3 and "ok" in pressed: + elif self.settings_page == 0 and 1 <= self.settings_selection <= 3 and "ok" in pressed and (self.settings_selection != 2 or config["dualReceiver"]): self.settings_state = "keyboard" self.keyboard_index = 0 - elif self.settings_page == 2 and self.settings_selection == 0: + elif self.settings_page == 1 and self.settings_selection == 1: config["timeZoneOffset"] = max(-12, min(12, config.get("timeZoneOffset", 0) + (1 if inc else -1 if dec else 0))) - elif self.settings_page == 2 and self.settings_selection == 1 and (inc or dec): + elif self.settings_page == 1 and self.settings_selection == 2 and (inc or dec): config["imperialUnits"] = not config.get("imperialUnits", False) - elif self.settings_page == 2 and self.settings_selection == 2: + elif self.settings_page == 1 and self.settings_selection == 3: if inc: config["startupAnimation"] = True if dec: config["startupAnimation"] = False - + elif self.settings_page == 2 and self.settings_selection == 2 and "ok" in pressed: + self.screen = "self_test" if "down" in pressed: self.settings_selection = min(SETTING_COUNTS[self.settings_page] - 1, self.settings_selection + 1) if "up" in pressed: @@ -993,29 +1059,68 @@ def settings_step(self, pressed: set[str]) -> None: self.emit("configuration_saved") self.to_menu() + def firmware_update_step(self, pressed: set[str]) -> None: + if "down" in pressed: + self.firmware_update_selection = 1 + if "up" in pressed: + self.firmware_update_selection = 0 + if "back" in pressed: + self.screen = "settings" + self.settings_page = 2 + self.settings_selection = 3 + return + if "ok" not in pressed: + return + if self.firmware_update_selection == 0: + self.screen = "bootloader" + self.emit("bootloader_requested") + return + self.radio_update_state = "browse" + self.radio_update_started_ms = 0 + self.screen = "radio_update" + self.emit("radio_update_browse") + + def radio_update_step(self, pressed: set[str]) -> None: + if self.radio_update_state == "browse": + if "ok" in pressed: + self.radio_update_state = "confirm" + elif "back" in pressed: + self.firmware_update_selection = 1 + self.screen = "firmware_update" + elif self.radio_update_state == "confirm": + if "back" in pressed: + self.radio_update_state = "browse" + elif "ok" in pressed: + self.radio_update_state = "updating" + self.radio_update_started_ms = self.now_ms + self.emit("radio_update_started") + elif self.radio_update_state == "updating": + if self.now_ms - self.radio_update_started_ms >= 4000: + self.radio_update_state = "complete" + self.emit("radio_update_complete") + elif self.radio_update_state == "complete" and "back" in pressed: + self.radio_update_state = "browse" + self.radio_update_started_ms = 0 + self.firmware_update_selection = 1 + self.screen = "firmware_update" + def usb_storage_step(self, pressed: set[str]) -> None: storage_state = self.state["deviceStatus"].get("usbStorageState", "firmware") if self.usb_storage_session and storage_state == "firmware": self.usb_storage_session = False self.emit("usb_storage_reclaimed") self.screen = "settings" - self.settings_page = 0 - self.settings_selection = 2 elif self.usb_storage_session and storage_state == "fault": self.usb_storage_session = False self.usb_storage_message = "Storage could not be remounted." elif self.usb_storage_session and storage_state == "host" and "back" in pressed: self.usb_storage_session = False self.screen = "settings" - self.settings_page = 0 - self.settings_selection = 2 elif self.usb_storage_session and ((storage_state == "host" and "ok" in pressed) or (storage_state == "preparing" and "back" in pressed)): self.state["deviceStatus"]["usbStorageState"] = "firmware" elif not self.usb_storage_session and "back" in pressed: self.screen = "settings" - self.settings_page = 0 - self.settings_selection = 2 def set_value(self, path: str, value: Any) -> None: path = canonical_path(path) @@ -1166,9 +1271,18 @@ def snapshot(self) -> dict[str, Any]: azimuth = math.atan2(dx, dy) return { "activeScreen": self.screen, + "firmwareUpdateSelection": self.firmware_update_selection, + "radioUpdateState": self.radio_update_state, + "radioUpdateLink": (1 if self.now_ms - self.radio_update_started_ms < 2000 else 2) + if self.radio_update_state == "updating" else (2 if self.radio_update_state == "complete" else 0), + "radioUpdatePercent": min(99, ((self.now_ms - self.radio_update_started_ms) % 2000) * 100 // 2000) + if self.radio_update_state == "updating" else (100 if self.radio_update_state == "complete" else 0), "testingState": self.testing_state, "calibrationState": self.calibration_state, + "sensorView": self.sensor_view, "settingsState": self.settings_state, + "telemetryVersion1": self.settings_versions[0], + "telemetryVersion2": self.settings_versions[1], "inputState": "held" if any(self.held.values()) else "idle", "menuSelection": self.menu_selection, "settingsPage": self.settings_page, @@ -1267,6 +1381,127 @@ def run(self, steps: list[Any]) -> list[dict[str, Any]]: return snapshots +def load_fixture_manifest(path: Path) -> dict[str, Any]: + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + fail(f"fixture manifest {path}:{error.lineno}:{error.colno}: invalid JSON: {error.msg}") + except OSError as error: + fail(f"cannot read fixture manifest '{path}': {error}") + fixtures = manifest.get("fixtures") if isinstance(manifest, dict) else None + if not isinstance(manifest, dict) or manifest.get("schemaVersion") != 1 or not isinstance(fixtures, list): + fail(f"fixture manifest {path}: unsupported schema") + ids = [fixture.get("id") for fixture in fixtures if isinstance(fixture, dict)] + if len(ids) != len(fixtures) or any(not isinstance(identifier, str) or not identifier for identifier in ids): + fail(f"fixture manifest {path}: every fixture needs an id") + if len(set(ids)) != len(ids): + fail(f"fixture manifest {path}: fixture ids must be unique") + return manifest + + +def complete_fixture_log(name: str, offset: int, spec: dict[str, Any]) -> dict[str, str]: + rows = [] + for link in (1, 2): + latitude = int(spec["baseLatitudeE4"]) + offset + link * 5 + longitude = int(spec["baseLongitudeE4"]) + offset + link * 5 + rows.extend(( + f"{link},10,3,0,{latitude},{longitude},100,30,42,1,1", + f"{link},20,5,0,{latitude},{longitude},1000,0,41,1,1", + f"{link},30,6,0,{latitude},{longitude},400,-25,40,1,1", + f"{link},50,7,0,{latitude},{longitude},5,0,39,1,1", + )) + return {"name": name, "csv": CSV_HEADER + "\n".join(rows) + "\n"} + + +def resolve_fixture_logs(spec: Any, base_dir: Path, light_mode: bool) -> list[dict[str, Any]]: + if isinstance(spec, list): + return copy.deepcopy(spec) + if not isinstance(spec, dict): + fail("fixture logs must be an array or object") + if "files" in spec: + files = spec.get("lightFiles") if light_mode and spec.get("lightFiles") else spec["files"] + logs = [] + for filename in files: + path = base_dir / str(filename) + try: + logs.append({"name": path.name, "csv": path.read_text(encoding="utf-8")}) + except OSError as error: + fail(f"cannot read fixture log '{path}': {error}") + return logs + if "generated" in spec: + generated = spec["generated"] + if not isinstance(generated, dict): + fail("fixture generated logs must be an object") + count = int(generated["count"]) + start_index = int(generated["startIndex"]) + return [complete_fixture_log(f"log_{start_index - offset:03d}.csv", offset, generated) + for offset in range(count)] + if "entries" in spec: + return [{"name": entry["name"], "csv": CSV_HEADER + "\n".join(entry["rows"]) + "\n"} + for entry in spec["entries"]] + fail("fixture logs need files, generated, or entries") + + +def apply_fixture_to_simulator(sim: Simulator, fixture: dict[str, Any], base_dir: Path, + light_mode: bool = False) -> None: + operations = fixture.get("operations") + if not isinstance(operations, list): + fail(f"fixture {fixture.get('id', '')}: operations must be an array") + for index, step in enumerate(operations, 1): + if not isinstance(step, dict) or len(step) != 1: + fail(f"fixture {fixture.get('id', '')} step {index}: expected one operation") + operation, value = next(iter(step.items())) + if operation == "logs": + sim.set_initial({"logs": resolve_fixture_logs(value, base_dir, light_mode)}) + elif operation in ("configuration", "navigation", "deviceStatus"): + if not isinstance(value, dict): + fail(f"fixture {fixture.get('id', '')} step {index}: {operation} must be an object") + sim.set_initial({operation: value}) + if operation == "navigation": + sim.advance(0) + elif operation == "link": + if not isinstance(value, dict) or value.get("index") not in (1, 2) or not isinstance(value.get("value"), dict): + fail(f"fixture {fixture.get('id', '')} step {index}: link must have index and value") + link = sim.link(int(value["index"]) - 1) + payload = value["value"] + for field in ("connected", "enabled"): + if field in payload: + link[field] = payload[field] + for field in ("state", "errors", "altitudeM", "velocityMps", "latitude", "longitude", "voltage", + "pyroContinuity", "testingMode", "timestampDs", "lastUpdateMs", "updated"): + if field in payload: + link["telemetry"][field] = payload[field] + for field in ("linkQuality", "rssi", "snr"): + if field in payload: + link["info"][field] = payload[field] + link["telemetry"]["updated"] = True + link["info"]["updated"] = True + sim.advance(0) + elif operation == "tap": + sim.press(str(value)) + sim.release(str(value)) + elif operation == "hold": + if not isinstance(value, dict): + fail(f"fixture {fixture.get('id', '')} step {index}: hold must be an object") + sim.hold(str(value.get("button", "")), int(value.get("ms", 0))) + elif operation == "advance": + sim.advance(int(value)) + else: + fail(f"fixture {fixture.get('id', '')} step {index}: unknown operation '{operation}'") + if light_mode and fixture.get("lightAdvanceMs"): + sim.advance(int(fixture["lightAdvanceMs"])) + + +def run_fixture(manifest_path: Path, fixture_id: str, light_mode: bool = False) -> dict[str, Any]: + manifest = load_fixture_manifest(manifest_path) + fixture = next((item for item in manifest["fixtures"] if item["id"] == fixture_id), None) + if fixture is None: + fail(f"unknown simulator fixture '{fixture_id}'") + sim = Simulator(ready=not light_mode, base_dir=manifest_path.parent) + apply_fixture_to_simulator(sim, fixture, manifest_path.parent, light_mode) + return {"fixture": fixture_id, "snapshot": sim.snapshot(), "ok": True} + + def run_scenario(path: Path, write_snapshots: bool = False) -> dict[str, Any]: try: scenario = json.loads(path.read_text(encoding="utf-8")) @@ -1301,11 +1536,11 @@ def deterministic_test(root: Path) -> int: except (OSError, json.JSONDecodeError) as error: print(f"cannot load golden snapshot manifest {golden_path}: {error}", file=sys.stderr) return 1 - for scenario_name in ("startup-intro.json", "startup-static.json", "menu.json", "testing-timeout.json", - "settings.json", "replay.json", + for scenario_name in ("startup-intro.json", "startup-static.json", "menu.json", "live.json", "testing-timeout.json", + "sensors.json", "sensors-orientation.json", "settings.json", "settings-versions.json", "bootloader.json", "replay.json", "qr-recovery.json", "qr-data.json", "qr-no-fix.json", "qr-zero-coordinate.json", "recording-independent.json", "recording-modes.json", "log-management.json", - "legacy-log-names.json", "usb-storage.json"): + "legacy-log-names.json", "usb-storage.json", "radio-update.json"): scenario_path = scenario_dir / scenario_name if scenario_path.exists(): try: @@ -1314,6 +1549,13 @@ def deterministic_test(root: Path) -> int: print(f"cannot load simulator fixture {scenario_path}: {error}", file=sys.stderr) return 1 + fixture_manifest_path = scenario_dir.parent / "web" / "fixtures.json" + try: + fixture_manifest = load_fixture_manifest(fixture_manifest_path) + except ScenarioError as error: + print(f"cannot load shared simulator fixtures: {error}", file=sys.stderr) + return 1 + for name, case in cases: first_initial = copy.deepcopy(case["initial"]) first_ready = bool(first_initial.pop("ready", case.get("ready", True))) @@ -1346,7 +1588,27 @@ def deterministic_test(root: Path) -> int: print(f"golden snapshot failed: {name}/{snapshot_name}: expected {snapshot_hash}, " f"got {actual_snapshots.get(snapshot_name)}", file=sys.stderr) return 1 + for fixture in fixture_manifest["fixtures"]: + try: + first = Simulator(base_dir=fixture_manifest_path.parent) + apply_fixture_to_simulator(first, fixture, fixture_manifest_path.parent) + second = Simulator(base_dir=fixture_manifest_path.parent) + apply_fixture_to_simulator(second, fixture, fixture_manifest_path.parent) + except ScenarioError as error: + print(f"shared fixture failed: {fixture['id']}: {error}", file=sys.stderr) + return 1 + if (json.dumps(first.snapshot(), sort_keys=True) != json.dumps(second.snapshot(), sort_keys=True) + or first.frame.png() != second.frame.png()): + print(f"shared fixture determinism failed: {fixture['id']}", file=sys.stderr) + return 1 + light_demo = run_fixture(fixture_manifest_path, "light-demo", light_mode=True)["snapshot"] + if (light_demo["activeScreen"] != "menu" or light_demo["logCount"] != 1 + or light_demo["navigation"]["homeLatitude"] != 39.3899 + or light_demo["navigation"]["homeLongitude"] != -8.2895): + print("shared light demo fixture failed", file=sys.stderr) + return 1 print(f"gs-sim: {len(cases)} deterministic controller/scenario tests passed") + print(f"fixtures: {len(fixture_manifest['fixtures'])} browser/CLI fixtures passed") print(f"framebuffer: {len(first.frame.packed())} bytes, sha256={a['framebufferSha256']}") return 0 @@ -1357,12 +1619,21 @@ def main(argv: list[str]) -> int: run = sub.add_parser("run") run.add_argument("scenario", type=Path) run.add_argument("--write-snapshots", action="store_true") + fixture = sub.add_parser("fixture") + fixture.add_argument("fixture_id") + fixture.add_argument("--manifest", type=Path, + default=Path(__file__).resolve().parent / "web" / "fixtures.json") + fixture.add_argument("--light", action="store_true") test = sub.add_parser("test") test.add_argument("--root", type=Path, default=Path.cwd()) args = parser.parse_args(argv) try: if args.command == "test": return deterministic_test(args.root) + if args.command == "fixture": + result = run_fixture(args.manifest.resolve(), args.fixture_id, args.light) + print(json.dumps(result, sort_keys=True, indent=2)) + return 0 result = run_scenario(args.scenario.resolve(), args.write_snapshots) print(json.dumps(result, sort_keys=True, indent=2)) return 0 diff --git a/ground_station/simulator/hmi_controller.cpp b/ground_station/simulator/hmi_controller.cpp index 32165707..c8c84b30 100644 --- a/ground_station/simulator/hmi_controller.cpp +++ b/ground_station/simulator/hmi_controller.cpp @@ -5,6 +5,7 @@ #include "hmi_controller.hpp" #include "hmi/location_qr.hpp" +#include "hmi/settings.hpp" #include #include @@ -36,6 +37,9 @@ void HmiController::start() { menuSelection_ = 0; settingsPage_ = 0; settingsSelection_ = -1; + versionLastRequest_ = nowMs_ - 1000U; + versionReadsComplete_ = {}; + settingsVersions_ = {"Reading...", "Reading..."}; dataSelection_ = 0; testingSelection_ = 0; dataSubview_ = DataSubview::List; @@ -43,17 +47,26 @@ void HmiController::start() { recoveryLocations_ = {}; clearQr(); liveDownrange_ = false; + sensorOrientation_ = false; keyboardActive_ = false; keyboardSelection_ = 0; keyboardUppercase_ = false; + usbStorageSession_ = false; + usbStorageMessage_.clear(); usbPreviouslyConnected_ = false; automaticUsbSharePending_ = false; previousRecorderState_ = logs_.recorderStatus().state; + firmwareUpdateSelection_ = 0; + radioUpdateState_ = "browse"; + radioUpdateStartedMs_ = 0; input_ = {}; previousInput_ = {}; heldSince_.fill(0); lastRepeat_.fill(0); actions_.clear(); + selfTest_ = SelfTest{}; + selfTestInput_ = {}; + selfTestResultIndex_ = 0; renderer_.begin(); render(); } @@ -63,9 +76,29 @@ void HmiController::step(const HmiInput& input, uint64_t nowMs) { previousInput_ = input_; input_ = input; + // Model the telemetry tasks' bounded startup reads, independently of the current screen. + ITelemetryLink* links[] = {&link1_, &link2_}; + const bool requestVersion = nowMs_ > startupStartedMs_ && nowMs_ - versionLastRequest_ >= 1000U; + for (size_t i = 0; i < 2; ++i) { + const auto link = links[i]->snapshot(); + if (!versionReadsComplete_[i]) { + if (link.versionReplies != 0 || nowMs_ - startupStartedMs_ >= 8000U) { + versionReadsComplete_[i] = true; + } else if (requestVersion) { + links[i]->requestVersion(); + emit("version_requested", static_cast(i + 1)); + } + } + settingsVersions_[i] = link.versionReplies != 0 ? link.firmwareVersion + : (versionReadsComplete_[i] ? "No response" : "Reading..."); + } + if (requestVersion) versionLastRequest_ = nowMs_; + updateRecoveryLocations(); - ingestTelemetry(); - updateAutomaticUsbStorage(); + if (screen_ != Screen::SelfTest) { + ingestTelemetry(); + updateAutomaticUsbStorage(); + } for (size_t i = 0; i < input_.held.size(); ++i) { if (input_.held[i] && !previousInput_.held[i]) { @@ -102,9 +135,17 @@ void HmiController::step(const HmiInput& input, uint64_t nowMs) { case Screen::Settings: settingsStep(nowMs_); break; + case Screen::SelfTest: + selfTestStep(nowMs_); + break; + case Screen::FirmwareUpdate: + firmwareUpdateStep(); + break; + case Screen::RadioUpdate: + radioUpdateStep(); + break; case Screen::Recovery: - if (qrView_ == "none" && config_.config().dualReceiver && - (pressed(HmiButton::Up) || pressed(HmiButton::Down))) { + if (qrView_ == "none" && config_.config().dualReceiver && (pressed(HmiButton::Up) || pressed(HmiButton::Down))) { selectedRecoveryLink_ = static_cast(1 - selectedRecoveryLink_); } if (pressed(HmiButton::Right)) { @@ -134,7 +175,8 @@ void HmiController::step(const HmiInput& input, uint64_t nowMs) { break; case Screen::Bootloader: if (pressed(HmiButton::Back)) { - enter(Screen::Settings); + firmwareUpdateSelection_ = 0; + enter(Screen::FirmwareUpdate); } break; case Screen::UsbStorage: { @@ -143,24 +185,18 @@ void HmiController::step(const HmiInput& input, uint64_t nowMs) { usbStorageSession_ = false; emit("usb_storage_reclaimed"); enter(Screen::Settings); - settingsPage_ = 0; - settingsSelection_ = 2; } else if (usbStorageSession_ && storageState == "fault") { usbStorageSession_ = false; usbStorageMessage_ = "Storage could not be remounted."; } else if (usbStorageSession_ && storageState == "host" && pressed(HmiButton::Back)) { usbStorageSession_ = false; enter(Screen::Settings); - settingsPage_ = 0; - settingsSelection_ = 2; } else if (usbStorageSession_ && ((storageState == "host" && pressed(HmiButton::Ok)) || (storageState == "preparing" && pressed(HmiButton::Back)))) { device_.requestFirmwareStorage(); } else if (!usbStorageSession_ && pressed(HmiButton::Back)) { enter(Screen::Settings); - settingsPage_ = 0; - settingsSelection_ = 2; } break; } @@ -190,6 +226,9 @@ bool HmiController::repeated(HmiButton button, uint64_t nowMs) { } void HmiController::enter(Screen screen) { + const bool returningToSettings = screen_ == Screen::UsbStorage || screen_ == Screen::SelfTest || + screen_ == Screen::Bootloader || screen_ == Screen::FirmwareUpdate || + screen_ == Screen::RadioUpdate; screen_ = screen; clearQr(); if (screen_ == Screen::Testing) { @@ -198,15 +237,20 @@ void HmiController::enter(Screen screen) { } if (screen_ == Screen::Sensors) { calibrationState_ = CalibrationState::Idle; + sensorOrientation_ = false; } if (screen_ == Screen::Settings) { - settingsPage_ = 0; - settingsSelection_ = -1; + if (!returningToSettings) { + settingsPage_ = SETTING_TELEMETRY; + settingsSelection_ = -1; + } keyboardActive_ = false; } if (screen_ == Screen::Recovery) { - const bool link1Valid = LocationQr::IsValid(recoveryLocations_[0].lastLatitude, recoveryLocations_[0].lastLongitude); - const bool link2Valid = LocationQr::IsValid(recoveryLocations_[1].lastLatitude, recoveryLocations_[1].lastLongitude); + const bool link1Valid = + LocationQr::IsValid(recoveryLocations_[0].lastLatitude, recoveryLocations_[0].lastLongitude); + const bool link2Valid = + LocationQr::IsValid(recoveryLocations_[1].lastLatitude, recoveryLocations_[1].lastLongitude); selectedRecoveryLink_ = link1Valid ? 0 : (link2Valid ? 1 : 0); } if (screen_ == Screen::Data) { @@ -216,8 +260,10 @@ void HmiController::enter(Screen screen) { } dataSubview_ = DataSubview::List; const auto entries = logs_.listLogs(); - if (entries.empty()) dataSelection_ = 0; - else dataSelection_ = std::min(dataSelection_, static_cast(entries.size() - 1U)); + if (entries.empty()) + dataSelection_ = 0; + else + dataSelection_ = std::min(dataSelection_, static_cast(entries.size() - 1U)); } } @@ -433,10 +479,11 @@ void HmiController::dataStep(uint64_t nowMs) { return; } if (dataSubview_ == DataSubview::Options) { - if (pressed(HmiButton::Back) || pressed(HmiButton::Up)) dataSubview_ = DataSubview::Details; + if (pressed(HmiButton::Back) || pressed(HmiButton::Up)) + dataSubview_ = DataSubview::Details; else if (pressed(HmiButton::Ok) && dataSelection_ >= 0 && dataSelection_ < static_cast(logs.size())) { - dataSubview_ = logs[static_cast(dataSelection_)].active ? DataSubview::ConfirmFinalize - : DataSubview::ConfirmDelete; + dataSubview_ = + logs[static_cast(dataSelection_)].active ? DataSubview::ConfirmFinalize : DataSubview::ConfirmDelete; } return; } @@ -455,7 +502,8 @@ void HmiController::dataStep(uint64_t nowMs) { const bool success = deleting ? logs_.remove(logs[static_cast(dataSelection_)].name) : logs_.finalize(); if (success) { const auto updated = logs_.listLogs(); - dataSelection_ = updated.empty() ? 0 : std::min(dataSelection_, static_cast(updated.size() - 1U)); + dataSelection_ = + updated.empty() ? 0 : std::min(dataSelection_, static_cast(updated.size() - 1U)); logScrollOffset_ = std::min(logScrollOffset_, static_cast(dataSelection_)); dataSubview_ = DataSubview::List; } else { @@ -500,7 +548,7 @@ void HmiController::dataStep(uint64_t nowMs) { void HmiController::updateRecoveryLocations() { const std::array links = {link1_.snapshot(), link2_.snapshot()}; for (size_t index = 0; index < links.size(); ++index) { - const TelemetrySample &telemetry = links[index].telemetry; + const TelemetrySample& telemetry = links[index].telemetry; if (LocationQr::IsValid(telemetry.latitude, telemetry.longitude)) { recoveryLocations_[index].lastLatitude = telemetry.latitude; recoveryLocations_[index].lastLongitude = telemetry.longitude; @@ -512,14 +560,22 @@ bool HmiController::showRecoveryLocation(size_t linkIndex) { if (linkIndex >= recoveryLocations_.size()) { return false; } - const FlightStatisticsSnapshot &location = recoveryLocations_[linkIndex]; - const char *view = linkIndex == 0 ? "recovery_link_1" : "recovery_link_2"; + const FlightStatisticsSnapshot& location = recoveryLocations_[linkIndex]; + const char* view = linkIndex == 0 ? "recovery_link_1" : "recovery_link_2"; return showQr(view, location.lastLatitude, location.lastLongitude); } void HmiController::sensorsStep(uint64_t nowMs) { switch (calibrationState_) { case CalibrationState::Idle: + if (pressed(HmiButton::Right) || pressed(HmiButton::Down)) { + sensorOrientation_ = true; + emit("sensor_view", 0, 1, "orientation"); + } + if (pressed(HmiButton::Left) || pressed(HmiButton::Up)) { + sensorOrientation_ = false; + emit("sensor_view", 0, 0, "readings"); + } if (pressed(HmiButton::Ok)) { calibrationState_ = CalibrationState::Prepare; } @@ -535,6 +591,7 @@ void HmiController::sensorsStep(uint64_t nowMs) { } if (pressed(HmiButton::Back)) { calibrationState_ = CalibrationState::Idle; + sensorOrientation_ = false; } break; case CalibrationState::Calibrating: @@ -551,6 +608,7 @@ void HmiController::sensorsStep(uint64_t nowMs) { case CalibrationState::Concluded: if (pressed(HmiButton::Ok) || pressed(HmiButton::Back)) { calibrationState_ = CalibrationState::Idle; + sensorOrientation_ = false; device_.requestFirmwareStorage(); config_.save(); automaticUsbSharePending_ = device_.snapshot().usb; @@ -562,7 +620,6 @@ void HmiController::sensorsStep(uint64_t nowMs) { } void HmiController::settingsStep(uint64_t nowMs) { - const int16_t counts[3] = {4, 4, 3}; if (keyboardActive_) { const bool moveRight = pressed(HmiButton::Right) || repeated(HmiButton::Right, nowMs); const bool moveLeft = pressed(HmiButton::Left) || repeated(HmiButton::Left, nowMs); @@ -623,35 +680,43 @@ void HmiController::settingsStep(uint64_t nowMs) { return; } if (settingsSelection_ < 0) { - if (pressed(HmiButton::Right) && settingsPage_ < 2) { + if (pressed(HmiButton::Right) && settingsPage_ < kSettingPages - 1) { ++settingsPage_; } if (pressed(HmiButton::Left) && settingsPage_ > 0) { --settingsPage_; } } else { + const auto& setting = settingsTable[settingsPage_][settingsSelection_]; + const auto action = setting.type == BUTTON ? setting.config.buttonAction : BUTTON_ACTION_NONE; const bool increment = pressed(HmiButton::Right) || repeated(HmiButton::Right, nowMs); const bool decrement = pressed(HmiButton::Left) || repeated(HmiButton::Left, nowMs); - if (settingsPage_ == 0 && settingsSelection_ == 0) { + if (settingsPage_ == SETTING_PREFERENCES && settingsSelection_ == 0) { if (increment) config_.config().neverStopLogging = true; if (decrement) config_.config().neverStopLogging = false; - } else if (settingsPage_ == 1 && settingsSelection_ == 0) { + } else if (settingsPage_ == SETTING_TELEMETRY && settingsSelection_ == 0) { if (increment) config_.config().dualReceiver = true; if (decrement) config_.config().dualReceiver = false; - } else if (settingsPage_ == 1 && settingsSelection_ >= 1 && settingsSelection_ <= 3 && pressed(HmiButton::Ok)) { + } else if (settingsPage_ == SETTING_TELEMETRY && settingsSelection_ >= 1 && settingsSelection_ <= 3 && + (settingsSelection_ != 2 || config_.config().dualReceiver) && pressed(HmiButton::Ok)) { keyboardActive_ = true; keyboardSelection_ = 0; keyboardUppercase_ = false; - } else if (settingsPage_ == 2 && settingsSelection_ == 0) { + } else if (settingsPage_ == SETTING_PREFERENCES && settingsSelection_ == 1) { if (increment) config_.config().timeZoneOffset = std::min(12, config_.config().timeZoneOffset + 1); if (decrement) config_.config().timeZoneOffset = std::max(-12, config_.config().timeZoneOffset - 1); - } else if (settingsPage_ == 2 && settingsSelection_ == 1) { + } else if (settingsPage_ == SETTING_PREFERENCES && settingsSelection_ == 2) { if (pressed(HmiButton::Left) || pressed(HmiButton::Right)) config_.config().imperialUnits = !config_.config().imperialUnits; - } else if (settingsPage_ == 2 && settingsSelection_ == 2) { + } else if (settingsPage_ == SETTING_PREFERENCES && settingsSelection_ == 3) { if (increment) config_.config().startupAnimation = true; if (decrement) config_.config().startupAnimation = false; } - if (pressed(HmiButton::Ok) && settingsPage_ == 0 && settingsSelection_ == 2) { + if (pressed(HmiButton::Ok) && action == BUTTON_ACTION_UPDATE_FIRMWARE) { + firmwareUpdateSelection_ = 0; + enter(Screen::FirmwareUpdate); + return; + } + if (pressed(HmiButton::Ok) && action == BUTTON_ACTION_USB_STORAGE) { usbStorageMessage_.clear(); const DeviceStatusSnapshot device = device_.snapshot(); if (!device.usb) { @@ -669,12 +734,15 @@ void HmiController::settingsStep(uint64_t nowMs) { enter(Screen::UsbStorage); return; } - if (pressed(HmiButton::Ok) && settingsPage_ == 0 && settingsSelection_ == 3) { - enter(Screen::Bootloader); - emit("bootloader_requested"); + if (pressed(HmiButton::Ok) && action == BUTTON_ACTION_SELF_TEST) { + selfTest_ = SelfTest{}; + selfTestInput_ = {}; + selfTestResultIndex_ = 0; + enter(Screen::SelfTest); + return; } } - if (pressed(HmiButton::Down) && settingsSelection_ < counts[settingsPage_] - 1) { + if (pressed(HmiButton::Down) && settingsSelection_ < settingsTableValueCount[settingsPage_] - 1) { ++settingsSelection_; } if (pressed(HmiButton::Up) && settingsSelection_ > -1) { @@ -696,6 +764,61 @@ void HmiController::settingsStep(uint64_t nowMs) { } } +void HmiController::firmwareUpdateStep() { + if (pressed(HmiButton::Down) && firmwareUpdateSelection_ < 1) { + ++firmwareUpdateSelection_; + } + if (pressed(HmiButton::Up) && firmwareUpdateSelection_ > 0) { + --firmwareUpdateSelection_; + } + if (pressed(HmiButton::Back)) { + enter(Screen::Settings); + settingsPage_ = SETTING_SYSTEM; + settingsSelection_ = 3; + return; + } + if (!pressed(HmiButton::Ok)) { + return; + } + if (firmwareUpdateSelection_ == 0) { + enter(Screen::Bootloader); + emit("bootloader_requested"); + return; + } + radioUpdateState_ = "browse"; + radioFirmwareSelection_ = 0; + radioUpdateStartedMs_ = 0; + enter(Screen::RadioUpdate); + emit("radio_update_browse"); +} + +void HmiController::radioUpdateStep() { + if (radioUpdateState_ == "browse") { + if (pressed(HmiButton::Ok)) radioUpdateState_ = "confirm"; + if (pressed(HmiButton::Back)) { + firmwareUpdateSelection_ = 1; + enter(Screen::FirmwareUpdate); + } + } else if (radioUpdateState_ == "confirm") { + if (pressed(HmiButton::Back)) radioUpdateState_ = "browse"; + if (pressed(HmiButton::Ok)) { + radioUpdateState_ = "updating"; + radioUpdateStartedMs_ = nowMs_; + emit("radio_update_started"); + } + } else if (radioUpdateState_ == "updating") { + if (nowMs_ - radioUpdateStartedMs_ >= 2U * kRadioUpdateLinkDurationMs) { + radioUpdateState_ = "complete"; + emit("radio_update_complete"); + } + } else if (radioUpdateState_ == "complete" && pressed(HmiButton::Back)) { + radioUpdateState_ = "browse"; + radioUpdateStartedMs_ = 0; + firmwareUpdateSelection_ = 1; + enter(Screen::FirmwareUpdate); + } +} + void HmiController::emit(const char* type, uint8_t link, int32_t value, const std::string& text) { actions_.push_back(PlatformAction{type, link, value, text}); } @@ -719,8 +842,7 @@ void HmiController::render() { if (screen_ == Screen::Logo) { const uint64_t startupDurationMs = config_.config().startupAnimation ? StartupIntro::kDurationMs : StartupIntro::kStaticLogoDurationMs; - const auto elapsedMs = - static_cast(std::min(nowMs_ - startupStartedMs_, startupDurationMs)); + const auto elapsedMs = static_cast(std::min(nowMs_ - startupStartedMs_, startupDurationMs)); const uint32_t frame = config_.config().startupAnimation ? elapsedMs / StartupIntro::kFrameIntervalMs : 0U; if (frame == lastStartupFrame_) { return; @@ -731,13 +853,16 @@ void HmiController::render() { } std::string HmiController::screenName() const { - static constexpr const char* names[] = {"logo", "menu", "live", "recovery", "testing", "data", "sensors", - "settings", "bootloader", "usb_storage"}; + static constexpr const char* names[] = {"logo", "menu", "live", "recovery", + "testing", "data", "sensors", "settings", + "bootloader", "usb_storage", "self_test", "firmware_update", + "radio_update"}; return names[static_cast(screen_)]; } std::string HmiController::testingName() const { - static constexpr const char* names[] = {"disclaimer", "can_start", "cannot_start", "waiting", "failed", "started", "confirm_event"}; + static constexpr const char* names[] = {"disclaimer", "can_start", "cannot_start", "waiting", + "failed", "started", "confirm_event"}; return names[static_cast(testingState_)]; } @@ -747,18 +872,23 @@ std::string HmiController::calibrationName() const { } std::string HmiController::dataSubviewName() const { - static constexpr const char* names[] = {"list", "details", "options", "confirm_finalize", "confirm_delete", "message"}; + static constexpr const char* names[] = {"list", "details", "options", "confirm_finalize", + "confirm_delete", "message"}; return names[static_cast(dataSubview_)]; } HmiSnapshot HmiController::snapshot() const { HmiSnapshot result; result.screen = screenName(); + result.selfTest = selfTest_; + result.selfTestResultIndex = selfTestResultIndex_; result.testingState = testingName(); result.calibrationState = calibrationName(); result.settingsState = keyboardActive_ ? "keyboard" : "list"; + result.settingsVersions = settingsVersions_; result.inputState = "idle"; result.liveView = liveDownrange_ ? "downrange" : "gnss"; + result.sensorView = sensorOrientation_ ? "orientation" : "readings"; for (const bool held : input_.held) { if (held) { result.inputState = "held"; @@ -778,6 +908,19 @@ HmiSnapshot HmiController::snapshot() const { result.dataMessageTitle = dataMessageTitle_; result.dataMessageText = dataMessageText_; result.usbStorageMessage = usbStorageMessage_; + result.firmwareUpdateSelection = firmwareUpdateSelection_; + result.radioUpdateState = radioUpdateState_; + result.radioFirmwareSelection = radioFirmwareSelection_; + if (radioUpdateState_ == "updating") { + const uint64_t elapsed = nowMs_ - radioUpdateStartedMs_; + result.radioUpdateLink = elapsed < kRadioUpdateLinkDurationMs ? 1 : 2; + const uint64_t linkElapsed = elapsed % kRadioUpdateLinkDurationMs; + result.radioUpdatePercent = static_cast(std::min(99, linkElapsed * 100U / + kRadioUpdateLinkDurationMs)); + } else if (radioUpdateState_ == "complete") { + result.radioUpdateLink = 2; + result.radioUpdatePercent = 100; + } result.qrView = qrView_; result.qrUrl = qrUrl_; result.recoveryLocations = recoveryLocations_; @@ -785,14 +928,12 @@ HmiSnapshot HmiController::snapshot() const { result.configuration = config_.config(); const uint64_t startupDurationMs = result.configuration.startupAnimation ? StartupIntro::kDurationMs : StartupIntro::kStaticLogoDurationMs; - const auto startupElapsed = - static_cast(std::min(nowMs_ - startupStartedMs_, startupDurationMs)); + const auto startupElapsed = static_cast(std::min(nowMs_ - startupStartedMs_, startupDurationMs)); result.startupElapsedMs = startupElapsed; - result.startupPhase = screen_ != Screen::Logo - ? StartupIntro::PhaseName(StartupIntro::Phase::kComplete) - : (result.configuration.startupAnimation - ? StartupIntro::PhaseName(StartupIntro::PhaseAt(startupElapsed)) - : "static_logo"); + result.startupPhase = screen_ != Screen::Logo ? StartupIntro::PhaseName(StartupIntro::Phase::kComplete) + : (result.configuration.startupAnimation + ? StartupIntro::PhaseName(StartupIntro::PhaseAt(startupElapsed)) + : "static_logo"); result.links[0] = link1_.snapshot(); result.links[1] = link2_.snapshot(); result.navigation = navigation_.snapshot(); @@ -801,15 +942,16 @@ HmiSnapshot HmiController::snapshot() const { result.device.logging = result.recorder.state == "recording"; result.device.recorderFault = result.recorder.state == "fault"; result.logs = logs_.listLogs(); - if (dataSubview_ != DataSubview::List && dataSelection_ >= 0 && dataSelection_ < static_cast(result.logs.size())) { + if (dataSubview_ != DataSubview::List && dataSelection_ >= 0 && + dataSelection_ < static_cast(result.logs.size())) { result.flightStatistics[0] = logs_.statistics(result.logs[static_cast(dataSelection_)], 1); result.flightStatistics[1] = logs_.statistics(result.logs[static_cast(dataSelection_)], 2); const bool anyParticipant = result.flightStatistics[0].participant || result.flightStatistics[1].participant; const bool complete = anyParticipant && - (!result.flightStatistics[0].participant || result.flightStatistics[0].complete) && - (!result.flightStatistics[1].participant || result.flightStatistics[1].complete); - result.selectedLogHealth = result.logs[static_cast(dataSelection_)].active ? "active" : - (complete ? "complete" : "incomplete"); + (!result.flightStatistics[0].participant || result.flightStatistics[0].complete) && + (!result.flightStatistics[1].participant || result.flightStatistics[1].complete); + result.selectedLogHealth = + result.logs[static_cast(dataSelection_)].active ? "active" : (complete ? "complete" : "incomplete"); } result.selectedRecoveryLink = result.configuration.dualReceiver ? selectedRecoveryLink_ : -1; const float targetLatitude = result.configuration.dualReceiver diff --git a/ground_station/simulator/hmi_controller.hpp b/ground_station/simulator/hmi_controller.hpp index 130905a3..172296a1 100644 --- a/ground_station/simulator/hmi_controller.hpp +++ b/ground_station/simulator/hmi_controller.hpp @@ -10,6 +10,7 @@ #include #include "hmi/startup_intro.hpp" +#include "self_test.hpp" #include "clock.hpp" @@ -43,6 +44,8 @@ struct LinkSnapshot { LinkInfoSnapshot info; bool enabled = true; bool connected = false; + std::string firmwareVersion; + uint32_t versionReplies = 0; }; struct NavigationSnapshot { @@ -51,6 +54,8 @@ struct NavigationSnapshot { float rocketLatitude = 0.0F; float rocketLongitude = 0.0F; float northRad = 0.0F; + float pitchRad = 0.0F; + float rollRad = 0.0F; float azimuthRad = 0.0F; float distanceM = 0.0F; float elevationRad = 0.0F; @@ -79,6 +84,16 @@ struct DeviceStatusSnapshot { bool logging = false; bool recorderFault = false; std::string usbStorageState = "firmware"; + uint8_t selfTestMissingReceiver = 0; + bool selfTestGnss = true; + bool selfTestSensorFailure = false; + bool selfTestStorageFailure = false; + uint8_t selfTestLq = 90; + int8_t selfTestSnr = 10; + bool selfTestGyroSaveFailure = false; + std::array gyroBias{}; + uint32_t gyroSaveCount = 0; + uint32_t usbReadCount = 0; }; struct GsConfigSnapshot { @@ -164,6 +179,7 @@ class ITelemetryLink { virtual void disable() = 0; virtual void setLinkPhrase(const std::string& phrase) = 0; virtual void setTestingPhrase(const std::string& phrase) = 0; + virtual void requestVersion() = 0; }; class INavigation { @@ -200,6 +216,7 @@ class IDeviceStatus { [[nodiscard]] virtual DeviceStatusSnapshot snapshot() const = 0; virtual bool requestMassStorage() = 0; virtual void requestFirmwareStorage() = 0; + virtual bool saveGyroCalibration(const std::array& bias) = 0; }; struct HmiSnapshot { @@ -207,8 +224,10 @@ struct HmiSnapshot { std::string testingState = "disclaimer"; std::string calibrationState = "idle"; std::string settingsState = "list"; + std::array settingsVersions{}; std::string inputState = "idle"; std::string liveView = "gnss"; + std::string sensorView = "readings"; int16_t menuSelection = 0; int16_t testingSelection = 0; int16_t keyboardSelection = 0; @@ -223,6 +242,12 @@ struct HmiSnapshot { std::string dataMessageTitle; std::string dataMessageText; std::string usbStorageMessage; + int16_t firmwareUpdateSelection = 0; + std::string radioUpdateState = "browse"; + std::vector radioFirmware{"telemetry-1.2.0.bin"}; + int16_t radioFirmwareSelection = 0; + uint8_t radioUpdateLink = 0; + uint8_t radioUpdatePercent = 0; std::string qrView = "none"; std::string qrUrl; uint64_t virtualTimeMs = 0; @@ -240,6 +265,8 @@ struct HmiSnapshot { RecoverySolutionSnapshot recoverySolution; std::vector actions; uint32_t framebufferRevision = 0; + SelfTest selfTest; + int16_t selfTestResultIndex = 0; }; class IHmiRenderer { @@ -262,12 +289,27 @@ class HmiController { void clearActions() { actions_.clear(); } private: - enum class Screen : uint8_t { Logo, Menu, Live, Recovery, Testing, Data, Sensors, Settings, Bootloader, UsbStorage }; + enum class Screen : uint8_t { + Logo, + Menu, + Live, + Recovery, + Testing, + Data, + Sensors, + Settings, + Bootloader, + UsbStorage, + SelfTest, + FirmwareUpdate, + RadioUpdate + }; enum class TestingState : uint8_t { Disclaimer, CanStart, CannotStart, Waiting, Failed, Started, ConfirmEvent }; enum class CalibrationState : uint8_t { Idle, Prepare, Calibrating, Concluded }; enum class DataSubview : uint8_t { List, Details, Options, ConfirmFinalize, ConfirmDelete, Message }; static constexpr uint64_t kTestingTimeoutMs = 10000; + static constexpr uint64_t kRadioUpdateLinkDurationMs = 2000; static constexpr uint64_t kLongPressMs = 500; static constexpr size_t kKeyboardMaxLength = 16; @@ -283,6 +325,10 @@ class HmiController { void dataStep(uint64_t nowMs); void sensorsStep(uint64_t nowMs); void settingsStep(uint64_t nowMs); + void selfTestStep(uint64_t nowMs); + void selfTestRadio(SelfTest::RadioConfiguration configuration); + void firmwareUpdateStep(); + void radioUpdateStep(); void updateRecoveryLocations(); bool showRecoveryLocation(size_t linkIndex); void emit(const char* type, uint8_t link = 0, int32_t value = 0, const std::string& text = {}); @@ -329,11 +375,25 @@ class HmiController { std::array recoveryLocations_{}; int8_t selectedRecoveryLink_ = 0; bool liveDownrange_ = false; + bool sensorOrientation_ = false; bool keyboardActive_ = false; + uint64_t versionLastRequest_ = 0; + std::array versionReadsComplete_{}; + std::array settingsVersions_{}; int16_t keyboardSelection_ = 0; bool keyboardUppercase_ = false; bool usbStorageSession_ = false; bool usbPreviouslyConnected_ = false; bool automaticUsbSharePending_ = false; std::string previousRecorderState_ = "idle"; + SelfTest selfTest_{}; + SelfTestInput selfTestInput_{}; + int16_t selfTestResultIndex_ = 0; + uint64_t selfTestLastPacket_ = 0; + uint64_t selfTestLastFix_ = 0; + SelfTest::RadioConfiguration selfTestRadio_ = SelfTest::RadioConfiguration::Restore; + int16_t firmwareUpdateSelection_ = 0; + std::string radioUpdateState_ = "browse"; + int16_t radioFirmwareSelection_ = 0; + uint64_t radioUpdateStartedMs_ = 0; }; diff --git a/ground_station/simulator/scenarios/bootloader.json b/ground_station/simulator/scenarios/bootloader.json new file mode 100644 index 00000000..0207939e --- /dev/null +++ b/ground_station/simulator/scenarios/bootloader.json @@ -0,0 +1,20 @@ +{ + "initial": {"ready": true}, + "steps": [ + {"press": "right"}, {"release": "right"}, + {"press": "right"}, {"release": "right"}, + {"press": "down"}, {"release": "down"}, + {"press": "ok"}, {"release": "ok"}, + {"press": "right"}, {"release": "right"}, + {"press": "right"}, {"release": "right"}, + {"press": "down"}, {"release": "down"}, + {"press": "down"}, {"release": "down"}, + {"press": "down"}, {"release": "down"}, + {"press": "down"}, {"release": "down"}, + {"press": "ok"}, {"release": "ok"}, + {"assert": {"activeScreen": "firmware_update", "firmwareUpdateSelection": 0}}, + {"press": "ok"}, {"release": "ok"}, + {"assert": {"activeScreen": "bootloader", "emittedAction": "bootloader_requested"}}, + {"snapshot": "bootloader-requested"} + ] +} diff --git a/ground_station/simulator/scenarios/live.json b/ground_station/simulator/scenarios/live.json new file mode 100644 index 00000000..2ca947ed --- /dev/null +++ b/ground_station/simulator/scenarios/live.json @@ -0,0 +1,13 @@ +{ + "initial": { + "ready": true, + "links": [ + {"connected": true, "telemetry": {"state": 3, "altitudeM": 120, "velocityMps": 42, "latitude": 33.7365, "longitude": 132.4881, "voltage": 4.1, "timestampDs": 10, "updated": true}} + ] + }, + "steps": [ + {"press": "ok"}, {"release": "ok"}, + {"assert": {"activeScreen": "live", "links.0.connected": true, "links.0.telemetry.state": 3}}, + {"snapshot": "live-telemetry"} + ] +} diff --git a/ground_station/simulator/scenarios/radio-update.json b/ground_station/simulator/scenarios/radio-update.json new file mode 100644 index 00000000..ef767943 --- /dev/null +++ b/ground_station/simulator/scenarios/radio-update.json @@ -0,0 +1,44 @@ +{ + "initial": {"ready": true}, + "steps": [ + {"press": "right"}, + {"press": "right"}, + {"press": "down"}, + {"press": "ok"}, + {"press": "right"}, + {"press": "right"}, + {"press": "down"}, + {"press": "down"}, + {"press": "down"}, + {"press": "down"}, + {"press": "ok"}, + {"assert": {"activeScreen": "firmware_update", "firmwareUpdateSelection": 0}}, + {"snapshot": "firmware-update-menu"}, + {"press": "down"}, + {"assert": {"activeScreen": "firmware_update", "firmwareUpdateSelection": 1}}, + {"press": "ok"}, + {"assert": {"activeScreen": "radio_update", "emittedAction": "radio_update_browse"}}, + {"snapshot": "radio-update-list"}, + {"press": "ok"}, + {"snapshot": "radio-update-confirm"}, + {"press": "ok"}, + {"assert": {"activeScreen": "radio_update", "radioUpdateState": "updating", + "radioUpdateLink": 1, "radioUpdatePercent": 0, + "emittedAction": "radio_update_started"}}, + {"advance": 1000}, + {"assert": {"radioUpdateState": "updating", "radioUpdateLink": 1, "radioUpdatePercent": 50}}, + {"snapshot": "radio-update-progress"}, + {"advance": 1000}, + {"assert": {"radioUpdateState": "updating", "radioUpdateLink": 2, "radioUpdatePercent": 0}}, + {"advance": 2000}, + {"assert": {"activeScreen": "radio_update", "radioUpdateState": "complete", + "radioUpdateLink": 2, "radioUpdatePercent": 100, + "emittedAction": "radio_update_complete"}}, + {"snapshot": "radio-update-complete"}, + {"press": "back"}, + {"assert": {"activeScreen": "firmware_update", "firmwareUpdateSelection": 1, + "radioUpdateState": "browse", "radioUpdateLink": 0, "radioUpdatePercent": 0}}, + {"press": "back"}, + {"assert": {"activeScreen": "settings", "settingsPage": 2, "settingsSelection": 3}} + ] +} diff --git a/ground_station/simulator/scenarios/sensors-orientation.json b/ground_station/simulator/scenarios/sensors-orientation.json new file mode 100644 index 00000000..d0da2c7e --- /dev/null +++ b/ground_station/simulator/scenarios/sensors-orientation.json @@ -0,0 +1,29 @@ +{ + "initial": { + "ready": true, + "navigation": { + "northRad": -1.5707963, + "pitchRad": -0.2094395, + "rollRad": 0.1396263, + "mx": 940.0, + "my": 180.0, + "mz": -290.0 + } + }, + "steps": [ + {"press": "down"}, {"release": "down"}, + {"press": "right"}, {"release": "right"}, + {"press": "ok"}, {"release": "ok"}, + {"assert": {"activeScreen": "sensors", "sensorView": "readings"}}, + {"press": "right"}, {"release": "right"}, + {"assert": {"sensorView": "orientation", "action": "sensor_view"}}, + {"snapshot": "orientation-east"}, + {"press": "left"}, {"release": "left"}, + {"assert": {"sensorView": "readings"}}, + {"press": "right"}, {"release": "right"}, + {"press": "ok"}, {"release": "ok"}, + {"assert": {"calibrationState": "prepare"}}, + {"press": "back"}, {"release": "back"}, + {"assert": {"calibrationState": "idle", "sensorView": "readings"}} + ] +} diff --git a/ground_station/simulator/scenarios/sensors.json b/ground_station/simulator/scenarios/sensors.json new file mode 100644 index 00000000..fc187b4d --- /dev/null +++ b/ground_station/simulator/scenarios/sensors.json @@ -0,0 +1,16 @@ +{ + "initial": {"ready": true}, + "steps": [ + {"press": "right"}, {"release": "right"}, {"press": "down"}, {"release": "down"}, + {"press": "ok"}, {"release": "ok"}, + {"assert": {"activeScreen": "sensors", "calibrationState": "idle"}}, + {"press": "ok"}, {"release": "ok"}, + {"assert": {"calibrationState": "prepare"}}, + {"press": "ok"}, {"release": "ok"}, + {"assert": {"calibrationState": "calibrating", "emittedAction": "calibration_started"}}, + {"set": {"navigation.calibrationPercentage": 100.0}}, + {"advance": 0}, + {"assert": {"calibrationState": "concluded", "emittedAction": "calibration_completed"}}, + {"snapshot": "sensors-calibration-complete"} + ] +} diff --git a/ground_station/simulator/scenarios/settings-versions.json b/ground_station/simulator/scenarios/settings-versions.json new file mode 100644 index 00000000..bc3a7e0e --- /dev/null +++ b/ground_station/simulator/scenarios/settings-versions.json @@ -0,0 +1,142 @@ +{ + "initial": { + "ready": true, + "links": [ + { + "firmwareVersion": "1.1.3" + }, + { + "firmwareVersion": "2.0.0" + } + ] + }, + "steps": [ + { + "assert": { + "telemetryVersion1": "1.1.3", + "telemetryVersion2": "2.0.0" + } + }, + { + "press": "down" + }, + { + "release": "down" + }, + { + "press": "right" + }, + { + "release": "right" + }, + { + "press": "right" + }, + { + "release": "right" + }, + { + "press": "ok" + }, + { + "release": "ok" + }, + { + "press": "right" + }, + { + "release": "right" + }, + { + "press": "right" + }, + { + "release": "right" + }, + { + "press": "down" + }, + { + "release": "down" + }, + { + "assert": { + "telemetryVersion1": "1.1.3", + "telemetryVersion2": "2.0.0", + "settingsSelection": 0, + "settingsPage": 2 + } + }, + { + "set": { + "link1.firmwareVersion": "", + "link2.firmwareVersion": "2.0.1", + "configuration.dualReceiver": true + } + }, + { + "press": "ok" + }, + { + "release": "ok" + }, + { + "advance": 4000 + }, + { + "assert": { + "telemetryVersion1": "1.1.3", + "telemetryVersion2": "2.0.0" + } + }, + { + "press": "down" + }, + { + "release": "down" + }, + { + "press": "up" + }, + { + "release": "up" + }, + { + "assert": { + "telemetryVersion1": "1.1.3", + "telemetryVersion2": "2.0.0", + "settingsSelection": 0, + "settingsPage": 2 + } + }, + { + "press": "up" + }, + { + "release": "up" + }, + { + "assert": { + "settingsSelection": -1, + "settingsPage": 2 + } + }, + { + "press": "back" + }, + { + "release": "back" + }, + { + "press": "back" + }, + { + "release": "back" + }, + { + "assert": { + "activeScreen": "menu" + } + } + ] +} diff --git a/ground_station/simulator/scenarios/settings.json b/ground_station/simulator/scenarios/settings.json index 78a5a5f5..65aed11b 100644 --- a/ground_station/simulator/scenarios/settings.json +++ b/ground_station/simulator/scenarios/settings.json @@ -1,26 +1,26 @@ { "initial": {"ready": true}, "steps": [ - {"press": "down"}, - {"press": "right"}, - {"press": "right"}, - {"press": "ok"}, - {"press": "down"}, - {"press": "right"}, - {"press": "back"}, - {"press": "back"}, - {"assert": {"activeScreen": "menu", "configuration.neverStopLogging": true, "emittedAction": "configuration_saved"}}, - {"press": "ok"}, - {"press": "right"}, - {"press": "right"}, - {"press": "down"}, - {"press": "down"}, - {"press": "down"}, - {"press": "left"}, - {"press": "back"}, - {"press": "back"}, - {"assert": {"activeScreen": "menu", "configuration.startupAnimation": false, - "emittedAction": "configuration_saved"}}, + {"press": "down"}, {"release": "down"}, + {"press": "right"}, {"release": "right"}, + {"press": "right"}, {"release": "right"}, + {"press": "ok"}, {"release": "ok"}, + {"press": "right"}, {"release": "right"}, + {"press": "down"}, {"release": "down"}, + {"press": "right"}, {"release": "right"}, + {"assert": {"settingsPage": 1, "settingsSelection": 0, "configuration.neverStopLogging": true}}, + {"press": "down"}, {"release": "down"}, + {"press": "right"}, {"release": "right"}, + {"assert": {"configuration.timeZoneOffset": 1}}, + {"press": "down"}, {"release": "down"}, + {"press": "right"}, {"release": "right"}, + {"assert": {"configuration.imperialUnits": true}}, + {"press": "down"}, {"release": "down"}, + {"press": "left"}, {"release": "left"}, + {"assert": {"configuration.startupAnimation": false}}, + {"press": "back"}, {"release": "back"}, + {"press": "back"}, {"release": "back"}, + {"assert": {"activeScreen": "menu", "emittedAction": "configuration_saved"}}, {"snapshot": "settings"} ] } diff --git a/ground_station/simulator/scenarios/usb-storage.json b/ground_station/simulator/scenarios/usb-storage.json index 3cdc8d4b..3f21dfff 100644 --- a/ground_station/simulator/scenarios/usb-storage.json +++ b/ground_station/simulator/scenarios/usb-storage.json @@ -6,22 +6,22 @@ "steps": [ {"assert":{"activeScreen":"menu","usbStorageState":"host","recorderState":"idle"}}, {"press":"right"},{"press":"right"},{"press":"down"},{"press":"ok"}, - {"press":"down"},{"press":"down"},{"press":"down"},{"press":"ok"}, + {"press":"right"},{"press":"right"},{"press":"down"},{"press":"down"},{"press":"ok"}, {"assert":{"activeScreen":"usb_storage","usbStorageState":"host","recorderState":"idle"}}, {"snapshot":"usb-storage-controls"}, {"press":"back"}, - {"assert":{"activeScreen":"settings","settingsPage":0,"settingsSelection":2,"usbStorageState":"host"}}, + {"assert":{"activeScreen":"settings","settingsPage":2,"settingsSelection":1,"usbStorageState":"host"}}, {"press":"ok"}, {"assert":{"activeScreen":"usb_storage","usbStorageState":"host"}}, {"press":"back"}, - {"assert":{"activeScreen":"settings","settingsPage":0,"settingsSelection":2,"usbStorageState":"host"}}, + {"assert":{"activeScreen":"settings","settingsPage":2,"settingsSelection":1,"usbStorageState":"host"}}, {"press":"ok"}, {"press":"ok"},{"advance":0}, - {"assert":{"activeScreen":"settings","settingsPage":0,"settingsSelection":2,"usbStorageState":"firmware"}}, + {"assert":{"activeScreen":"settings","settingsPage":2,"settingsSelection":1,"usbStorageState":"firmware"}}, {"press":"ok"}, {"assert":{"activeScreen":"usb_storage","usbStorageState":"host"}}, {"press":"ok"},{"advance":0}, - {"assert":{"activeScreen":"settings","settingsPage":0,"settingsSelection":2,"usbStorageState":"firmware"}}, + {"assert":{"activeScreen":"settings","settingsPage":2,"settingsSelection":1,"usbStorageState":"firmware"}}, {"set":{"link1.telemetry.state":2,"link1.telemetry.timestampDs":11}}, {"advance":0}, {"set":{"link1.telemetry.state":3,"link1.telemetry.timestampDs":12}}, @@ -30,7 +30,7 @@ {"press":"ok"}, {"assert":{"activeScreen":"usb_storage","usbStorageState":"firmware","usbStorageMessage":"Finalize the active log first."}}, {"press":"back"}, - {"assert":{"activeScreen":"settings","settingsPage":0,"settingsSelection":2}}, + {"assert":{"activeScreen":"settings","settingsPage":2,"settingsSelection":1}}, {"set":{"link1.telemetry.state":7,"link1.telemetry.timestampDs":20}}, {"advance":0}, {"assert":{"recorderState":"idle","recordedRowCount":2,"usbStorageState":"host"}}, diff --git a/ground_station/simulator/self_test_fixture.cpp b/ground_station/simulator/self_test_fixture.cpp new file mode 100644 index 00000000..258a814b --- /dev/null +++ b/ground_station/simulator/self_test_fixture.cpp @@ -0,0 +1,128 @@ +// Simulated hardware observations for the production factory-test state machine. +// Acceptance and phase transitions live exclusively in src/self_test.cpp. +#include "hmi_controller.hpp" +#include +#include + +void HmiController::selfTestRadio(SelfTest::RadioConfiguration configuration) { + using Radio = SelfTest::RadioConfiguration; + selfTestRadio_ = configuration; + const char* first = SelfTestProfile::kPhrase1; + const char* second = SelfTestProfile::kPhrase2; + if (configuration == Radio::Restore) { + link1_.setLinkPhrase(config_.config().linkPhrase1); + link2_.setLinkPhrase(config_.config().dualReceiver ? config_.config().linkPhrase2 : config_.config().linkPhrase1); + if (config_.config().linkPhrase1.empty()) link1_.disable(); else link1_.enable(); + if ((config_.config().dualReceiver ? config_.config().linkPhrase2 : config_.config().linkPhrase1).empty()) link2_.disable(); else link2_.enable(); + emit("self_test_restored"); + return; + } + if (configuration == Radio::Swapped) std::swap(first, second); + else if (configuration != Radio::Dual) second = first; + link1_.setLinkPhrase(first); + link2_.setLinkPhrase(second); + if (configuration == Radio::Receiver2Only) link1_.disable(); else link1_.enable(); + if (configuration == Radio::Receiver1Only) link2_.disable(); else link2_.enable(); + emit("self_test_radio", 0, static_cast(configuration)); +} + +void HmiController::selfTestStep(uint64_t nowMs) { + const auto now = static_cast(nowMs); + const auto device = device_.snapshot(); + selfTestInput_.buttonsHeld = selfTestInput_.buttonsPressed = 0; + selfTestInput_.usbConnected = device.usb; + selfTestInput_.usbStorageReady = device.usbStorageState == "host"; + selfTestInput_.usbStorageFault = device.usbStorageState == "fault"; + selfTestInput_.usbReadCount = device.usbReadCount; + selfTestInput_.batteryVoltage = device.batteryVoltage; + selfTestInput_.linksReady = true; + for (size_t i = 0; i < 7; ++i) { + if (input_.held[i]) selfTestInput_.buttonsHeld |= static_cast(1U << i); + if (pressed(static_cast(i))) selfTestInput_.buttonsPressed |= static_cast(1U << i); + } + if (!selfTest_.started() || selfTest_.finished()) { + if (pressed(HmiButton::Back)) { + screen_ = Screen::Settings; + automaticUsbSharePending_ = device.usb; + return; + } + if (selfTest_.finished()) { + if (pressed(HmiButton::Down)) selfTestResultIndex_ = std::min(static_cast(SelfTest::Check::Count)-1, selfTestResultIndex_+1); + if (pressed(HmiButton::Up)) selfTestResultIndex_ = std::max(0, selfTestResultIndex_-1); + } + if (!pressed(HmiButton::Ok)) return; + if (logs_.recorderStatus().state != "idle") { emit("self_test_busy"); return; } + device_.requestFirmwareStorage(); + selfTest_.start(now, selfTestInput_); + selfTest_.setResult(SelfTest::Check::Storage, device.selfTestStorageFailure ? SelfTest::Result::Fail : SelfTest::Result::Pass, + device.selfTestStorageFailure ? "Storage write/read check failed" : "Written, reopened and verified"); + selfTestRadio_ = SelfTest::RadioConfiguration::Restore; + selfTestLastPacket_ = selfTestLastFix_ = nowMs; + selfTestResultIndex_ = 0; + emit("self_test_started"); + } + if (input_.held[6] && nowMs - heldSince_[6] >= 2000U && !selfTest_.cancelled) selfTest_.cancel(now); + + // Fixture packets arrive at 20 Hz; the normal combined stream is 10 Hz. + if (nowMs - selfTestLastPacket_ >= 50) { + selfTestLastPacket_ = nowMs; + bool received = false; + for (size_t i = 0; i < 2; ++i) { + const bool enabled = (i == 0 ? link1_.snapshot() : link2_.snapshot()).enabled; + if (!enabled || (device.selfTestMissingReceiver & (1U << i)) != 0) continue; + auto& radio = selfTestInput_.links[i].radio; + ++radio.packets; + ++radio.infoSamples; + radio.maxGapMs = std::max(radio.maxGapMs, now - radio.lastPacketMs); + radio.lastPacketMs = now; + radio.lqSum += device.selfTestLq; + radio.minimumSnr = std::min(radio.minimumSnr, device.selfTestSnr); + received = true; + } + if (received && selfTestRadio_ >= SelfTest::RadioConfiguration::Single && now % 100 < 60) ++selfTestInput_.combinedPackets; + } + if (device.selfTestGnss && nowMs - selfTestLastFix_ >= 1000) { + selfTestLastFix_ = nowMs; + auto& gps = selfTestInput_.links[1]; + gps.fixCount += 5; + gps.lastFixMs = gps.lastTimeMs = now; + ++gps.timeUpdates; + gps.utcSeconds = now / 1000; + gps.latitude = 46; + gps.longitude = 8; + gps.satellites = 9; + } + const auto nav = navigation_.snapshot(); + auto& sensor = selfTestInput_.sensors; + ++sensor.sequence; + sensor.sampledAtMs = now; + sensor.imuDetected = sensor.accelerationFresh = sensor.gyroFresh = sensor.magnetometerFresh = !device.selfTestSensorFailure; + sensor.magnetometer = device.selfTestSensorFailure ? 0 : 0x0d; + sensor.readError = device.selfTestSensorFailure; + sensor.acceleration = {nav.ax, nav.ay, nav.az}; + sensor.rawGyro = {nav.gx, nav.gy, nav.gz}; + for (size_t i = 0; i < 3; ++i) sensor.gyro[i] = sensor.rawGyro[i] - device.gyroBias[i]; + sensor.magnetic = {nav.mx, nav.my, nav.mz}; + // A default field allows a stationary healthy fixture; motion still requires + // explicit navigation injections, so the motion test cannot pass by waiting. + if (nav.mx == 0 && nav.my == 0 && nav.mz == 0) sensor.magnetic = {500, 300, 700}; + + selfTest_.update(now, selfTestInput_); + std::array bias{}; + if (selfTest_.takeGyroCalibration(bias)) + selfTest_.completeGyroCalibration(device_.saveGyroCalibration(bias), now, selfTestInput_); + if (selfTest_.takeUsbStorageRequest() && !device_.requestMassStorage()) + selfTest_.setResult(SelfTest::Check::Usb, SelfTest::Result::Fail, "Could not share USB storage"); + const auto radio = selfTest_.takeRadioConfiguration(); + if (radio != SelfTest::RadioConfiguration::None) selfTestRadio(radio); + if (selfTest_.takeVersionRequest()) { + for (size_t i = 0; i < 2; ++i) if ((device.selfTestMissingReceiver & (1U << i)) == 0) { + ++selfTestInput_.links[i].versionReplies; + std::strcpy(selfTestInput_.links[i].version, "fixture-version"); + } + } + if (selfTest_.takeRadioReset()) { + for (auto& link : selfTestInput_.links) { link.radio = {}; link.radio.lastPacketMs = now; } + } + if (selfTest_.finished() && device.usb) device_.requestMassStorage(); +} diff --git a/ground_station/simulator/wasm_api.cpp b/ground_station/simulator/wasm_api.cpp index 9077f290..4a9526f9 100644 --- a/ground_station/simulator/wasm_api.cpp +++ b/ground_station/simulator/wasm_api.cpp @@ -36,11 +36,18 @@ class Link final : public ITelemetryLink { void disable() override { value.enabled = false; } void setLinkPhrase(const std::string& phrase) override { linkPhrase = phrase; } void setTestingPhrase(const std::string& phrase) override { testingPhrase = phrase; } + void requestVersion() override { + if (!versionReply.empty()) { + value.firmwareVersion = versionReply; + ++value.versionReplies; + } + } LinkSnapshot value{}; uint8_t lastEvent = 0; std::string linkPhrase; std::string testingPhrase; + std::string versionReply = "1.1.3"; }; class Navigation final : public INavigation { @@ -337,6 +344,12 @@ class Device final : public IDeviceStatus { return true; } void requestFirmwareStorage() override { value.usbStorageState = "firmware"; } + bool saveGyroCalibration(const std::array& bias) override { + if (value.selfTestGyroSaveFailure) return false; + value.gyroBias = bias; + ++value.gyroSaveCount; + return true; + } DeviceStatusSnapshot value; }; @@ -435,6 +448,9 @@ bool stringField(const char* json, const char* key, std::string& output) { void updateLink(Link& link, const char* json) { int32_t integer = 0; bool boolean = false; + if (stringField(json, "firmwareVersion", link.versionReply)) { + link.versionReply.resize(std::min(16, link.versionReply.size())); + } if (numberField(json, "state", integer)) link.value.telemetry.state = static_cast(integer); if (numberField(json, "errors", integer)) link.value.telemetry.errors = static_cast(integer); if (numberField(json, "altitudeM", integer)) link.value.telemetry.altitudeM = integer; @@ -456,6 +472,8 @@ void updateLink(Link& link, const char* json) { link.value.telemetry.updated = true; link.value.info.updated = true; } + if (link.value.telemetry.updated) link.value.telemetry.lastUpdateMs = virtualClock.now; + if (link.value.info.updated) link.value.info.lastUpdateMs = virtualClock.now; } std::string quote(const std::string& value) { @@ -471,13 +489,32 @@ std::string quote(const std::string& value) { void rebuildSnapshot() { const HmiSnapshot state = controller.snapshot(); snapshot = "{\"activeScreen\":" + quote(state.screen) + + ",\"selfTestPhase\":" + std::to_string(static_cast(state.selfTest.phase)) + + ",\"selfTestWaiting\":" + std::string(state.selfTest.awaitingConfirmation ? "true" : "false") + + ",\"selfTestResult\":" + quote(SelfTest::resultName(state.selfTest.overall())) + + ",\"selfTestRadioStage\":" + std::to_string(state.selfTest.radioStage) + + ",\"selfTestButtons\":" + std::to_string(state.selfTest.completedButtons) + + ",\"gyroBias\":[" + std::to_string(state.device.gyroBias[0]) + "," + + std::to_string(state.device.gyroBias[1]) + "," + std::to_string(state.device.gyroBias[2]) + "]" + + ",\"gyroSaveCount\":" + std::to_string(state.device.gyroSaveCount) + + ",\"selfTestChecks\":[" + [&state]() { + std::string output; + for (const auto& result : state.selfTest.results) { + if (!output.empty()) output += ','; + output += quote(SelfTest::resultName(result.result)); + } + return output; + }() + "]" + ",\"liveView\":" + quote(state.liveView) + + ",\"sensorView\":" + quote(state.sensorView) + ",\"testingState\":" + quote(state.testingState) + ",\"testingSelection\":" + std::to_string(state.testingSelection) + ",\"keyboardSelection\":" + std::to_string(state.keyboardSelection) + ",\"keyboardUppercase\":" + std::string(state.keyboardUppercase ? "true" : "false") + ",\"calibrationState\":" + quote(state.calibrationState) + ",\"settingsState\":" + quote(state.settingsState) + + ",\"telemetryVersion1\":" + quote(state.settingsVersions[0]) + + ",\"telemetryVersion2\":" + quote(state.settingsVersions[1]) + ",\"dataStatistics\":" + std::string(state.dataStatistics ? "true" : "false") + ",\"currentDataSubview\":" + quote(state.currentDataSubview) + ",\"dataSelection\":" + std::to_string(state.dataSelection) + @@ -490,6 +527,10 @@ void rebuildSnapshot() { ",\"menuSelection\":" + std::to_string(state.menuSelection) + ",\"settingsPage\":" + std::to_string(state.settingsPage) + ",\"settingsSelection\":" + std::to_string(state.settingsSelection) + + ",\"firmwareUpdateSelection\":" + std::to_string(state.firmwareUpdateSelection) + + ",\"radioUpdateState\":" + quote(state.radioUpdateState) + + ",\"radioUpdateLink\":" + std::to_string(state.radioUpdateLink) + + ",\"radioUpdatePercent\":" + std::to_string(state.radioUpdatePercent) + ",\"recorderState\":" + quote(state.recorder.state) + ",\"activeFilename\":" + quote(state.recorder.activeFilename) + ",\"recordedRowCount\":" + std::to_string(state.recorder.writtenRows) + @@ -513,6 +554,22 @@ void rebuildSnapshot() { ",\"linkPhrase1\":" + quote(state.configuration.linkPhrase1) + ",\"linkPhrase2\":" + quote(state.configuration.linkPhrase2) + ",\"testingPhrase\":" + quote(state.configuration.testingPhrase) + "}" + + ",\"navigation\":{\"homeLatitude\":" + std::to_string(state.navigation.homeLatitude) + + ",\"homeLongitude\":" + std::to_string(state.navigation.homeLongitude) + + ",\"rocketLatitude\":" + std::to_string(state.navigation.rocketLatitude) + + ",\"rocketLongitude\":" + std::to_string(state.navigation.rocketLongitude) + + ",\"northRad\":" + std::to_string(state.navigation.northRad) + + ",\"pitchRad\":" + std::to_string(state.navigation.pitchRad) + + ",\"rollRad\":" + std::to_string(state.navigation.rollRad) + + ",\"ax\":" + std::to_string(state.navigation.ax) + + ",\"ay\":" + std::to_string(state.navigation.ay) + + ",\"az\":" + std::to_string(state.navigation.az) + + ",\"gx\":" + std::to_string(state.navigation.gx) + + ",\"gy\":" + std::to_string(state.navigation.gy) + + ",\"gz\":" + std::to_string(state.navigation.gz) + + ",\"mx\":" + std::to_string(state.navigation.mx) + + ",\"my\":" + std::to_string(state.navigation.my) + + ",\"mz\":" + std::to_string(state.navigation.mz) + "}" + ",\"framebufferRevision\":" + std::to_string(state.framebufferRevision) + ",\"actions\":["; for (size_t index = 0; index < state.actions.size(); ++index) { @@ -528,6 +585,7 @@ void step() { controller.step(input, virtualClock.now); } void resetState(bool skipStartup, bool preserveConfiguration = false) { const GsConfigSnapshot savedConfiguration = configStore.value; + const auto savedGyroBias = device.value.gyroBias; virtualClock.now = 0; virtualClock.hourValue = 0; virtualClock.minuteValue = 0; @@ -539,8 +597,11 @@ void resetState(bool skipStartup, bool preserveConfiguration = false) { configStore = ConfigStore{}; if (preserveConfiguration) configStore.value = savedConfiguration; device = Device{}; + if (preserveConfiguration) device.value.gyroBias = savedGyroBias; controller.start(); if (skipStartup) { + virtualClock.now = 20; + step(); virtualClock.now = StartupIntro::kDurationMs; step(); } @@ -600,11 +661,22 @@ void gs_set_navigation_json(const char* json) { if (numberField(json, "calibrationPercentage", integer)) navigation.value.calibrationPercentage = static_cast(integer); (void)decimalField(json, "distanceM", navigation.value.distanceM); (void)decimalField(json, "northRad", navigation.value.northRad); + (void)decimalField(json, "pitchRad", navigation.value.pitchRad); + (void)decimalField(json, "rollRad", navigation.value.rollRad); (void)decimalField(json, "homeLatitude", navigation.value.homeLatitude); (void)decimalField(json, "homeLongitude", navigation.value.homeLongitude); (void)decimalField(json, "rocketLatitude", navigation.value.rocketLatitude); (void)decimalField(json, "rocketLongitude", navigation.value.rocketLongitude); (void)decimalField(json, "azimuthRad", navigation.value.azimuthRad); + (void)decimalField(json, "ax", navigation.value.ax); + (void)decimalField(json, "ay", navigation.value.ay); + (void)decimalField(json, "az", navigation.value.az); + (void)decimalField(json, "gx", navigation.value.gx); + (void)decimalField(json, "gy", navigation.value.gy); + (void)decimalField(json, "gz", navigation.value.gz); + (void)decimalField(json, "mx", navigation.value.mx); + (void)decimalField(json, "my", navigation.value.my); + (void)decimalField(json, "mz", navigation.value.mz); step(); rebuildSnapshot(); } @@ -621,6 +693,14 @@ void gs_set_device_status_json(const char* json) { if (!device.value.usb && device.value.usbStorageState == "host") device.value.usbStorageState = "firmware"; (void)stringField(json, "usbStorageState", device.value.usbStorageState); if (booleanField(json, "gnss", boolean)) device.value.gnss = boolean; + if (booleanField(json, "selfTestGnss", boolean)) device.value.selfTestGnss = boolean; + if (booleanField(json, "selfTestSensorFailure", boolean)) device.value.selfTestSensorFailure = boolean; + if (booleanField(json, "selfTestStorageFailure", boolean)) device.value.selfTestStorageFailure = boolean; + if (numberField(json, "selfTestMissingReceiver", integer)) device.value.selfTestMissingReceiver = static_cast(integer); + if (numberField(json, "selfTestLq", integer)) device.value.selfTestLq = static_cast(integer); + if (numberField(json, "selfTestSnr", integer)) device.value.selfTestSnr = static_cast(integer); + if (numberField(json, "usbReadCount", integer)) device.value.usbReadCount = static_cast(integer); + if (booleanField(json, "selfTestGyroSaveFailure", boolean)) device.value.selfTestGyroSaveFailure = boolean; if (booleanField(json, "recorderWriteFailure", boolean)) logs.failWrite = boolean; if (booleanField(json, "deleteFailure", boolean)) logs.failDelete = boolean; if (booleanField(json, "finalizeFailure", boolean)) logs.failFinalize = boolean; @@ -635,6 +715,9 @@ void gs_set_configuration_json(const char* json) { if (booleanField(json, "startupAnimation", boolean)) configStore.value.startupAnimation = boolean; if (booleanField(json, "dualReceiver", boolean)) configStore.value.dualReceiver = boolean; if (booleanField(json, "imperialUnits", boolean)) configStore.value.imperialUnits = boolean; + (void)stringField(json, "linkPhrase1", configStore.value.linkPhrase1); + (void)stringField(json, "linkPhrase2", configStore.value.linkPhrase2); + (void)stringField(json, "testingPhrase", configStore.value.testingPhrase); rebuildSnapshot(); } void gs_set_logs_json(const char* json) { diff --git a/ground_station/simulator/web/app.js b/ground_station/simulator/web/app.js index 92be2c2c..a8037d98 100644 --- a/ground_station/simulator/web/app.js +++ b/ground_station/simulator/web/app.js @@ -1,3 +1,5 @@ +import { applyFixtureToWasm, fixtureById, injectReplayRow, loadFixtureManifest, parseReplayCsv } from './fixture-runtime.js'; + const canvas = document.querySelector('#display'); const ctx = canvas.getContext('2d', { alpha: false }); ctx.imageSmoothingEnabled = false; @@ -5,133 +7,188 @@ const snapshotNode = document.querySelector('#snapshot'); const statusNode = document.querySelector('#status'); const fixtureSelect = document.querySelector('#fixture'); const fixtureDescriptionNode = document.querySelector('#fixture-description'); +const coverageBody = document.querySelector('#coverage-body'); +const lightDemoButton = document.querySelector('#light-reset'); +const lightRebootButton = document.querySelector('#light-reboot'); +const phoneSensorPanel = document.querySelector('#phone-sensor-panel'); +const phoneSensorButton = document.querySelector('#phone-sensor-toggle'); +const phoneSensorStatus = document.querySelector('#phone-sensor-status'); +const lightMode = new URLSearchParams(window.location.search).get('mode') === 'light'; +document.documentElement.classList.toggle('light-mode', lightMode); +document.querySelector('h1').textContent = lightMode ? 'Try the CATS Ground Station' : 'CATS Ground Station Simulator'; let paused = false; let held = new Set(); let state = { activeScreen: 'menu', virtualTimeMs: 0, framebufferRevision: 0 }; let loadError = null; let demoLogError = null; let fixtureStatus = null; +let fixtureManifest = null; +let demoPlayback = null; let lastRealtimeMs = performance.now(); let realtimeRemainderMs = 0; -const simulatorBuildTag = 'cat-island-fixtures-20260816-1'; +const simulatorBuildTag = 'radio-update-integration-20260903-1'; +const phoneSensorRefreshIntervalMs = 200; +const standardGravity = 9.80665; +const phoneSensorValues = { ax: 0, ay: 0, az: 1, gx: 0, gy: 0, gz: 0, pitchRad: 0, rollRad: 0 }; +const phoneSensorLabels = { location: 'Location waiting', compass: 'Compass waiting', motion: 'Motion waiting' }; +let phoneSensorsEnabled = false; +let geolocationWatchId = null; +let phoneSensorUpdateQueued = false; +let lastPhoneStatusUpdateMs = 0; -const demoLogNames = ['log_001.csv', 'log_002.csv']; -const csvHeader = 'link,ts[deciseconds],state,errors,lat[deg/10000],lon[deg/10000],altitude[m],velocity[m/s],battery[decivolts],pyro1,pyro2\n'; -const fixtureDescriptions = { - demo: 'Loads one complete mission log and one damaged or partial log. Use it to check valid statistics, unavailable values shown as --, and safe malformed-row handling.', - dual: 'Runs a completed two-rocket mission. Both links record liftoff through touchdown and both recovery targets have valid locations.', - many: 'Loads 24 completed logs. Use it to check list scrolling, viewport edge arrows, and selection at the first and last log.', - 'missing-fix': 'Runs Dual Recovery with a valid location for Link 1 and no location for Link 2. Use it to check target selection and No location behavior.', - 'zero-coordinate': 'Loads valid locations on the prime meridian and equator. Both must remain available as map QR codes.', - never: 'Creates an active dual-link log with Stop Logging set to Never. Recording remains active after touchdown until Finalize Log is confirmed.', - 'delete-failure': 'Loads a finalized log and injects a storage deletion error. The log must remain in the catalog and an error message should appear.', - 'usb-delete': 'Loads a finalized log while USB mass storage is connected. Delete Log must be blocked until USB is disconnected.', - 'usb-storage': 'Connects USB while idle, automatically shares the logs, and opens Settings > General > USB Drive. Press B to return without disconnecting, or press A to return storage to the Ground Station.', - 'finalize-failure': 'Creates an active Never-mode log and injects a finalization error. The log must remain active after the failed attempt.', - 'recorder-fault': 'Injects a storage write failure as recording starts. Use it to check the recorder fault state and crossed logging indicator.' -}; +function isMobileDevice() { + if (typeof navigator.userAgentData?.mobile === 'boolean') return navigator.userAgentData.mobile; + return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); +} -function updateFixtureDescription() { - fixtureDescriptionNode.textContent = fixtureDescriptions[fixtureSelect.value] || ''; +function updatePhoneSensorStatus(force = false) { + const now = performance.now(); + if (!force && now - lastPhoneStatusUpdateMs < 250) return; + lastPhoneStatusUpdateMs = now; + phoneSensorStatus.textContent = Object.values(phoneSensorLabels).join(' · '); +} + +function queuePhoneSensorUpdate() { + if (phoneSensorUpdateQueued) return; + phoneSensorUpdateQueued = true; + setTimeout(() => { + phoneSensorUpdateQueued = false; + if (phoneSensorsEnabled && wasm) sendJson('gs_set_sensor_json', phoneSensorValues); + updatePhoneSensorStatus(); + }, phoneSensorRefreshIntervalMs); } -function completeLog(name, offset = 0) { - const rows = []; - for (const link of [1, 2]) { - const lat = 337363 + offset + link * 5; - const lon = 1324829 + offset + link * 5; - rows.push(`${link},10,3,0,${lat},${lon},100,30,42,1,1`); - rows.push(`${link},20,5,0,${lat},${lon},1000,0,41,1,1`); - rows.push(`${link},30,6,0,${lat},${lon},400,-25,40,1,1`); - rows.push(`${link},50,7,0,${lat},${lon},5,0,39,1,1`); +function handleDeviceMotion(event) { + const acceleration = event.accelerationIncludingGravity || event.acceleration; + if (acceleration) { + if (Number.isFinite(acceleration.x)) phoneSensorValues.ax = acceleration.x / standardGravity; + if (Number.isFinite(acceleration.y)) phoneSensorValues.ay = acceleration.y / standardGravity; + if (Number.isFinite(acceleration.z)) phoneSensorValues.az = acceleration.z / standardGravity; + } + const rotation = event.rotationRate; + if (rotation) { + if (Number.isFinite(rotation.beta)) phoneSensorValues.gx = rotation.beta; + if (Number.isFinite(rotation.gamma)) phoneSensorValues.gy = rotation.gamma; + if (Number.isFinite(rotation.alpha)) phoneSensorValues.gz = rotation.alpha; } - return { name, csv: csvHeader + rows.join('\n') + '\n' }; + phoneSensorLabels.motion = `Motion ${phoneSensorValues.ax.toFixed(2)}, ${phoneSensorValues.ay.toFixed(2)}, ${phoneSensorValues.az.toFixed(2)} g`; + queuePhoneSensorUpdate(); } -function incompleteLog() { - return { - name: 'log_099.csv', - csv: csvHeader + - '1,10,3,0,337363,1324829,100,30,42,1,1\n' + - 'malformed,row\n' + - '1,20,5,0,337369,1324832,900,0,41,1,1\n' + - '2,12,3,0,0,0,80,20,42,1,1\n' - }; +function compassHeading(event) { + if (Number.isFinite(event.webkitCompassHeading)) return event.webkitCompassHeading; + if (event.absolute === true && Number.isFinite(event.alpha)) return (360 - event.alpha) % 360; + return null; } -function sendJson(name, value) { - wasm.ccall(name, null, ['string'], [JSON.stringify(value)]); +function handleDeviceOrientation(event) { + const heading = compassHeading(event); + if (heading === null) return; + const northRadians = -heading * Math.PI / 180; + phoneSensorValues.northRad = Math.atan2(Math.sin(northRadians), Math.cos(northRadians)); + if (Number.isFinite(event.beta)) phoneSensorValues.pitchRad = event.beta * Math.PI / 180; + if (Number.isFinite(event.gamma)) phoneSensorValues.rollRad = event.gamma * Math.PI / 180; + phoneSensorLabels.compass = `Compass ${Math.round(heading)}°`; + queuePhoneSensorUpdate(); } -function sendLink(link, value) { - wasm.ccall('gs_set_link_json', null, ['string', 'string'], [String(link), JSON.stringify(value)]); +function handleLocation(position) { + phoneSensorValues.homeLatitude = position.coords.latitude; + phoneSensorValues.homeLongitude = position.coords.longitude; + phoneSensorLabels.location = `Location ±${Math.round(position.coords.accuracy)} m`; + if (wasm) sendJson('gs_set_device_status_json', { gnss: true }); + queuePhoneSensorUpdate(); } -function tapSimulatorButton(button) { - wasm.ccall('gs_press', null, ['string'], [button]); - wasm.ccall('gs_release', null, ['string'], [button]); +function handleLocationError(error) { + phoneSensorLabels.location = error.code === 1 ? 'Location denied' : 'Location unavailable'; + if (wasm) sendJson('gs_set_device_status_json', { gnss: false }); + updatePhoneSensorStatus(true); } -async function applyFixture(name) { - if (!wasm) return; - wasm.ccall('gs_reset', null, [], []); - if (name === 'demo') { - sendJson('gs_set_logs_json', [completeLog('log_100.csv'), incompleteLog()]); - } else if (name === 'many') { - sendJson('gs_set_logs_json', Array.from({ length: 24 }, (_, index) => completeLog(`log_${String(124 - index).padStart(3, '0')}.csv`, index))); - } else if (name === 'dual' || name === 'missing-fix') { - sendJson('gs_set_logs_json', []); - sendJson('gs_set_configuration_json', { dualReceiver: true, neverStopLogging: false }); - sendJson('gs_set_navigation_json', { homeLatitude: 33.7340, homeLongitude: 132.4730, northRad: 0.35 }); - sendLink(1, { state: 3, timestampDs: 10, latitude: 33.7363, longitude: 132.4829, - altitudeM: 120, velocityMps: 42, voltage: 4.1, pyroContinuity: 3, connected: true }); - sendLink(2, { state: name === 'missing-fix' ? 2 : 3, - timestampDs: 10, latitude: name === 'missing-fix' ? 0 : 33.7358, - longitude: name === 'missing-fix' ? 0 : 132.4762, altitudeM: 110, velocityMps: 39, - voltage: 4.1, pyroContinuity: 3, connected: true }); - for (const [state, timestampDs, altitudeM, velocityMps] of [[5, 20, 1000, 0], [6, 30, 400, -25], [7, 50, 5, 0]]) { - sendLink(1, { state, timestampDs, latitude: 33.7363, longitude: 132.4829, altitudeM, velocityMps, - voltage: 4.0, pyroContinuity: 3, connected: true }); - sendLink(2, { state: name === 'missing-fix' ? 2 : state, timestampDs, - latitude: name === 'missing-fix' ? 0 : 33.7358, longitude: name === 'missing-fix' ? 0 : 132.4762, - altitudeM, velocityMps, voltage: 4.0, pyroContinuity: 3, connected: true }); +async function requestPhoneMotionPermissions() { + const requests = []; + if (typeof globalThis.DeviceMotionEvent?.requestPermission === 'function') { + requests.push(globalThis.DeviceMotionEvent.requestPermission()); + } + if (typeof globalThis.DeviceOrientationEvent?.requestPermission === 'function') { + requests.push(globalThis.DeviceOrientationEvent.requestPermission()); + } + if (!requests.length) return true; + const results = await Promise.all(requests); + return results.every(result => result === 'granted'); +} + +function stopPhoneSensors() { + phoneSensorsEnabled = false; + window.removeEventListener('devicemotion', handleDeviceMotion); + window.removeEventListener('deviceorientationabsolute', handleDeviceOrientation); + window.removeEventListener('deviceorientation', handleDeviceOrientation); + if (geolocationWatchId !== null) navigator.geolocation.clearWatch(geolocationWatchId); + geolocationWatchId = null; + phoneSensorLabels.location = 'Location stopped'; + phoneSensorLabels.compass = 'Compass stopped'; + phoneSensorLabels.motion = 'Motion stopped'; + phoneSensorButton.textContent = 'Use phone sensors'; + updatePhoneSensorStatus(true); +} + +async function startPhoneSensors() { + phoneSensorButton.disabled = true; + phoneSensorStatus.textContent = 'Requesting sensor access…'; + try { + const motionAllowed = await requestPhoneMotionPermissions(); + phoneSensorsEnabled = true; + if (motionAllowed) { + window.addEventListener('devicemotion', handleDeviceMotion); + window.addEventListener('deviceorientationabsolute', handleDeviceOrientation); + window.addEventListener('deviceorientation', handleDeviceOrientation); + } else { + phoneSensorLabels.compass = 'Compass denied'; + phoneSensorLabels.motion = 'Motion denied'; } - } else if (name === 'zero-coordinate') { - sendJson('gs_set_navigation_json', { - homeLatitude: 0.0, homeLongitude: -30.0, rocketLatitude: 1.0, rocketLongitude: -30.0 - }); - sendJson('gs_set_logs_json', [{ - name: 'zero-coordinate.csv', - csv: csvHeader + - '1,10,3,0,5000,0,100,20,42,0,0\n' + - '2,11,3,0,0,-300000,100,20,42,0,0\n' - }]); - } else if (name === 'never' || name === 'finalize-failure') { - sendJson('gs_set_logs_json', []); - sendJson('gs_set_configuration_json', { dualReceiver: true, neverStopLogging: true }); - wasm.ccall('gs_set_link_json', null, ['string', 'string'], ['1', JSON.stringify({ state: 7, timestampDs: 50, - latitude: 33.7363, longitude: 132.4829, connected: true })]); - wasm.ccall('gs_set_link_json', null, ['string', 'string'], ['2', JSON.stringify({ state: 7, timestampDs: 50, - latitude: 33.7358, longitude: 132.4762, connected: true })]); - if (name === 'finalize-failure') sendJson('gs_set_device_status_json', { finalizeFailure: true }); - } else if (name === 'delete-failure') { - sendJson('gs_set_logs_json', [completeLog('log_105.csv')]); - sendJson('gs_set_device_status_json', { deleteFailure: true }); - } else if (name === 'usb-delete') { - sendJson('gs_set_logs_json', [completeLog('log_106.csv')]); - sendJson('gs_set_device_status_json', { usb: true, usbStorageState: 'host' }); - } else if (name === 'usb-storage') { - sendJson('gs_set_device_status_json', { usb: true, usbStorageState: 'firmware' }); - for (const button of ['right', 'right', 'down', 'ok', 'down', 'down', 'down', 'ok']) { - tapSimulatorButton(button); + if ('geolocation' in navigator) { + geolocationWatchId = navigator.geolocation.watchPosition(handleLocation, handleLocationError, { + enableHighAccuracy: true, + maximumAge: 1000, + timeout: 10000 + }); + } else { + phoneSensorLabels.location = 'Location unavailable'; } - } else if (name === 'recorder-fault') { - sendJson('gs_set_logs_json', []); - sendJson('gs_set_device_status_json', { recorderWriteFailure: true }); - wasm.ccall('gs_set_link_json', null, ['string', 'string'], ['1', JSON.stringify({ state: 3, timestampDs: 10, - latitude: 33.7363, longitude: 132.4829, connected: true })]); + phoneSensorButton.textContent = 'Stop phone sensors'; + updatePhoneSensorStatus(true); + } catch { + phoneSensorsEnabled = false; + phoneSensorStatus.textContent = 'Sensor access was not granted. You can try again.'; + phoneSensorButton.textContent = 'Try again'; + } finally { + phoneSensorButton.disabled = false; } - fixtureStatus = `${document.querySelector('#fixture').selectedOptions[0].textContent} fixture`; +} + +if (lightMode && isMobileDevice()) phoneSensorPanel.hidden = false; +phoneSensorButton.addEventListener('click', () => { + if (phoneSensorsEnabled) stopPhoneSensors(); + else startPhoneSensors(); +}); + +function updateFixtureDescription() { + if (!fixtureManifest) return; + fixtureDescriptionNode.textContent = fixtureById(fixtureManifest, fixtureSelect.value).description; +} + +function sendJson(name, value) { + wasm.ccall(name, null, ['string'], [JSON.stringify(value)]); +} + +async function applyFixture(name) { + if (!wasm || !fixtureManifest) return; + demoPlayback = null; + const fixture = fixtureById(fixtureManifest, name); + await applyFixtureToWasm(wasm, fixture); + fixtureStatus = `${fixture.label} fixture`; render(); } @@ -140,17 +197,79 @@ async function applyFixture(name) { // this file without changing the controls below. let wasm = null; -async function loadDemoLogs() { - if (!wasm) return; - const logs = await Promise.all(demoLogNames.map(async name => { - const response = await fetch(`./demo-logs/${name}`); - if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`); - return { name, csv: await response.text() }; - })); - wasm.ccall('gs_set_logs_json', null, ['string'], [JSON.stringify(logs)]); +async function loadDefaultDemo(play = false) { + const fixture = fixtureById(fixtureManifest, lightMode ? 'light-demo' : 'demo'); + const applied = await applyFixtureToWasm(wasm, fixture, { lightMode, reset: false }); + demoPlayback = null; + if (lightMode && play && applied.logs.length) { + wasm.ccall('gs_press', null, ['string'], ['ok']); + wasm.ccall('gs_release', null, ['string'], ['ok']); + demoPlayback = { + rows: parseReplayCsv(applied.logs[0].csv), + index: 0, + elapsedMs: 0, + speed: fixture.lightPlaybackSpeed || 1 + }; + advanceDemoPlayback(0); + } +} + +function advanceDemoPlayback(elapsedMs) { + if (!demoPlayback || demoPlayback.index >= demoPlayback.rows.length) return false; + demoPlayback.elapsedMs += elapsedMs * demoPlayback.speed; + let changed = false; + while (demoPlayback.index < demoPlayback.rows.length && + demoPlayback.rows[demoPlayback.index].elapsedMs <= demoPlayback.elapsedMs) { + injectReplayRow(wasm, demoPlayback.rows[demoPlayback.index]); + demoPlayback.index += 1; + changed = true; + } + return changed; +} + +async function loadFixtures() { + fixtureManifest = await loadFixtureManifest(); + fixtureSelect.replaceChildren(...fixtureManifest.fixtures.map(fixture => + new Option(fixture.label, fixture.id))); + updateFixtureDescription(); +} + +async function loadCoverage() { + const response = await fetch('./coverage.json'); + if (!response.ok) throw new Error(`coverage: HTTP ${response.status}`); + const coverage = await response.json(); + if (coverage.schemaVersion !== 1 || !Array.isArray(coverage.groups)) { + throw new Error('Unsupported simulator coverage matrix.'); + } + for (const group of coverage.groups) { + const heading = document.createElement('tr'); + heading.className = 'coverage-group'; + const cell = document.createElement('th'); + cell.colSpan = 5; + cell.scope = 'colgroup'; + cell.textContent = group.name; + heading.append(cell); + coverageBody.append(heading); + for (const row of group.rows) { + const tableRow = document.createElement('tr'); + if (row.gap) tableRow.className = 'coverage-gap'; + for (const value of [row.path, row.scenario, row.snapshot, row.framebuffer, row.browser]) { + const tableCell = document.createElement('td'); + tableCell.textContent = value; + tableRow.append(tableCell); + } + coverageBody.append(tableRow); + } + } } async function loadController() { + try { + await loadFixtures(); + } catch (error) { + demoLogError = error instanceof Error ? error.message : String(error); + console.error('Ground Station simulator fixtures failed to load:', error); + } try { const moduleFactory = (await import(`./gs-sim.js?v=${simulatorBuildTag}`)).default; wasm = await moduleFactory({ @@ -161,11 +280,11 @@ async function loadController() { loadError = error instanceof Error ? error.message : String(error); console.error('Ground Station simulator WebAssembly failed to load:', error); } - if (wasm) { + if (wasm && fixtureManifest) { loadError = null; wasm.ccall('gs_restart', null, [], []); try { - await loadDemoLogs(); + await loadDefaultDemo(); demoLogError = null; } catch (error) { demoLogError = error instanceof Error ? error.message : String(error); @@ -188,7 +307,9 @@ function refreshSnapshot() { ? `WebAssembly simulator ready: ${fixtureStatus}.` : demoLogError ? `WebAssembly simulator ready; demo logs unavailable: ${demoLogError}` - : 'WebAssembly simulator ready with 2 demo logs.'; + : lightMode + ? 'Demo ready.' + : 'WebAssembly simulator ready with the demo fixture.'; } else { state = { activeScreen: 'unavailable', @@ -198,7 +319,8 @@ function refreshSnapshot() { }; statusNode.textContent = `Simulator unavailable: ${state.error}`; } - snapshotNode.textContent = JSON.stringify(state, null, 2); + statusNode.hidden = lightMode && wasm !== null && demoLogError === null; + if (!lightMode) snapshotNode.textContent = JSON.stringify(state, null, 2); } function render() { @@ -261,19 +383,33 @@ document.querySelector('#pause').addEventListener('click', event => { button.title = paused ? 'Resume virtual time' : 'Pause virtual time'; canvas.focus({ preventScroll: true }); }); -document.querySelector('#reset').addEventListener('click', async () => { +async function resetDemo(play = false) { if (wasm) controllerCall('gs_restart'); fixtureStatus = null; try { - await loadDemoLogs(); + await loadDefaultDemo(play); demoLogError = null; + if (lightMode && play) lightDemoButton.textContent = 'Restart demo'; } catch (error) { demoLogError = error instanceof Error ? error.message : String(error); console.error('Ground Station simulator demo logs failed to load:', error); } render(); canvas.focus({ preventScroll: true }); -}); +} + +function rebootDemo() { + demoPlayback = null; + fixtureStatus = null; + if (wasm) controllerCall('gs_restart'); + lightDemoButton.textContent = 'Play demo'; + render(); + canvas.focus({ preventScroll: true }); +} + +document.querySelector('#reset').addEventListener('click', () => resetDemo()); +lightDemoButton.addEventListener('click', () => resetDemo(true)); +lightRebootButton.addEventListener('click', rebootDemo); document.querySelector('#advance').addEventListener('click', () => { if (wasm) controllerCall('gs_advance', [Number(document.querySelector('#step').value) || 0]); render(); @@ -292,16 +428,20 @@ document.querySelector('#eject-usb').addEventListener('click', () => { fixtureSelect.addEventListener('change', updateFixtureDescription); function realtimeLoop(nowMs) { - const elapsed = Math.min(250, Math.max(0, nowMs - lastRealtimeMs)); + const elapsed = Math.max(0, nowMs - lastRealtimeMs); + const controllerElapsed = Math.min(250, elapsed); lastRealtimeMs = nowMs; if (!paused && wasm) { - realtimeRemainderMs += elapsed; + let changed = false; + realtimeRemainderMs += controllerElapsed; const ticks = Math.floor(realtimeRemainderMs / 20); if (ticks > 0) { realtimeRemainderMs -= ticks * 20; controllerCall('gs_advance', [ticks * 20]); - render(); + changed = true; } + if (lightMode && advanceDemoPlayback(elapsed)) changed = true; + if (changed) render(); } else { realtimeRemainderMs = 0; } @@ -309,5 +449,6 @@ function realtimeLoop(nowMs) { } updateFixtureDescription(); +if (!lightMode) loadCoverage().catch(error => console.error('Coverage matrix failed to load:', error)); loadController(); requestAnimationFrame(realtimeLoop); diff --git a/ground_station/simulator/web/browser-golden.json b/ground_station/simulator/web/browser-golden.json new file mode 100644 index 00000000..cef00389 --- /dev/null +++ b/ground_station/simulator/web/browser-golden.json @@ -0,0 +1,20 @@ +{ + "menu": "2102f0f91cd0c03176ee70f679063cdc9fb95593e64c401b88149c4316c5c117", + "startupRocket": "c8db9d72adcd669e7f60cb33e213e1f05c9f4eecead98bf8d2510d254163ae64", + "lightDemo": "4f619bbbf53bda15a3e7d7a7f3e152808b01f6f65aa24f8624f4a12f8e5228af", + "recovery": "eda5a41b5f22562e8628f879dbe04f9e3d7e929d54157840398fe49b2dca0a56", + "compassOrientation": "6d5a84bc0813d49a491c11b1dbd3924046aa5ac43bcf8767dc4738e15eb1954a", + "phoneHeading0": "41742a8683e13fcebfa75265c01050a76aacaa7dcd4bc0e148ac991c22128d2e", + "phoneHeading45": "f4bb2bcc468dc9af42b6d6e278b1d3a663e59216908030dc96d06d9eff68cb53", + "phoneHeading90": "2bff3a8f661784e963e42f45e65057acbcac36f5a4eb8514edb6ab8dacebe697", + "phoneHeading135": "2eedbd095f7aa8153030e42f8e35ad66a12339dd646079d2d2e25d95a34a20f2", + "phoneHeading180": "1cc123e8ef83d9586b0d754c06934d0956552f880c46660bb5ca86252208716d", + "phoneHeading225": "7ae8ea2756a5eebea4d6b0b4944ded1748377844a198ceb3bf41fa9d7ea427f9", + "phoneHeading270": "f8496afd46f01c1ae8a7adb2b2f41d8041ef70af98acace3f33b90f63ae85f5e", + "phoneHeading315": "98f68820f68cf7049458c78f7a68dd5e46bbd6c4ace40efc98a67061eca69e4b", + "usbStorage": "cd4a76e556b38614ce3194c15fb40de92c012ab78f128d9624d4c3043047c089", + "recorderFault": "32219f426fded5eafe2a0a5199c593e94d9e858f5bb973190bcd71ce76e5ca2c", + "liveAheadRight": "1cfb144c6e9a8c51eac0a67ef329e83f1f2bc56b7c79e9a117f3793d3962bf65", + "liveLeftAhead": "0b4cce4e33c9e93fe44c5e36e8ac0a178e1f9b16ab7704775116aba5480ace08", + "liveBehindLeft": "7c1cba8d8e141bcacb76a3bc538ed3f973d7464cc29ecff42ca3c23ddc780198" +} diff --git a/ground_station/simulator/web/browser-tests.html b/ground_station/simulator/web/browser-tests.html new file mode 100644 index 00000000..c65e0f59 --- /dev/null +++ b/ground_station/simulator/web/browser-tests.html @@ -0,0 +1,12 @@ + + + + + Ground Station browser tests + + +

Ground Station browser/WASM regression tests

+
Running...
+ + + diff --git a/ground_station/simulator/web/browser-tests.js b/ground_station/simulator/web/browser-tests.js new file mode 100644 index 00000000..b5c6a6a3 --- /dev/null +++ b/ground_station/simulator/web/browser-tests.js @@ -0,0 +1,688 @@ +import { + applyFixtureToWasm, + DECISECOND_MS, + fixtureById, + injectReplayRow, + loadFixtureManifest, + parseReplayCsv +} from './fixture-runtime.js'; + +const resultNode = document.querySelector('#result'); +const results = []; +const actualHashes = {}; + +function snapshot(wasm) { + return JSON.parse(wasm.ccall('gs_snapshot_json', 'string', [], [])); +} + +function framebuffer(wasm) { + const size = wasm.ccall('gs_framebuffer_size', 'number', [], []); + const pointer = wasm.ccall('gs_framebuffer', 'number', [], []); + return wasm.HEAPU8.slice(pointer, pointer + size); +} + +async function framebufferHash(wasm) { + const digest = await crypto.subtle.digest('SHA-256', framebuffer(wasm)); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function assertClose(actual, expected, tolerance, label) { + assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, got ${actual}`); +} + +function tap(wasm, button) { + wasm.ccall('gs_press', null, ['string'], [button]); + wasm.ccall('gs_release', null, ['string'], [button]); +} + +async function test(name, body) { + try { + await body(); + results.push({ name, passed: true }); + } catch (error) { + results.push({ name, passed: false, error: error instanceof Error ? error.message : String(error) }); + } +} + +async function run() { + const [moduleImport, manifest, goldens] = await Promise.all([ + import('./gs-sim.js?v=browser-tests-1'), + loadFixtureManifest(), + fetch('./browser-golden.json', {cache: 'no-store'}).then(response => response.json()) + ]); + const wasm = await moduleImport.default({ locateFile: path => `${path}?v=browser-tests-1` }); + + await test('module bootstrap', async () => { + wasm.ccall('gs_reset', null, [], []); + const state = snapshot(wasm); + assert(state.activeScreen === 'menu', `expected menu, got ${state.activeScreen}`); + assert(framebuffer(wasm).length === 12000, 'expected a 12,000-byte framebuffer'); + actualHashes.menu = await framebufferHash(wasm); + captureSelfTest('Main menu status bar'); + }); + + await test('startup animation', async () => { + wasm.ccall('gs_restart', null, [], []); + let state = snapshot(wasm); + assert(state.activeScreen === 'logo', `expected startup logo, got ${state.activeScreen}`); + assert(state.startupPhase === 'rocket_flight', `expected rocket_flight, got ${state.startupPhase}`); + wasm.ccall('gs_advance', null, ['number'], [600]); + actualHashes.startupRocket = await framebufferHash(wasm); + wasm.ccall('gs_advance', null, ['number'], [2900]); + state = snapshot(wasm); + assert(state.activeScreen === 'menu', `expected menu after startup, got ${state.activeScreen}`); + assert(state.startupPhase === 'complete', `expected completed startup, got ${state.startupPhase}`); + }); + + await test('light demo', async () => { + wasm.ccall('gs_restart', null, [], []); + const fixture = fixtureById(manifest, 'light-demo'); + const applied = await applyFixtureToWasm(wasm, fixture, { lightMode: true, reset: false }); + tap(wasm, 'ok'); + const replayRows = parseReplayCsv(applied.logs[0].csv); + assert(applied.logs.length === 1, `expected one demo replay, got ${applied.logs.length}`); + assert(replayRows.length === 3300, `expected 3300 replay rows, got ${replayRows.length}`); + assert(fixture.lightPlaybackSpeed === 1, `expected real-time playback, got ${fixture.lightPlaybackSpeed}x`); + assert(DECISECOND_MS === 100, `expected one decisecond to equal 100 ms, got ${DECISECOND_MS} ms`); + assert(replayRows[0].timestampDs === 5929, `expected first timestamp 5929 ds, got ${replayRows[0].timestampDs}`); + assert(replayRows.at(-1).timestampDs === 8629, `expected last timestamp 8629 ds, got ${replayRows.at(-1).timestampDs}`); + assert(replayRows.at(-1).elapsedMs === 270000, `expected 270000 ms duration, got ${replayRows.at(-1).elapsedMs}`); + assert(replayRows.find(row => row.timestampDs === 5930)?.elapsedMs === 100, + 'expected a one-decisecond timestamp step to replay after 100 ms'); + assertClose(replayRows[0].latitude, 39.3899, 0.00001, 'replay latitude'); + assertClose(replayRows[0].longitude, -8.2895, 0.00001, 'replay longitude'); + assertClose(replayRows[0].voltage, 8.1, 0.001, 'replay voltage'); + injectReplayRow(wasm, replayRows[0]); + const state = snapshot(wasm); + assert(state.activeScreen === 'live', `expected live, got ${state.activeScreen}`); + assert(state.logCount >= 1, `expected the demo log to remain available, got ${state.logCount}`); + assertClose(state.navigation.homeLatitude, 39.3899, 0.00001, 'home latitude'); + assertClose(state.navigation.homeLongitude, -8.2895, 0.00001, 'home longitude'); + actualHashes.lightDemo = await framebufferHash(wasm); + captureSelfTest('Live status bar'); + }); + + await test('physical controls and snapshot', async () => { + wasm.ccall('gs_reset', null, [], []); + tap(wasm, 'right'); + tap(wasm, 'ok'); + const state = snapshot(wasm); + assert(state.activeScreen === 'recovery', `expected recovery, got ${state.activeScreen}`); + assert(state.framebufferRevision > 0, 'framebuffer revision did not advance'); + actualHashes.recovery = await framebufferHash(wasm); + captureSelfTest('Recovery status bar'); + }); + + await test('phone sensor injection', async () => { + wasm.ccall('gs_set_sensor_json', null, ['string'], [JSON.stringify({ + homeLatitude: 47.1661, + homeLongitude: 8.5155, + northRad: -0.75, + pitchRad: 0.2, + rollRad: -0.1, + ax: 0.1, + ay: -0.2, + az: 0.95, + gx: 12.5, + gy: -3.25, + gz: 0.5 + })]); + const state = snapshot(wasm); + assertClose(state.navigation.homeLatitude, 47.1661, 0.00001, 'sensor latitude'); + assertClose(state.navigation.homeLongitude, 8.5155, 0.00001, 'sensor longitude'); + assertClose(state.navigation.northRad, -0.75, 0.00001, 'sensor heading'); + assertClose(state.navigation.pitchRad, 0.2, 0.00001, 'sensor pitch'); + assertClose(state.navigation.rollRad, -0.1, 0.00001, 'sensor roll'); + assertClose(state.navigation.ax, 0.1, 0.00001, 'sensor acceleration X'); + assertClose(state.navigation.ay, -0.2, 0.00001, 'sensor acceleration Y'); + assertClose(state.navigation.az, 0.95, 0.00001, 'sensor acceleration Z'); + assertClose(state.navigation.gx, 12.5, 0.00001, 'sensor rotation X'); + assertClose(state.navigation.gy, -3.25, 0.00001, 'sensor rotation Y'); + assertClose(state.navigation.gz, 0.5, 0.00001, 'sensor rotation Z'); + }); + + await test('compass orientation view', async () => { + wasm.ccall('gs_reset', null, [], []); + wasm.ccall('gs_set_navigation_json', null, ['string'], [JSON.stringify({ + northRad: -Math.PI / 2, + pitchRad: -Math.PI / 15, + rollRad: Math.PI / 22.5, + mx: 940, + my: 180, + mz: -290 + })]); + tap(wasm, 'down'); + tap(wasm, 'right'); + tap(wasm, 'ok'); + tap(wasm, 'right'); + const state = snapshot(wasm); + assert(state.activeScreen === 'sensors', `expected sensors, got ${state.activeScreen}`); + assert(state.sensorView === 'orientation', `expected orientation, got ${state.sensorView}`); + actualHashes.compassOrientation = await framebufferHash(wasm); + captureSelfTest('Compass status bar'); + }); + + await test('phone compass headings through the browser controls', async () => { + const frame = document.createElement('iframe'); + frame.src = './index.html'; + frame.style.cssText = 'width:1000px;height:700px'; + document.body.append(frame); + const waitFor = async condition => { + for (let attempt = 0; attempt < 200; ++attempt) { + if (condition()) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error('Phone compass page did not reach the expected state'); + }; + try { + await waitFor(() => frame.contentDocument?.querySelector('#status')?.textContent.includes('simulator ready')); + const page = frame.contentWindow; + const document = frame.contentDocument; + const state = () => JSON.parse(document.querySelector('#snapshot').textContent); + document.querySelector('#pause').click(); + document.querySelector('#step').value = '4000'; + document.querySelector('#advance').click(); + const press = button => { + const control = document.querySelector(`[data-button="${button}"]`); + control.dispatchEvent(new page.Event('pointerdown', {cancelable: true})); + control.dispatchEvent(new page.Event('pointerup', {cancelable: true})); + }; + for (const button of ['down', 'right', 'ok', 'right']) press(button); + assert(state().sensorView === 'orientation', 'phone page opens the production compass view'); + // Exercise the real event handler without accessing host sensors or location. + Object.defineProperty(page.navigator, 'geolocation', {value: {watchPosition: () => 1, clearWatch: () => {}}}); + for (const type of ['DeviceMotionEvent', 'DeviceOrientationEvent']) { + Object.defineProperty(page, type, {value: {requestPermission: async () => 'granted'}}); + } + document.querySelector('#phone-sensor-toggle').click(); + await waitFor(() => document.querySelector('#phone-sensor-toggle').textContent === 'Stop phone sensors'); + for (const heading of [0, 45, 90, 135, 180, 225, 270, 315]) { + const event = new page.Event('deviceorientation'); + // Cover Safari compass headings and absolute-orientation alpha values. + const properties = heading % 90 === 0 ? {webkitCompassHeading: heading} + : {absolute: true, alpha: (360 - heading) % 360}; + for (const [name, value] of Object.entries(properties)) Object.defineProperty(event, name, {value}); + page.dispatchEvent(event); + await new Promise(resolve => setTimeout(resolve, 220)); + document.querySelector('#step').value = '20'; + document.querySelector('#advance').click(); + const expectedNorth = Math.atan2(Math.sin(-heading * Math.PI / 180), Math.cos(-heading * Math.PI / 180)); + assertClose(state().navigation.northRad, expectedNorth, 0.00001, `phone heading ${heading}`); + const canvas = document.querySelector('#display'); + const pixels = canvas.getContext('2d').getImageData(215, 82, 185, 28); + const digest = await crypto.subtle.digest('SHA-256', pixels.data); + actualHashes[`phoneHeading${heading}`] = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); + const figure = globalThis.document.createElement('figure'); + const caption = globalThis.document.createElement('figcaption'); + caption.textContent = `Phone compass ${heading} degrees`; + const capture = globalThis.document.createElement('canvas'); + capture.width = 400; capture.height = 240; + capture.getContext('2d').drawImage(canvas, 0, 0); + figure.append(caption, capture); + globalThis.document.body.append(figure); + } + document.querySelector('#phone-sensor-toggle').click(); + } finally { + frame.remove(); + } + }); + + await test('all declarative fixtures', async () => { + for (const fixture of manifest.fixtures) { + await applyFixtureToWasm(wasm, fixture); + const state = snapshot(wasm); + const bytes = framebuffer(wasm); + assert(typeof state.activeScreen === 'string', `${fixture.id}: missing activeScreen`); + assert(state.framebufferRevision > 0, `${fixture.id}: framebuffer was not rendered`); + assert(bytes.length === 12000 && bytes.some(byte => byte !== 0), `${fixture.id}: invalid framebuffer`); + } + }); + + await test('USB storage fixture', async () => { + await applyFixtureToWasm(wasm, fixtureById(manifest, 'usb-storage')); + const state = snapshot(wasm); + assert(state.activeScreen === 'usb_storage', `expected usb_storage, got ${state.activeScreen}`); + assert(state.usbStorageState === 'host', `expected host storage, got ${state.usbStorageState}`); + actualHashes.usbStorage = await framebufferHash(wasm); + }); + + await test('recorder fault fixture', async () => { + await applyFixtureToWasm(wasm, fixtureById(manifest, 'recorder-fault')); + const state = snapshot(wasm); + assert(state.recorderState === 'fault', `expected recorder fault, got ${state.recorderState}`); + actualHashes.recorderFault = await framebufferHash(wasm); + captureSelfTest('Recorder fault status bar'); + }); + + await test('framebuffer goldens', async () => { + for (const [name, actual] of Object.entries(actualHashes)) { + assert(goldens[name], `missing browser golden for ${name}`); + assert(actual === goldens[name], `${name}: expected ${goldens[name]}, got ${actual}`); + } + }); + + function openSelfTest(faults = {}) { + wasm.ccall('gs_reset', null, [], []); + wasm.ccall('gs_set_configuration_json', null, ['string'], [JSON.stringify({linkPhrase1: 'normal-1', linkPhrase2: 'normal-2'})]); + wasm.ccall('gs_set_device_status_json', null, ['string'], [JSON.stringify({usb: false, batteryVoltage: 3.8, ...faults})]); + for (const button of ['down', 'right', 'right', 'ok', 'right', 'right', 'down', 'down', 'down', 'ok']) tap(wasm, button); + assert(snapshot(wasm).activeScreen === 'self_test', 'Settings > System > Self-Test opens'); + } + function captureSelfTest(label) { + const figure = document.createElement('figure'); + const caption = document.createElement('figcaption'); + caption.textContent = label; + const canvas = document.createElement('canvas'); + canvas.width = 400; canvas.height = 240; + canvas.style.cssText = 'width:600px;max-width:100%;image-rendering:pixelated;border:1px solid #888'; + const context = canvas.getContext('2d'); + const pixels = context.createImageData(400, 240); + const bytes = framebuffer(wasm); + for (let i = 0; i < 400 * 240; ++i) { + const value = (bytes[i >> 3] & (0x80 >> (i & 7))) ? 255 : 0; + pixels.data.set([value, value, value, 255], i * 4); + } + context.putImageData(pixels, 0, 0); + figure.append(caption, canvas); + document.body.append(figure); + } + const advanceSelfTest = ms => wasm.ccall('gs_advance', null, ['number'], [ms]); + + function leaveSelfTestAndCheckHeader() { + // Inspect the first exit frame, before a release or subsequent redraw can + // clear away the stale pixels we are trying to detect. + wasm.ccall('gs_press', null, ['string'], ['back']); + const exitHeader = framebuffer(wasm).slice(0, 400 * 19 / 8); + wasm.ccall('gs_release', null, ['string'], ['back']); + const cleanHeader = framebuffer(wasm).slice(0, exitHeader.length); + assert(exitHeader.every((value, index) => value === cleanHeader[index]), 'first exit frame restores the entire status bar'); + } + + await test('factory self-test: leaving the entry restores the status bar', async () => { + openSelfTest(); + leaveSelfTestAndCheckHeader(); + assert(snapshot(wasm).activeScreen === 'settings', 'back returns to Settings'); + captureSelfTest('Settings after leaving self-test entry'); + }); + + function confirmSelfTestStep(phase, label, activePhase = phase) { + let state = snapshot(wasm); + assert(state.selfTestPhase === phase && state.selfTestWaiting, `${label} waits for Start`); + const resultsBefore = JSON.stringify(state.selfTestChecks); + const actionsBefore = state.actions.length; + advanceSelfTest(120000); + tap(wasm, 'up'); + state = snapshot(wasm); + assert(state.selfTestPhase === phase && state.selfTestWaiting, `${label} does not start from waiting or another button`); + assert(JSON.stringify(state.selfTestChecks) === resultsBefore, `${label} does not score before Start`); + assert(state.actions.length === actionsBefore, `${label} has no radio actions before Start`); + captureSelfTest(label); + tap(wasm, 'ok'); + state = snapshot(wasm); + assert(state.selfTestPhase === activePhase && !state.selfTestWaiting, `${label} starts only on A`); + } + + await test('factory self-test: saved gyro calibration and failure reporting', async () => { + openSelfTest(); + wasm.ccall('gs_set_sensor_json', null, ['string'], ['{"gx":1.2,"gy":-0.6,"gz":0.3}']); + tap(wasm, 'ok'); + advanceSelfTest(6500); + let state = snapshot(wasm); + assert(state.selfTestChecks[0] === 'PASS' && state.gyroSaveCount === 1, 'step 0 saves the stationary offsets once'); + const expected = [1.2, -0.6, 0.3]; + state.gyroBias.forEach((value, axis) => assert(Math.abs(value - expected[axis]) < 0.001, `gyro bias axis ${axis}`)); + wasm.ccall('gs_restart', null, [], []); + state = snapshot(wasm); + state.gyroBias.forEach((value, axis) => assert(Math.abs(value - expected[axis]) < 0.001, `saved bias survives restart, axis ${axis}`)); + openSelfTest({selfTestGyroSaveFailure: true}); + tap(wasm, 'ok'); advanceSelfTest(12000); + state = snapshot(wasm); + assert(state.selfTestChecks[0] === 'FAIL' && state.gyroSaveCount === 0, 'save failure cannot report calibrated'); + }); + + await test('factory self-test: initial battery check rejects USB and low voltage', async () => { + for (const fault of [{usb: true}, {batteryVoltage: 2.9}]) { + openSelfTest(fault); + tap(wasm, 'ok'); advanceSelfTest(6000); + let state = snapshot(wasm); + assert(state.selfTestPhase === 2 && state.selfTestChecks[15] === 'WAIT', 'battery runs with the initial sensors'); + advanceSelfTest(6000); + state = snapshot(wasm); + assert(state.selfTestChecks[15] === 'FAIL', `battery rejects ${JSON.stringify(fault)}`); + assert(state.selfTestPhase === 3 && state.selfTestWaiting, 'battery failure still reaches the telemetry prompt'); + } + }); + + await test('factory self-test: strict SNR reception limit', async () => { + for (const snr of [-5, -4]) { + openSelfTest({selfTestSnr: snr}); + tap(wasm, 'ok'); advanceSelfTest(12000); + tap(wasm, 'ok'); advanceSelfTest(140000); + assert(snapshot(wasm).selfTestChecks[6] === (snr > -5 ? 'PASS' : 'FAIL'), `SNR ${snr} dB uses a strict greater-than limit`); + } + }); + + await test('factory self-test: calibration, automatic phases and manual confirmation gates', async () => { + openSelfTest(); + captureSelfTest('Self-test entry'); + tap(wasm, 'ok'); + advanceSelfTest(800); + captureSelfTest('Step 0 - gyro calibration'); + advanceSelfTest(5100); + captureSelfTest('Phase 1 - Ground Station'); + advanceSelfTest(6200); + let state = snapshot(wasm); + assert(state.selfTestChecks[15] === 'PASS', 'battery is checked automatically before telemetry'); + assert(!state.actions.some(action => action.type === 'self_test_radio'), 'Phase 1 does not change radio settings'); + confirmSelfTestStep(3, 'Phase 2 - ready for telemetry'); + advanceSelfTest(8000); + captureSelfTest('Phase 2 - telemetry running'); + advanceSelfTest(64000); + state = snapshot(wasm); + assert(state.selfTestChecks.slice(0, 12).every(value => value === 'PASS'), `automatic results: ${state.selfTestChecks}`); + assert(state.configuration.linkPhrase1 === 'normal-1' && state.configuration.linkPhrase2 === 'normal-2', 'test phrases never alter saved config'); + confirmSelfTestStep(4, '3.1 - ready for sensor movement'); + captureSelfTest('Sensor movement running'); + for (let axis = 0; axis < 3; ++axis) { + for (const sign of [-1, 1]) { + const sample = {ax: 0, ay: 0, az: 0, gx: 0, gy: 0, gz: 0, mx: sign*1000, my: sign*800, mz: sign*600}; + sample[['ax', 'ay', 'az'][axis]] = sign; + sample[['gx', 'gy', 'gz'][axis]] = 30; + wasm.ccall('gs_set_sensor_json', null, ['string'], [JSON.stringify(sample)]); + } + } + confirmSelfTestStep(5, '3.2 - ready for buttons'); + assert(snapshot(wasm).selfTestButtons === 0, 'Start press and release do not count as a tested button'); + wasm.ccall('gs_press', null, ['string'], ['up']); + assert(snapshot(wasm).selfTestButtons === 0, 'a press without release does not pass'); + wasm.ccall('gs_release', null, ['string'], ['up']); + captureSelfTest('Button press and release check'); + for (const button of ['down', 'left', 'right', 'ok', 'back']) tap(wasm, button); + assert(snapshot(wasm).selfTestChecks[13] === 'PASS', 'six physical buttons complete the check without center'); + confirmSelfTestStep(6, '3.3 - ready for display'); + advanceSelfTest(2100); + captureSelfTest('Display - black pattern'); + advanceSelfTest(4000); + captureSelfTest('Display - white pattern'); + advanceSelfTest(4000); + captureSelfTest('Display - checkerboard'); + advanceSelfTest(4000); + captureSelfTest('Display - inverse checkerboard'); + advanceSelfTest(4000); + captureSelfTest('Display confirmation'); + wasm.ccall('gs_press', null, ['string'], ['ok']); + advanceSelfTest(15000); + assert(snapshot(wasm).selfTestWaiting, 'holding display confirmation cannot start the USB check'); + wasm.ccall('gs_release', null, ['string'], ['ok']); + assert(snapshot(wasm).selfTestPhase === 8, 'display is followed directly by the USB check'); + confirmSelfTestStep(8, '3.4 - ready for USB drive'); + advanceSelfTest(1000); + assert(snapshot(wasm).selfTestPhase === 8, 'USB check waits for a connection'); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":true}']); + advanceSelfTest(20); + state = snapshot(wasm); + assert(state.selfTestPhase === 8 && state.usbStorageState === 'host', 'USB drive is shared before passing'); + captureSelfTest('USB - waiting for computer read'); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usbReadCount":1}']); + advanceSelfTest(20); + state = snapshot(wasm); + assert(state.selfTestResult === 'PASS' && state.selfTestChecks.length === 18, `complete test: ${state.selfTestChecks}`); + assert(state.usbStorageState === 'host', 'normal storage sharing resumes after the test'); + captureSelfTest('Completed result'); + for (let index = 0; index < 12; ++index) tap(wasm, 'down'); + captureSelfTest('Movement result detail'); + for (let index = 0; index < 6; ++index) tap(wasm, 'down'); + captureSelfTest('Restoration result detail'); + leaveSelfTestAndCheckHeader(); + captureSelfTest('Settings after completed self-test'); + state = snapshot(wasm); + assert(state.activeScreen === 'settings' && state.settingsPage === 2 && state.settingsSelection === 2, 'return to self-test Settings entry'); + }); + + await test('factory self-test: missing receiver cannot be hidden by the other', async () => { + openSelfTest({selfTestMissingReceiver: 1}); + tap(wasm, 'ok'); advanceSelfTest(12000); + tap(wasm, 'ok'); advanceSelfTest(140000); + const state = snapshot(wasm); + assert(state.selfTestChecks[1] === 'FAIL' && state.selfTestChecks[2] === 'PASS', 'independent firmware replies'); + assert(state.selfTestChecks[6] === 'FAIL' && state.selfTestChecks[8] === 'FAIL', 'both dual and single expose missing receiver'); + assert(state.selfTestChecks[10] === 'PASS', 'receiver 2 still tested independently'); + assert(state.selfTestPhase === 4 && state.selfTestWaiting, 'failures still reach the first manual confirmation'); + }); + + await test('factory self-test: GNSS and storage failures never become passes', async () => { + openSelfTest({selfTestGnss: false, selfTestStorageFailure: true}); + tap(wasm, 'ok'); advanceSelfTest(127000); + let state = snapshot(wasm); + assert(state.selfTestChecks[3] === 'FAIL', 'GNSS timeout fails acquisition'); + assert(state.selfTestChecks[11] === 'FAIL', 'storage failure is visible'); + assert(state.selfTestPhase === 3 && state.selfTestWaiting, 'telemetry waits for its own confirmation'); + tap(wasm, 'ok'); advanceSelfTest(72000); + state = snapshot(wasm); + assert(state.selfTestPhase === 4 && state.selfTestWaiting, 'manual checks wait after independent telemetry checks'); + assert(state.selfTestChecks[3] === 'FAIL' && state.selfTestChecks[11] === 'FAIL', 'later checks preserve Phase 1 failures'); + }); + + await test('factory self-test: cancellation cannot pass and restores configuration', async () => { + openSelfTest(); tap(wasm, 'ok'); advanceSelfTest(12000); + tap(wasm, 'ok'); advanceSelfTest(12000); + wasm.ccall('gs_hold', null, ['string', 'number'], ['back', 2100]); + advanceSelfTest(2600); + const state = snapshot(wasm); + assert(state.selfTestPhase === 9 && state.selfTestResult === 'FAIL', `cancellation phase=${state.selfTestPhase} result=${state.selfTestResult} checks=${state.selfTestChecks}`); + assert(state.selfTestChecks[12] === 'NOT TESTED', 'unfinished manual checks remain not tested'); + assert(state.actions.some(action => action.type === 'self_test_restored'), 'restore requested'); + assert(state.configuration.linkPhrase1 === 'normal-1', 'normal phrase retained'); + captureSelfTest('Cancelled result'); + leaveSelfTestAndCheckHeader(); + captureSelfTest('Settings after cancelled self-test'); + }); + + await test('settings organization: controls, inactive phrase and System actions', async () => { + wasm.ccall('gs_reset', null, [], []); + wasm.ccall('gs_set_configuration_json', null, ['string'], [JSON.stringify({dualReceiver: false, linkPhrase1: 'cats_test_1', linkPhrase2: 'cats_test_2', testingPhrase: 'cats_test'})]); + for (const button of ['down', 'right', 'right', 'ok']) tap(wasm, button); + assert(snapshot(wasm).settingsPage === 0, 'Settings opens on Telemetry'); + captureSelfTest('Settings - Telemetry single'); + for (const button of ['down', 'down', 'down', 'ok']) tap(wasm, button); + assert(snapshot(wasm).settingsState === 'list', 'Link Phrase 2 cannot be edited in Single mode'); + assert(snapshot(wasm).configuration.linkPhrase2 === 'cats_test_2', 'inactive phrase is retained'); + captureSelfTest('Settings - inactive Link Phrase 2'); + for (const button of ['up', 'up', 'right']) tap(wasm, button); + assert(snapshot(wasm).configuration.dualReceiver, 'Receiver Mode changes the receiver setting'); + captureSelfTest('Settings - Telemetry dual'); + for (const key of ['linkPhrase1', 'linkPhrase2', 'testingPhrase']) { + tap(wasm, 'down'); tap(wasm, 'ok'); + assert(snapshot(wasm).settingsState === 'keyboard', `${key} opens its keyboard`); + const before = snapshot(wasm).configuration[key]; + tap(wasm, 'ok'); tap(wasm, 'back'); + assert(snapshot(wasm).configuration[key] === `${before}1`, `${key} edits the correct phrase`); + } + for (const button of ['up', 'up', 'up', 'up', 'right', 'down', 'right']) tap(wasm, button); + assert(snapshot(wasm).settingsPage === 1 && snapshot(wasm).configuration.neverStopLogging, 'Stop Logging is first in Preferences'); + tap(wasm, 'left'); tap(wasm, 'down'); tap(wasm, 'right'); + assert(snapshot(wasm).configuration.timeZoneOffset === 1, 'Time Zone is second'); + tap(wasm, 'down'); tap(wasm, 'right'); + assert(snapshot(wasm).configuration.imperialUnits, 'Units is third'); + tap(wasm, 'down'); tap(wasm, 'left'); + assert(!snapshot(wasm).configuration.startupAnimation, 'Startup Animation is fourth'); + captureSelfTest('Settings - Preferences'); + for (const button of ['up', 'up', 'up', 'up', 'right', 'down']) tap(wasm, button); + assert(snapshot(wasm).settingsPage === 2 && snapshot(wasm).settingsSelection === 0, 'Firmware Versions is first in System'); + captureSelfTest('Settings - System versions'); + tap(wasm, 'down'); tap(wasm, 'ok'); + assert(snapshot(wasm).activeScreen === 'usb_storage', 'USB Drive invokes storage'); + tap(wasm, 'back'); + assert(snapshot(wasm).activeScreen === 'settings' && snapshot(wasm).settingsPage === 2 && snapshot(wasm).settingsSelection === 1, 'USB Drive returns to its System entry'); + tap(wasm, 'down'); tap(wasm, 'ok'); + assert(snapshot(wasm).activeScreen === 'self_test', 'Self-Test is third'); + tap(wasm, 'back'); + assert(snapshot(wasm).settingsPage === 2 && snapshot(wasm).settingsSelection === 2, 'Self-Test returns to its System entry'); + tap(wasm, 'down'); + captureSelfTest('Settings - System update'); + tap(wasm, 'ok'); + assert(snapshot(wasm).activeScreen === 'firmware_update' && snapshot(wasm).firmwareUpdateSelection === 0, 'Update Firmware opens the target chooser'); + captureSelfTest('Update Firmware - Ground Station'); + tap(wasm, 'ok'); + assert(snapshot(wasm).activeScreen === 'bootloader' && snapshot(wasm).actions.some(action => action.type === 'bootloader_requested'), 'Ground Station invokes the existing bootloader'); + }); + + await test('settings versions: startup cache, bounded retries and navigation', async () => { + const setVersion = (link, firmwareVersion) => wasm.ccall('gs_set_link_json', null, + ['string', 'string'], [String(link), JSON.stringify({firmwareVersion})]); + const requests = link => snapshot(wasm).actions.filter(action => action.type === 'version_requested' && action.link === link).length; + const bootVersions = (dualReceiver, first = '1.1.3', second = '2.0.0') => { + wasm.ccall('gs_reset', null, [], []); + wasm.ccall('gs_set_configuration_json', null, ['string'], [JSON.stringify({dualReceiver, startupAnimation: false})]); + wasm.ccall('gs_restart', null, [], []); + setVersion(1, first); setVersion(2, second); + advanceSelfTest(2000); + const startup = snapshot(wasm); + assert(startup.telemetryVersion1 === (first || 'Reading...'), 'chip 1 acquired before opening Settings'); + assert(startup.telemetryVersion2 === (second || 'Reading...'), 'chip 2 acquired before opening Settings'); + for (const button of ['down', 'right', 'right', 'ok', 'right', 'right', 'down']) tap(wasm, button); + const state = snapshot(wasm); + assert(state.settingsPage === 2 && state.settingsSelection === 0, 'Firmware Versions item selected'); + }; + bootVersions(false); + captureSelfTest('Settings versions - single mode'); + const before = framebuffer(wasm).slice(); + setVersion(1, '9.0.0'); setVersion(2, ''); + tap(wasm, 'ok'); + assert(before.every((value, index) => value === framebuffer(wasm)[index]), 'A leaves cached readout unchanged'); + tap(wasm, 'down'); tap(wasm, 'up'); + assert(before.every((value, index) => value === framebuffer(wasm)[index]), 'returning to Version restores cached values'); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":true}']); + advanceSelfTest(4000); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":false}']); + advanceSelfTest(20); + assert(requests(1) === 1 && requests(2) === 1, 'no reads from A, navigation, USB or elapsed time after success'); + tap(wasm, 'up'); + captureSelfTest('Settings after leaving Version'); + + bootVersions(true); + assert(snapshot(wasm).telemetryVersion2 === '2.0.0', 'both chips read in dual mode'); + captureSelfTest('Settings versions - dual mode'); + bootVersions(false, '1.1.3', ''); + captureSelfTest('Settings versions - reading'); + advanceSelfTest(6200); + assert(snapshot(wasm).telemetryVersion2 === 'No response', 'missing chip times out independently'); + assert(requests(1) === 1 && requests(2) === 8, 'only the missing chip is retried'); + captureSelfTest('Settings versions - missing chip'); + setVersion(2, '2.0.1'); + tap(wasm, 'ok'); advanceSelfTest(4000); + assert(snapshot(wasm).telemetryVersion2 === 'No response' && requests(2) === 8, 'no manual retry or ongoing polling after timeout'); + bootVersions(false, '1.1.3', '2.0.1'); + assert(snapshot(wasm).telemetryVersion2 === '2.0.1', 'restart acquires updated firmware version'); + captureSelfTest('Settings versions - recovered chip'); + bootVersions(false, '999.999.999-beta', '999.999.999-beta'); + captureSelfTest('Settings versions - long values'); + bootVersions(false, '', ''); + advanceSelfTest(6200); + const state = snapshot(wasm); + assert(state.telemetryVersion1 === 'No response' && state.telemetryVersion2 === 'No response', 'both missing chips shown'); + assert(requests(1) === 8 && requests(2) === 8, 'both startup retries bounded'); + captureSelfTest('Settings versions - both chips missing'); + bootVersions(false, '', ''); + advanceSelfTest(2600); + setVersion(1, '1.1.3'); setVersion(2, '2.0.0'); + advanceSelfTest(1000); + assert(snapshot(wasm).telemetryVersion1 === '1.1.3' && snapshot(wasm).telemetryVersion2 === '2.0.0', 'normal cold boot delay is covered'); + assert(requests(1) === 6 && requests(2) === 6, 'startup stops retrying after delayed replies'); + }); + + await test('live guidance: signed angles, dual targets and missing coordinates', async () => { + const setNavigation = values => wasm.ccall('gs_set_navigation_json', null, ['string'], [JSON.stringify(values)]); + const setLink = (link, values) => wasm.ccall('gs_set_link_json', null, ['string', 'string'], [String(link), JSON.stringify(values)]); + const pixels = (link, row) => { + const bytes = framebuffer(wasm); + const output = []; + for (let y = 99 + row * 25; y < 124 + row * 25; ++y) { + for (let x = 28 + link * 200; x < 158 + link * 200; ++x) { + const bit = y * 400 + x; + output.push((bytes[bit >> 3] >> (7 - (bit & 7))) & 1); + } + } + return output; + }; + const equal = (a, b) => a.every((value, index) => value === b[index]); + const blank = link => [0, 1].every(row => pixels(link, row).every(value => value === 1)); + wasm.ccall('gs_reset', null, [], []); + wasm.ccall('gs_set_configuration_json', null, ['string'], ['{"dualReceiver":true}']); + setNavigation({homeLatitude: 47, homeLongitude: 8, northRad: 0}); + // Keep both packet streams idle. Only sensor/GPS changes drive subsequent frames. + setLink(1, {latitude: 47.001, longitude: 8, connected: true, updated: false}); + setLink(2, {latitude: 47, longitude: 8.002, connected: true, updated: false}); + tap(wasm, 'ok'); tap(wasm, 'right'); + assert(snapshot(wasm).activeScreen === 'live', 'live screen opens'); + const initialDistance = [pixels(0, 0), pixels(1, 0)]; + assert(!equal(initialDistance[0], initialDistance[1]), 'each link gets its own GPS distance'); + const ahead = pixels(0, 1), right = pixels(1, 1); + assert(!equal(ahead, right), 'north and east targets have different turn angles'); + captureSelfTest('Live - ahead 0 and right 90'); + actualHashes.liveAheadRight = await framebufferHash(wasm); + setNavigation({northRad: -Math.PI / 2}); + advanceSelfTest(220); + assert(equal(pixels(1, 1), ahead), 'turning east puts the eastern rocket straight ahead'); + assert(!equal(pixels(0, 1), right), 'the northern rocket is now left, with a negative angle'); + assert(initialDistance.every((value, link) => equal(value, pixels(link, 0))), 'rotation leaves GPS distances unchanged'); + captureSelfTest('Live - left -90 and ahead 0'); + actualHashes.liveLeftAhead = await framebufferHash(wasm); + setNavigation({northRad: -Math.PI}); + advanceSelfTest(220); + captureSelfTest('Live - behind 180 and left -90'); + actualHashes.liveBehindLeft = await framebufferHash(wasm); + setNavigation({northRad: Math.PI}); + assert(await framebufferHash(wasm) === actualHashes.liveBehindLeft, 'both representations of behind render positive 180'); + const otherBearing = pixels(1, 1), otherDistance = pixels(1, 0); + setLink(1, {latitude: 0, longitude: 0, updated: false}); + assert(blank(0), 'missing rocket coordinates clear both fields'); + assert(equal(otherBearing, pixels(1, 1)) && equal(otherDistance, pixels(1, 0)), 'missing Link 1 GPS does not affect Link 2'); + captureSelfTest('Live - Link 1 GPS missing'); + setNavigation({homeLatitude: 0, homeLongitude: 0}); + assert(blank(0) && blank(1), 'missing GS coordinates clear both panels'); + captureSelfTest('Live - GS GPS missing'); + setNavigation({homeLatitude: 47.002, homeLongitude: 8, northRad: 0}); + advanceSelfTest(220); + assert(!equal(otherDistance, pixels(1, 0)), 'moving the GS updates distance without rocket packets'); + setLink(2, {latitude: 91, longitude: 8, updated: false}); + assert(blank(1), 'out-of-range coordinates do not produce a bearing'); + + // Single mode preserves receiver redundancy, selecting the newest available GPS. + wasm.ccall('gs_set_configuration_json', null, ['string'], ['{"dualReceiver":false}']); + setNavigation({homeLatitude: 47, homeLongitude: 8, northRad: 0}); + setLink(1, {latitude: 47.001, longitude: 8}); + advanceSelfTest(20); + setLink(2, {latitude: 47, longitude: 8.002}); + assert(equal(pixels(0, 1), right) && equal(pixels(1, 1), right), 'single mode shares the newest receiver position'); + advanceSelfTest(20); + setLink(1, {latitude: 47.001, longitude: 8}); + assert(equal(pixels(0, 1), ahead) && equal(pixels(1, 1), ahead), 'single mode follows the other receiver when it becomes newer'); + setLink(1, {latitude: 0, longitude: 0}); + assert(equal(pixels(0, 1), right) && equal(pixels(1, 1), right), 'single mode falls back to the receiver that has GPS'); + setLink(2, {latitude: 0, longitude: 0}); + assert(blank(0) && blank(1), 'single mode has no stale target when neither receiver has GPS'); + for (const name of ['liveAheadRight', 'liveLeftAhead', 'liveBehindLeft']) { + assert(actualHashes[name] === goldens[name], `${name}: expected ${goldens[name]}, got ${actualHashes[name]}`); + } + }); + + const failed = results.filter(result => !result.passed); + const report = { passed: failed.length === 0, results, framebufferSha256: actualHashes }; + globalThis.browserTestReport = report; + resultNode.dataset.status = report.passed ? 'passed' : 'failed'; + resultNode.textContent = JSON.stringify(report, null, 2); + document.title = report.passed ? 'PASS - Ground Station browser tests' : 'FAIL - Ground Station browser tests'; +} + +run().catch(error => { + const report = { passed: false, fatal: error instanceof Error ? error.stack || error.message : String(error), results }; + globalThis.browserTestReport = report; + resultNode.dataset.status = 'failed'; + resultNode.textContent = JSON.stringify(report, null, 2); + document.title = 'FAIL - Ground Station browser tests'; +}); diff --git a/ground_station/simulator/web/coverage.json b/ground_station/simulator/web/coverage.json new file mode 100644 index 00000000..06503d10 --- /dev/null +++ b/ground_station/simulator/web/coverage.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "groups": [ + { + "name": "Screens", + "rows": [ + {"path": "Animated startup", "scenario": "startup-intro.json", "snapshot": "rocket-launch through logo-hold", "framebuffer": "Golden", "browser": "startup-rocket browser golden"}, + {"path": "Static startup", "scenario": "startup-static.json", "snapshot": "static-logo", "framebuffer": "Golden", "browser": "—"}, + {"path": "Main menu", "scenario": "menu.json", "snapshot": "menu", "framebuffer": "Golden", "browser": "menu browser golden"}, + {"path": "Live telemetry", "scenario": "live.json", "snapshot": "live-telemetry", "framebuffer": "Deterministic", "browser": "Physical-controls smoke"}, + {"path": "Recovery", "scenario": "qr-recovery.json", "snapshot": "recovery-link-2-qr", "framebuffer": "Golden", "browser": "Physical-controls smoke"}, + {"path": "Testing", "scenario": "testing-timeout.json", "snapshot": "timeout", "framebuffer": "Golden", "browser": "—"}, + {"path": "Data and flight statistics", "scenario": "log-management.json", "snapshot": "log-management", "framebuffer": "Deterministic", "browser": "Fixture smoke"}, + {"path": "Sensors, compass, and calibration", "scenario": "sensors.json; sensors-orientation.json", "snapshot": "sensors-calibration-complete; orientation-east", "framebuffer": "Deterministic", "browser": "compass-orientation browser golden"}, + {"path": "Settings", "scenario": "settings.json", "snapshot": "settings", "framebuffer": "Golden", "browser": "USB fixture flow"}, + {"path": "USB Drive", "scenario": "usb-storage.json", "snapshot": "usb-storage-controls; usb-storage-handoff", "framebuffer": "Deterministic", "browser": "usb-storage browser golden"}, + {"path": "Bootloader request", "scenario": "bootloader.json", "snapshot": "bootloader-requested", "framebuffer": "Deterministic", "browser": "—"} + ] + }, + { + "name": "Data, recovery, and failure paths", + "rows": [ + {"path": "Replay timing", "scenario": "replay.json", "snapshot": "replay", "framebuffer": "Golden", "browser": "—"}, + {"path": "Recorded light-mode flight timing and units", "scenario": "light-demo fixture", "snapshot": "—", "framebuffer": "Deterministic fixture load", "browser": "light-demo browser golden"}, + {"path": "Recovery QR, dual link", "scenario": "qr-recovery.json", "snapshot": "recovery-link-2-qr", "framebuffer": "Golden", "browser": "Physical-controls smoke"}, + {"path": "Data QR, dual link", "scenario": "qr-data.json", "snapshot": "data-link-2-qr", "framebuffer": "Golden", "browser": "—"}, + {"path": "Missing recovery fix", "scenario": "qr-no-fix.json; missing-fix fixture", "snapshot": "no-fix-statistics", "framebuffer": "Golden", "browser": "Fixture smoke"}, + {"path": "Equator or prime-meridian fix", "scenario": "qr-zero-coordinate.json; zero-coordinate fixture", "snapshot": "zero-coordinate-qr", "framebuffer": "Golden", "browser": "Fixture smoke"}, + {"path": "More than 11 logs", "scenario": "log-management.json; many fixture", "snapshot": "log-management", "framebuffer": "Deterministic", "browser": "Fixture smoke"}, + {"path": "Legacy CSV names", "scenario": "legacy-log-names.json", "snapshot": "legacy-log-names", "framebuffer": "Deterministic", "browser": "—"}, + {"path": "Recording independent of screen", "scenario": "recording-independent.json", "snapshot": "screen-independent-recording", "framebuffer": "Deterministic", "browser": "—"}, + {"path": "Automatic and Never-mode finalization", "scenario": "recording-modes.json; never fixture", "snapshot": "recording-modes", "framebuffer": "Deterministic", "browser": "Fixture smoke"}, + {"path": "Malformed or partial log", "scenario": "demo fixture", "snapshot": "—", "framebuffer": "Deterministic fixture load", "browser": "Fixture smoke"}, + {"path": "Deletion failure", "scenario": "delete-failure fixture", "snapshot": "—", "framebuffer": "Deterministic fixture load", "browser": "Fixture smoke"}, + {"path": "USB blocks deletion", "scenario": "usb-delete fixture", "snapshot": "—", "framebuffer": "Deterministic fixture load", "browser": "Fixture smoke"}, + {"path": "Finalization failure", "scenario": "finalize-failure fixture", "snapshot": "—", "framebuffer": "Deterministic fixture load", "browser": "Fixture smoke"}, + {"path": "Recorder write failure", "scenario": "recorder-fault fixture", "snapshot": "—", "framebuffer": "Deterministic fixture load", "browser": "recorder-fault browser golden"}, + {"path": "USB remount failure", "scenario": "—", "snapshot": "—", "framebuffer": "Gap", "browser": "Gap", "gap": true} + ] + } + ] +} diff --git a/ground_station/simulator/web/demo-logs/gs_log_main_1_1.csv b/ground_station/simulator/web/demo-logs/gs_log_main_1_1.csv new file mode 100644 index 00000000..a70e49d9 --- /dev/null +++ b/ground_station/simulator/web/demo-logs/gs_log_main_1_1.csv @@ -0,0 +1,3301 @@ +link,ts[deciseconds],state,errors,lat[deg/10000],lon[deg/10000],altitude[m],velocity[m/s],battery[decivolts],pyro1,pyro2 +1,5929,3,0,393899,-82895,0,3,81,1,1 +2,5929,3,0,393899,-82895,0,3,81,1,1 +1,5930,3,0,393899,-82895,0,9,81,1,1 +1,5931,3,0,393899,-82895,2,16,81,1,1 +2,5931,3,0,393899,-82895,2,16,81,1,1 +1,5932,3,0,393899,-82895,4,22,81,1,1 +2,5932,3,0,393899,-82895,4,22,81,1,1 +1,5934,3,0,393899,-82895,9,35,81,1,1 +2,5934,3,0,393899,-82895,9,35,81,1,1 +1,5935,3,0,393899,-82895,13,41,81,1,1 +2,5935,3,0,393899,-82895,13,41,81,1,1 +1,5937,3,0,393899,-82894,23,54,81,1,1 +2,5937,3,0,393899,-82894,23,54,81,1,1 +1,5938,3,0,393899,-82894,29,61,81,1,1 +2,5938,3,0,393899,-82894,29,61,81,1,1 +1,5939,3,0,393899,-82894,35,67,81,1,1 +2,5939,3,0,393899,-82894,35,67,81,1,1 +1,5940,3,0,393899,-82894,42,73,81,1,1 +2,5940,3,0,393899,-82894,42,73,81,1,1 +1,5941,3,0,393899,-82894,50,80,81,1,1 +2,5941,3,0,393899,-82894,50,80,81,1,1 +1,5943,3,0,393899,-82894,67,93,81,1,1 +2,5943,3,0,393899,-82894,67,93,81,1,1 +1,5944,3,0,393899,-82894,76,99,81,1,1 +2,5944,3,0,393899,-82894,76,99,81,1,1 +1,5946,3,0,393899,-82894,97,112,81,1,1 +1,5949,3,0,393900,-82893,132,130,81,1,1 +2,5949,3,0,393900,-82893,132,130,81,1,1 +1,5950,3,0,393899,-82893,144,135,82,1,1 +2,5950,3,0,393899,-82893,144,135,82,1,1 +1,5955,3,0,393899,-82892,210,159,81,1,1 +2,5955,3,0,393899,-82892,210,159,81,1,1 +1,5956,3,0,393899,-82892,224,162,81,1,1 +1,5957,3,0,393899,-82891,238,166,81,1,1 +2,5957,3,0,393899,-82891,238,166,81,1,1 +1,5958,3,0,393899,-82891,251,169,81,1,1 +1,5959,3,0,393899,-82891,265,172,81,1,1 +1,5960,3,0,393899,-82891,278,174,81,1,1 +2,5960,3,0,393899,-82891,278,174,81,1,1 +1,5961,3,0,393899,-82891,292,177,81,1,1 +1,5963,3,0,393899,-82890,319,181,81,1,1 +2,5963,3,0,393899,-82890,319,181,81,1,1 +1,5964,3,0,393899,-82889,333,183,81,1,1 +2,5964,3,0,393899,-82889,333,183,81,1,1 +1,5965,3,0,393899,-82889,346,185,84,1,1 +2,5965,3,0,393899,-82889,346,185,84,1,1 +1,5966,3,0,393899,-82889,360,187,82,1,1 +2,5966,3,0,393899,-82889,360,187,82,1,1 +1,5967,3,0,393899,-82889,376,190,81,1,1 +1,5968,3,0,393898,-82888,393,192,81,1,1 +1,5970,3,0,393898,-82888,427,198,81,1,1 +2,5970,3,0,393898,-82888,427,198,81,1,1 +1,5971,3,0,393898,-82888,444,200,81,1,1 +2,5971,3,0,393898,-82888,444,200,81,1,1 +1,5973,3,0,393898,-82887,479,205,81,1,1 +2,5973,3,0,393898,-82887,479,205,81,1,1 +1,5974,3,0,393897,-82887,497,207,81,1,1 +1,5975,3,0,393897,-82887,515,210,81,1,1 +2,5975,3,0,393897,-82887,515,210,81,1,1 +1,5993,4,0,393897,-82886,875,208,81,1,1 +2,5993,4,0,393897,-82886,875,208,81,1,1 +1,5995,4,0,393897,-82886,920,206,80,1,1 +1,5997,4,0,393897,-82886,965,204,80,1,1 +2,5997,4,0,393897,-82886,965,204,80,1,1 +1,5998,4,0,393897,-82886,988,203,80,1,1 +2,5998,4,0,393897,-82886,988,203,80,1,1 +1,5999,4,0,393897,-82886,1012,202,80,1,1 +2,5999,4,0,393897,-82886,1012,202,80,1,1 +1,6000,4,0,393897,-82886,1035,201,80,1,1 +2,6000,4,0,393897,-82886,1035,201,80,1,1 +1,6001,4,0,393897,-82886,1059,200,81,1,1 +1,6003,4,0,393897,-82886,1106,199,80,1,1 +2,6003,4,0,393897,-82886,1106,199,80,1,1 +1,6004,4,0,393891,-82879,1130,198,80,1,1 +2,6004,4,0,393891,-82879,1130,198,80,1,1 +1,6005,4,0,393891,-82879,1153,197,80,1,1 +2,6005,4,0,393891,-82879,1153,197,80,1,1 +1,6006,4,0,393891,-82879,1177,197,80,1,1 +2,6006,4,0,393891,-82879,1177,197,80,1,1 +1,6007,4,0,393890,-82879,1201,196,80,1,1 +2,6007,4,0,393890,-82879,1201,196,80,1,1 +1,6008,4,0,393890,-82878,1224,196,80,1,1 +2,6008,4,0,393890,-82878,1224,196,80,1,1 +1,6010,4,0,393890,-82879,1272,195,80,1,1 +2,6010,4,0,393890,-82879,1272,195,80,1,1 +1,6011,4,0,393890,-82879,1295,194,80,1,1 +1,6013,4,0,393889,-82880,1342,193,80,1,1 +1,6014,4,0,393888,-82883,1366,193,80,1,1 +2,6014,4,0,393888,-82883,1366,193,80,1,1 +1,6015,4,0,393888,-82883,1389,192,80,1,1 +2,6015,4,0,393888,-82883,1389,192,80,1,1 +1,6018,4,0,393887,-82881,1458,191,80,1,1 +2,6018,4,0,393887,-82881,1458,191,80,1,1 +1,6019,4,0,393887,-82881,1480,191,80,1,1 +2,6019,4,0,393887,-82881,1480,191,80,1,1 +1,6020,4,0,393887,-82881,1503,190,80,1,1 +2,6020,4,0,393887,-82881,1503,190,80,1,1 +1,6021,4,0,393887,-82881,1525,189,80,1,1 +2,6021,4,0,393887,-82881,1525,189,80,1,1 +1,6022,4,0,393886,-82881,1548,189,80,1,1 +1,6024,4,0,393886,-82881,1591,188,80,1,1 +2,6024,4,0,393886,-82881,1591,188,80,1,1 +2,6025,4,0,393886,-82881,1613,187,80,1,1 +1,6026,4,0,393886,-82881,1635,186,80,1,1 +2,6026,4,0,393886,-82881,1635,186,80,1,1 +1,6027,4,0,393886,-82881,1656,186,80,1,1 +2,6027,4,0,393886,-82881,1656,186,80,1,1 +1,6029,4,0,393885,-82880,1699,184,80,1,1 +2,6029,4,0,393885,-82880,1699,184,80,1,1 +1,6030,4,0,393885,-82880,1720,183,80,1,1 +2,6030,4,0,393885,-82880,1720,183,80,1,1 +1,6031,4,0,393885,-82880,1740,183,80,1,1 +1,6035,4,0,393884,-82879,1821,179,80,1,1 +2,6035,4,0,393884,-82879,1821,179,80,1,1 +2,6036,4,0,393884,-82879,1841,178,80,1,1 +1,6038,4,0,393882,-82878,1879,176,80,1,1 +2,6038,4,0,393882,-82878,1879,176,80,1,1 +2,6039,4,0,393882,-82878,1899,175,80,1,1 +1,6040,4,0,393882,-82877,1917,174,80,1,1 +2,6040,4,0,393882,-82877,1917,174,80,1,1 +1,6042,4,0,393882,-82877,1955,172,80,1,1 +2,6042,4,0,393882,-82877,1955,172,80,1,1 +1,6043,4,0,393882,-82877,1973,171,80,1,1 +2,6043,4,0,393882,-82877,1973,171,80,1,1 +1,6044,4,0,393882,-82877,1991,170,80,1,1 +1,6045,4,0,393882,-82877,2009,168,80,1,1 +2,6045,4,0,393882,-82877,2009,168,80,1,1 +1,6046,4,0,393882,-82877,2027,167,80,1,1 +2,6046,4,0,393882,-82877,2027,167,80,1,1 +1,6047,4,0,393881,-82876,2044,166,80,1,1 +1,6049,4,0,393881,-82876,2078,164,80,1,1 +2,6049,4,0,393881,-82876,2078,164,80,1,1 +1,6050,4,0,393880,-82875,2095,162,80,1,1 +2,6050,4,0,393880,-82875,2095,162,80,1,1 +1,6051,4,0,393880,-82875,2112,161,80,1,1 +2,6051,4,0,393880,-82875,2112,161,80,1,1 +1,6053,4,0,393880,-82875,2145,158,80,1,1 +2,6053,4,0,393880,-82875,2145,158,80,1,1 +1,6054,4,0,393879,-82874,2161,157,80,1,1 +2,6054,4,0,393879,-82874,2161,157,80,1,1 +1,6056,4,0,393879,-82874,2192,154,80,1,1 +2,6056,4,0,393879,-82874,2192,154,80,1,1 +1,6058,4,0,393879,-82874,2223,152,80,1,1 +2,6058,4,0,393879,-82874,2223,152,80,1,1 +1,6059,4,0,393879,-82874,2239,150,80,1,1 +2,6059,4,0,393879,-82874,2239,150,80,1,1 +1,6060,4,0,393877,-82875,2254,149,80,1,1 +1,6061,4,0,393877,-82875,2268,147,80,1,1 +2,6061,4,0,393877,-82875,2268,147,80,1,1 +1,6062,4,0,393877,-82875,2283,146,80,1,1 +1,6064,4,0,393877,-82875,2312,143,80,1,1 +1,6065,4,0,393877,-82875,2326,142,80,1,1 +2,6065,4,0,393877,-82875,2326,142,80,1,1 +1,6066,4,0,393877,-82875,2340,141,80,1,1 +2,6066,4,0,393877,-82875,2340,141,80,1,1 +1,6067,4,0,393877,-82875,2354,139,80,1,1 +2,6067,4,0,393877,-82875,2354,139,80,1,1 +1,6068,4,0,393877,-82875,2367,138,80,1,1 +2,6068,4,0,393877,-82875,2367,138,80,1,1 +1,6069,4,0,393877,-82875,2381,136,80,1,1 +1,6073,4,0,393874,-82876,2433,131,80,1,1 +2,6073,4,0,393874,-82876,2433,131,80,1,1 +1,6074,4,0,393873,-82875,2446,129,80,1,1 +1,6075,4,0,393873,-82875,2459,128,80,1,1 +1,6076,4,0,393873,-82875,2471,126,80,1,1 +2,6076,4,0,393873,-82875,2471,126,80,1,1 +1,6077,4,0,393873,-82876,2483,125,80,1,1 +2,6077,4,0,393873,-82876,2483,125,80,1,1 +1,6078,4,0,393873,-82876,2495,124,80,1,1 +2,6078,4,0,393873,-82876,2495,124,80,1,1 +1,6080,4,0,393872,-82876,2519,121,80,1,1 +2,6080,4,0,393872,-82876,2519,121,80,1,1 +1,6081,4,0,393872,-82876,2531,119,80,1,1 +2,6081,4,0,393872,-82876,2531,119,80,1,1 +1,6082,4,0,393872,-82876,2542,118,80,1,1 +2,6082,4,0,393872,-82876,2542,118,80,1,1 +1,6083,4,0,393872,-82876,2554,117,80,1,1 +1,6084,4,0,393871,-82876,2565,115,80,1,1 +2,6084,4,0,393871,-82876,2565,115,80,1,1 +1,6085,4,0,393871,-82876,2576,114,80,1,1 +1,6087,4,0,393870,-82877,2598,111,80,1,1 +2,6087,4,0,393870,-82877,2598,111,80,1,1 +1,6088,4,0,393870,-82877,2608,110,80,1,1 +2,6088,4,0,393870,-82877,2608,110,80,1,1 +2,6089,4,0,393870,-82877,2619,108,80,1,1 +1,6091,4,0,393869,-82878,2640,106,80,1,1 +2,6091,4,0,393869,-82878,2640,106,80,1,1 +2,6092,4,0,393869,-82878,2650,104,80,1,1 +1,6093,4,0,393869,-82878,2660,103,80,1,1 +1,6094,4,0,393868,-82878,2670,102,80,1,1 +1,6097,4,0,393868,-82879,2698,98,80,1,1 +2,6097,4,0,393868,-82879,2698,98,80,1,1 +1,6098,4,0,393867,-82879,2708,96,80,1,1 +2,6098,4,0,393867,-82879,2708,96,80,1,1 +1,6099,4,0,393867,-82879,2717,95,80,1,1 +2,6099,4,0,393867,-82879,2717,95,80,1,1 +1,6100,4,0,393867,-82879,2726,94,80,1,1 +2,6100,4,0,393867,-82879,2726,94,80,1,1 +1,6101,4,0,393867,-82879,2735,93,80,1,1 +2,6101,4,0,393867,-82879,2735,93,80,1,1 +1,6104,4,0,393866,-82880,2761,89,80,1,1 +2,6104,4,0,393866,-82880,2761,89,80,1,1 +1,6105,4,0,393866,-82880,2770,87,80,1,1 +2,6105,4,0,393866,-82880,2770,87,80,1,1 +1,6106,4,0,393866,-82880,2778,86,80,1,1 +2,6106,4,0,393866,-82880,2778,86,80,1,1 +1,6107,4,0,393865,-82880,2786,85,80,1,1 +2,6107,4,0,393865,-82880,2786,85,80,1,1 +1,6109,4,0,393865,-82880,2803,82,80,1,1 +2,6109,4,0,393865,-82880,2803,82,80,1,1 +2,6111,4,0,393864,-82881,2818,80,80,1,1 +2,6112,4,0,393863,-82881,2826,79,80,1,1 +1,6113,4,0,393863,-82881,2834,77,80,1,1 +2,6113,4,0,393863,-82881,2834,77,80,1,1 +1,6114,4,0,393863,-82882,2841,76,80,1,1 +2,6114,4,0,393863,-82882,2841,76,80,1,1 +1,6116,4,0,393863,-82882,2856,74,80,1,1 +1,6117,4,0,393863,-82882,2863,73,80,1,1 +2,6117,4,0,393863,-82882,2863,73,80,1,1 +1,6118,4,0,393863,-82882,2870,71,80,1,1 +2,6118,4,0,393863,-82882,2870,71,80,1,1 +1,6121,4,0,393863,-82882,2891,68,80,1,1 +2,6121,4,0,393863,-82882,2891,68,80,1,1 +1,6122,4,0,393863,-82882,2898,67,80,1,1 +2,6122,4,0,393863,-82882,2898,67,80,1,1 +1,6123,4,0,393863,-82882,2904,66,80,1,1 +2,6124,4,0,393863,-82882,2911,65,80,1,1 +1,6126,4,0,393863,-82882,2923,62,80,1,1 +2,6126,4,0,393863,-82882,2923,62,80,1,1 +1,6127,4,0,393863,-82882,2929,61,80,1,1 +2,6127,4,0,393863,-82882,2929,61,80,1,1 +2,6128,4,0,393863,-82882,2935,60,80,1,1 +1,6130,4,0,393863,-82882,2947,58,80,1,1 +1,6131,4,0,393863,-82882,2953,57,80,1,1 +2,6132,4,0,393863,-82882,2958,56,80,1,1 +2,6134,4,0,393863,-82882,2969,54,80,1,1 +1,6135,4,0,393863,-82882,2975,52,80,1,1 +2,6135,4,0,393863,-82882,2975,52,80,1,1 +1,6137,4,0,393863,-82882,2985,50,80,1,1 +2,6137,4,0,393863,-82882,2985,50,80,1,1 +2,6139,4,0,393863,-82882,2995,48,80,1,1 +1,6140,4,0,393863,-82882,3000,47,80,1,1 +2,6140,4,0,393863,-82882,3000,47,80,1,1 +1,6141,4,0,393863,-82882,3005,46,80,1,1 +2,6141,4,0,393863,-82882,3005,46,80,1,1 +1,6142,4,0,393857,-82878,3009,45,80,1,1 +2,6142,4,0,393857,-82878,3009,45,80,1,1 +1,6143,4,0,393857,-82878,3014,44,80,1,1 +2,6143,4,0,393857,-82878,3014,44,80,1,1 +1,6144,4,0,393857,-82878,3018,43,80,1,1 +2,6144,4,0,393857,-82878,3018,43,80,1,1 +1,6146,4,0,393857,-82878,3027,41,80,1,1 +2,6146,4,0,393857,-82878,3027,41,80,1,1 +1,6147,4,0,393857,-82878,3031,40,80,1,1 +2,6147,4,0,393857,-82878,3031,40,80,1,1 +1,6148,4,0,393857,-82878,3035,39,80,1,1 +2,6148,4,0,393857,-82878,3035,39,80,1,1 +1,6149,4,0,393857,-82878,3039,38,80,1,1 +2,6149,4,0,393857,-82878,3039,38,80,1,1 +1,6150,4,0,393857,-82878,3043,37,80,1,1 +2,6150,4,0,393857,-82878,3043,37,80,1,1 +1,6153,4,0,393854,-82878,3054,34,80,1,1 +1,6154,4,0,393854,-82878,3057,33,80,1,1 +1,6155,4,0,393854,-82878,3061,32,80,1,1 +2,6155,4,0,393854,-82878,3061,32,80,1,1 +1,6157,4,0,393854,-82878,3067,30,80,1,1 +2,6157,4,0,393854,-82878,3067,30,80,1,1 +1,6158,4,0,393854,-82878,3070,29,80,1,1 +2,6158,4,0,393854,-82878,3070,29,80,1,1 +1,6159,4,0,393854,-82878,3073,28,80,1,1 +2,6159,4,0,393854,-82878,3073,28,80,1,1 +1,6160,4,0,393852,-82877,3076,27,80,1,1 +2,6161,4,0,393852,-82877,3078,26,80,1,1 +1,6162,4,0,393852,-82877,3081,25,80,1,1 +2,6162,4,0,393852,-82877,3081,25,80,1,1 +1,6163,4,0,393852,-82877,3084,24,80,1,1 +2,6163,4,0,393852,-82877,3084,24,80,1,1 +1,6164,4,0,393852,-82877,3086,23,80,1,1 +2,6164,4,0,393852,-82877,3086,23,80,1,1 +1,6165,4,0,393852,-82877,3088,22,80,1,1 +1,6168,4,0,393851,-82876,3095,19,80,1,1 +2,6168,4,0,393851,-82876,3095,19,80,1,1 +1,6169,4,0,393851,-82876,3096,18,80,1,1 +2,6169,4,0,393851,-82876,3096,18,80,1,1 +1,6170,4,0,393851,-82876,3098,17,80,1,1 +2,6170,4,0,393851,-82876,3098,17,80,1,1 +1,6171,4,0,393851,-82876,3100,16,80,1,1 +2,6171,4,0,393851,-82876,3100,16,80,1,1 +2,6172,4,0,393851,-82876,3102,15,80,1,1 +1,6173,4,0,393851,-82876,3103,14,79,1,1 +1,6176,4,0,393851,-82876,3108,11,80,1,1 +1,6177,4,0,393849,-82875,3109,11,80,1,1 +2,6177,4,0,393849,-82875,3109,11,80,1,1 +2,6178,4,0,393849,-82875,3110,10,80,1,1 +1,6178,4,0,393849,-82875,3110,10,80,1,1 +1,6179,4,0,393849,-82875,3111,9,80,1,1 +2,6179,4,0,393849,-82875,3111,9,80,1,1 +1,6180,4,0,393849,-82875,3112,8,80,1,1 +2,6180,4,0,393849,-82875,3112,8,80,1,1 +1,6181,4,0,393849,-82875,3113,7,80,1,1 +1,6184,4,0,393849,-82875,3114,4,80,1,1 +2,6184,4,0,393849,-82875,3114,4,80,1,1 +1,6186,4,0,393849,-82875,3115,2,80,1,1 +2,6186,4,0,393849,-82875,3115,2,80,1,1 +1,6187,4,0,393849,-82875,3115,1,80,1,1 +2,6187,4,0,393849,-82875,3115,1,80,1,1 +1,6188,4,0,393846,-82873,3116,0,80,1,1 +2,6188,4,0,393846,-82873,3116,0,80,1,1 +1,6189,4,0,393846,-82873,3115,0,80,1,1 +2,6189,4,0,393846,-82873,3115,0,80,1,1 +1,6192,5,0,393846,-82873,3115,-1,80,0,1 +2,6192,5,0,393846,-82873,3115,-1,80,0,1 +1,6195,5,0,393846,-82873,3114,-2,80,0,1 +2,6195,5,0,393846,-82873,3114,-2,80,0,1 +1,6197,5,0,393846,-82873,3117,0,80,0,1 +2,6197,5,0,393846,-82873,3117,0,80,0,1 +1,6198,5,0,393846,-82873,3119,2,80,0,1 +2,6198,5,0,393846,-82873,3119,2,80,0,1 +1,6199,5,0,393846,-82873,3120,3,80,0,1 +2,6199,5,0,393846,-82873,3120,3,80,0,1 +1,6201,5,0,393843,-82872,3122,4,80,0,1 +2,6201,5,0,393843,-82872,3122,4,80,0,1 +1,6202,5,0,393843,-82872,3122,4,80,0,1 +2,6202,5,0,393843,-82872,3122,4,80,0,1 +1,6203,5,0,393843,-82872,3122,3,80,0,1 +1,6204,5,0,393843,-82871,3121,2,80,0,1 +2,6204,5,0,393843,-82871,3121,2,80,0,1 +1,6205,5,0,393843,-82871,3122,3,80,0,1 +2,6205,5,0,393843,-82871,3122,3,80,0,1 +2,6206,5,0,393843,-82871,3123,3,80,0,1 +1,6209,5,0,393843,-82871,3119,-1,80,0,1 +2,6209,5,0,393843,-82871,3119,-1,80,0,1 +1,6210,5,0,393843,-82871,3117,-2,80,0,1 +2,6210,5,0,393843,-82871,3117,-2,80,0,1 +1,6212,5,0,393843,-82871,3113,-5,80,0,1 +2,6212,5,0,393843,-82871,3113,-5,80,0,1 +1,6214,5,0,393843,-82871,3110,-7,80,0,1 +1,6215,5,0,393843,-82871,3108,-9,80,0,1 +2,6215,5,0,393843,-82871,3108,-9,80,0,1 +1,6216,5,0,393843,-82871,3106,-10,80,0,1 +1,6218,5,0,393843,-82871,3103,-11,80,0,1 +2,6218,5,0,393843,-82871,3103,-11,80,0,1 +1,6219,5,0,393843,-82871,3102,-10,80,0,1 +2,6219,5,0,393843,-82871,3102,-10,80,0,1 +1,6220,5,0,393843,-82870,3101,-10,80,0,1 +2,6220,5,0,393843,-82870,3101,-10,80,0,1 +1,6221,5,0,393843,-82870,3098,-12,80,0,1 +2,6221,5,0,393843,-82870,3098,-12,80,0,1 +1,6222,5,0,393843,-82870,3095,-14,80,0,1 +2,6222,5,0,393843,-82870,3095,-14,80,0,1 +1,6226,5,0,393843,-82870,3088,-14,80,0,1 +2,6226,5,0,393843,-82870,3088,-14,80,0,1 +1,6227,5,0,393843,-82870,3088,-13,80,0,1 +2,6227,5,0,393843,-82870,3088,-13,80,0,1 +1,6228,5,0,393843,-82870,3087,-13,80,0,1 +2,6228,5,0,393843,-82870,3087,-13,80,0,1 +1,6229,5,0,393843,-82870,3085,-13,80,0,1 +2,6229,5,0,393843,-82870,3085,-13,80,0,1 +1,6230,5,0,393843,-82870,3084,-13,80,0,1 +2,6230,5,0,393843,-82870,3084,-13,80,0,1 +1,6231,5,0,393843,-82870,3083,-13,80,0,1 +2,6233,5,0,393843,-82869,3080,-13,81,0,1 +2,6234,5,0,393843,-82869,3079,-13,80,0,1 +1,6235,5,0,393843,-82869,3079,-11,80,0,1 +2,6235,5,0,393843,-82869,3079,-11,80,0,1 +1,6237,5,0,393843,-82869,3078,-10,81,0,1 +2,6237,5,0,393843,-82869,3078,-10,81,0,1 +1,6238,5,0,393843,-82869,3077,-10,80,0,1 +1,6239,5,0,393843,-82869,3076,-10,81,0,1 +2,6239,5,0,393843,-82869,3076,-10,81,0,1 +1,6241,5,0,393844,-82868,3072,-12,81,0,1 +1,6242,5,0,393844,-82868,3070,-13,81,0,1 +2,6242,5,0,393844,-82868,3070,-13,81,0,1 +1,6243,5,0,393844,-82868,3068,-13,81,0,1 +1,6244,5,0,393844,-82868,3066,-14,80,0,1 +2,6244,5,0,393844,-82868,3066,-14,80,0,1 +1,6246,5,0,393844,-82868,3062,-15,80,0,1 +2,6246,5,0,393844,-82868,3062,-15,80,0,1 +1,6247,5,0,393844,-82868,3058,-17,80,0,1 +2,6248,5,0,393844,-82868,3055,-18,81,0,1 +1,6249,5,0,393844,-82868,3053,-18,80,0,1 +1,6251,5,0,393844,-82868,3051,-17,80,0,1 +2,6251,5,0,393844,-82868,3051,-17,80,0,1 +1,6252,5,0,393844,-82868,3049,-17,81,0,1 +2,6252,5,0,393844,-82868,3049,-17,81,0,1 +1,6253,5,0,393844,-82868,3048,-16,80,0,1 +2,6253,5,0,393844,-82868,3048,-16,80,0,1 +1,6254,5,0,393844,-82868,3048,-15,80,0,1 +2,6254,5,0,393844,-82868,3048,-15,80,0,1 +1,6255,5,0,393844,-82868,3047,-15,80,0,1 +2,6257,5,0,393844,-82866,3045,-14,80,0,1 +2,6258,5,0,393844,-82866,3044,-13,81,0,1 +2,6259,5,0,393844,-82866,3042,-13,81,0,1 +1,6260,5,0,393844,-82866,3041,-13,80,0,1 +2,6260,5,0,393844,-82866,3041,-13,80,0,1 +1,6261,5,0,393844,-82866,3040,-13,81,0,1 +2,6261,5,0,393844,-82866,3040,-13,81,0,1 +1,6262,5,0,393844,-82866,3039,-12,81,0,1 +2,6262,5,0,393844,-82866,3039,-12,81,0,1 +1,6263,5,0,393844,-82866,3037,-13,81,0,1 +1,6267,5,0,393844,-82866,3025,-19,81,0,1 +2,6267,5,0,393844,-82866,3025,-19,81,0,1 +1,6268,5,0,393844,-82866,3022,-20,81,0,1 +2,6268,5,0,393844,-82866,3022,-20,81,0,1 +1,6269,5,0,393844,-82866,3021,-19,81,0,1 +2,6269,5,0,393844,-82866,3021,-19,81,0,1 +1,6270,5,0,393845,-82865,3021,-17,81,0,1 +1,6271,5,0,393845,-82865,3021,-16,80,0,1 +2,6271,5,0,393845,-82865,3021,-16,80,0,1 +1,6272,5,0,393845,-82865,3020,-15,81,0,1 +2,6276,5,0,393845,-82865,3014,-14,81,0,1 +1,6277,5,0,393846,-82864,3013,-14,81,0,1 +2,6277,5,0,393846,-82864,3013,-14,81,0,1 +1,6278,5,0,393846,-82864,3012,-13,80,0,1 +2,6278,5,0,393846,-82864,3012,-13,80,0,1 +1,6279,5,0,393846,-82864,3012,-13,80,0,1 +2,6279,5,0,393846,-82864,3012,-13,80,0,1 +1,6280,5,0,393846,-82864,3008,-15,80,0,1 +2,6280,5,0,393846,-82864,3008,-15,80,0,1 +1,6281,5,0,393846,-82864,3004,-17,81,0,1 +1,6284,5,0,393846,-82864,2996,-19,81,0,1 +1,6285,5,0,393846,-82864,2995,-18,80,0,1 +2,6285,5,0,393846,-82864,2995,-18,80,0,1 +1,6286,5,0,393846,-82864,2995,-17,81,0,1 +2,6286,5,0,393846,-82864,2995,-17,81,0,1 +1,6287,5,0,393846,-82864,2994,-16,80,0,1 +2,6287,5,0,393846,-82864,2994,-16,80,0,1 +2,6288,5,0,393846,-82864,2995,-14,81,0,1 +1,6289,5,0,393846,-82864,2994,-13,81,0,1 +1,6290,5,0,393846,-82864,2994,-12,81,0,1 +2,6290,5,0,393846,-82864,2994,-12,81,0,1 +2,6292,5,0,393847,-82862,2993,-11,80,0,1 +1,6293,5,0,393847,-82862,2991,-11,80,0,1 +2,6293,5,0,393847,-82862,2991,-11,80,0,1 +1,6295,5,0,393847,-82862,2987,-13,81,0,1 +2,6295,5,0,393847,-82862,2987,-13,81,0,1 +1,6297,5,0,393847,-82862,2982,-15,81,0,1 +2,6297,5,0,393847,-82862,2982,-15,81,0,1 +1,6298,5,0,393847,-82861,2980,-16,81,0,1 +2,6298,5,0,393847,-82861,2980,-16,81,0,1 +2,6300,5,0,393847,-82861,2975,-17,81,0,1 +1,6301,5,0,393847,-82861,2972,-18,81,0,1 +2,6301,5,0,393847,-82861,2972,-18,81,0,1 +1,6302,5,0,393847,-82861,2970,-19,80,0,1 +1,6303,5,0,393847,-82861,2967,-19,81,0,1 +2,6303,5,0,393847,-82861,2967,-19,81,0,1 +1,6304,5,0,393847,-82860,2965,-20,81,0,1 +2,6304,5,0,393847,-82860,2965,-20,81,0,1 +1,6305,5,0,393847,-82860,2964,-18,81,0,1 +2,6305,5,0,393847,-82860,2964,-18,81,0,1 +2,6308,5,0,393847,-82860,2966,-11,81,0,1 +1,6309,5,0,393847,-82860,2966,-11,81,0,1 +2,6309,5,0,393847,-82860,2966,-11,81,0,1 +1,6310,5,0,393847,-82860,2965,-11,81,0,1 +2,6310,5,0,393847,-82860,2965,-11,81,0,1 +1,6312,5,0,393847,-82860,2961,-12,81,0,1 +2,6312,5,0,393847,-82860,2961,-12,81,0,1 +1,6313,5,0,393847,-82860,2959,-13,80,0,1 +1,6314,5,0,393847,-82860,2957,-13,81,0,1 +2,6314,5,0,393847,-82860,2957,-13,81,0,1 +1,6315,5,0,393847,-82860,2956,-14,81,0,1 +1,6317,5,0,393847,-82860,2953,-13,81,0,1 +2,6317,5,0,393847,-82860,2953,-13,81,0,1 +1,6318,5,0,393847,-82860,2952,-13,81,0,1 +2,6318,5,0,393847,-82860,2952,-13,81,0,1 +1,6319,5,0,393847,-82860,2951,-13,80,0,1 +2,6319,5,0,393847,-82860,2951,-13,80,0,1 +1,6320,5,0,393847,-82860,2948,-14,81,0,1 +1,6321,5,0,393847,-82860,2945,-16,80,0,1 +2,6321,5,0,393847,-82860,2945,-16,80,0,1 +1,6322,5,0,393847,-82860,2943,-16,81,0,1 +1,6324,5,0,393847,-82860,2943,-13,81,0,1 +2,6324,5,0,393847,-82860,2943,-13,81,0,1 +1,6325,5,0,393847,-82860,2943,-12,81,0,1 +2,6325,5,0,393847,-82860,2943,-12,81,0,1 +1,6326,5,0,393847,-82860,2943,-11,81,0,1 +2,6326,5,0,393847,-82860,2943,-11,81,0,1 +1,6327,5,0,393849,-82858,2941,-11,81,0,1 +2,6327,5,0,393849,-82858,2941,-11,81,0,1 +1,6328,5,0,393849,-82858,2939,-12,80,0,1 +2,6328,5,0,393849,-82858,2939,-12,80,0,1 +1,6332,5,0,393849,-82857,2935,-12,81,0,1 +1,6333,5,0,393849,-82857,2933,-12,81,0,1 +2,6333,5,0,393849,-82857,2933,-12,81,0,1 +1,6334,5,0,393849,-82857,2932,-12,81,0,1 +1,6335,5,0,393849,-82857,2931,-12,81,0,1 +1,6337,5,0,393849,-82856,2928,-13,81,0,1 +1,6338,5,0,393849,-82856,2926,-13,81,0,1 +1,6339,5,0,393849,-82856,2924,-13,81,0,1 +2,6339,5,0,393849,-82856,2924,-13,81,0,1 +1,6341,5,0,393849,-82856,2920,-15,80,0,1 +2,6341,5,0,393849,-82856,2920,-15,80,0,1 +1,6342,5,0,393849,-82856,2917,-16,81,0,1 +1,6343,5,0,393849,-82856,2914,-17,80,0,1 +2,6343,5,0,393849,-82856,2914,-17,80,0,1 +1,6344,5,0,393850,-82855,2911,-19,81,0,1 +2,6344,5,0,393850,-82855,2911,-19,81,0,1 +1,6346,5,0,393850,-82855,2907,-19,81,0,1 +2,6346,5,0,393850,-82855,2907,-19,81,0,1 +1,6347,5,0,393850,-82855,2906,-18,81,0,1 +2,6347,5,0,393850,-82855,2906,-18,81,0,1 +1,6349,5,0,393850,-82855,2904,-16,81,0,1 +2,6349,5,0,393850,-82855,2904,-16,81,0,1 +1,6351,5,0,393850,-82855,2901,-16,81,0,1 +2,6351,5,0,393850,-82855,2901,-16,81,0,1 +1,6352,5,0,393850,-82854,2900,-15,81,0,1 +2,6352,5,0,393850,-82854,2900,-15,81,0,1 +1,6355,5,0,393850,-82854,2896,-15,80,0,1 +2,6355,5,0,393850,-82854,2896,-15,80,0,1 +1,6357,5,0,393850,-82854,2893,-14,81,0,1 +1,6360,5,0,393851,-82854,2890,-13,81,0,1 +2,6360,5,0,393851,-82854,2890,-13,81,0,1 +1,6361,5,0,393851,-82854,2889,-13,81,0,1 +2,6361,5,0,393851,-82854,2889,-13,81,0,1 +1,6362,5,0,393851,-82853,2888,-12,81,0,1 +2,6362,5,0,393851,-82853,2888,-12,81,0,1 +1,6363,5,0,393851,-82853,2887,-12,81,0,1 +1,6366,5,0,393851,-82853,2884,-12,81,0,1 +2,6366,5,0,393851,-82853,2884,-12,81,0,1 +1,6367,5,0,393851,-82853,2883,-11,81,0,1 +1,6369,5,0,393851,-82853,2877,-15,81,0,1 +2,6369,5,0,393851,-82853,2877,-15,81,0,1 +1,6370,5,0,393851,-82853,2875,-15,81,0,1 +2,6370,5,0,393851,-82853,2875,-15,81,0,1 +1,6371,5,0,393851,-82853,2874,-15,81,0,1 +2,6371,5,0,393851,-82853,2874,-15,81,0,1 +1,6372,5,0,393851,-82852,2873,-13,81,0,1 +2,6372,5,0,393851,-82852,2873,-13,81,0,1 +1,6375,5,0,393851,-82852,2869,-14,81,0,1 +2,6375,5,0,393851,-82852,2869,-14,81,0,1 +1,6378,5,0,393851,-82852,2865,-14,81,0,1 +2,6378,5,0,393851,-82852,2865,-14,81,0,1 +1,6379,5,0,393851,-82852,2862,-15,81,0,1 +1,6380,5,0,393851,-82852,2859,-16,81,0,1 +2,6380,5,0,393851,-82852,2859,-16,81,0,1 +1,6381,5,0,393851,-82852,2857,-17,80,0,1 +2,6381,5,0,393851,-82852,2857,-17,80,0,1 +1,6382,5,0,393851,-82852,2856,-16,81,0,1 +2,6382,5,0,393851,-82852,2856,-16,81,0,1 +1,6383,5,0,393851,-82852,2855,-15,81,0,1 +2,6383,5,0,393851,-82852,2855,-15,81,0,1 +1,6386,5,0,393851,-82852,2851,-15,81,0,1 +2,6386,5,0,393851,-82852,2851,-15,81,0,1 +1,6387,5,0,393851,-82852,2850,-14,81,0,1 +2,6387,5,0,393851,-82852,2850,-14,81,0,1 +1,6390,5,0,393850,-82850,2845,-15,81,0,1 +2,6390,5,0,393850,-82850,2845,-15,81,0,1 +1,6391,5,0,393850,-82850,2843,-16,81,0,1 +2,6391,5,0,393850,-82850,2843,-16,81,0,1 +1,6392,5,0,393850,-82849,2840,-17,81,0,1 +2,6392,5,0,393850,-82849,2840,-17,81,0,1 +2,6395,5,0,393850,-82849,2839,-13,81,0,1 +1,6395,5,0,393850,-82849,2839,-13,81,0,1 +1,6396,5,0,393850,-82849,2838,-12,81,0,1 +2,6396,5,0,393850,-82849,2838,-12,81,0,1 +1,6397,5,0,393850,-82849,2838,-12,81,0,1 +2,6397,5,0,393850,-82849,2838,-12,81,0,1 +1,6398,5,0,393850,-82849,2837,-11,81,0,1 +1,6399,5,0,393850,-82849,2835,-12,80,0,1 +1,6400,5,0,393850,-82848,2834,-12,81,0,1 +2,6400,5,0,393850,-82848,2834,-12,81,0,1 +1,6403,5,0,393850,-82848,2831,-11,81,0,1 +2,6403,5,0,393850,-82848,2831,-11,81,0,1 +1,6404,5,0,393850,-82848,2830,-10,81,0,1 +2,6404,5,0,393850,-82848,2830,-10,81,0,1 +1,6405,5,0,393850,-82848,2829,-10,80,0,1 +1,6406,5,0,393850,-82848,2828,-11,81,0,1 +2,6406,5,0,393850,-82848,2828,-11,81,0,1 +1,6407,5,0,393850,-82848,2825,-12,81,0,1 +2,6407,5,0,393850,-82848,2825,-12,81,0,1 +1,6410,5,0,393850,-82847,2819,-14,81,0,1 +2,6410,5,0,393850,-82847,2819,-14,81,0,1 +1,6411,5,0,393850,-82847,2818,-13,81,0,1 +2,6411,5,0,393850,-82847,2818,-13,81,0,1 +1,6412,5,0,393850,-82847,2817,-13,81,0,1 +2,6412,5,0,393850,-82847,2817,-13,81,0,1 +1,6413,5,0,393850,-82847,2816,-13,81,0,1 +2,6413,5,0,393850,-82847,2816,-13,81,0,1 +1,6414,5,0,393850,-82847,2814,-13,81,0,1 +2,6414,5,0,393850,-82847,2814,-13,81,0,1 +1,6415,5,0,393850,-82847,2813,-13,80,0,1 +2,6417,5,0,393850,-82847,2812,-12,81,0,1 +1,6418,5,0,393850,-82847,2810,-12,81,0,1 +2,6418,5,0,393850,-82847,2810,-12,81,0,1 +2,6419,5,0,393850,-82847,2809,-13,81,0,1 +1,6420,5,0,393850,-82847,2806,-14,81,0,1 +2,6420,5,0,393850,-82847,2806,-14,81,0,1 +1,6421,5,0,393850,-82847,2804,-14,81,0,1 +2,6421,5,0,393850,-82847,2804,-14,81,0,1 +1,6422,5,0,393850,-82847,2803,-13,81,0,1 +2,6422,5,0,393850,-82847,2803,-13,81,0,1 +1,6426,5,0,393850,-82847,2803,-9,80,0,1 +2,6426,5,0,393850,-82847,2803,-9,80,0,1 +1,6428,5,0,393850,-82845,2800,-10,81,0,1 +1,6429,5,0,393850,-82845,2798,-11,80,0,1 +2,6429,5,0,393850,-82845,2798,-11,80,0,1 +1,6430,5,0,393850,-82845,2797,-11,81,0,1 +1,6431,5,0,393850,-82845,2795,-11,81,0,1 +2,6431,5,0,393850,-82845,2795,-11,81,0,1 +1,6432,5,0,393850,-82845,2794,-12,81,0,1 +1,6435,5,0,393850,-82845,2790,-12,81,0,1 +2,6435,5,0,393850,-82845,2790,-12,81,0,1 +2,6436,5,0,393850,-82845,2788,-12,81,0,1 +1,6437,5,0,393850,-82844,2788,-12,81,0,1 +2,6437,5,0,393850,-82844,2788,-12,81,0,1 +1,6438,5,0,393850,-82844,2787,-11,81,0,1 +1,6439,5,0,393850,-82844,2786,-11,80,0,1 +1,6440,5,0,393850,-82844,2784,-11,81,0,1 +2,6440,5,0,393850,-82844,2784,-11,81,0,1 +1,6441,5,0,393850,-82844,2782,-12,81,0,1 +2,6441,5,0,393850,-82844,2782,-12,81,0,1 +1,6444,5,0,393850,-82844,2776,-14,81,0,1 +2,6444,5,0,393850,-82844,2776,-14,81,0,1 +1,6445,5,0,393850,-82844,2776,-13,81,0,1 +1,6446,5,0,393850,-82844,2775,-12,81,0,1 +1,6447,5,0,393850,-82844,2775,-11,81,0,1 +2,6447,5,0,393850,-82844,2775,-11,81,0,1 +1,6448,5,0,393850,-82844,2774,-12,81,0,1 +2,6449,5,0,393850,-82844,2772,-12,81,0,1 +1,6450,5,0,393850,-82844,2770,-12,81,0,1 +2,6450,5,0,393850,-82844,2770,-12,81,0,1 +1,6451,5,0,393850,-82844,2769,-12,81,0,1 +2,6452,5,0,393850,-82844,2768,-12,81,0,1 +1,6453,5,0,393850,-82844,2767,-12,81,0,1 +1,6456,5,0,393850,-82844,2764,-11,81,0,1 +2,6456,5,0,393850,-82844,2764,-11,81,0,1 +1,6457,5,0,393850,-82844,2763,-11,81,0,1 +1,6458,5,0,393850,-82844,2762,-11,81,0,1 +2,6458,5,0,393850,-82844,2762,-11,81,0,1 +1,6459,5,0,393850,-82844,2761,-11,81,0,1 +2,6461,5,0,393850,-82844,2757,-12,80,0,1 +1,6463,5,0,393850,-82844,2755,-12,81,0,1 +2,6463,5,0,393850,-82844,2755,-12,81,0,1 +1,6464,5,0,393849,-82842,2754,-12,81,0,1 +2,6464,5,0,393849,-82842,2754,-12,81,0,1 +1,6465,5,0,393849,-82842,2753,-12,81,0,1 +2,6466,5,0,393849,-82842,2752,-11,81,0,1 +1,6467,5,0,393849,-82841,2751,-11,81,0,1 +1,6468,5,0,393849,-82841,2748,-12,81,0,1 +2,6468,5,0,393849,-82841,2748,-12,81,0,1 +1,6471,5,0,393850,-82841,2742,-14,81,0,1 +2,6471,5,0,393850,-82841,2742,-14,81,0,1 +1,6472,5,0,393850,-82841,2741,-14,81,0,1 +2,6472,5,0,393850,-82841,2741,-14,81,0,1 +1,6473,5,0,393850,-82841,2741,-13,81,0,1 +1,6475,5,0,393850,-82841,2738,-13,81,0,1 +2,6475,5,0,393850,-82841,2738,-13,81,0,1 +1,6477,5,0,393850,-82841,2735,-14,81,0,1 +2,6477,5,0,393850,-82841,2735,-14,81,0,1 +1,6478,5,0,393850,-82841,2734,-13,81,0,1 +1,6480,5,0,393850,-82841,2732,-12,81,0,1 +1,6481,5,0,393850,-82841,2731,-12,81,0,1 +2,6481,5,0,393850,-82841,2731,-12,81,0,1 +1,6483,5,0,393850,-82841,2726,-14,81,0,1 +2,6483,5,0,393850,-82841,2726,-14,81,0,1 +2,6484,5,0,393850,-82841,2724,-15,81,0,1 +1,6486,5,0,393850,-82841,2720,-15,81,0,1 +2,6486,5,0,393850,-82841,2720,-15,81,0,1 +1,6487,5,0,393850,-82841,2720,-14,81,0,1 +2,6487,5,0,393850,-82841,2720,-14,81,0,1 +1,6491,5,0,393850,-82840,2716,-12,81,0,1 +2,6491,5,0,393850,-82840,2716,-12,81,0,1 +1,6492,5,0,393850,-82840,2715,-13,81,0,1 +2,6492,5,0,393850,-82840,2715,-13,81,0,1 +1,6495,5,0,393850,-82840,2711,-13,81,0,1 +1,6497,5,0,393850,-82839,2708,-13,80,0,1 +1,6498,5,0,393850,-82839,2707,-13,81,0,1 +1,6499,5,0,393850,-82839,2705,-13,81,0,1 +2,6499,5,0,393850,-82839,2705,-13,81,0,1 +1,6500,5,0,393850,-82839,2704,-13,80,0,1 +2,6500,5,0,393850,-82839,2704,-13,80,0,1 +1,6503,5,0,393850,-82839,2700,-12,81,0,1 +2,6503,5,0,393850,-82839,2700,-12,81,0,1 +1,6504,5,0,393850,-82839,2700,-12,81,0,1 +2,6504,5,0,393850,-82839,2700,-12,81,0,1 +1,6505,5,0,393850,-82839,2699,-11,81,0,1 +2,6506,5,0,393850,-82839,2698,-11,81,0,1 +1,6507,5,0,393850,-82838,2696,-12,81,0,1 +2,6507,5,0,393850,-82838,2696,-12,81,0,1 +1,6508,5,0,393850,-82838,2694,-13,81,0,1 +1,6511,5,0,393850,-82838,2688,-15,81,0,1 +2,6511,5,0,393850,-82838,2688,-15,81,0,1 +2,6512,5,0,393850,-82838,2687,-14,81,0,1 +2,6513,5,0,393850,-82838,2686,-14,81,0,1 +1,6514,5,0,393850,-82837,2684,-14,81,0,1 +2,6514,5,0,393850,-82837,2684,-14,81,0,1 +1,6515,5,0,393850,-82837,2683,-14,81,0,1 +2,6515,5,0,393850,-82837,2683,-14,81,0,1 +1,6517,5,0,393850,-82837,2681,-13,81,0,1 +2,6517,5,0,393850,-82837,2681,-13,81,0,1 +1,6520,5,0,393850,-82837,2675,-14,81,0,1 +2,6520,5,0,393850,-82837,2675,-14,81,0,1 +1,6521,5,0,393850,-82837,2675,-13,81,0,1 +2,6521,5,0,393850,-82837,2675,-13,81,0,1 +2,6523,5,0,393850,-82837,2672,-13,81,0,1 +1,6523,5,0,393850,-82837,2672,-13,81,0,1 +1,6524,5,0,393850,-82836,2671,-13,81,0,1 +2,6524,5,0,393850,-82836,2671,-13,81,0,1 +1,6526,5,0,393850,-82836,2670,-11,81,0,1 +2,6526,5,0,393850,-82836,2670,-11,81,0,1 +1,6529,5,0,393850,-82836,2665,-13,81,0,1 +1,6530,5,0,393850,-82835,2662,-14,80,0,1 +2,6530,5,0,393850,-82835,2662,-14,80,0,1 +1,6531,5,0,393850,-82835,2660,-15,81,0,1 +2,6531,5,0,393850,-82835,2660,-15,81,0,1 +1,6532,5,0,393850,-82835,2657,-16,81,0,1 +2,6532,5,0,393850,-82835,2657,-16,81,0,1 +1,6533,5,0,393850,-82835,2655,-16,81,0,1 +1,6534,5,0,393850,-82835,2653,-16,81,0,1 +1,6535,5,0,393850,-82835,2652,-17,81,0,1 +1,6537,5,0,393850,-82835,2648,-17,81,0,1 +2,6537,5,0,393850,-82835,2648,-17,81,0,1 +1,6539,5,0,393850,-82835,2645,-16,81,0,1 +1,6540,5,0,393850,-82835,2644,-16,81,0,1 +1,6541,5,0,393850,-82834,2642,-16,81,0,1 +1,6543,5,0,393850,-82834,2641,-14,81,0,1 +2,6543,5,0,393850,-82834,2641,-14,81,0,1 +1,6544,5,0,393850,-82834,2640,-13,81,0,1 +2,6544,5,0,393850,-82834,2640,-13,81,0,1 +1,6545,5,0,393850,-82834,2640,-12,81,0,1 +1,6546,5,0,393850,-82834,2640,-10,81,0,1 +2,6547,5,0,393850,-82834,2640,-9,81,0,1 +1,6548,5,0,393850,-82834,2641,-8,81,0,1 +2,6548,5,0,393850,-82834,2641,-8,81,0,1 +1,6550,5,0,393850,-82834,2640,-7,81,0,1 +2,6550,5,0,393850,-82834,2640,-7,81,0,1 +1,6551,5,0,393850,-82834,2639,-7,81,0,1 +2,6551,5,0,393850,-82834,2639,-7,81,0,1 +1,6552,5,0,393839,-82816,2638,-7,81,0,1 +2,6552,5,0,393839,-82816,2638,-7,81,0,1 +1,6557,5,0,393839,-82816,2634,-8,81,0,1 +2,6557,5,0,393839,-82816,2634,-8,81,0,1 +1,6558,5,0,393839,-82815,2632,-8,81,0,1 +2,6558,5,0,393839,-82815,2632,-8,81,0,1 +1,6559,5,0,393839,-82815,2631,-9,81,0,1 +2,6559,5,0,393839,-82815,2631,-9,81,0,1 +1,6560,5,0,393839,-82815,2630,-9,81,0,1 +2,6560,5,0,393839,-82815,2630,-9,81,0,1 +1,6561,5,0,393839,-82815,2629,-9,81,0,1 +2,6561,5,0,393839,-82815,2629,-9,81,0,1 +1,6564,5,0,393839,-82814,2626,-9,81,0,1 +2,6564,5,0,393839,-82814,2626,-9,81,0,1 +1,6565,5,0,393839,-82814,2624,-10,81,0,1 +2,6565,5,0,393839,-82814,2624,-10,81,0,1 +1,6566,5,0,393839,-82814,2623,-10,81,0,1 +2,6566,5,0,393839,-82814,2623,-10,81,0,1 +1,6567,5,0,393839,-82814,2621,-11,81,0,1 +2,6567,5,0,393839,-82814,2621,-11,81,0,1 +1,6568,5,0,393839,-82814,2620,-11,81,0,1 +2,6568,5,0,393839,-82814,2620,-11,81,0,1 +2,6571,5,0,393839,-82814,2615,-12,81,0,1 +1,6572,5,0,393839,-82813,2613,-13,81,0,1 +2,6572,5,0,393839,-82813,2613,-13,81,0,1 +1,6573,5,0,393839,-82813,2611,-14,81,0,1 +2,6573,5,0,393839,-82813,2611,-14,81,0,1 +2,6574,5,0,393839,-82813,2609,-14,81,0,1 +1,6575,5,0,393839,-82813,2607,-15,81,0,1 +2,6575,5,0,393839,-82813,2607,-15,81,0,1 +1,6576,5,0,393839,-82813,2606,-14,81,0,1 +2,6576,5,0,393839,-82813,2606,-14,81,0,1 +1,6578,5,0,393839,-82813,2603,-14,81,0,1 +1,6579,5,0,393839,-82813,2602,-14,81,0,1 +2,6579,5,0,393839,-82813,2602,-14,81,0,1 +1,6580,5,0,393839,-82813,2601,-14,81,0,1 +2,6580,5,0,393839,-82813,2601,-14,81,0,1 +1,6581,5,0,393839,-82812,2599,-14,81,0,1 +1,6583,5,0,393839,-82812,2595,-15,81,0,1 +2,6583,5,0,393839,-82812,2595,-15,81,0,1 +1,6584,5,0,393839,-82812,2593,-16,81,0,1 +2,6584,5,0,393839,-82812,2593,-16,81,0,1 +1,6587,5,0,393839,-82812,2589,-15,81,0,1 +2,6587,5,0,393839,-82812,2589,-15,81,0,1 +1,6589,5,0,393839,-82812,2588,-13,81,0,1 +2,6589,5,0,393839,-82812,2588,-13,81,0,1 +1,6590,5,0,393839,-82812,2587,-12,81,0,1 +2,6590,5,0,393839,-82812,2587,-12,81,0,1 +1,6591,5,0,393839,-82812,2586,-12,81,0,1 +2,6591,5,0,393839,-82812,2586,-12,81,0,1 +1,6592,5,0,393839,-82811,2585,-12,81,0,1 +2,6592,5,0,393839,-82811,2585,-12,81,0,1 +1,6595,5,0,393839,-82811,2580,-13,81,0,1 +2,6595,5,0,393839,-82811,2580,-13,81,0,1 +1,6597,5,0,393839,-82811,2578,-13,81,0,1 +2,6597,5,0,393839,-82811,2578,-13,81,0,1 +2,6598,5,0,393839,-82811,2576,-13,81,0,1 +2,6599,5,0,393839,-82811,2575,-13,81,0,1 +1,6600,5,0,393839,-82811,2574,-13,81,0,1 +2,6600,5,0,393839,-82811,2574,-13,81,0,1 +1,6601,5,0,393839,-82811,2572,-13,81,0,1 +2,6601,5,0,393839,-82811,2572,-13,81,0,1 +1,6603,5,0,393839,-82810,2570,-12,81,0,1 +2,6603,5,0,393839,-82810,2570,-12,81,0,1 +1,6604,5,0,393839,-82810,2568,-13,81,0,1 +2,6604,5,0,393839,-82810,2568,-13,81,0,1 +1,6605,5,0,393839,-82810,2566,-14,81,0,1 +2,6605,5,0,393839,-82810,2566,-14,81,0,1 +1,6606,5,0,393839,-82810,2564,-15,81,0,1 +2,6606,5,0,393839,-82810,2564,-15,81,0,1 +1,6607,5,0,393839,-82810,2561,-16,81,0,1 +2,6607,5,0,393839,-82810,2561,-16,81,0,1 +1,6609,5,0,393839,-82809,2559,-14,80,0,1 +1,6612,5,0,393839,-82809,2556,-13,81,0,1 +2,6612,5,0,393839,-82809,2556,-13,81,0,1 +1,6613,5,0,393839,-82809,2556,-12,81,0,1 +2,6614,5,0,393839,-82809,2553,-13,81,0,1 +1,6615,5,0,393839,-82808,2551,-14,81,0,1 +2,6615,5,0,393839,-82808,2551,-14,81,0,1 +1,6617,5,0,393839,-82808,2550,-13,81,0,1 +2,6617,5,0,393839,-82808,2550,-13,81,0,1 +1,6618,5,0,393839,-82808,2549,-12,81,0,1 +2,6618,5,0,393839,-82808,2549,-12,81,0,1 +1,6621,5,0,393839,-82807,2545,-13,81,0,1 +1,6623,5,0,393839,-82807,2543,-11,81,0,1 +2,6623,5,0,393839,-82807,2543,-11,81,0,1 +1,6624,5,0,393839,-82807,2543,-11,81,0,1 +2,6624,5,0,393839,-82807,2543,-11,81,0,1 +1,6625,5,0,393839,-82807,2542,-11,81,0,1 +2,6625,5,0,393839,-82807,2542,-11,81,0,1 +1,6626,5,0,393839,-82807,2540,-11,81,0,1 +2,6626,5,0,393839,-82807,2540,-11,81,0,1 +1,6628,5,0,393839,-82806,2535,-14,81,0,1 +2,6628,5,0,393839,-82806,2535,-14,81,0,1 +1,6630,5,0,393839,-82806,2531,-14,81,0,1 +2,6630,5,0,393839,-82806,2531,-14,81,0,1 +1,6631,5,0,393839,-82806,2531,-13,81,0,1 +2,6631,5,0,393839,-82806,2531,-13,81,0,1 +1,6632,5,0,393839,-82806,2530,-13,81,0,1 +2,6632,5,0,393839,-82806,2530,-13,81,0,1 +2,6633,5,0,393839,-82806,2528,-13,81,0,1 +1,6643,5,0,393839,-82804,2512,-16,81,0,1 +2,6643,5,0,393839,-82804,2512,-16,81,0,1 +2,6645,5,0,393839,-82804,2509,-16,81,0,1 +1,6645,5,0,393839,-82804,2509,-16,81,0,1 +2,6645,5,0,393839,-82804,2509,-16,81,0,1 +1,6646,5,0,393839,-82804,2507,-16,81,0,1 +2,6646,5,0,393839,-82804,2507,-16,81,0,1 +1,6647,5,0,393839,-82804,2505,-16,81,0,1 +2,6647,5,0,393839,-82804,2505,-16,81,0,1 +1,6648,5,0,393839,-82804,2503,-16,81,0,1 +2,6648,5,0,393839,-82804,2503,-16,81,0,1 +1,6649,5,0,393839,-82803,2502,-16,81,0,1 +2,6649,5,0,393839,-82803,2502,-16,81,0,1 +1,6651,5,0,393839,-82803,2501,-14,81,0,1 +2,6651,5,0,393839,-82803,2501,-14,81,0,1 +1,6652,5,0,393839,-82803,2500,-13,81,0,1 +2,6652,5,0,393839,-82803,2500,-13,81,0,1 +1,6653,5,0,393838,-82803,2501,-11,81,0,1 +1,6654,5,0,393838,-82803,2501,-10,81,0,1 +2,6654,5,0,393838,-82803,2501,-10,81,0,1 +1,6656,5,0,393838,-82803,2499,-10,81,0,1 +2,6656,5,0,393838,-82803,2499,-10,81,0,1 +1,6658,5,0,393838,-82802,2497,-9,84,0,1 +2,6658,5,0,393838,-82802,2497,-9,84,0,1 +1,6659,5,0,393838,-82802,2497,-9,83,0,1 +2,6659,5,0,393838,-82802,2497,-9,83,0,1 +1,6660,5,0,393838,-82802,2496,-9,81,0,1 +2,6660,5,0,393838,-82802,2496,-9,81,0,1 +2,6661,5,0,393838,-82802,2495,-9,81,0,1 +1,6662,5,0,393838,-82802,2493,-9,81,0,1 +1,6663,5,0,393838,-82801,2491,-11,81,0,1 +1,6665,5,0,393838,-82801,2488,-12,81,0,1 +2,6665,5,0,393838,-82801,2488,-12,81,0,1 +1,6666,5,0,393838,-82801,2487,-11,81,0,1 +2,6666,5,0,393838,-82801,2487,-11,81,0,1 +1,6667,5,0,393838,-82801,2487,-10,81,0,1 +2,6667,5,0,393838,-82801,2487,-10,81,0,1 +1,6668,5,0,393838,-82801,2486,-10,81,0,1 +2,6668,5,0,393838,-82801,2486,-10,81,0,1 +1,6669,5,0,393838,-82801,2485,-10,81,0,1 +2,6669,5,0,393838,-82801,2485,-10,81,0,1 +1,6671,5,0,393838,-82800,2482,-11,81,0,1 +2,6671,5,0,393838,-82800,2482,-11,81,0,1 +1,6673,5,0,393838,-82800,2480,-10,81,0,1 +1,6674,5,0,393838,-82800,2479,-11,80,0,1 +2,6674,5,0,393838,-82800,2479,-11,80,0,1 +1,6676,5,0,393838,-82800,2475,-12,80,0,1 +2,6676,5,0,393838,-82800,2475,-12,80,0,1 +1,6677,5,0,393838,-82800,2473,-13,81,0,1 +2,6677,5,0,393838,-82800,2473,-13,81,0,1 +1,6678,5,0,393838,-82800,2471,-14,81,0,1 +2,6678,5,0,393838,-82800,2471,-14,81,0,1 +1,6682,5,0,393838,-82799,2466,-13,80,0,1 +2,6682,5,0,393838,-82799,2466,-13,80,0,1 +1,6683,5,0,393838,-82799,2466,-12,80,0,1 +1,6684,5,0,393838,-82799,2465,-11,81,0,1 +2,6684,5,0,393838,-82799,2465,-11,81,0,1 +1,6685,5,0,393838,-82799,2464,-11,81,0,1 +2,6685,5,0,393838,-82799,2464,-11,81,0,1 +1,6686,5,0,393838,-82799,2463,-11,80,0,1 +2,6686,5,0,393838,-82799,2463,-11,80,0,1 +1,6689,5,0,393838,-82798,2459,-11,80,0,1 +2,6689,5,0,393838,-82798,2459,-11,80,0,1 +1,6690,5,0,393838,-82798,2459,-10,80,0,1 +2,6690,5,0,393838,-82798,2459,-10,80,0,1 +1,6691,5,0,393838,-82798,2458,-10,80,0,1 +2,6691,5,0,393838,-82798,2458,-10,80,0,1 +1,6692,5,0,393838,-82798,2457,-10,80,0,1 +1,6693,5,0,393838,-82798,2455,-11,80,0,1 +2,6693,5,0,393838,-82798,2455,-11,80,0,1 +1,6694,5,0,393838,-82798,2453,-12,80,0,1 +2,6694,5,0,393838,-82798,2453,-12,80,0,1 +2,6696,5,0,393838,-82797,2448,-14,80,0,1 +1,6697,5,0,393838,-82797,2447,-14,80,0,1 +2,6697,5,0,393838,-82797,2447,-14,80,0,1 +1,6698,5,0,393838,-82797,2446,-14,80,0,1 +2,6698,5,0,393838,-82797,2446,-14,80,0,1 +1,6699,5,0,393838,-82797,2445,-13,80,0,1 +2,6699,5,0,393838,-82797,2445,-13,80,0,1 +1,6700,5,0,393838,-82797,2445,-12,80,0,1 +2,6700,5,0,393838,-82797,2445,-12,80,0,1 +1,6703,5,0,393838,-82796,2444,-9,80,0,1 +2,6703,5,0,393838,-82796,2444,-9,80,0,1 +1,6704,5,0,393838,-82796,2443,-9,80,0,1 +1,6705,5,0,393838,-82796,2442,-9,80,0,1 +2,6705,5,0,393838,-82796,2442,-9,80,0,1 +1,6706,5,0,393838,-82796,2441,-9,80,0,1 +2,6706,5,0,393838,-82796,2441,-9,80,0,1 +1,6707,5,0,393838,-82796,2440,-10,80,0,1 +2,6707,5,0,393838,-82796,2440,-10,80,0,1 +1,6708,5,0,393838,-82796,2438,-10,80,0,1 +2,6708,5,0,393838,-82796,2438,-10,80,0,1 +2,6710,5,0,393838,-82796,2435,-12,80,0,1 +1,6712,5,0,393838,-82796,2432,-12,80,0,1 +2,6712,5,0,393838,-82796,2432,-12,80,0,1 +1,6713,5,0,393838,-82795,2431,-12,80,0,1 +1,6714,5,0,393838,-82795,2430,-11,80,0,1 +1,6715,5,0,393838,-82795,2429,-11,80,0,1 +2,6715,5,0,393838,-82795,2429,-11,80,0,1 +1,6716,5,0,393838,-82795,2427,-11,80,0,1 +2,6716,5,0,393838,-82795,2427,-11,80,0,1 +1,6718,5,0,393838,-82795,2424,-12,80,0,1 +2,6718,5,0,393838,-82795,2424,-12,80,0,1 +1,6719,5,0,393838,-82795,2423,-12,80,0,1 +2,6719,5,0,393838,-82795,2423,-12,80,0,1 +1,6720,5,0,393838,-82795,2421,-13,80,0,1 +1,6721,5,0,393838,-82795,2419,-13,80,0,1 +2,6721,5,0,393838,-82795,2419,-13,80,0,1 +1,6722,5,0,393838,-82795,2418,-13,80,0,1 +2,6722,5,0,393838,-82795,2418,-13,80,0,1 +1,6723,5,0,393838,-82795,2416,-14,80,0,1 +2,6723,5,0,393838,-82795,2416,-14,80,0,1 +1,6726,5,0,393838,-82795,2414,-12,80,0,1 +2,6726,5,0,393838,-82795,2414,-12,80,0,1 +2,6727,5,0,393838,-82794,2413,-12,80,0,1 +1,6728,5,0,393838,-82794,2412,-11,80,0,1 +2,6728,5,0,393838,-82794,2412,-11,80,0,1 +1,6729,5,0,393838,-82794,2411,-11,80,0,1 +2,6729,5,0,393838,-82794,2411,-11,80,0,1 +1,6730,5,0,393838,-82794,2410,-11,80,0,1 +1,6731,5,0,393839,-82794,2408,-12,80,0,1 +1,6734,5,0,393839,-82794,2403,-13,80,0,1 +1,6736,5,0,393839,-82794,2400,-13,80,0,1 +2,6736,5,0,393839,-82794,2400,-13,80,0,1 +1,6737,5,0,393839,-82794,2399,-13,80,0,1 +2,6737,5,0,393839,-82794,2399,-13,80,0,1 +1,6738,5,0,393839,-82794,2398,-13,80,0,1 +1,6739,5,0,393839,-82794,2397,-13,80,0,1 +2,6739,5,0,393839,-82794,2397,-13,80,0,1 +1,6740,5,0,393839,-82794,2395,-12,80,0,1 +2,6740,5,0,393839,-82794,2395,-12,80,0,1 +1,6742,5,0,393839,-82793,2392,-13,80,0,1 +1,6743,5,0,393839,-82793,2391,-13,80,0,1 +2,6743,5,0,393839,-82793,2391,-13,80,0,1 +1,6745,5,0,393840,-82793,2388,-13,80,0,1 +2,6745,5,0,393840,-82793,2388,-13,80,0,1 +1,6746,5,0,393840,-82793,2388,-12,80,0,1 +2,6746,5,0,393840,-82793,2388,-12,80,0,1 +1,6747,5,0,393840,-82793,2386,-12,80,0,1 +2,6747,5,0,393840,-82793,2386,-12,80,0,1 +1,6748,5,0,393840,-82793,2385,-12,80,0,1 +2,6748,5,0,393840,-82793,2385,-12,80,0,1 +1,6751,5,0,393840,-82793,2378,-15,80,0,1 +2,6751,5,0,393840,-82793,2378,-15,80,0,1 +2,6752,5,0,393840,-82793,2377,-14,80,0,1 +1,6754,5,0,393840,-82793,2374,-14,80,0,1 +2,6754,5,0,393840,-82793,2374,-14,80,0,1 +1,6756,5,0,393840,-82793,2373,-13,80,0,1 +1,6757,5,0,393840,-82792,2371,-14,80,0,1 +2,6757,5,0,393840,-82792,2371,-14,80,0,1 +1,6758,5,0,393840,-82792,2369,-14,80,0,1 +1,6760,5,0,393840,-82792,2367,-13,80,0,1 +1,6761,5,0,393840,-82792,2366,-13,80,0,1 +2,6761,5,0,393840,-82792,2366,-13,80,0,1 +1,6762,5,0,393840,-82792,2365,-13,80,0,1 +1,6763,5,0,393840,-82792,2363,-13,80,0,1 +1,6764,5,0,393840,-82792,2362,-13,80,0,1 +2,6764,5,0,393840,-82792,2362,-13,80,0,1 +1,6765,5,0,393840,-82792,2361,-12,80,0,1 +2,6765,5,0,393840,-82792,2361,-12,80,0,1 +1,6766,5,0,393840,-82792,2360,-12,80,0,1 +2,6766,5,0,393840,-82792,2360,-12,80,0,1 +1,6769,5,0,393840,-82792,2358,-10,80,0,1 +2,6769,5,0,393840,-82792,2358,-10,80,0,1 +1,6770,5,0,393840,-82792,2358,-10,80,0,1 +2,6770,5,0,393840,-82792,2358,-10,80,0,1 +1,6771,5,0,393840,-82792,2356,-10,80,0,1 +2,6771,5,0,393840,-82792,2356,-10,80,0,1 +1,6772,5,0,393840,-82792,2355,-10,80,0,1 +2,6772,5,0,393840,-82792,2355,-10,80,0,1 +1,6773,5,0,393840,-82792,2353,-11,80,0,1 +1,6776,5,0,393840,-82792,2349,-12,80,0,1 +2,6776,5,0,393840,-82792,2349,-12,80,0,1 +1,6777,5,0,393840,-82791,2347,-12,80,0,1 +2,6777,5,0,393840,-82791,2347,-12,80,0,1 +1,6778,5,0,393840,-82791,2345,-13,80,0,1 +2,6778,5,0,393840,-82791,2345,-13,80,0,1 +1,6779,5,0,393840,-82791,2344,-13,80,0,1 +2,6779,5,0,393840,-82791,2344,-13,80,0,1 +1,6780,5,0,393840,-82791,2343,-13,80,0,1 +2,6780,5,0,393840,-82791,2343,-13,80,0,1 +1,6783,5,0,393840,-82791,2341,-11,80,0,1 +2,6783,5,0,393840,-82791,2341,-11,80,0,1 +1,6785,5,0,393840,-82791,2338,-12,80,0,1 +2,6785,5,0,393840,-82791,2338,-12,80,0,1 +2,6786,5,0,393840,-82791,2336,-12,80,0,1 +1,6787,5,0,393840,-82791,2334,-12,80,0,1 +2,6787,5,0,393840,-82791,2334,-12,80,0,1 +1,6788,5,0,393840,-82791,2333,-13,80,0,1 +2,6788,5,0,393840,-82791,2333,-13,80,0,1 +1,6789,5,0,393840,-82790,2331,-13,80,0,1 +2,6789,5,0,393840,-82790,2331,-13,80,0,1 +1,6792,5,0,393840,-82790,2325,-15,80,0,1 +2,6792,5,0,393840,-82790,2325,-15,80,0,1 +1,6793,5,0,393840,-82790,2323,-16,80,0,1 +1,6794,5,0,393840,-82790,2320,-17,80,0,1 +2,6794,5,0,393840,-82790,2320,-17,80,0,1 +1,6795,5,0,393840,-82790,2318,-17,80,0,1 +1,6796,5,0,393840,-82790,2316,-17,80,0,1 +2,6796,5,0,393840,-82790,2316,-17,80,0,1 +1,6797,5,0,393840,-82790,2315,-17,80,0,1 +2,6797,5,0,393840,-82790,2315,-17,80,0,1 +2,6799,5,0,393840,-82789,2313,-15,80,0,1 +1,6801,5,0,393840,-82789,2312,-13,80,0,1 +2,6801,5,0,393840,-82789,2312,-13,80,0,1 +1,6802,5,0,393840,-82789,2312,-12,80,0,1 +2,6802,5,0,393840,-82789,2312,-12,80,0,1 +1,6803,5,0,393840,-82789,2311,-11,80,0,1 +2,6803,5,0,393840,-82789,2311,-11,80,0,1 +1,6804,5,0,393840,-82789,2310,-11,80,0,1 +2,6804,5,0,393840,-82789,2310,-11,80,0,1 +1,6806,5,0,393840,-82789,2308,-11,80,0,1 +2,6806,5,0,393840,-82789,2308,-11,80,0,1 +1,6808,5,0,393840,-82789,2306,-11,80,0,1 +2,6808,5,0,393840,-82789,2306,-11,80,0,1 +1,6809,5,0,393840,-82788,2306,-10,80,0,1 +2,6809,5,0,393840,-82788,2306,-10,80,0,1 +1,6810,5,0,393840,-82788,2305,-9,80,0,1 +2,6810,5,0,393840,-82788,2305,-9,80,0,1 +1,6811,5,0,393840,-82788,2305,-9,80,0,1 +2,6811,5,0,393840,-82788,2305,-9,80,0,1 +1,6814,5,0,393840,-82788,2301,-10,80,0,1 +1,6816,5,0,393840,-82788,2297,-12,80,0,1 +2,6816,5,0,393840,-82788,2297,-12,80,0,1 +1,6817,5,0,393840,-82788,2295,-12,80,0,1 +2,6817,5,0,393840,-82788,2295,-12,80,0,1 +1,6818,5,0,393840,-82788,2293,-13,80,0,1 +2,6818,5,0,393840,-82788,2293,-13,80,0,1 +1,6819,5,0,393840,-82788,2291,-14,80,0,1 +2,6819,5,0,393840,-82788,2291,-14,80,0,1 +1,6820,5,0,393840,-82788,2289,-14,80,0,1 +2,6820,5,0,393840,-82788,2289,-14,80,0,1 +1,6822,5,0,393840,-82787,2286,-15,80,0,1 +1,6823,5,0,393840,-82787,2284,-14,80,0,1 +2,6823,5,0,393840,-82787,2284,-14,80,0,1 +1,6824,5,0,393840,-82787,2284,-14,80,0,1 +1,6825,5,0,393840,-82787,2283,-13,80,0,1 +2,6825,5,0,393840,-82787,2283,-13,80,0,1 +1,6826,5,0,393840,-82787,2283,-11,80,0,1 +2,6826,5,0,393840,-82787,2283,-11,80,0,1 +1,6827,5,0,393840,-82787,2283,-10,80,0,1 +1,6830,5,0,393840,-82787,2279,-11,80,0,1 +2,6830,5,0,393840,-82787,2279,-11,80,0,1 +1,6831,5,0,393840,-82787,2278,-11,80,0,1 +2,6831,5,0,393840,-82787,2278,-11,80,0,1 +1,6832,5,0,393840,-82787,2277,-11,80,0,1 +1,6833,5,0,393840,-82787,2276,-10,80,0,1 +1,6835,5,0,393840,-82787,2274,-10,80,0,1 +1,6836,5,0,393840,-82787,2273,-11,80,0,1 +2,6836,5,0,393840,-82787,2273,-11,80,0,1 +1,6837,5,0,393840,-82787,2271,-11,80,0,1 +2,6837,5,0,393840,-82787,2271,-11,80,0,1 +1,6840,5,0,393840,-82787,2267,-12,80,0,1 +2,6840,5,0,393840,-82787,2267,-12,80,0,1 +1,6841,5,0,393840,-82786,2265,-12,80,0,1 +2,6841,5,0,393840,-82786,2265,-12,80,0,1 +1,6842,5,0,393840,-82786,2264,-12,80,0,1 +2,6842,5,0,393840,-82786,2264,-12,80,0,1 +1,6843,5,0,393840,-82786,2263,-12,80,0,1 +1,6845,5,0,393840,-82786,2260,-12,80,0,1 +2,6845,5,0,393840,-82786,2260,-12,80,0,1 +1,6847,5,0,393840,-82786,2258,-12,80,0,1 +2,6847,5,0,393840,-82786,2258,-12,80,0,1 +1,6848,5,0,393840,-82786,2257,-12,80,0,1 +1,6851,5,0,393840,-82786,2253,-12,80,0,1 +1,6865,5,0,393841,-82785,2237,-11,80,0,1 +1,6871,5,0,393841,-82785,2231,-10,80,0,1 +1,6877,5,0,393841,-82785,2223,-12,80,0,1 +1,6878,5,0,393841,-82785,2221,-12,80,0,1 +1,6881,5,0,393841,-82785,2217,-13,80,0,1 +2,6881,5,0,393841,-82785,2217,-13,80,0,1 +1,6884,5,0,393841,-82785,2212,-13,80,0,1 +1,6885,5,0,393842,-82785,2211,-13,80,0,1 +2,6885,5,0,393842,-82785,2211,-13,80,0,1 +1,6886,5,0,393842,-82785,2209,-13,79,0,1 +2,6886,5,0,393842,-82785,2209,-13,79,0,1 +1,6887,5,0,393842,-82785,2208,-13,78,0,1 +2,6887,5,0,393842,-82785,2208,-13,78,0,1 +1,6888,5,0,393842,-82785,2207,-13,80,0,1 +2,6888,5,0,393842,-82785,2207,-13,80,0,1 +1,6889,5,0,393842,-82785,2206,-13,80,0,1 +2,6889,5,0,393842,-82785,2206,-13,80,0,1 +2,6891,5,0,393842,-82784,2203,-13,80,0,1 +1,6893,5,0,393842,-82784,2201,-12,80,0,1 +2,6894,5,0,393842,-82784,2201,-11,80,0,1 +2,6897,5,0,393842,-82784,2198,-10,80,0,1 +1,6898,5,0,393842,-82784,2197,-11,80,0,1 +2,6898,5,0,393842,-82784,2197,-11,80,0,1 +1,6899,5,0,393843,-82784,2195,-12,80,0,1 +2,6899,5,0,393843,-82784,2195,-12,80,0,1 +1,6900,5,0,393843,-82784,2192,-13,80,0,1 +2,6900,5,0,393843,-82784,2192,-13,80,0,1 +1,6903,5,0,393843,-82784,2186,-15,80,0,1 +2,6903,5,0,393843,-82784,2186,-15,80,0,1 +1,6904,5,0,393843,-82784,2184,-16,80,0,1 +2,6904,5,0,393843,-82784,2184,-16,80,0,1 +1,6905,5,0,393843,-82783,2182,-16,80,0,1 +2,6905,5,0,393843,-82783,2182,-16,80,0,1 +2,6906,5,0,393843,-82783,2180,-16,80,0,1 +1,6907,5,0,393843,-82783,2179,-16,80,0,1 +2,6907,5,0,393843,-82783,2179,-16,80,0,1 +1,6909,5,0,393844,-82783,2178,-14,80,0,1 +1,6910,5,0,393844,-82783,2178,-12,80,0,1 +1,6911,5,0,393844,-82783,2177,-11,80,0,1 +2,6911,5,0,393844,-82783,2177,-11,80,0,1 +1,6912,5,0,393844,-82783,2177,-11,80,0,1 +2,6912,5,0,393844,-82783,2177,-11,80,0,1 +1,6914,5,0,393844,-82783,2175,-11,80,0,1 +1,6915,5,0,393844,-82782,2174,-11,80,0,1 +1,6916,5,0,393844,-82782,2173,-10,80,0,1 +2,6916,5,0,393844,-82782,2173,-10,80,0,1 +1,6921,5,0,393845,-82781,2166,-12,80,0,1 +2,6921,5,0,393845,-82781,2166,-12,80,0,1 +1,6922,5,0,393845,-82781,2164,-12,80,0,1 +2,6922,5,0,393845,-82781,2164,-12,80,0,1 +1,6923,5,0,393845,-82781,2164,-12,80,0,1 +2,6925,5,0,393845,-82781,2161,-12,80,0,1 +1,6926,5,0,393845,-82781,2160,-12,80,0,1 +2,6926,5,0,393845,-82781,2160,-12,80,0,1 +1,6927,5,0,393845,-82781,2159,-11,80,0,1 +2,6927,5,0,393845,-82781,2159,-11,80,0,1 +1,6929,5,0,393846,-82780,2157,-11,80,0,1 +2,6929,5,0,393846,-82780,2157,-11,80,0,1 +1,6930,5,0,393846,-82780,2155,-13,80,0,1 +2,6930,5,0,393846,-82780,2155,-13,80,0,1 +1,6931,5,0,393846,-82780,2153,-13,80,0,1 +2,6931,5,0,393846,-82780,2153,-13,80,0,1 +1,6932,5,0,393846,-82780,2153,-12,80,0,1 +2,6932,5,0,393846,-82780,2153,-12,80,0,1 +1,6933,5,0,393846,-82780,2152,-12,80,0,1 +1,6935,5,0,393846,-82779,2149,-12,80,0,1 +1,6936,5,0,393846,-82779,2149,-11,80,0,1 +1,6939,5,0,393847,-82779,2144,-13,80,0,1 +2,6939,5,0,393847,-82779,2144,-13,80,0,1 +1,6940,5,0,393847,-82779,2142,-13,80,0,1 +2,6940,5,0,393847,-82779,2142,-13,80,0,1 +1,6941,5,0,393847,-82779,2141,-13,80,0,1 +2,6941,5,0,393847,-82779,2141,-13,80,0,1 +1,6942,5,0,393847,-82779,2141,-12,80,0,1 +2,6942,5,0,393847,-82779,2141,-12,80,0,1 +1,6943,5,0,393847,-82778,2140,-11,81,0,1 +2,6943,5,0,393847,-82778,2140,-11,81,0,1 +1,6945,5,0,393847,-82778,2137,-12,81,0,1 +2,6945,5,0,393847,-82778,2137,-12,81,0,1 +1,6946,5,0,393847,-82778,2135,-12,80,0,1 +2,6946,5,0,393847,-82778,2135,-12,80,0,1 +1,6947,5,0,393848,-82778,2134,-12,80,0,1 +2,6947,5,0,393848,-82778,2134,-12,80,0,1 +1,6948,5,0,393848,-82778,2132,-12,80,0,1 +2,6948,5,0,393848,-82778,2132,-12,80,0,1 +1,6949,5,0,393848,-82778,2131,-12,80,0,1 +2,6949,5,0,393848,-82778,2131,-12,80,0,1 +1,6951,5,0,393848,-82778,2129,-12,80,0,1 +2,6951,5,0,393848,-82778,2129,-12,80,0,1 +1,6952,5,0,393848,-82778,2128,-12,81,0,1 +1,6954,5,0,393848,-82777,2125,-12,80,0,1 +2,6954,5,0,393848,-82777,2125,-12,80,0,1 +1,6956,5,0,393848,-82777,2122,-12,80,0,1 +2,6956,5,0,393848,-82777,2122,-12,80,0,1 +1,6957,5,0,393849,-82777,2121,-12,81,0,1 +2,6957,5,0,393849,-82777,2121,-12,81,0,1 +1,6958,5,0,393849,-82777,2120,-12,80,0,1 +2,6958,5,0,393849,-82777,2120,-12,80,0,1 +1,6971,5,0,393850,-82775,2107,-11,80,0,1 +1,6976,5,0,393850,-82775,2095,-16,81,0,1 +1,6983,5,0,393851,-82773,2081,-17,81,0,1 +2,6983,5,0,393851,-82773,2081,-17,81,0,1 +1,6985,5,0,393851,-82773,2079,-16,81,0,1 +1,6991,5,0,393851,-82772,2073,-13,81,0,1 +2,6991,5,0,393851,-82772,2073,-13,81,0,1 +1,6992,5,0,393851,-82772,2073,-11,80,0,1 +1,6994,5,0,393851,-82772,2073,-9,80,0,1 +1,6995,5,0,393851,-82771,2074,-8,81,0,1 +1,6996,5,0,393851,-82771,2073,-7,81,0,1 +2,6996,5,0,393851,-82771,2073,-7,81,0,1 +1,6997,5,0,393851,-82771,2073,-7,80,0,1 +2,6997,5,0,393851,-82771,2073,-7,80,0,1 +1,7000,5,0,393851,-82771,2071,-6,81,0,1 +2,7000,5,0,393851,-82771,2071,-6,81,0,1 +1,7001,5,0,393851,-82770,2070,-7,81,0,1 +2,7001,5,0,393851,-82770,2070,-7,81,0,1 +1,7005,5,0,393852,-82770,2066,-8,80,0,1 +2,7005,5,0,393852,-82770,2066,-8,80,0,1 +1,7006,5,0,393852,-82770,2065,-8,81,0,1 +2,7006,5,0,393852,-82770,2065,-8,81,0,1 +1,7007,5,0,393852,-82769,2064,-8,81,0,1 +2,7007,5,0,393852,-82769,2064,-8,81,0,1 +1,7008,5,0,393852,-82769,2063,-8,81,0,1 +2,7008,5,0,393852,-82769,2063,-8,81,0,1 +1,7009,5,0,393852,-82769,2062,-9,81,0,1 +2,7009,5,0,393852,-82769,2062,-9,81,0,1 +1,7012,5,0,393852,-82769,2057,-10,81,0,1 +1,7013,5,0,393852,-82768,2056,-11,81,0,1 +2,7013,5,0,393852,-82768,2056,-11,81,0,1 +1,7014,5,0,393852,-82768,2054,-11,80,0,1 +1,7015,5,0,393852,-82768,2052,-12,80,0,1 +2,7015,5,0,393852,-82768,2052,-12,80,0,1 +1,7016,5,0,393852,-82768,2051,-12,81,0,1 +2,7016,5,0,393852,-82768,2051,-12,81,0,1 +1,7017,5,0,393852,-82768,2049,-13,80,0,1 +2,7017,5,0,393852,-82768,2049,-13,80,0,1 +1,7018,5,0,393852,-82768,2047,-14,81,0,1 +1,7020,5,0,393852,-82768,2044,-13,81,0,1 +2,7020,5,0,393852,-82768,2044,-13,81,0,1 +1,7021,5,0,393852,-82767,2043,-13,80,0,1 +2,7021,5,0,393852,-82767,2043,-13,80,0,1 +1,7022,5,0,393852,-82767,2042,-13,80,0,1 +2,7022,5,0,393852,-82767,2042,-13,80,0,1 +1,7023,5,0,393852,-82767,2041,-13,80,0,1 +2,7023,5,0,393852,-82767,2041,-13,80,0,1 +1,7024,5,0,393852,-82767,2040,-12,81,0,1 +2,7024,5,0,393852,-82767,2040,-12,81,0,1 +1,7027,5,0,393852,-82767,2037,-12,81,0,1 +2,7027,5,0,393852,-82767,2037,-12,81,0,1 +1,7028,5,0,393852,-82767,2036,-12,81,0,1 +2,7028,5,0,393852,-82767,2036,-12,81,0,1 +1,7029,5,0,393852,-82767,2035,-11,80,0,1 +2,7029,5,0,393852,-82767,2035,-11,80,0,1 +1,7030,5,0,393852,-82767,2034,-11,81,0,1 +2,7030,5,0,393852,-82767,2034,-11,81,0,1 +1,7031,5,0,393852,-82767,2032,-11,81,0,1 +1,7034,5,0,393852,-82767,2029,-12,81,0,1 +1,7035,5,0,393852,-82766,2027,-11,81,0,1 +1,7036,5,0,393852,-82766,2026,-12,81,0,1 +2,7036,5,0,393852,-82766,2026,-12,81,0,1 +1,7037,5,0,393852,-82766,2024,-12,80,0,1 +2,7037,5,0,393852,-82766,2024,-12,80,0,1 +1,7038,5,0,393852,-82766,2023,-12,80,0,1 +2,7038,5,0,393852,-82766,2023,-12,80,0,1 +1,7039,5,0,393852,-82766,2021,-12,81,0,1 +2,7039,5,0,393852,-82766,2021,-12,81,0,1 +1,7041,5,0,393852,-82766,2018,-13,81,0,1 +2,7041,5,0,393852,-82766,2018,-13,81,0,1 +1,7042,5,0,393852,-82766,2016,-13,81,0,1 +2,7042,5,0,393852,-82766,2016,-13,81,0,1 +1,7043,5,0,393852,-82766,2015,-14,81,0,1 +2,7043,5,0,393852,-82766,2015,-14,81,0,1 +1,7044,5,0,393852,-82766,2013,-14,80,0,1 +2,7044,5,0,393852,-82766,2013,-14,80,0,1 +1,7045,5,0,393852,-82766,2012,-14,80,0,1 +1,7046,5,0,393852,-82766,2011,-14,81,0,1 +2,7046,5,0,393852,-82766,2011,-14,81,0,1 +1,7049,5,0,393853,-82766,2005,-15,81,0,1 +2,7049,5,0,393853,-82766,2005,-15,81,0,1 +1,7050,5,0,393853,-82766,2003,-15,81,0,1 +2,7050,5,0,393853,-82766,2003,-15,81,0,1 +1,7051,5,0,393853,-82765,2000,-16,81,0,1 +2,7051,5,0,393853,-82765,2000,-16,81,0,1 +2,7052,5,0,393853,-82765,1998,-17,81,0,1 +1,7053,5,0,393853,-82765,1997,-17,81,0,1 +2,7053,5,0,393853,-82765,1997,-17,81,0,1 +1,7054,5,0,393853,-82765,1996,-16,81,0,1 +2,7056,5,0,393853,-82765,1996,-12,81,0,1 +1,7057,5,0,393853,-82765,1995,-12,81,0,1 +2,7057,5,0,393853,-82765,1995,-12,81,0,1 +1,7058,5,0,393853,-82765,1994,-13,81,0,1 +2,7058,5,0,393853,-82765,1994,-13,81,0,1 +1,7059,5,0,393854,-82765,1992,-13,81,0,1 +2,7059,5,0,393854,-82765,1992,-13,81,0,1 +1,7060,5,0,393854,-82765,1991,-13,81,0,1 +2,7060,5,0,393854,-82765,1991,-13,81,0,1 +1,7061,5,0,393854,-82764,1989,-13,80,0,1 +2,7061,5,0,393854,-82764,1989,-13,80,0,1 +1,7063,5,0,393854,-82764,1987,-13,81,0,1 +2,7063,5,0,393854,-82764,1987,-13,81,0,1 +1,7064,5,0,393854,-82764,1985,-13,81,0,1 +2,7064,5,0,393854,-82764,1985,-13,81,0,1 +1,7065,5,0,393854,-82764,1983,-13,81,0,1 +2,7065,5,0,393854,-82764,1983,-13,81,0,1 +1,7066,5,0,393854,-82764,1982,-13,81,0,1 +2,7066,5,0,393854,-82764,1982,-13,81,0,1 +1,7067,5,0,393854,-82764,1981,-14,80,0,1 +2,7067,5,0,393854,-82764,1981,-14,80,0,1 +1,7070,5,0,393855,-82763,1976,-14,81,0,1 +2,7070,5,0,393855,-82763,1976,-14,81,0,1 +1,7071,5,0,393855,-82763,1975,-13,81,0,1 +2,7071,5,0,393855,-82763,1975,-13,81,0,1 +1,7072,5,0,393855,-82763,1974,-13,80,0,1 +1,7073,5,0,393855,-82763,1973,-13,81,0,1 +2,7073,5,0,393855,-82763,1973,-13,81,0,1 +1,7074,5,0,393855,-82763,1972,-13,81,0,1 +1,7076,5,0,393855,-82762,1970,-12,81,0,1 +1,7077,5,0,393855,-82762,1969,-12,81,0,1 +2,7077,5,0,393855,-82762,1969,-12,81,0,1 +2,7078,5,0,393855,-82762,1968,-11,81,0,1 +1,7078,5,0,393855,-82762,1968,-11,81,0,1 +1,7079,5,0,393855,-82762,1967,-11,80,0,1 +2,7079,5,0,393855,-82762,1967,-11,80,0,1 +1,7080,5,0,393855,-82762,1967,-10,81,0,1 +2,7080,5,0,393855,-82762,1967,-10,81,0,1 +1,7081,5,0,393855,-82762,1966,-10,80,0,1 +2,7081,5,0,393855,-82762,1966,-10,80,0,1 +1,7082,5,0,393855,-82762,1965,-10,81,0,1 +2,7082,5,0,393855,-82762,1965,-10,81,0,1 +1,7086,5,0,393856,-82761,1957,-13,81,0,1 +2,7086,5,0,393856,-82761,1957,-13,81,0,1 +1,7087,5,0,393856,-82761,1954,-15,80,0,1 +2,7087,5,0,393856,-82761,1954,-15,80,0,1 +2,7088,5,0,393856,-82761,1952,-15,80,0,1 +1,7089,5,0,393856,-82760,1950,-16,81,0,1 +2,7089,5,0,393856,-82760,1950,-16,81,0,1 +1,7092,5,0,393856,-82760,1947,-15,80,0,1 +1,7093,5,0,393856,-82760,1945,-14,81,0,1 +2,7093,5,0,393856,-82760,1945,-14,81,0,1 +1,7094,5,0,393856,-82760,1944,-15,81,0,1 +2,7094,5,0,393856,-82760,1944,-15,81,0,1 +1,7096,5,0,393857,-82759,1941,-15,81,0,1 +1,7098,5,0,393857,-82759,1937,-15,80,0,1 +1,7099,5,0,393857,-82759,1935,-15,81,0,1 +2,7099,5,0,393857,-82759,1935,-15,81,0,1 +1,7100,5,0,393857,-82759,1934,-15,81,0,1 +1,7103,5,0,393857,-82758,1931,-14,80,0,1 +2,7103,5,0,393857,-82758,1931,-14,80,0,1 +1,7104,5,0,393857,-82758,1930,-14,80,0,1 +2,7104,5,0,393857,-82758,1930,-14,80,0,1 +1,7105,5,0,393857,-82758,1928,-13,81,0,1 +2,7105,5,0,393857,-82758,1928,-13,81,0,1 +1,7106,5,0,393857,-82758,1927,-13,81,0,1 +2,7106,5,0,393857,-82758,1927,-13,81,0,1 +1,7107,5,0,393858,-82757,1927,-12,81,0,1 +2,7107,5,0,393858,-82757,1927,-12,81,0,1 +1,7109,5,0,393858,-82757,1927,-9,81,0,1 +2,7110,5,0,393858,-82757,1927,-9,81,0,1 +1,7111,5,0,393858,-82757,1926,-9,80,0,1 +2,7111,5,0,393858,-82757,1926,-9,80,0,1 +1,7112,5,0,393858,-82757,1926,-8,81,0,1 +1,7114,5,0,393858,-82757,1924,-8,80,0,1 +2,7114,5,0,393858,-82757,1924,-8,80,0,1 +1,7115,5,0,393858,-82756,1922,-10,80,0,1 +1,7116,5,0,393858,-82756,1919,-11,80,0,1 +2,7116,5,0,393858,-82756,1919,-11,80,0,1 +1,7117,5,0,393858,-82756,1918,-11,81,0,1 +2,7117,5,0,393858,-82756,1918,-11,81,0,1 +2,7119,5,0,393858,-82756,1917,-10,80,0,1 +1,7120,5,0,393858,-82756,1916,-10,81,0,1 +2,7120,5,0,393858,-82756,1916,-10,81,0,1 +2,7123,5,0,393858,-82755,1911,-11,81,0,1 +1,7124,5,0,393858,-82755,1910,-11,81,0,1 +2,7124,5,0,393858,-82755,1910,-11,81,0,1 +1,7125,5,0,393858,-82755,1909,-11,81,0,1 +2,7125,5,0,393858,-82755,1909,-11,81,0,1 +1,7127,5,0,393858,-82755,1908,-10,81,0,1 +2,7127,5,0,393858,-82755,1908,-10,81,0,1 +1,7128,5,0,393858,-82755,1906,-11,81,0,1 +1,7129,5,0,393858,-82755,1904,-12,81,0,1 +2,7129,5,0,393858,-82755,1904,-12,81,0,1 +1,7130,5,0,393858,-82755,1901,-13,80,0,1 +2,7131,5,0,393858,-82755,1900,-13,81,0,1 +1,7132,5,0,393858,-82755,1899,-13,81,0,1 +2,7132,5,0,393858,-82755,1899,-13,81,0,1 +2,7133,5,0,393858,-82755,1898,-12,81,0,1 +1,7134,5,0,393858,-82755,1897,-12,81,0,1 +1,7136,5,0,393858,-82755,1895,-11,81,0,1 +2,7136,5,0,393858,-82755,1895,-11,81,0,1 +1,7137,5,0,393858,-82754,1894,-11,81,0,1 +2,7137,5,0,393858,-82754,1894,-11,81,0,1 +1,7138,5,0,393858,-82754,1892,-12,81,0,1 +2,7138,5,0,393858,-82754,1892,-12,81,0,1 +1,7139,5,0,393858,-82754,1890,-13,81,0,1 +2,7139,5,0,393858,-82754,1890,-13,81,0,1 +1,7140,5,0,393858,-82754,1888,-13,81,0,1 +2,7140,5,0,393858,-82754,1888,-13,81,0,1 +1,7142,5,0,393859,-82754,1887,-12,81,0,1 +2,7142,5,0,393859,-82754,1887,-12,81,0,1 +1,7144,5,0,393859,-82754,1884,-12,80,0,1 +2,7144,5,0,393859,-82754,1884,-12,80,0,1 +1,7145,5,0,393859,-82754,1883,-12,81,0,1 +1,7146,5,0,393859,-82754,1882,-12,81,0,1 +2,7146,5,0,393859,-82754,1882,-12,81,0,1 +1,7147,5,0,393859,-82754,1881,-11,81,0,1 +2,7147,5,0,393859,-82754,1881,-11,81,0,1 +1,7150,5,0,393859,-82754,1876,-13,81,0,1 +2,7150,5,0,393859,-82754,1876,-13,81,0,1 +1,7151,5,0,393859,-82754,1873,-14,81,0,1 +2,7151,5,0,393859,-82754,1873,-14,81,0,1 +1,7152,5,0,393859,-82754,1872,-14,81,0,1 +1,7153,5,0,393859,-82754,1871,-13,80,0,1 +2,7153,5,0,393859,-82754,1871,-13,80,0,1 +1,7154,5,0,393859,-82754,1870,-13,80,0,1 +2,7154,5,0,393859,-82754,1870,-13,80,0,1 +1,7155,5,0,393859,-82754,1869,-12,81,0,1 +1,7158,5,0,393859,-82754,1865,-13,81,0,1 +2,7158,5,0,393859,-82754,1865,-13,81,0,1 +1,7159,5,0,393860,-82754,1864,-13,81,0,1 +2,7159,5,0,393860,-82754,1864,-13,81,0,1 +1,7160,5,0,393860,-82754,1863,-12,81,0,1 +2,7160,5,0,393860,-82754,1863,-12,81,0,1 +1,7161,5,0,393860,-82754,1862,-12,80,0,1 +2,7161,5,0,393860,-82754,1862,-12,80,0,1 +1,7163,5,0,393860,-82754,1859,-12,80,0,1 +2,7163,5,0,393860,-82754,1859,-12,80,0,1 +1,7165,5,0,393860,-82754,1855,-14,81,0,1 +1,7166,5,0,393860,-82754,1853,-14,81,0,1 +2,7166,5,0,393860,-82754,1853,-14,81,0,1 +2,7167,5,0,393860,-82754,1851,-15,81,0,1 +1,7167,5,0,393860,-82754,1851,-15,81,0,1 +1,7168,5,0,393860,-82754,1850,-14,81,0,1 +1,7169,5,0,393861,-82754,1850,-13,81,0,1 +1,7170,5,0,393861,-82754,1849,-12,81,0,1 +2,7170,5,0,393861,-82754,1849,-12,81,0,1 +1,7171,5,0,393861,-82754,1849,-12,81,0,1 +1,7172,5,0,393861,-82754,1848,-11,81,0,1 +1,7174,5,0,393861,-82754,1845,-11,80,0,1 +2,7174,5,0,393861,-82754,1845,-11,80,0,1 +1,7175,5,0,393861,-82753,1844,-12,80,0,1 +1,7176,5,0,393861,-82753,1843,-12,81,0,1 +2,7176,5,0,393861,-82753,1843,-12,81,0,1 +1,7177,5,0,393862,-82753,1841,-12,80,0,1 +2,7177,5,0,393862,-82753,1841,-12,80,0,1 +1,7178,5,0,393862,-82753,1841,-11,81,0,1 +1,7179,5,0,393862,-82753,1840,-11,81,0,1 +2,7179,5,0,393862,-82753,1840,-11,81,0,1 +1,7182,5,0,393862,-82753,1833,-14,81,0,1 +2,7182,5,0,393862,-82753,1833,-14,81,0,1 +1,7183,5,0,393862,-82753,1832,-14,81,0,1 +2,7183,5,0,393862,-82753,1832,-14,81,0,1 +1,7185,5,0,393863,-82753,1830,-13,81,0,1 +2,7185,5,0,393863,-82753,1830,-13,81,0,1 +1,7186,5,0,393863,-82753,1830,-12,81,0,1 +2,7186,5,0,393863,-82753,1830,-12,81,0,1 +1,7187,5,0,393863,-82753,1829,-12,81,0,1 +1,7189,5,0,393863,-82753,1826,-12,81,0,1 +2,7189,5,0,393863,-82753,1826,-12,81,0,1 +1,7190,5,0,393863,-82752,1825,-12,81,0,1 +2,7190,5,0,393863,-82752,1825,-12,81,0,1 +1,7191,5,0,393863,-82752,1824,-12,81,0,1 +2,7191,5,0,393863,-82752,1824,-12,81,0,1 +2,7193,5,0,393863,-82752,1822,-11,81,0,1 +1,7194,5,0,393863,-82752,1821,-10,81,0,1 +2,7194,5,0,393863,-82752,1821,-10,81,0,1 +1,7196,5,0,393864,-82752,1818,-12,81,0,1 +1,7198,5,0,393864,-82752,1813,-14,81,0,1 +2,7198,5,0,393864,-82752,1813,-14,81,0,1 +1,7199,5,0,393864,-82752,1811,-15,81,0,1 +2,7199,5,0,393864,-82752,1811,-15,81,0,1 +1,7200,5,0,393864,-82752,1809,-15,81,0,1 +2,7200,5,0,393864,-82752,1809,-15,81,0,1 +1,7201,5,0,393864,-82751,1807,-16,80,0,1 +2,7201,5,0,393864,-82751,1807,-16,80,0,1 +1,7202,5,0,393864,-82751,1805,-16,81,0,1 +2,7202,5,0,393864,-82751,1805,-16,81,0,1 +1,7203,5,0,393864,-82751,1803,-16,80,0,1 +1,7207,5,0,393865,-82751,1796,-16,81,0,1 +2,7207,5,0,393865,-82751,1796,-16,81,0,1 +1,7208,5,0,393865,-82751,1795,-16,81,0,1 +2,7208,5,0,393865,-82751,1795,-16,81,0,1 +1,7209,5,0,393865,-82750,1794,-16,81,0,1 +2,7209,5,0,393865,-82750,1794,-16,81,0,1 +1,7210,5,0,393865,-82750,1792,-15,80,0,1 +2,7210,5,0,393865,-82750,1792,-15,80,0,1 +1,7211,5,0,393865,-82750,1792,-14,81,0,1 +1,7213,5,0,393865,-82750,1790,-13,81,0,1 +1,7214,5,0,393865,-82750,1789,-13,81,0,1 +2,7214,5,0,393865,-82750,1789,-13,81,0,1 +1,7216,5,0,393865,-82749,1788,-11,81,0,1 +1,7217,5,0,393865,-82749,1788,-10,80,0,1 +2,7217,5,0,393865,-82749,1788,-10,80,0,1 +1,7218,5,0,393865,-82749,1788,-9,81,0,1 +2,7218,5,0,393865,-82749,1788,-9,81,0,1 +1,7219,5,0,393866,-82749,1787,-9,81,0,1 +2,7219,5,0,393866,-82749,1787,-9,81,0,1 +1,7221,5,0,393866,-82749,1786,-8,81,0,1 +2,7221,5,0,393866,-82749,1786,-8,81,0,1 +1,7222,5,0,393866,-82749,1786,-7,81,0,1 +2,7222,5,0,393866,-82749,1786,-7,81,0,1 +1,7223,5,0,393866,-82748,1786,-7,81,0,1 +2,7223,5,0,393866,-82748,1786,-7,81,0,1 +1,7224,5,0,393866,-82748,1785,-6,80,0,1 +2,7224,5,0,393866,-82748,1785,-6,80,0,1 +1,7225,5,0,393866,-82748,1785,-6,81,0,1 +2,7225,5,0,393866,-82748,1785,-6,81,0,1 +1,7228,5,0,393866,-82748,1782,-7,81,0,1 +2,7228,5,0,393866,-82748,1782,-7,81,0,1 +1,7229,5,0,393866,-82748,1780,-8,81,0,1 +2,7229,5,0,393866,-82748,1780,-8,81,0,1 +1,7230,5,0,393866,-82748,1778,-9,81,0,1 +2,7230,5,0,393866,-82748,1778,-9,81,0,1 +1,7231,5,0,393866,-82747,1777,-10,80,0,1 +2,7231,5,0,393866,-82747,1777,-10,80,0,1 +1,7232,5,0,393866,-82747,1775,-11,81,0,1 +2,7232,5,0,393866,-82747,1775,-11,81,0,1 +1,7234,5,0,393866,-82747,1772,-11,80,0,1 +2,7234,5,0,393866,-82747,1772,-11,80,0,1 +1,7235,5,0,393866,-82747,1770,-11,81,0,1 +2,7235,5,0,393866,-82747,1770,-11,81,0,1 +1,7236,5,0,393866,-82747,1769,-11,81,0,1 +2,7236,5,0,393866,-82747,1769,-11,81,0,1 +1,7237,5,0,393866,-82747,1768,-11,80,0,1 +2,7237,5,0,393866,-82747,1768,-11,80,0,1 +1,7238,5,0,393866,-82747,1767,-11,81,0,1 +2,7238,5,0,393866,-82747,1767,-11,81,0,1 +1,7239,5,0,393866,-82747,1766,-11,81,0,1 +1,7241,5,0,393866,-82746,1765,-10,80,0,1 +2,7241,5,0,393866,-82746,1765,-10,80,0,1 +1,7242,5,0,393866,-82746,1764,-10,81,0,1 +2,7242,5,0,393866,-82746,1764,-10,81,0,1 +1,7243,5,0,393867,-82746,1763,-10,81,0,1 +1,7244,5,0,393867,-82746,1762,-10,81,0,1 +2,7244,5,0,393867,-82746,1762,-10,81,0,1 +1,7245,5,0,393867,-82746,1760,-11,81,0,1 +2,7245,5,0,393867,-82746,1760,-11,81,0,1 +1,7247,5,0,393867,-82746,1754,-14,81,0,1 +2,7247,5,0,393867,-82746,1754,-14,81,0,1 +1,7249,5,0,393867,-82746,1753,-13,81,0,1 +1,7250,5,0,393867,-82746,1751,-13,80,0,1 +2,7250,5,0,393867,-82746,1751,-13,80,0,1 +1,7251,5,0,393867,-82746,1749,-14,81,0,1 +2,7251,5,0,393867,-82746,1749,-14,81,0,1 +1,7252,5,0,393867,-82746,1748,-13,80,0,1 +1,7253,5,0,393867,-82746,1747,-13,81,0,1 +1,7256,5,0,393867,-82746,1741,-15,81,0,1 +2,7256,5,0,393867,-82746,1741,-15,81,0,1 +1,7257,5,0,393868,-82746,1739,-15,81,0,1 +2,7258,5,0,393868,-82746,1738,-14,80,0,1 +1,7260,5,0,393868,-82746,1736,-14,81,0,1 +2,7260,5,0,393868,-82746,1736,-14,81,0,1 +1,7261,5,0,393868,-82746,1735,-14,80,0,1 +2,7261,5,0,393868,-82746,1735,-14,80,0,1 +1,7262,5,0,393868,-82746,1733,-14,81,0,1 +2,7262,5,0,393868,-82746,1733,-14,81,0,1 +1,7263,5,0,393868,-82746,1732,-14,81,0,1 +1,7266,5,0,393868,-82746,1729,-12,81,0,1 +2,7266,5,0,393868,-82746,1729,-12,81,0,1 +1,7267,5,0,393868,-82746,1728,-12,81,0,1 +2,7267,5,0,393868,-82746,1728,-12,81,0,1 +1,7268,5,0,393868,-82746,1728,-11,80,0,1 +1,7269,5,0,393868,-82746,1727,-11,81,0,1 +2,7269,5,0,393868,-82746,1727,-11,81,0,1 +1,7270,5,0,393868,-82746,1726,-10,80,0,1 +2,7270,5,0,393868,-82746,1726,-10,80,0,1 +1,7271,5,0,393868,-82746,1726,-10,81,0,1 +2,7271,5,0,393868,-82746,1726,-10,81,0,1 +1,7273,5,0,393868,-82746,1723,-11,81,0,1 +2,7274,5,0,393868,-82746,1720,-12,80,0,1 +1,7275,5,0,393868,-82746,1717,-14,80,0,1 +1,7276,5,0,393868,-82746,1714,-15,81,0,1 +2,7276,5,0,393868,-82746,1714,-15,81,0,1 +1,7277,5,0,393868,-82746,1712,-16,81,0,1 +1,7278,5,0,393868,-82746,1711,-16,80,0,1 +2,7278,5,0,393868,-82746,1711,-16,80,0,1 +1,7279,5,0,393868,-82746,1710,-14,81,0,1 +1,7282,5,0,393868,-82746,1707,-14,81,0,1 +2,7282,5,0,393868,-82746,1707,-14,81,0,1 +1,7283,5,0,393868,-82746,1705,-14,81,0,1 +2,7283,5,0,393868,-82746,1705,-14,81,0,1 +1,7284,5,0,393868,-82746,1705,-12,81,0,1 +2,7284,5,0,393868,-82746,1705,-12,81,0,1 +1,7285,5,0,393868,-82746,1705,-11,81,0,1 +2,7285,5,0,393868,-82746,1705,-11,81,0,1 +1,7286,5,0,393868,-82746,1703,-12,81,0,1 +2,7286,5,0,393868,-82746,1703,-12,81,0,1 +1,7288,5,0,393868,-82746,1698,-14,80,0,1 +2,7288,5,0,393868,-82746,1698,-14,80,0,1 +1,7290,5,0,393868,-82746,1698,-12,80,0,1 +2,7290,5,0,393868,-82746,1698,-12,80,0,1 +1,7292,5,0,393868,-82746,1695,-12,81,0,1 +2,7292,5,0,393868,-82746,1695,-12,81,0,1 +1,7295,5,0,393868,-82746,1693,-11,81,0,1 +1,7296,5,0,393868,-82746,1691,-12,81,0,1 +2,7296,5,0,393868,-82746,1691,-12,81,0,1 +1,7298,5,0,393868,-82746,1686,-14,80,0,1 +1,7300,5,0,393868,-82746,1685,-12,81,0,1 +2,7300,5,0,393868,-82746,1685,-12,81,0,1 +1,7302,5,0,393868,-82746,1683,-11,81,0,1 +2,7302,5,0,393868,-82746,1683,-11,81,0,1 +1,7303,5,0,393868,-82746,1681,-12,81,0,1 +2,7303,5,0,393868,-82746,1681,-12,81,0,1 +2,7304,5,0,393868,-82746,1680,-13,81,0,1 +1,7305,5,0,393868,-82746,1677,-13,81,0,1 +2,7305,5,0,393868,-82746,1677,-13,81,0,1 +1,7306,5,0,393868,-82746,1676,-14,81,0,1 +2,7306,5,0,393868,-82746,1676,-14,81,0,1 +1,7307,5,0,393868,-82746,1674,-14,81,0,1 +1,7309,5,0,393868,-82746,1671,-14,80,0,1 +2,7309,5,0,393868,-82746,1671,-14,80,0,1 +1,7310,5,0,393868,-82746,1670,-13,81,0,1 +2,7310,5,0,393868,-82746,1670,-13,81,0,1 +1,7311,5,0,393868,-82746,1669,-13,81,0,1 +2,7311,5,0,393868,-82746,1669,-13,81,0,1 +1,7312,5,0,393868,-82746,1669,-12,81,0,1 +1,7313,5,0,393868,-82746,1667,-12,81,0,1 +1,7314,5,0,393868,-82746,1666,-13,81,0,1 +1,7316,5,0,393868,-82746,1662,-14,81,0,1 +2,7316,5,0,393868,-82746,1662,-14,81,0,1 +1,7317,5,0,393868,-82746,1659,-15,81,0,1 +2,7317,5,0,393868,-82746,1659,-15,81,0,1 +1,7318,5,0,393868,-82746,1658,-15,81,0,1 +1,7319,5,0,393868,-82746,1657,-14,81,0,1 +2,7319,5,0,393868,-82746,1657,-14,81,0,1 +1,7320,5,0,393868,-82746,1657,-13,81,0,1 +2,7320,5,0,393868,-82746,1657,-13,81,0,1 +1,7323,5,0,393868,-82746,1654,-12,81,0,1 +2,7323,5,0,393868,-82746,1654,-12,81,0,1 +2,7324,5,0,393868,-82746,1654,-11,81,0,1 +2,7325,5,0,393868,-82746,1653,-10,81,0,1 +1,7326,5,0,393868,-82746,1653,-10,81,0,1 +2,7326,5,0,393868,-82746,1653,-10,81,0,1 +1,7327,5,0,393868,-82746,1651,-11,81,0,1 +2,7327,5,0,393868,-82746,1651,-11,81,0,1 +1,7328,5,0,393868,-82746,1648,-12,81,0,1 +2,7328,5,0,393868,-82746,1648,-12,81,0,1 +1,7330,5,0,393868,-82746,1646,-12,81,0,1 +2,7330,5,0,393868,-82746,1646,-12,81,0,1 +1,7331,5,0,393868,-82746,1645,-11,81,0,1 +2,7331,5,0,393868,-82746,1645,-11,81,0,1 +1,7332,5,0,393868,-82746,1645,-11,81,0,1 +2,7332,5,0,393868,-82746,1645,-11,81,0,1 +1,7334,5,0,393868,-82746,1643,-10,81,0,1 +1,7335,5,0,393868,-82746,1642,-10,81,0,1 +2,7335,5,0,393868,-82746,1642,-10,81,0,1 +1,7336,5,0,393868,-82746,1641,-10,81,0,1 +2,7336,5,0,393868,-82746,1641,-10,81,0,1 +1,7338,5,0,393868,-82746,1640,-9,81,0,1 +1,7339,5,0,393868,-82746,1639,-9,81,0,1 +2,7339,5,0,393868,-82746,1639,-9,81,0,1 +1,7340,5,0,393868,-82746,1637,-10,81,0,1 +2,7340,5,0,393868,-82746,1637,-10,81,0,1 +1,7341,5,0,393868,-82746,1635,-11,81,0,1 +2,7341,5,0,393868,-82746,1635,-11,81,0,1 +1,7342,5,0,393868,-82746,1632,-13,81,0,1 +2,7342,5,0,393868,-82746,1632,-13,81,0,1 +1,7345,5,0,393868,-82746,1624,-16,80,0,1 +2,7345,5,0,393868,-82746,1624,-16,80,0,1 +1,7346,5,0,393868,-82746,1622,-17,81,0,1 +1,7347,5,0,393868,-82746,1620,-17,81,0,1 +2,7347,5,0,393868,-82746,1620,-17,81,0,1 +1,7348,5,0,393868,-82746,1618,-17,80,0,1 +2,7348,5,0,393868,-82746,1618,-17,80,0,1 +1,7349,5,0,393868,-82746,1618,-16,80,0,1 +2,7349,5,0,393868,-82746,1618,-16,80,0,1 +1,7350,5,0,393868,-82746,1617,-15,81,0,1 +2,7350,5,0,393868,-82746,1617,-15,81,0,1 +1,7353,5,0,393868,-82746,1614,-13,81,0,1 +2,7353,5,0,393868,-82746,1614,-13,81,0,1 +1,7354,5,0,393868,-82746,1614,-12,81,0,1 +2,7354,5,0,393868,-82746,1614,-12,81,0,1 +1,7355,5,0,393868,-82746,1614,-11,81,0,1 +1,7356,5,0,393868,-82746,1614,-10,81,0,1 +2,7356,5,0,393868,-82746,1614,-10,81,0,1 +1,7357,5,0,393868,-82746,1613,-10,81,0,1 +2,7357,5,0,393868,-82746,1613,-10,81,0,1 +1,7360,5,0,393868,-82746,1608,-11,81,0,1 +2,7360,5,0,393868,-82746,1608,-11,81,0,1 +1,7361,5,0,393868,-82746,1606,-12,81,0,1 +2,7361,5,0,393868,-82746,1606,-12,81,0,1 +1,7362,5,0,393868,-82746,1604,-13,81,0,1 +1,7363,5,0,393868,-82746,1602,-13,81,0,1 +2,7363,5,0,393868,-82746,1602,-13,81,0,1 +1,7364,5,0,393868,-82746,1601,-13,81,0,1 +2,7364,5,0,393868,-82746,1601,-13,81,0,1 +1,7365,5,0,393868,-82746,1600,-13,81,0,1 +2,7365,5,0,393868,-82746,1600,-13,81,0,1 +1,7368,5,0,393868,-82746,1598,-11,80,0,1 +2,7368,5,0,393868,-82746,1598,-11,80,0,1 +1,7369,5,0,393868,-82746,1597,-11,81,0,1 +2,7369,5,0,393868,-82746,1597,-11,81,0,1 +1,7370,5,0,393868,-82746,1596,-11,81,0,1 +2,7370,5,0,393868,-82746,1596,-11,81,0,1 +1,7371,5,0,393868,-82746,1595,-11,81,0,1 +2,7371,5,0,393868,-82746,1595,-11,81,0,1 +1,7372,5,0,393868,-82746,1594,-11,81,0,1 +1,7375,5,0,393868,-82746,1592,-9,81,0,1 +2,7375,5,0,393868,-82746,1592,-9,81,0,1 +1,7376,5,0,393868,-82746,1591,-9,81,0,1 +2,7376,5,0,393868,-82746,1591,-9,81,0,1 +1,7377,5,0,393868,-82746,1591,-9,81,0,1 +2,7377,5,0,393868,-82746,1591,-9,81,0,1 +1,7378,5,0,393868,-82746,1589,-10,81,0,1 +2,7378,5,0,393868,-82746,1589,-10,81,0,1 +1,7379,5,0,393868,-82746,1587,-11,81,0,1 +2,7379,5,0,393868,-82746,1587,-11,81,0,1 +1,7381,5,0,393868,-82746,1582,-13,81,0,1 +2,7381,5,0,393868,-82746,1582,-13,81,0,1 +1,7382,5,0,393868,-82746,1580,-14,81,0,1 +2,7382,5,0,393868,-82746,1580,-14,81,0,1 +1,7383,5,0,393868,-82746,1577,-15,81,0,1 +2,7383,5,0,393868,-82746,1577,-15,81,0,1 +1,7384,5,0,393868,-82746,1575,-15,81,0,1 +2,7384,5,0,393868,-82746,1575,-15,81,0,1 +1,7385,5,0,393868,-82746,1573,-16,81,0,1 +2,7385,5,0,393868,-82746,1573,-16,81,0,1 +1,7389,5,0,393868,-82746,1570,-13,81,0,1 +2,7389,5,0,393868,-82746,1570,-13,81,0,1 +1,7390,5,0,393868,-82746,1570,-11,80,0,1 +2,7390,5,0,393868,-82746,1570,-11,80,0,1 +1,7391,5,0,393868,-82746,1570,-11,81,0,1 +2,7391,5,0,393868,-82746,1570,-11,81,0,1 +1,7392,5,0,393868,-82746,1569,-10,81,0,1 +1,7393,5,0,393868,-82746,1568,-10,81,0,1 +1,7394,5,0,393868,-82746,1567,-10,81,0,1 +2,7394,5,0,393868,-82746,1567,-10,81,0,1 +1,7396,5,0,393868,-82746,1566,-9,81,0,1 +1,7397,5,0,393868,-82746,1564,-10,81,0,1 +2,7397,5,0,393868,-82746,1564,-10,81,0,1 +1,7398,5,0,393868,-82746,1562,-11,81,0,1 +1,7399,5,0,393868,-82746,1561,-11,81,0,1 +2,7399,5,0,393868,-82746,1561,-11,81,0,1 +1,7400,5,0,393868,-82746,1561,-10,81,0,1 +2,7400,5,0,393868,-82746,1561,-10,81,0,1 +1,7401,5,0,393868,-82746,1560,-10,81,0,1 +2,7401,5,0,393868,-82746,1560,-10,81,0,1 +1,7402,5,0,393868,-82746,1559,-10,81,0,1 +1,7404,5,0,393868,-82746,1558,-9,81,0,1 +2,7405,5,0,393868,-82746,1556,-9,81,0,1 +1,7406,5,0,393868,-82746,1554,-11,81,0,1 +2,7406,5,0,393868,-82746,1554,-11,81,0,1 +1,7406,5,0,393868,-82746,1554,-11,81,0,1 +2,7406,5,0,393868,-82746,1554,-11,81,0,1 +1,7407,5,0,393868,-82746,1553,-11,81,0,1 +1,7409,5,0,393868,-82746,1551,-10,81,0,1 +2,7409,5,0,393868,-82746,1551,-10,81,0,1 +1,7412,5,0,393868,-82746,1548,-10,81,0,1 +2,7412,5,0,393868,-82746,1548,-10,81,0,1 +1,7413,5,0,393868,-82746,1547,-10,81,0,1 +2,7413,5,0,393868,-82746,1547,-10,81,0,1 +1,7414,5,0,393868,-82746,1546,-10,81,0,1 +2,7414,5,0,393868,-82746,1546,-10,81,0,1 +1,7415,5,0,393868,-82746,1543,-11,81,0,1 +2,7415,5,0,393868,-82746,1543,-11,81,0,1 +1,7416,5,0,393868,-82746,1542,-12,80,0,1 +2,7416,5,0,393868,-82746,1542,-12,80,0,1 +1,7419,5,0,393868,-82746,1540,-10,82,0,1 +1,7420,5,0,393868,-82746,1539,-10,81,0,1 +1,7421,5,0,393868,-82746,1538,-10,81,0,1 +2,7421,5,0,393868,-82746,1538,-10,81,0,1 +1,7422,5,0,393868,-82746,1536,-11,81,0,1 +2,7422,5,0,393868,-82746,1536,-11,81,0,1 +1,7423,5,0,393868,-82746,1534,-12,81,0,1 +1,7424,5,0,393868,-82746,1534,-11,81,0,1 +2,7424,5,0,393868,-82746,1534,-11,81,0,1 +1,7425,5,0,393868,-82746,1533,-11,81,0,1 +2,7425,5,0,393868,-82746,1533,-11,81,0,1 +1,7427,5,0,393868,-82746,1531,-10,81,0,1 +2,7427,5,0,393868,-82746,1531,-10,81,0,1 +1,7429,5,0,393868,-82746,1529,-11,81,0,1 +2,7429,5,0,393868,-82746,1529,-11,81,0,1 +1,7430,5,0,393868,-82746,1527,-11,81,0,1 +2,7430,5,0,393868,-82746,1527,-11,81,0,1 +1,7433,5,0,393868,-82746,1524,-11,80,0,1 +1,7434,5,0,393868,-82746,1523,-10,80,0,1 +2,7434,5,0,393868,-82746,1523,-10,80,0,1 +1,7437,5,0,393868,-82746,1520,-11,81,0,1 +2,7437,5,0,393868,-82746,1520,-11,81,0,1 +1,7438,5,0,393868,-82746,1518,-12,81,0,1 +2,7438,5,0,393868,-82746,1518,-12,81,0,1 +1,7439,5,0,393868,-82746,1516,-12,80,0,1 +2,7439,5,0,393868,-82746,1516,-12,80,0,1 +1,7440,5,0,393868,-82746,1516,-11,80,0,1 +2,7440,5,0,393868,-82746,1516,-11,80,0,1 +1,7441,5,0,393868,-82746,1515,-11,80,0,1 +2,7441,5,0,393868,-82746,1515,-11,80,0,1 +1,7443,5,0,393868,-82746,1512,-11,80,0,1 +1,7444,5,0,393868,-82746,1511,-12,80,0,1 +1,7445,5,0,393868,-82746,1509,-12,80,0,1 +2,7445,5,0,393868,-82746,1509,-12,80,0,1 +1,7446,5,0,393868,-82746,1508,-12,80,0,1 +2,7446,5,0,393868,-82746,1508,-12,80,0,1 +1,7447,5,0,393868,-82746,1506,-12,80,0,1 +2,7447,5,0,393868,-82746,1506,-12,80,0,1 +1,7448,5,0,393868,-82746,1505,-13,80,0,1 +2,7448,5,0,393868,-82746,1505,-13,80,0,1 +1,7451,5,0,393868,-82746,1503,-11,80,0,1 +2,7451,5,0,393868,-82746,1503,-11,80,0,1 +1,7453,5,0,393868,-82746,1502,-10,80,0,1 +1,7454,5,0,393868,-82746,1501,-9,80,0,1 +2,7454,5,0,393868,-82746,1501,-9,80,0,1 +2,7455,5,0,393868,-82746,1500,-9,80,0,1 +1,7456,5,0,393868,-82746,1499,-10,80,0,1 +2,7456,5,0,393868,-82746,1499,-10,80,0,1 +1,7457,5,0,393868,-82746,1497,-10,80,0,1 +2,7457,5,0,393868,-82746,1497,-10,80,0,1 +1,7459,5,0,393868,-82746,1494,-11,80,0,1 +2,7459,5,0,393868,-82746,1494,-11,80,0,1 +1,7461,5,0,393868,-82746,1490,-13,80,0,1 +2,7461,5,0,393868,-82746,1490,-13,80,0,1 +1,7462,5,0,393868,-82746,1488,-14,80,0,1 +2,7462,5,0,393868,-82746,1488,-14,80,0,1 +2,7463,5,0,393868,-82746,1486,-15,80,0,1 +1,7464,5,0,393868,-82746,1484,-15,80,0,1 +2,7464,5,0,393868,-82746,1484,-15,80,0,1 +1,7465,5,0,393868,-82746,1482,-15,80,0,1 +1,7467,5,0,393868,-82746,1481,-13,80,0,1 +2,7467,5,0,393868,-82746,1481,-13,80,0,1 +1,7468,5,0,393868,-82746,1481,-12,80,0,1 +2,7468,5,0,393868,-82746,1481,-12,80,0,1 +1,7469,5,0,393868,-82746,1480,-11,80,0,1 +2,7469,5,0,393868,-82746,1480,-11,80,0,1 +1,7471,5,0,393868,-82746,1477,-12,80,0,1 +2,7472,5,0,393868,-82746,1476,-12,80,0,1 +1,7473,5,0,393868,-82746,1475,-11,80,0,1 +2,7474,5,0,393868,-82746,1475,-11,80,0,1 +1,7476,5,0,393868,-82746,1472,-11,80,0,1 +2,7476,5,0,393868,-82746,1472,-11,80,0,1 +1,7477,5,0,393868,-82746,1470,-12,80,0,1 +2,7477,5,0,393868,-82746,1470,-12,80,0,1 +1,7478,5,0,393868,-82746,1468,-13,80,0,1 +2,7478,5,0,393868,-82746,1468,-13,80,0,1 +1,7486,5,0,393868,-82746,1457,-12,80,0,1 +2,7487,5,0,393868,-82746,1457,-11,80,0,1 +1,7487,5,0,393868,-82746,1457,-11,80,0,1 +1,7488,5,0,393868,-82746,1457,-10,80,0,1 +1,7491,5,0,393868,-82746,1455,-9,80,0,1 +1,7492,5,0,393868,-82746,1454,-9,80,0,1 +2,7492,5,0,393868,-82746,1454,-9,80,0,1 +1,7493,5,0,393868,-82746,1454,-8,80,0,1 +2,7493,5,0,393868,-82746,1454,-8,80,0,1 +1,7494,5,0,393868,-82746,1452,-9,80,0,1 +2,7494,5,0,393868,-82746,1452,-9,80,0,1 +1,7495,5,0,393868,-82746,1452,-9,80,0,1 +2,7495,5,0,393868,-82746,1452,-9,80,0,1 +1,7496,5,0,393868,-82746,1451,-9,80,0,1 +2,7496,5,0,393868,-82746,1451,-9,80,0,1 +2,7498,5,0,393868,-82746,1448,-10,80,0,1 +1,7499,5,0,393868,-82746,1447,-10,80,0,1 +2,7499,5,0,393868,-82746,1447,-10,80,0,1 +1,7500,5,0,393868,-82746,1446,-10,80,0,1 +2,7500,5,0,393868,-82746,1446,-10,80,0,1 +1,7501,5,0,393868,-82746,1445,-10,80,0,1 +2,7501,5,0,393868,-82746,1445,-10,80,0,1 +1,7502,5,0,393868,-82746,1443,-10,80,0,1 +2,7502,5,0,393868,-82746,1443,-10,80,0,1 +1,7506,5,0,393868,-82746,1437,-12,80,0,1 +2,7506,5,0,393868,-82746,1437,-12,80,0,1 +1,7508,5,0,393868,-82746,1432,-14,80,0,1 +2,7508,5,0,393868,-82746,1432,-14,80,0,1 +1,7509,5,0,393868,-82746,1430,-15,80,0,1 +1,7510,5,0,393868,-82746,1429,-15,80,0,1 +2,7510,5,0,393868,-82746,1429,-15,80,0,1 +2,7511,5,0,393868,-82746,1428,-14,80,0,1 +1,7512,5,0,393868,-82746,1428,-13,80,0,1 +1,7513,5,0,393868,-82746,1427,-12,80,0,1 +1,7515,5,0,393868,-82746,1425,-12,80,0,1 +2,7515,5,0,393868,-82746,1425,-12,80,0,1 +1,7516,5,0,393868,-82746,1424,-11,80,0,1 +2,7516,5,0,393868,-82746,1424,-11,80,0,1 +1,7517,5,0,393868,-82746,1423,-11,80,0,1 +2,7517,5,0,393868,-82746,1423,-11,80,0,1 +1,7518,5,0,393868,-82746,1423,-10,80,0,1 +2,7518,5,0,393868,-82746,1423,-10,80,0,1 +2,7519,5,0,393868,-82746,1421,-11,80,0,1 +1,7520,5,0,393868,-82746,1418,-13,80,0,1 +1,7523,5,0,393868,-82746,1414,-12,80,0,1 +2,7523,5,0,393868,-82746,1414,-12,80,0,1 +1,7524,5,0,393868,-82746,1413,-12,80,0,1 +1,7525,5,0,393868,-82746,1412,-12,80,0,1 +2,7525,5,0,393868,-82746,1412,-12,80,0,1 +1,7526,5,0,393868,-82746,1411,-12,80,0,1 +2,7526,5,0,393868,-82746,1411,-12,80,0,1 +1,7527,5,0,393868,-82746,1411,-11,80,0,1 +2,7527,5,0,393868,-82746,1411,-11,80,0,1 +1,7528,5,0,393868,-82746,1410,-11,80,0,1 +2,7528,5,0,393868,-82746,1410,-11,80,0,1 +1,7529,5,0,393868,-82746,1407,-12,80,0,1 +2,7531,5,0,393868,-82746,1404,-13,80,0,1 +1,7533,5,0,393868,-82746,1402,-13,80,0,1 +2,7533,5,0,393868,-82746,1402,-13,80,0,1 +1,7534,5,0,393868,-82746,1400,-13,80,0,1 +2,7534,5,0,393868,-82746,1400,-13,80,0,1 +1,7535,5,0,393868,-82746,1399,-13,80,0,1 +2,7535,5,0,393868,-82746,1399,-13,80,0,1 +2,7536,5,0,393868,-82746,1398,-12,80,0,1 +1,7537,5,0,393868,-82746,1398,-11,80,0,1 +2,7537,5,0,393868,-82746,1398,-11,80,0,1 +1,7539,5,0,393868,-82746,1392,-15,80,0,1 +2,7539,5,0,393868,-82746,1392,-15,80,0,1 +1,7540,5,0,393868,-82746,1390,-15,80,0,1 +2,7540,5,0,393868,-82746,1390,-15,80,0,1 +1,7541,5,0,393868,-82746,1389,-14,80,0,1 +2,7541,5,0,393868,-82746,1389,-14,80,0,1 +1,7551,5,0,393868,-82746,1374,-15,80,0,1 +2,7551,5,0,393868,-82746,1374,-15,80,0,1 +1,7552,5,0,393868,-82746,1374,-14,80,0,1 +1,7554,5,0,393868,-82746,1373,-12,80,0,1 +1,7555,5,0,393868,-82746,1372,-12,80,0,1 +2,7555,5,0,393868,-82746,1372,-12,80,0,1 +1,7556,5,0,393868,-82746,1371,-12,80,0,1 +2,7556,5,0,393868,-82746,1371,-12,80,0,1 +1,7557,5,0,393868,-82746,1369,-12,80,0,1 +2,7557,5,0,393868,-82746,1369,-12,80,0,1 +1,7558,5,0,393868,-82746,1368,-13,80,0,1 +2,7558,5,0,393868,-82746,1368,-13,80,0,1 +1,7560,5,0,393868,-82746,1364,-13,80,0,1 +2,7560,5,0,393868,-82746,1364,-13,80,0,1 +1,7562,5,0,393868,-82746,1362,-13,80,0,1 +2,7562,5,0,393868,-82746,1362,-13,80,0,1 +1,7563,5,0,393868,-82746,1360,-13,80,0,1 +2,7563,5,0,393868,-82746,1360,-13,80,0,1 +1,7564,5,0,393868,-82746,1360,-12,80,0,1 +2,7564,5,0,393868,-82746,1360,-12,80,0,1 +1,7565,5,0,393868,-82746,1359,-12,80,0,1 +2,7565,5,0,393868,-82746,1359,-12,80,0,1 +1,7567,5,0,393868,-82746,1357,-11,80,0,1 +2,7567,5,0,393868,-82746,1357,-11,80,0,1 +1,7568,5,0,393868,-82746,1357,-10,80,0,1 +2,7568,5,0,393868,-82746,1357,-10,80,0,1 +1,7569,5,0,393868,-82746,1356,-10,80,0,1 +2,7569,5,0,393868,-82746,1356,-10,80,0,1 +1,7570,5,0,393868,-82746,1355,-10,80,0,1 +2,7570,5,0,393868,-82746,1355,-10,80,0,1 +1,7571,5,0,393868,-82746,1354,-10,80,0,1 +2,7571,5,0,393868,-82746,1354,-10,80,0,1 +1,7572,5,0,393868,-82746,1352,-11,80,0,1 +1,7573,5,0,393868,-82746,1351,-11,80,0,1 +1,7575,5,0,393868,-82746,1348,-11,80,0,1 +2,7575,5,0,393868,-82746,1348,-11,80,0,1 +1,7576,5,0,393868,-82746,1347,-11,80,0,1 +1,7577,5,0,393868,-82746,1346,-11,80,0,1 +2,7577,5,0,393868,-82746,1346,-11,80,0,1 +1,7578,5,0,393868,-82746,1346,-10,80,0,1 +2,7578,5,0,393868,-82746,1346,-10,80,0,1 +1,7579,5,0,393868,-82746,1345,-10,80,0,1 +1,7581,5,0,393868,-82746,1343,-10,80,0,1 +2,7581,5,0,393868,-82746,1343,-10,80,0,1 +1,7582,5,0,393868,-82746,1341,-11,80,0,1 +2,7582,5,0,393868,-82746,1341,-11,80,0,1 +1,7583,5,0,393868,-82746,1339,-11,80,0,1 +2,7583,5,0,393868,-82746,1339,-11,80,0,1 +1,7584,5,0,393868,-82746,1337,-12,80,0,1 +2,7584,5,0,393868,-82746,1337,-12,80,0,1 +1,7585,5,0,393868,-82746,1336,-12,80,0,1 +2,7585,5,0,393868,-82746,1336,-12,80,0,1 +1,7588,5,0,393868,-82746,1331,-14,80,0,1 +2,7588,5,0,393868,-82746,1331,-14,80,0,1 +1,7589,5,0,393868,-82746,1329,-14,80,0,1 +1,7590,5,0,393868,-82746,1327,-14,80,0,1 +1,7592,5,0,393868,-82746,1324,-14,80,0,1 +1,7593,5,0,393868,-82746,1323,-14,80,0,1 +1,7595,5,0,393868,-82746,1321,-13,80,0,1 +2,7595,5,0,393868,-82746,1321,-13,80,0,1 +1,7596,5,0,393868,-82746,1320,-13,80,0,1 +2,7596,5,0,393868,-82746,1320,-13,80,0,1 +1,7599,5,0,393868,-82746,1316,-12,80,0,1 +2,7599,5,0,393868,-82746,1316,-12,80,0,1 +1,7600,5,0,393868,-82746,1316,-11,80,0,1 +2,7600,5,0,393868,-82746,1316,-11,80,0,1 +1,7601,5,0,393868,-82746,1315,-12,80,0,1 +2,7601,5,0,393868,-82746,1315,-12,80,0,1 +1,7602,5,0,393868,-82746,1313,-12,80,0,1 +2,7602,5,0,393868,-82746,1313,-12,80,0,1 +1,7603,5,0,393868,-82746,1311,-13,80,0,1 +2,7603,5,0,393868,-82746,1311,-13,80,0,1 +1,7606,5,0,393868,-82746,1306,-13,80,0,1 +1,7607,5,0,393868,-82746,1305,-14,80,0,1 +2,7607,5,0,393868,-82746,1305,-14,80,0,1 +1,7608,5,0,393868,-82746,1303,-14,80,0,1 +1,7609,5,0,393868,-82746,1301,-14,80,0,1 +1,7610,5,0,393868,-82746,1300,-14,80,0,1 +2,7610,5,0,393868,-82746,1300,-14,80,0,1 +1,7611,5,0,393868,-82746,1298,-14,80,0,1 +1,7613,5,0,393868,-82746,1296,-14,80,0,1 +1,7614,5,0,393868,-82746,1295,-13,80,0,1 +1,7616,5,0,393868,-82746,1293,-13,80,0,1 +2,7616,5,0,393868,-82746,1293,-13,80,0,1 +1,7617,5,0,393868,-82746,1292,-12,80,0,1 +2,7617,5,0,393868,-82746,1292,-12,80,0,1 +1,7618,5,0,393868,-82746,1291,-12,80,0,1 +2,7618,5,0,393868,-82746,1291,-12,80,0,1 +1,7619,5,0,393868,-82746,1290,-12,80,0,1 +2,7619,5,0,393868,-82746,1290,-12,80,0,1 +1,7620,5,0,393868,-82746,1288,-12,80,0,1 +2,7620,5,0,393868,-82746,1288,-12,80,0,1 +1,7624,5,0,393868,-82746,1282,-13,80,0,1 +2,7624,5,0,393868,-82746,1282,-13,80,0,1 +1,7625,5,0,393868,-82746,1281,-13,80,0,1 +2,7625,5,0,393868,-82746,1281,-13,80,0,1 +1,7626,5,0,393868,-82746,1280,-13,80,0,1 +2,7626,5,0,393868,-82746,1280,-13,80,0,1 +1,7627,5,0,393868,-82746,1278,-13,80,0,1 +2,7627,5,0,393868,-82746,1278,-13,80,0,1 +1,7628,5,0,393868,-82746,1277,-13,80,0,1 +2,7628,5,0,393868,-82746,1277,-13,80,0,1 +1,7632,5,0,393868,-82746,1272,-13,80,0,1 +1,7633,5,0,393868,-82746,1271,-13,79,0,1 +2,7633,5,0,393868,-82746,1271,-13,79,0,1 +1,7635,5,0,393868,-82746,1268,-13,80,0,1 +2,7635,5,0,393868,-82746,1268,-13,80,0,1 +1,7636,5,0,393868,-82746,1267,-13,80,0,1 +2,7636,5,0,393868,-82746,1267,-13,80,0,1 +1,7637,5,0,393868,-82746,1266,-12,80,0,1 +2,7637,5,0,393868,-82746,1266,-12,80,0,1 +1,7638,5,0,393868,-82746,1264,-13,79,0,1 +2,7638,5,0,393868,-82746,1264,-13,79,0,1 +1,7641,5,0,393868,-82746,1261,-12,80,0,1 +2,7641,5,0,393868,-82746,1261,-12,80,0,1 +1,7642,5,0,393868,-82746,1260,-12,80,0,1 +2,7642,5,0,393868,-82746,1260,-12,80,0,1 +1,7643,5,0,393868,-82746,1259,-12,80,0,1 +2,7643,5,0,393868,-82746,1259,-12,80,0,1 +1,7644,5,0,393868,-82746,1258,-11,80,0,1 +2,7644,5,0,393868,-82746,1258,-11,80,0,1 +1,7647,5,0,393868,-82746,1256,-10,80,0,1 +2,7647,5,0,393868,-82746,1256,-10,80,0,1 +1,7648,5,0,393868,-82746,1255,-10,80,0,1 +1,7650,5,0,393868,-82746,1252,-10,80,0,1 +2,7650,5,0,393868,-82746,1252,-10,80,0,1 +1,7651,5,0,393868,-82746,1250,-11,80,0,1 +1,7652,5,0,393868,-82746,1249,-12,80,0,1 +2,7652,5,0,393868,-82746,1249,-12,80,0,1 +1,7653,5,0,393868,-82746,1247,-12,80,0,1 +2,7653,5,0,393868,-82746,1247,-12,80,0,1 +1,7654,5,0,393868,-82746,1247,-11,80,0,1 +2,7654,5,0,393868,-82746,1247,-11,80,0,1 +1,7655,5,0,393868,-82746,1247,-10,80,0,1 +1,7657,5,0,393868,-82746,1244,-10,80,0,1 +2,7657,5,0,393868,-82746,1244,-10,80,0,1 +1,7658,5,0,393868,-82746,1243,-11,80,0,1 +2,7658,5,0,393868,-82746,1243,-11,80,0,1 +1,7659,5,0,393868,-82746,1242,-11,80,0,1 +2,7659,5,0,393868,-82746,1242,-11,80,0,1 +1,7660,5,0,393868,-82746,1241,-10,80,0,1 +2,7660,5,0,393868,-82746,1241,-10,80,0,1 +1,7663,5,0,393868,-82746,1236,-12,80,0,1 +2,7664,5,0,393868,-82746,1235,-12,80,0,1 +1,7665,5,0,393868,-82746,1234,-12,80,0,1 +2,7665,5,0,393868,-82746,1234,-12,80,0,1 +1,7667,5,0,393868,-82746,1232,-11,80,0,1 +2,7667,5,0,393868,-82746,1232,-11,80,0,1 +1,7668,5,0,393868,-82746,1231,-11,80,0,1 +2,7668,5,0,393868,-82746,1231,-11,80,0,1 +1,7669,5,0,393868,-82746,1230,-11,80,0,1 +2,7669,5,0,393868,-82746,1230,-11,80,0,1 +1,7673,5,0,393868,-82746,1226,-10,80,0,1 +1,7675,5,0,393868,-82746,1223,-11,80,0,1 +2,7675,5,0,393868,-82746,1223,-11,80,0,1 +1,7676,5,0,393868,-82746,1222,-12,80,0,1 +2,7676,5,0,393868,-82746,1222,-12,80,0,1 +1,7677,5,0,393868,-82746,1219,-12,80,0,1 +2,7677,5,0,393868,-82746,1219,-12,80,0,1 +1,7678,5,0,393868,-82746,1217,-13,80,0,1 +2,7678,5,0,393868,-82746,1217,-13,80,0,1 +1,7679,5,0,393868,-82746,1215,-15,80,0,1 +2,7679,5,0,393868,-82746,1215,-15,80,0,1 +1,7681,5,0,393868,-82746,1210,-16,80,0,1 +2,7681,5,0,393868,-82746,1210,-16,80,0,1 +1,7682,5,0,393868,-82746,1207,-17,80,0,1 +2,7682,5,0,393868,-82746,1207,-17,80,0,1 +1,7683,5,0,393868,-82746,1205,-17,80,0,1 +1,7684,5,0,393868,-82746,1204,-17,80,0,1 +2,7684,5,0,393868,-82746,1204,-17,80,0,1 +1,7685,5,0,393868,-82746,1202,-17,80,0,1 +2,7685,5,0,393868,-82746,1202,-17,80,0,1 +1,7687,5,0,393868,-82746,1201,-14,80,0,1 +2,7687,5,0,393868,-82746,1201,-14,80,0,1 +1,7688,5,0,393868,-82746,1201,-13,81,0,1 +2,7688,5,0,393868,-82746,1201,-13,81,0,1 +1,7689,5,0,393868,-82746,1201,-12,80,0,1 +2,7689,5,0,393868,-82746,1201,-12,80,0,1 +1,7690,5,0,393868,-82746,1201,-11,80,0,1 +2,7690,5,0,393868,-82746,1201,-11,80,0,1 +2,7691,5,0,393868,-82746,1200,-11,81,0,1 +1,7692,5,0,393868,-82746,1198,-11,80,0,1 +1,7695,5,0,393868,-82746,1194,-12,81,0,1 +2,7695,5,0,393868,-82746,1194,-12,81,0,1 +1,7696,5,0,393868,-82746,1194,-11,80,0,1 +1,7697,5,0,393868,-82746,1193,-10,81,0,1 +1,7698,5,0,393868,-82746,1192,-10,80,0,1 +2,7698,5,0,393868,-82746,1192,-10,80,0,1 +1,7699,5,0,393868,-82746,1190,-11,80,0,1 +2,7699,5,0,393868,-82746,1190,-11,80,0,1 +1,7700,5,0,393868,-82746,1188,-12,80,0,1 +2,7700,5,0,393868,-82746,1188,-12,80,0,1 +1,7702,5,0,393868,-82746,1183,-14,80,0,1 +2,7702,5,0,393868,-82746,1183,-14,80,0,1 +1,7703,5,0,393868,-82746,1182,-14,81,0,1 +2,7703,5,0,393868,-82746,1182,-14,81,0,1 +1,7704,5,0,393868,-82746,1181,-14,80,0,1 +2,7704,5,0,393868,-82746,1181,-14,80,0,1 +1,7705,5,0,393868,-82746,1180,-13,80,0,1 +2,7705,5,0,393868,-82746,1180,-13,80,0,1 +1,7706,5,0,393868,-82746,1180,-12,81,0,1 +2,7706,5,0,393868,-82746,1180,-12,81,0,1 +1,7709,5,0,393868,-82746,1177,-11,80,0,1 +2,7709,5,0,393868,-82746,1177,-11,80,0,1 +2,7710,5,0,393868,-82746,1176,-12,80,0,1 +1,7711,5,0,393868,-82746,1174,-12,80,0,1 +2,7711,5,0,393868,-82746,1174,-12,80,0,1 +2,7712,5,0,393868,-82746,1173,-11,80,0,1 +1,7713,5,0,393868,-82746,1173,-11,80,0,1 +2,7713,5,0,393868,-82746,1173,-11,80,0,1 +1,7715,5,0,393868,-82746,1170,-11,80,0,1 +2,7715,5,0,393868,-82746,1170,-11,80,0,1 +1,7718,5,0,393868,-82746,1166,-11,80,0,1 +2,7718,5,0,393868,-82746,1166,-11,80,0,1 +1,7719,5,0,393868,-82746,1165,-11,81,0,1 +2,7719,5,0,393868,-82746,1165,-11,81,0,1 +1,7720,5,0,393868,-82746,1164,-11,81,0,1 +2,7720,5,0,393868,-82746,1164,-11,81,0,1 +1,7721,5,0,393868,-82746,1163,-11,80,0,1 +1,7722,5,0,393868,-82746,1161,-12,80,0,1 +2,7722,5,0,393868,-82746,1161,-12,80,0,1 +1,7723,5,0,393868,-82746,1159,-12,80,0,1 +1,7726,5,0,393868,-82746,1157,-11,81,0,1 +1,7727,5,0,393868,-82746,1155,-12,80,0,1 +2,7727,5,0,393868,-82746,1155,-12,80,0,1 +1,7728,5,0,393868,-82746,1154,-12,81,0,1 +2,7728,5,0,393868,-82746,1154,-12,81,0,1 +1,7729,5,0,393868,-82746,1153,-11,81,0,1 +2,7729,5,0,393868,-82746,1153,-11,81,0,1 +1,7730,5,0,393868,-82746,1152,-11,80,0,1 +2,7730,5,0,393868,-82746,1152,-11,80,0,1 +1,7731,5,0,393868,-82746,1150,-12,80,0,1 +1,7733,5,0,393868,-82746,1145,-14,80,0,1 +2,7733,5,0,393868,-82746,1145,-14,80,0,1 +1,7734,5,0,393868,-82746,1143,-14,80,0,1 +2,7734,5,0,393868,-82746,1143,-14,80,0,1 +1,7735,5,0,393868,-82746,1143,-14,81,0,1 +2,7735,5,0,393868,-82746,1143,-14,81,0,1 +1,7736,5,0,393868,-82746,1142,-13,80,0,1 +2,7736,5,0,393868,-82746,1142,-13,80,0,1 +1,7737,5,0,393868,-82746,1142,-12,81,0,1 +2,7737,5,0,393868,-82746,1142,-12,81,0,1 +1,7739,5,0,393868,-82746,1140,-11,80,0,1 +1,7740,5,0,393868,-82746,1139,-11,80,0,1 +2,7740,5,0,393868,-82746,1139,-11,80,0,1 +1,7741,5,0,393868,-82746,1137,-11,80,0,1 +2,7741,5,0,393868,-82746,1137,-11,80,0,1 +1,7742,5,0,393868,-82746,1136,-11,80,0,1 +2,7742,5,0,393868,-82746,1136,-11,80,0,1 +1,7743,5,0,393868,-82746,1135,-12,81,0,1 +2,7743,5,0,393868,-82746,1135,-12,81,0,1 +1,7744,5,0,393868,-82746,1134,-12,80,0,1 +2,7744,5,0,393868,-82746,1134,-12,80,0,1 +1,7747,5,0,393868,-82746,1131,-10,81,0,1 +2,7747,5,0,393868,-82746,1131,-10,81,0,1 +1,7748,5,0,393868,-82746,1130,-11,80,0,1 +2,7748,5,0,393868,-82746,1130,-11,80,0,1 +1,7749,5,0,393868,-82746,1128,-12,80,0,1 +2,7749,5,0,393868,-82746,1128,-12,80,0,1 +1,7750,5,0,393868,-82746,1125,-14,80,0,1 +2,7750,5,0,393868,-82746,1125,-14,80,0,1 +1,7751,5,0,393868,-82746,1122,-15,80,0,1 +1,7754,5,0,393868,-82746,1120,-13,80,0,1 +1,7756,5,0,393868,-82746,1117,-12,81,0,1 +2,7756,5,0,393868,-82746,1117,-12,81,0,1 +1,7757,5,0,393868,-82746,1116,-13,81,0,1 +2,7757,5,0,393868,-82746,1116,-13,81,0,1 +1,7758,5,0,393868,-82746,1114,-13,81,0,1 +2,7758,5,0,393868,-82746,1114,-13,81,0,1 +1,7759,5,0,393868,-82746,1112,-13,80,0,1 +2,7759,5,0,393868,-82746,1112,-13,80,0,1 +1,7760,5,0,393868,-82746,1111,-13,81,0,1 +1,7762,5,0,393868,-82746,1109,-12,81,0,1 +2,7762,5,0,393868,-82746,1109,-12,81,0,1 +1,7763,5,0,393868,-82746,1109,-11,81,0,1 +2,7763,5,0,393868,-82746,1109,-11,81,0,1 +1,7764,5,0,393868,-82746,1108,-11,80,0,1 +2,7764,5,0,393868,-82746,1108,-11,80,0,1 +1,7765,5,0,393868,-82746,1107,-11,81,0,1 +2,7765,5,0,393868,-82746,1107,-11,81,0,1 +1,7766,5,0,393868,-82746,1107,-10,80,0,1 +2,7766,5,0,393868,-82746,1107,-10,80,0,1 +1,7769,5,0,393868,-82746,1100,-13,81,0,1 +2,7769,5,0,393868,-82746,1100,-13,81,0,1 +1,7770,5,0,393868,-82746,1098,-14,81,0,1 +2,7770,5,0,393868,-82746,1098,-14,81,0,1 +1,7771,5,0,393868,-82746,1096,-15,81,0,1 +2,7771,5,0,393868,-82746,1096,-15,81,0,1 +1,7772,5,0,393868,-82746,1095,-14,80,0,1 +2,7772,5,0,393868,-82746,1095,-14,80,0,1 +1,7773,5,0,393868,-82746,1095,-13,80,0,1 +2,7774,5,0,393868,-82746,1094,-12,81,0,1 +1,7775,5,0,393868,-82746,1092,-13,80,0,1 +2,7775,5,0,393868,-82746,1092,-13,80,0,1 +1,7778,5,0,393868,-82746,1089,-12,80,0,1 +2,7778,5,0,393868,-82746,1089,-12,80,0,1 +1,7779,5,0,393868,-82746,1088,-11,81,0,1 +2,7779,5,0,393868,-82746,1088,-11,81,0,1 +1,7780,5,0,393868,-82746,1086,-12,81,0,1 +2,7780,5,0,393868,-82746,1086,-12,81,0,1 +1,7781,5,0,393868,-82746,1084,-14,80,0,1 +2,7781,5,0,393868,-82746,1084,-14,80,0,1 +1,7783,5,0,393868,-82746,1082,-13,81,0,1 +2,7783,5,0,393868,-82746,1082,-13,81,0,1 +1,7784,5,0,393868,-82746,1081,-12,81,0,1 +2,7784,5,0,393868,-82746,1081,-12,81,0,1 +1,7785,5,0,393868,-82746,1080,-12,80,0,1 +2,7785,5,0,393868,-82746,1080,-12,80,0,1 +1,7786,5,0,393868,-82746,1079,-12,80,0,1 +2,7786,5,0,393868,-82746,1079,-12,80,0,1 +1,7787,5,0,393868,-82746,1078,-12,81,0,1 +2,7787,5,0,393868,-82746,1078,-12,81,0,1 +1,7790,5,0,393868,-82746,1072,-13,81,0,1 +2,7790,5,0,393868,-82746,1072,-13,81,0,1 +1,7791,5,0,393868,-82746,1071,-13,80,0,1 +2,7791,5,0,393868,-82746,1071,-13,80,0,1 +1,7792,5,0,393868,-82746,1070,-13,80,0,1 +2,7792,5,0,393868,-82746,1070,-13,80,0,1 +1,7793,5,0,393868,-82746,1069,-13,81,0,1 +2,7793,5,0,393868,-82746,1069,-13,81,0,1 +1,7794,5,0,393868,-82746,1067,-13,81,0,1 +1,7795,5,0,393868,-82746,1065,-14,80,0,1 +2,7795,5,0,393868,-82746,1065,-14,80,0,1 +1,7798,5,0,393868,-82746,1062,-13,81,0,1 +2,7798,5,0,393868,-82746,1062,-13,81,0,1 +1,7799,5,0,393868,-82746,1060,-13,81,0,1 +1,7800,5,0,393868,-82746,1059,-13,81,0,1 +2,7800,5,0,393868,-82746,1059,-13,81,0,1 +1,7801,5,0,393868,-82746,1058,-12,80,0,1 +2,7801,5,0,393868,-82746,1058,-12,80,0,1 +1,7802,5,0,393868,-82746,1057,-12,81,0,1 +2,7802,5,0,393868,-82746,1057,-12,81,0,1 +1,7805,5,0,393868,-82746,1055,-11,81,0,1 +2,7805,5,0,393868,-82746,1055,-11,81,0,1 +1,7806,5,0,393868,-82746,1054,-10,81,0,1 +2,7806,5,0,393868,-82746,1054,-10,81,0,1 +1,7807,5,0,393868,-82746,1054,-10,80,0,1 +2,7807,5,0,393868,-82746,1054,-10,80,0,1 +1,7808,5,0,393868,-82746,1053,-10,81,0,1 +2,7808,5,0,393868,-82746,1053,-10,81,0,1 +1,7809,5,0,393868,-82746,1052,-9,81,0,1 +2,7809,5,0,393868,-82746,1052,-9,81,0,1 +1,7811,5,0,393868,-82746,1050,-9,80,0,1 +2,7811,5,0,393868,-82746,1050,-9,80,0,1 +1,7813,5,0,393868,-82746,1048,-10,81,0,1 +2,7814,5,0,393868,-82746,1047,-10,81,0,1 +1,7815,5,0,393868,-82746,1045,-11,81,0,1 +1,7816,5,0,393868,-82746,1042,-12,80,0,1 +2,7816,5,0,393868,-82746,1042,-12,80,0,1 +1,7817,5,0,393868,-82746,1040,-13,80,0,1 +2,7817,5,0,393868,-82746,1040,-13,80,0,1 +1,7818,5,0,393868,-82746,1038,-14,80,0,1 +1,7820,5,0,393868,-82746,1033,-15,81,0,1 +2,7820,5,0,393868,-82746,1033,-15,81,0,1 +1,7821,5,0,393868,-82746,1031,-16,81,0,1 +1,7822,5,0,393868,-82746,1029,-16,81,0,1 +2,7822,5,0,393868,-82746,1029,-16,81,0,1 +2,7823,5,0,393868,-82746,1027,-16,80,0,1 +1,7824,5,0,393868,-82746,1026,-17,80,0,1 +2,7824,5,0,393868,-82746,1026,-17,80,0,1 +1,7825,5,0,393868,-82746,1024,-17,81,0,1 +1,7826,5,0,393868,-82746,1023,-16,81,0,1 +2,7826,5,0,393868,-82746,1023,-16,81,0,1 +1,7829,5,0,393868,-82746,1019,-15,80,0,1 +2,7829,5,0,393868,-82746,1019,-15,80,0,1 +1,7831,5,0,393868,-82746,1017,-14,80,0,1 +2,7831,5,0,393868,-82746,1017,-14,80,0,1 +1,7832,5,0,393868,-82746,1016,-14,80,0,1 +1,7833,5,0,393868,-82746,1015,-13,81,0,1 +2,7833,5,0,393868,-82746,1015,-13,81,0,1 +1,7834,5,0,393868,-82746,1014,-13,81,0,1 +2,7834,5,0,393868,-82746,1014,-13,81,0,1 +1,7837,5,0,393868,-82746,1012,-11,81,0,1 +2,7837,5,0,393868,-82746,1012,-11,81,0,1 +1,7838,5,0,393868,-82746,1011,-10,81,0,1 +2,7838,5,0,393868,-82746,1011,-10,81,0,1 +1,7839,5,0,393868,-82746,1010,-10,81,0,1 +2,7839,5,0,393868,-82746,1010,-10,81,0,1 +1,7840,5,0,393868,-82746,1009,-11,81,0,1 +2,7840,5,0,393868,-82746,1009,-11,81,0,1 +1,7841,5,0,393868,-82746,1008,-10,81,0,1 +2,7841,5,0,393868,-82746,1008,-10,81,0,1 +1,7842,5,0,393868,-82746,1008,-10,81,0,1 +1,7844,5,0,393868,-82746,1005,-10,81,0,1 +2,7844,5,0,393868,-82746,1005,-10,81,0,1 +1,7845,5,0,393868,-82746,1003,-11,80,0,1 +2,7845,5,0,393868,-82746,1003,-11,80,0,1 +1,7846,5,0,393868,-82746,1001,-12,81,0,1 +2,7846,5,0,393868,-82746,1001,-12,81,0,1 +1,7847,5,0,393868,-82746,1000,-12,80,0,1 +2,7847,5,0,393868,-82746,1000,-12,80,0,1 +1,7848,5,0,393868,-82746,1000,-11,81,0,1 +1,7851,5,0,393868,-82746,998,-10,81,0,1 +2,7851,5,0,393868,-82746,998,-10,81,0,1 +2,7852,5,0,393868,-82746,996,-11,80,0,1 +1,7853,5,0,393868,-82746,994,-11,80,0,1 +2,7854,5,0,393868,-82746,993,-12,80,0,1 +1,7855,5,0,393868,-82746,991,-12,81,0,1 +2,7855,5,0,393868,-82746,991,-12,81,0,1 +1,7856,5,0,393868,-82746,990,-12,80,0,1 +2,7856,5,0,393868,-82746,990,-12,80,0,1 +1,7857,5,0,393868,-82746,988,-12,81,0,1 +2,7857,5,0,393868,-82746,988,-12,81,0,1 +1,7859,5,0,393868,-82746,984,-14,81,0,1 +2,7859,5,0,393868,-82746,984,-14,81,0,1 +1,7861,5,0,393868,-82746,983,-12,81,0,1 +2,7861,5,0,393868,-82746,983,-12,81,0,1 +1,7862,5,0,393868,-82746,982,-12,80,0,1 +2,7862,5,0,393868,-82746,982,-12,80,0,1 +1,7863,5,0,393868,-82746,981,-12,81,0,1 +2,7863,5,0,393868,-82746,981,-12,81,0,1 +1,7864,5,0,393868,-82746,979,-12,81,0,1 +1,7866,5,0,393897,-82705,978,-11,81,0,1 +2,7866,5,0,393897,-82705,978,-11,81,0,1 +1,7867,5,0,393897,-82705,976,-11,80,0,1 +2,7867,5,0,393897,-82705,976,-11,80,0,1 +1,7868,5,0,393897,-82705,975,-12,80,0,1 +2,7868,5,0,393897,-82705,975,-12,80,0,1 +1,7869,5,0,393897,-82705,972,-13,80,0,1 +2,7869,5,0,393897,-82705,972,-13,80,0,1 +1,7870,5,0,393897,-82705,970,-14,80,0,1 +2,7870,5,0,393897,-82705,970,-14,80,0,1 +1,7873,5,0,393897,-82705,967,-13,80,0,1 +2,7873,5,0,393897,-82705,967,-13,80,0,1 +1,7875,5,0,393898,-82705,966,-11,80,0,1 +2,7875,5,0,393898,-82705,966,-11,80,0,1 +1,7876,5,0,393898,-82705,965,-11,81,0,1 +2,7876,5,0,393898,-82705,965,-11,81,0,1 +1,7877,5,0,393898,-82705,964,-10,80,0,1 +2,7877,5,0,393898,-82705,964,-10,80,0,1 +1,7878,5,0,393898,-82705,963,-11,81,0,1 +2,7878,5,0,393898,-82705,963,-11,81,0,1 +1,7881,5,0,393898,-82705,958,-12,81,0,1 +2,7881,5,0,393898,-82705,958,-12,81,0,1 +1,7882,5,0,393898,-82705,957,-12,80,0,1 +2,7882,5,0,393898,-82705,957,-12,80,0,1 +2,7883,5,0,393899,-82705,956,-12,80,0,1 +1,7884,5,0,393899,-82705,954,-12,81,0,1 +2,7884,5,0,393899,-82705,954,-12,81,0,1 +1,7885,5,0,393899,-82705,954,-12,80,0,1 +2,7885,5,0,393899,-82705,954,-12,80,0,1 +1,7886,5,0,393899,-82705,953,-11,80,0,1 +2,7886,5,0,393899,-82705,953,-11,80,0,1 +2,7888,5,0,393899,-82705,952,-10,81,0,1 +1,7889,5,0,393899,-82704,951,-10,81,0,1 +2,7889,5,0,393899,-82704,951,-10,81,0,1 +1,7890,5,0,393899,-82704,949,-11,81,0,1 +2,7890,5,0,393899,-82704,949,-11,81,0,1 +1,7891,5,0,393899,-82704,947,-12,80,0,1 +2,7891,5,0,393899,-82704,947,-12,80,0,1 +1,7893,5,0,393900,-82704,942,-14,80,0,1 +2,7893,5,0,393900,-82704,942,-14,80,0,1 +1,7896,5,0,393900,-82704,936,-15,80,0,1 +2,7896,5,0,393900,-82704,936,-15,80,0,1 +1,7897,5,0,393900,-82704,935,-15,80,0,1 +2,7897,5,0,393900,-82704,935,-15,80,0,1 +1,7898,5,0,393900,-82704,933,-15,81,0,1 +2,7898,5,0,393900,-82704,933,-15,81,0,1 +1,7899,5,0,393900,-82704,932,-15,81,0,1 +2,7899,5,0,393900,-82704,932,-15,81,0,1 +1,7900,5,0,393900,-82704,931,-15,80,0,1 +2,7900,5,0,393900,-82704,931,-15,80,0,1 +1,7901,5,0,393900,-82703,930,-14,81,0,1 +1,7902,5,0,393900,-82703,929,-13,81,0,1 +2,7902,5,0,393900,-82703,929,-13,81,0,1 +1,7904,5,0,393901,-82703,929,-11,80,0,1 +2,7904,5,0,393901,-82703,929,-11,80,0,1 +1,7905,5,0,393901,-82703,928,-11,80,0,1 +2,7905,5,0,393901,-82703,928,-11,80,0,1 +1,7906,5,0,393901,-82703,927,-10,80,0,1 +2,7906,5,0,393901,-82703,927,-10,80,0,1 +1,7907,5,0,393901,-82703,927,-10,80,0,1 +2,7907,5,0,393901,-82703,927,-10,80,0,1 +1,7910,5,0,393901,-82703,923,-10,80,0,1 +2,7910,5,0,393901,-82703,923,-10,80,0,1 +1,7912,5,0,393901,-82702,921,-10,81,0,1 +1,7913,5,0,393901,-82702,920,-10,80,0,1 +2,7913,5,0,393901,-82702,920,-10,80,0,1 +1,7914,5,0,393901,-82702,918,-10,80,0,1 +1,7915,5,0,393901,-82702,917,-10,81,0,1 +1,7916,5,0,393901,-82702,917,-10,81,0,1 +2,7916,5,0,393901,-82702,917,-10,81,0,1 +1,7919,5,0,393901,-82702,914,-10,81,0,1 +2,7919,5,0,393901,-82702,914,-10,81,0,1 +1,7920,5,0,393901,-82702,913,-9,80,0,1 +2,7920,5,0,393901,-82702,913,-9,80,0,1 +1,7921,5,0,393901,-82701,912,-9,80,0,1 +2,7921,5,0,393901,-82701,912,-9,80,0,1 +1,7922,5,0,393901,-82701,911,-10,80,0,1 +2,7922,5,0,393901,-82701,911,-10,80,0,1 +1,7923,5,0,393901,-82701,909,-10,81,0,1 +2,7923,5,0,393901,-82701,909,-10,81,0,1 +1,7926,5,0,393901,-82701,902,-14,80,0,1 +2,7926,5,0,393901,-82701,902,-14,80,0,1 +1,7927,5,0,393901,-82701,900,-14,81,0,1 +2,7927,5,0,393901,-82701,900,-14,81,0,1 +1,7928,5,0,393901,-82701,898,-15,81,0,1 +2,7928,5,0,393901,-82701,898,-15,81,0,1 +1,7929,5,0,393901,-82701,897,-15,81,0,1 +2,7929,5,0,393901,-82701,897,-15,81,0,1 +1,7930,5,0,393901,-82701,896,-14,80,0,1 +2,7930,5,0,393901,-82701,896,-14,80,0,1 +1,7932,5,0,393901,-82700,895,-12,81,0,1 +2,7932,5,0,393901,-82700,895,-12,81,0,1 +1,7934,5,0,393901,-82700,895,-10,80,0,1 +1,7935,5,0,393901,-82700,894,-10,81,0,1 +1,7936,5,0,393901,-82700,892,-10,80,0,1 +1,7937,5,0,393901,-82700,891,-11,81,0,1 +2,7937,5,0,393901,-82700,891,-11,81,0,1 +1,7938,5,0,393901,-82700,889,-11,81,0,1 +2,7938,5,0,393901,-82700,889,-11,81,0,1 +1,7939,5,0,393902,-82700,888,-11,81,0,1 +1,7941,5,0,393902,-82700,885,-12,80,0,1 +2,7941,5,0,393902,-82700,885,-12,80,0,1 +1,7942,5,0,393902,-82700,884,-12,81,0,1 +2,7942,5,0,393902,-82700,884,-12,81,0,1 +1,7943,5,0,393902,-82700,883,-12,80,0,1 +2,7943,5,0,393902,-82700,883,-12,80,0,1 +1,7944,5,0,393902,-82700,881,-12,80,0,1 +2,7944,5,0,393902,-82700,881,-12,80,0,1 +1,7946,5,0,393902,-82700,879,-11,81,0,1 +2,7946,5,0,393902,-82700,879,-11,81,0,1 +1,7949,5,0,393902,-82700,877,-10,81,0,1 +2,7949,5,0,393902,-82700,877,-10,81,0,1 +1,7950,5,0,393902,-82700,876,-10,81,0,1 +2,7950,5,0,393902,-82700,876,-10,81,0,1 +1,7951,5,0,393902,-82700,875,-10,80,0,1 +2,7951,5,0,393902,-82700,875,-10,80,0,1 +1,7952,5,0,393902,-82700,873,-11,81,0,1 +2,7952,5,0,393902,-82700,873,-11,81,0,1 +1,7953,5,0,393902,-82700,872,-11,80,0,1 +1,7954,5,0,393902,-82700,870,-12,81,0,1 +2,7954,5,0,393902,-82700,870,-12,81,0,1 +1,7957,5,0,393902,-82700,865,-13,80,0,1 +2,7957,5,0,393902,-82700,865,-13,80,0,1 +1,7958,5,0,393902,-82700,863,-13,80,0,1 +2,7958,5,0,393902,-82700,863,-13,80,0,1 +1,7959,5,0,393903,-82700,862,-14,81,0,1 +2,7959,5,0,393903,-82700,862,-14,81,0,1 +1,7960,5,0,393903,-82700,860,-14,81,0,1 +2,7960,5,0,393903,-82700,860,-14,81,0,1 +1,7961,5,0,393903,-82700,859,-14,81,0,1 +2,7961,5,0,393903,-82700,859,-14,81,0,1 +1,7963,5,0,393903,-82700,856,-14,80,0,1 +2,7963,5,0,393903,-82700,856,-14,80,0,1 +1,7964,5,0,393903,-82700,855,-13,80,0,1 +2,7964,5,0,393903,-82700,855,-13,80,0,1 +1,7966,5,0,393903,-82699,852,-13,80,0,1 +2,7966,5,0,393903,-82699,852,-13,80,0,1 +1,7967,5,0,393903,-82699,851,-13,81,0,1 +2,7967,5,0,393903,-82699,851,-13,81,0,1 +1,7968,5,0,393903,-82699,850,-12,80,0,1 +2,7968,5,0,393903,-82699,850,-12,80,0,1 +1,7971,5,0,393904,-82699,849,-11,81,0,1 +2,7971,5,0,393904,-82699,849,-11,81,0,1 +1,7972,5,0,393904,-82699,848,-10,81,0,1 +1,7974,5,0,393904,-82699,846,-10,81,0,1 +1,7975,5,0,393904,-82699,845,-10,81,0,1 +2,7975,5,0,393904,-82699,845,-10,81,0,1 +1,7976,5,0,393904,-82699,844,-10,80,0,1 +2,7976,5,0,393904,-82699,844,-10,80,0,1 +1,7977,5,0,393904,-82699,842,-11,81,0,1 +2,7977,5,0,393904,-82699,842,-11,81,0,1 +1,7979,5,0,393905,-82699,840,-11,81,0,1 +1,7980,5,0,393905,-82699,838,-12,81,0,1 +2,7980,5,0,393905,-82699,838,-12,81,0,1 +1,7981,5,0,393905,-82699,836,-12,81,0,1 +1,7982,5,0,393905,-82699,835,-12,81,0,1 +2,7982,5,0,393905,-82699,835,-12,81,0,1 +1,7983,5,0,393905,-82699,834,-12,81,0,1 +2,7983,5,0,393905,-82699,834,-12,81,0,1 +1,7984,5,0,393905,-82699,832,-12,81,0,1 +2,7984,5,0,393905,-82699,832,-12,81,0,1 +1,7987,5,0,393905,-82698,829,-12,81,0,1 +2,7987,5,0,393905,-82698,829,-12,81,0,1 +1,7988,5,0,393905,-82698,828,-11,81,0,1 +2,7988,5,0,393905,-82698,828,-11,81,0,1 +1,7989,5,0,393906,-82698,827,-11,81,0,1 +1,7990,5,0,393906,-82698,826,-12,81,0,1 +2,7990,5,0,393906,-82698,826,-12,81,0,1 +2,7991,5,0,393906,-82698,824,-12,81,0,1 +1,7992,5,0,393906,-82698,822,-12,81,0,1 +1,7995,5,0,393906,-82697,819,-12,81,0,1 +2,7995,5,0,393906,-82697,819,-12,81,0,1 +1,7996,5,0,393906,-82697,818,-12,81,0,1 +2,7996,5,0,393906,-82697,818,-12,81,0,1 +1,7997,5,0,393906,-82697,818,-11,81,0,1 +2,7997,5,0,393906,-82697,818,-11,81,0,1 +1,7998,5,0,393906,-82697,817,-10,81,0,1 +2,7998,5,0,393906,-82697,817,-10,81,0,1 +1,7999,5,0,393906,-82697,816,-10,81,0,1 +2,7999,5,0,393906,-82697,816,-10,81,0,1 +1,8001,5,0,393906,-82697,812,-12,81,0,1 +2,8001,5,0,393906,-82697,812,-12,81,0,1 +1,8002,5,0,393906,-82697,810,-13,81,0,1 +2,8002,5,0,393906,-82697,810,-13,81,0,1 +1,8003,5,0,393907,-82697,808,-14,81,0,1 +2,8003,5,0,393907,-82697,808,-14,81,0,1 +1,8004,5,0,393907,-82697,805,-15,81,0,1 +2,8004,5,0,393907,-82697,805,-15,81,0,1 +1,8006,5,0,393907,-82697,803,-14,81,0,1 +2,8006,5,0,393907,-82697,803,-14,81,0,1 +1,8010,5,0,393907,-82696,802,-10,80,0,1 +2,8010,5,0,393907,-82696,802,-10,80,0,1 +1,8011,5,0,393907,-82696,802,-9,80,0,1 +2,8011,5,0,393907,-82696,802,-9,80,0,1 +1,8012,5,0,393907,-82696,801,-9,81,0,1 +2,8012,5,0,393907,-82696,801,-9,81,0,1 +1,8013,5,0,393908,-82696,800,-9,80,0,1 +2,8014,5,0,393908,-82696,799,-9,81,0,1 +1,8015,5,0,393908,-82696,798,-9,81,0,1 +1,8016,5,0,393908,-82696,798,-9,81,0,1 +1,8018,5,0,393908,-82696,795,-9,81,0,1 +2,8018,5,0,393908,-82696,795,-9,81,0,1 +1,8019,5,0,393908,-82696,794,-10,81,0,1 +2,8019,5,0,393908,-82696,794,-10,81,0,1 +1,8020,5,0,393908,-82696,792,-10,81,0,1 +2,8020,5,0,393908,-82696,792,-10,81,0,1 +1,8021,5,0,393908,-82695,792,-9,81,0,1 +2,8021,5,0,393908,-82695,792,-9,81,0,1 +1,8022,5,0,393908,-82695,791,-9,80,0,1 +2,8022,5,0,393908,-82695,791,-9,80,0,1 +1,8024,5,0,393909,-82695,789,-10,81,0,1 +1,8026,5,0,393909,-82695,786,-10,81,0,1 +2,8026,5,0,393909,-82695,786,-10,81,0,1 +1,8027,5,0,393909,-82695,786,-10,81,0,1 +2,8027,5,0,393909,-82695,786,-10,81,0,1 +1,8028,5,0,393909,-82695,785,-9,81,0,1 +2,8028,5,0,393909,-82695,785,-9,81,0,1 +1,8029,5,0,393909,-82695,784,-9,81,0,1 +2,8029,5,0,393909,-82695,784,-9,81,0,1 +2,8030,5,0,393909,-82695,784,-9,81,0,1 +1,8032,5,0,393909,-82695,781,-9,81,0,1 +2,8032,5,0,393909,-82695,781,-9,81,0,1 +2,8033,5,0,393909,-82694,779,-10,81,0,1 +1,8034,5,0,393909,-82694,777,-11,81,0,1 +2,8034,5,0,393909,-82694,777,-11,81,0,1 +2,8035,5,0,393909,-82694,775,-12,81,0,1 +1,8035,5,0,393909,-82694,775,-12,81,0,1 +2,8036,5,0,393909,-82694,774,-12,81,0,1 +1,8037,5,0,393909,-82694,773,-12,81,0,1 +2,8037,5,0,393909,-82694,773,-12,81,0,1 +1,8047,5,0,393909,-82694,756,-14,81,0,1 +1,8054,5,0,393910,-82694,740,-18,80,0,1 +2,8054,5,0,393910,-82694,740,-18,80,0,1 +1,8059,5,0,393910,-82694,737,-13,81,0,1 +2,8059,5,0,393910,-82694,737,-13,81,0,1 +1,8062,5,0,393910,-82694,734,-12,81,0,1 +2,8062,5,0,393910,-82694,734,-12,81,0,1 +1,8066,5,0,393910,-82694,731,-10,81,0,1 +1,8068,5,0,393910,-82694,729,-10,81,0,1 +2,8068,5,0,393910,-82694,729,-10,81,0,1 +1,8072,5,0,393911,-82694,725,-10,80,0,1 +2,8072,5,0,393911,-82694,725,-10,80,0,1 +1,8073,5,0,393911,-82693,722,-12,81,0,1 +2,8073,5,0,393911,-82693,722,-12,81,0,1 +1,8074,5,0,393911,-82693,719,-14,80,0,1 +2,8074,5,0,393911,-82693,719,-14,80,0,1 +1,8074,5,0,393911,-82693,719,-14,80,0,1 +2,8074,5,0,393911,-82693,719,-14,80,0,1 +1,8075,5,0,393911,-82693,716,-16,81,0,1 +2,8075,5,0,393911,-82693,716,-16,81,0,1 +1,8076,5,0,393911,-82693,713,-17,80,0,1 +2,8077,5,0,393911,-82693,711,-17,81,0,1 +1,8077,5,0,393911,-82693,711,-17,81,0,1 +1,8078,5,0,393911,-82693,710,-16,81,0,1 +2,8078,5,0,393911,-82693,710,-16,81,0,1 +1,8079,5,0,393911,-82693,710,-15,81,0,1 +2,8079,5,0,393911,-82693,710,-15,81,0,1 +1,8080,5,0,393911,-82693,708,-15,80,0,1 +2,8080,5,0,393911,-82693,708,-15,80,0,1 +1,8081,5,0,393911,-82693,706,-15,81,0,1 +2,8081,5,0,393911,-82693,706,-15,81,0,1 +1,8082,5,0,393911,-82693,705,-15,81,0,1 +1,8085,5,0,393911,-82693,701,-14,80,0,1 +2,8085,5,0,393911,-82693,701,-14,80,0,1 +1,8086,5,0,393911,-82693,699,-15,83,0,1 +2,8086,5,0,393911,-82693,699,-15,83,0,1 +1,8087,5,0,393911,-82693,698,-15,81,0,1 +2,8087,5,0,393911,-82693,698,-15,81,0,1 +1,8088,5,0,393911,-82693,698,-13,81,0,1 +2,8088,5,0,393911,-82693,698,-13,81,0,1 +1,8091,5,0,393911,-82693,694,-13,81,0,1 +2,8091,5,0,393911,-82693,694,-13,81,0,1 +1,8092,5,0,393911,-82693,693,-13,81,0,1 +1,8093,5,0,393911,-82693,692,-12,81,0,1 +2,8093,5,0,393911,-82693,692,-12,81,0,1 +1,8094,5,0,393911,-82693,691,-12,81,0,1 +2,8094,5,0,393911,-82693,691,-12,81,0,1 +1,8095,5,0,393911,-82693,690,-12,81,0,1 +1,8096,5,0,393911,-82693,688,-12,81,0,1 +2,8096,5,0,393911,-82693,688,-12,81,0,1 +1,8099,5,0,393911,-82693,681,-16,80,0,1 +1,8100,5,0,393911,-82693,678,-16,80,0,1 +2,8100,5,0,393911,-82693,678,-16,80,0,1 +1,8101,5,0,393911,-82693,676,-17,80,0,1 +1,8102,5,0,393911,-82693,675,-17,81,0,1 +2,8102,5,0,393911,-82693,675,-17,81,0,1 +1,8103,5,0,393911,-82693,675,-15,80,0,1 +2,8103,5,0,393911,-82693,675,-15,80,0,1 +1,8105,5,0,393911,-82693,675,-12,80,0,1 +2,8105,5,0,393911,-82693,675,-12,80,0,1 +1,8106,5,0,393911,-82693,674,-12,80,0,1 +2,8107,5,0,393911,-82693,673,-12,80,0,1 +1,8109,5,0,393911,-82693,670,-12,80,0,1 +2,8109,5,0,393911,-82693,670,-12,80,0,1 +1,8110,5,0,393911,-82693,669,-12,80,0,1 +2,8110,5,0,393911,-82693,669,-12,80,0,1 +1,8112,5,0,393912,-82693,668,-11,80,0,1 +1,8113,5,0,393912,-82693,667,-10,80,0,1 +1,8115,5,0,393912,-82692,661,-13,80,0,1 +1,8116,5,0,393912,-82692,658,-15,80,0,1 +1,8118,5,0,393912,-82692,653,-17,80,0,1 +2,8118,5,0,393912,-82692,653,-17,80,0,1 +1,8119,5,0,393912,-82692,652,-16,80,0,1 +2,8119,5,0,393912,-82692,652,-16,80,0,1 +1,8120,5,0,393912,-82692,651,-16,80,0,1 +2,8120,5,0,393912,-82692,651,-16,80,0,1 +1,8121,5,0,393912,-82692,651,-14,80,0,1 +2,8121,5,0,393912,-82692,651,-14,80,0,1 +1,8122,5,0,393912,-82692,650,-13,80,0,1 +2,8122,5,0,393912,-82692,650,-13,80,0,1 +1,8125,5,0,393912,-82692,649,-11,80,0,1 +2,8125,5,0,393912,-82692,649,-11,80,0,1 +1,8126,5,0,393912,-82692,648,-11,80,0,1 +2,8126,5,0,393912,-82692,648,-11,80,0,1 +1,8127,5,0,393913,-82691,647,-11,80,0,1 +2,8127,5,0,393913,-82691,647,-11,80,0,1 +1,8128,5,0,393913,-82691,645,-11,80,0,1 +2,8128,5,0,393913,-82691,645,-11,80,0,1 +1,8129,5,0,393913,-82691,643,-12,80,0,1 +2,8129,5,0,393913,-82691,643,-12,80,0,1 +1,8130,5,0,393913,-82691,641,-13,80,0,1 +1,8132,5,0,393913,-82691,636,-15,80,0,1 +2,8132,5,0,393913,-82691,636,-15,80,0,1 +1,8133,5,0,393913,-82691,634,-16,80,0,1 +2,8133,5,0,393913,-82691,634,-16,80,0,1 +1,8134,5,0,393913,-82691,632,-16,80,0,1 +2,8134,5,0,393913,-82691,632,-16,80,0,1 +1,8135,5,0,393913,-82691,631,-15,80,0,1 +2,8135,5,0,393913,-82691,631,-15,80,0,1 +1,8136,5,0,393913,-82691,630,-14,80,0,1 +2,8136,5,0,393913,-82691,630,-14,80,0,1 +1,8143,5,0,393913,-82690,624,-12,80,0,1 +2,8143,5,0,393913,-82690,624,-12,80,0,1 +1,8145,5,0,393913,-82690,622,-11,80,0,1 +2,8145,5,0,393913,-82690,622,-11,80,0,1 +1,8146,5,0,393913,-82690,621,-11,80,0,1 +1,8147,5,0,393913,-82690,619,-11,80,0,1 +2,8147,5,0,393913,-82690,619,-11,80,0,1 +1,8148,5,0,393913,-82690,618,-11,80,0,1 +2,8148,5,0,393913,-82690,618,-11,80,0,1 +1,8154,5,0,393913,-82689,613,-10,80,0,1 +2,8154,5,0,393913,-82689,613,-10,80,0,1 +1,8157,5,0,393913,-82689,611,-8,80,0,1 +2,8157,5,0,393913,-82689,611,-8,80,0,1 +2,8158,5,0,393913,-82689,610,-9,80,0,1 +1,8159,5,0,393913,-82689,608,-10,80,0,1 +2,8159,5,0,393913,-82689,608,-10,80,0,1 +1,8160,5,0,393913,-82689,606,-11,80,0,1 +2,8160,5,0,393913,-82689,606,-11,80,0,1 +1,8161,5,0,393913,-82689,603,-13,80,0,1 +1,8163,5,0,393913,-82689,599,-14,80,0,1 +2,8163,5,0,393913,-82689,599,-14,80,0,1 +1,8164,5,0,393913,-82689,598,-14,80,0,1 +2,8164,5,0,393913,-82689,598,-14,80,0,1 +1,8165,5,0,393914,-82689,597,-13,80,0,1 +2,8165,5,0,393914,-82689,597,-13,80,0,1 +1,8166,5,0,393914,-82689,595,-13,80,0,1 +2,8166,5,0,393914,-82689,595,-13,80,0,1 +1,8167,5,0,393914,-82689,594,-13,80,0,1 +2,8167,5,0,393914,-82689,594,-13,80,0,1 +1,8169,5,0,393914,-82689,591,-14,80,0,1 +1,8170,5,0,393914,-82689,590,-13,80,0,1 +2,8170,5,0,393914,-82689,590,-13,80,0,1 +1,8171,5,0,393914,-82689,590,-12,80,0,1 +2,8171,5,0,393914,-82689,590,-12,80,0,1 +2,8172,5,0,393914,-82689,588,-12,80,0,1 +1,8173,5,0,393914,-82689,586,-13,80,0,1 +2,8173,5,0,393914,-82689,586,-13,80,0,1 +1,8174,5,0,393914,-82689,584,-14,80,0,1 +2,8174,5,0,393914,-82689,584,-14,80,0,1 +1,8176,5,0,393914,-82690,582,-13,80,0,1 +1,8177,5,0,393914,-82690,581,-12,80,0,1 +2,8177,5,0,393914,-82690,581,-12,80,0,1 +1,8178,5,0,393914,-82690,581,-11,80,0,1 +2,8178,5,0,393914,-82690,581,-11,80,0,1 +1,8179,5,0,393914,-82690,581,-11,80,0,1 +2,8179,5,0,393914,-82690,581,-11,80,0,1 +1,8180,5,0,393914,-82690,580,-10,80,0,1 +1,8181,5,0,393914,-82690,579,-10,80,0,1 +2,8181,5,0,393914,-82690,579,-10,80,0,1 +1,8183,5,0,393915,-82690,575,-11,80,0,1 +2,8183,5,0,393915,-82690,575,-11,80,0,1 +1,8184,5,0,393915,-82690,574,-12,80,0,1 +1,8185,5,0,393915,-82690,572,-12,80,0,1 +2,8185,5,0,393915,-82690,572,-12,80,0,1 +1,8186,5,0,393915,-82690,571,-12,80,0,1 +2,8186,5,0,393915,-82690,571,-12,80,0,1 +1,8187,5,0,393915,-82690,570,-12,80,0,1 +2,8187,5,0,393915,-82690,570,-12,80,0,1 +1,8188,5,0,393915,-82690,568,-12,80,0,1 +2,8188,5,0,393915,-82690,568,-12,80,0,1 +1,8189,5,0,393915,-82690,568,-12,80,0,1 +2,8190,5,0,393915,-82690,567,-11,80,0,1 +1,8192,5,0,393915,-82690,565,-11,80,0,1 +2,8192,5,0,393915,-82690,565,-11,80,0,1 +1,8193,5,0,393915,-82690,565,-10,80,0,1 +1,8194,5,0,393915,-82690,564,-10,80,0,1 +2,8194,5,0,393915,-82690,564,-10,80,0,1 +1,8195,5,0,393915,-82690,563,-10,80,0,1 +2,8195,5,0,393915,-82690,563,-10,80,0,1 +1,8197,5,0,393915,-82690,562,-9,80,0,1 +2,8197,5,0,393915,-82690,562,-9,80,0,1 +1,8198,5,0,393915,-82690,561,-9,80,0,1 +1,8199,5,0,393915,-82690,560,-9,80,0,1 +2,8199,5,0,393915,-82690,560,-9,80,0,1 +1,8200,5,0,393915,-82690,560,-8,80,0,1 +2,8200,5,0,393915,-82690,560,-8,80,0,1 +1,8201,5,0,393916,-82690,559,-8,80,0,1 +2,8201,5,0,393916,-82690,559,-8,80,0,1 +1,8202,5,0,393916,-82690,558,-8,80,0,1 +1,8205,5,0,393916,-82690,554,-9,80,0,1 +1,8206,5,0,393916,-82690,553,-10,80,0,1 +1,8207,5,0,393916,-82690,551,-10,80,0,1 +2,8207,5,0,393916,-82690,551,-10,80,0,1 +1,8208,5,0,393916,-82690,550,-11,80,0,1 +2,8208,5,0,393916,-82690,550,-11,80,0,1 +1,8209,5,0,393916,-82689,548,-11,80,0,1 +2,8209,5,0,393916,-82689,548,-11,80,0,1 +1,8210,5,0,393916,-82689,546,-12,80,0,1 +2,8210,5,0,393916,-82689,546,-12,80,0,1 +1,8211,5,0,393916,-82689,544,-13,80,0,1 +1,8213,5,0,393916,-82689,541,-13,80,0,1 +2,8213,5,0,393916,-82689,541,-13,80,0,1 +1,8214,5,0,393916,-82689,539,-13,80,0,1 +2,8214,5,0,393916,-82689,539,-13,80,0,1 +1,8215,5,0,393916,-82689,538,-13,80,0,1 +2,8215,5,0,393916,-82689,538,-13,80,0,1 +1,8216,5,0,393916,-82689,537,-13,80,0,1 +2,8216,5,0,393916,-82689,537,-13,80,0,1 +1,8217,5,0,393916,-82689,536,-13,80,0,1 +2,8217,5,0,393916,-82689,536,-13,80,0,1 +1,8218,5,0,393916,-82689,535,-12,80,0,1 +1,8220,5,0,393916,-82689,534,-11,80,0,1 +2,8220,5,0,393916,-82689,534,-11,80,0,1 +1,8221,5,0,393916,-82689,533,-10,80,0,1 +2,8221,5,0,393916,-82689,533,-10,80,0,1 +1,8222,5,0,393916,-82689,532,-10,80,0,1 +2,8222,5,0,393916,-82689,532,-10,80,0,1 +1,8223,5,0,393916,-82689,531,-11,80,0,1 +2,8223,5,0,393916,-82689,531,-11,80,0,1 +1,8225,5,0,393916,-82688,528,-12,80,0,1 +2,8225,5,0,393916,-82688,528,-12,80,0,1 +1,8227,5,0,393916,-82688,525,-12,80,0,1 +1,8228,5,0,393916,-82688,523,-13,80,0,1 +2,8228,5,0,393916,-82688,523,-13,80,0,1 +1,8229,5,0,393916,-82688,521,-13,80,0,1 +2,8229,5,0,393916,-82688,521,-13,80,0,1 +1,8230,5,0,393916,-82688,519,-14,80,0,1 +2,8230,5,0,393916,-82688,519,-14,80,0,1 +1,8231,5,0,393916,-82688,518,-14,80,0,1 +2,8231,5,0,393916,-82688,518,-14,80,0,1 +1,8233,5,0,393916,-82688,515,-14,80,0,1 +1,8234,5,0,393916,-82688,513,-14,80,0,1 +2,8234,5,0,393916,-82688,513,-14,80,0,1 +1,8235,5,0,393916,-82688,512,-13,80,0,1 +2,8235,5,0,393916,-82688,512,-13,80,0,1 +1,8236,5,0,393916,-82688,511,-13,80,0,1 +2,8236,5,0,393916,-82688,511,-13,80,0,1 +1,8237,5,0,393916,-82688,510,-13,80,0,1 +2,8237,5,0,393916,-82688,510,-13,80,0,1 +1,8240,5,0,393916,-82688,505,-14,80,0,1 +2,8240,5,0,393916,-82688,505,-14,80,0,1 +1,8241,5,0,393917,-82688,504,-14,80,0,1 +2,8241,5,0,393917,-82688,504,-14,80,0,1 +1,8242,5,0,393917,-82688,503,-14,80,0,1 +2,8242,5,0,393917,-82688,503,-14,80,0,1 +1,8243,5,0,393917,-82687,501,-14,80,0,1 +2,8243,5,0,393917,-82687,501,-14,80,0,1 +1,8244,5,0,393917,-82687,499,-14,80,0,1 +2,8244,5,0,393917,-82687,499,-14,80,0,1 +1,8245,5,0,393917,-82687,497,-14,80,0,1 +1,8247,5,0,393917,-82687,494,-15,80,0,1 +2,8247,5,0,393917,-82687,494,-15,80,0,1 +1,8248,5,0,393917,-82687,493,-14,80,0,1 +2,8248,5,0,393917,-82687,493,-14,80,0,1 +1,8249,5,0,393917,-82687,492,-14,80,0,1 +2,8249,5,0,393917,-82687,492,-14,80,0,1 +2,8250,5,0,393917,-82687,491,-13,80,0,1 +1,8251,5,0,393917,-82687,490,-13,80,0,1 +2,8251,5,0,393917,-82687,490,-13,80,0,1 +1,8253,5,0,393917,-82687,488,-12,80,0,1 +1,8255,5,0,393917,-82686,485,-13,80,0,1 +2,8255,5,0,393917,-82686,485,-13,80,0,1 +1,8256,5,0,393917,-82686,484,-12,80,0,1 +1,8257,5,0,393917,-82686,483,-12,80,0,1 +2,8257,5,0,393917,-82686,483,-12,80,0,1 +1,8258,5,0,393917,-82686,482,-12,80,0,1 +2,8258,5,0,393917,-82686,482,-12,80,0,1 +1,8259,5,0,393917,-82686,481,-12,80,0,1 +2,8259,5,0,393917,-82686,481,-12,80,0,1 +1,8263,5,0,393917,-82686,474,-14,80,0,1 +2,8263,5,0,393917,-82686,474,-14,80,0,1 +1,8264,5,0,393917,-82686,473,-13,80,0,1 +2,8264,5,0,393917,-82686,473,-13,80,0,1 +1,8265,5,0,393917,-82685,473,-12,80,0,1 +2,8265,5,0,393917,-82685,473,-12,80,0,1 +1,8266,5,0,393917,-82685,472,-12,80,0,1 +2,8266,5,0,393917,-82685,472,-12,80,0,1 +1,8267,5,0,393917,-82685,471,-11,80,0,1 +1,8268,5,0,393917,-82685,470,-11,80,0,1 +1,8270,5,0,393917,-82685,468,-11,80,0,1 +1,8271,5,0,393918,-82685,467,-11,80,0,1 +2,8271,5,0,393918,-82685,467,-11,80,0,1 +1,8272,5,0,393918,-82685,465,-12,80,0,1 +1,8273,5,0,393918,-82685,463,-12,80,0,1 +2,8273,5,0,393918,-82685,463,-12,80,0,1 +1,8274,5,0,393918,-82685,462,-12,80,0,1 +2,8274,5,0,393918,-82685,462,-12,80,0,1 +1,8275,5,0,393918,-82685,462,-11,80,0,1 +2,8275,5,0,393918,-82685,462,-11,80,0,1 +2,8277,5,0,393918,-82685,460,-11,80,0,1 +1,8278,5,0,393918,-82685,459,-11,79,0,1 +2,8278,5,0,393918,-82685,459,-11,79,0,1 +1,8279,5,0,393918,-82685,458,-11,78,0,1 +2,8279,5,0,393918,-82685,458,-11,78,0,1 +2,8280,5,0,393918,-82685,457,-10,79,0,1 +2,8281,5,0,393918,-82685,457,-10,80,0,1 +2,8282,5,0,393918,-82685,456,-9,80,0,1 +2,8283,5,0,393918,-82685,455,-9,80,0,1 +2,8284,5,0,393918,-82685,453,-10,80,0,1 +1,8287,5,0,393918,-82685,447,-13,80,0,1 +2,8287,5,0,393918,-82685,447,-13,80,0,1 +1,8288,5,0,393918,-82685,445,-14,80,0,1 +2,8288,5,0,393918,-82685,445,-14,80,0,1 +1,8289,5,0,393918,-82685,443,-14,79,0,1 +2,8289,5,0,393918,-82685,443,-14,79,0,1 +1,8290,5,0,393918,-82685,441,-14,80,0,1 +2,8290,5,0,393918,-82685,441,-14,80,0,1 +2,8291,5,0,393918,-82685,440,-15,80,0,1 +2,8292,5,0,393918,-82685,438,-15,80,0,1 +1,8295,5,0,393918,-82685,436,-12,80,0,1 +2,8295,5,0,393918,-82685,436,-12,80,0,1 +1,8296,5,0,393918,-82685,436,-11,80,0,1 +2,8296,5,0,393918,-82685,436,-11,80,0,1 +1,8297,5,0,393918,-82685,436,-10,80,0,1 +2,8297,5,0,393918,-82685,436,-10,80,0,1 +1,8298,5,0,393918,-82685,436,-9,80,0,1 +2,8298,5,0,393918,-82685,436,-9,80,0,1 +1,8299,5,0,393918,-82685,435,-9,80,0,1 +2,8299,5,0,393918,-82685,435,-9,80,0,1 +1,8303,5,0,393919,-82685,429,-11,80,0,1 +2,8303,5,0,393919,-82685,429,-11,80,0,1 +1,8304,5,0,393919,-82685,428,-11,80,0,1 +2,8304,5,0,393919,-82685,428,-11,80,0,1 +1,8305,5,0,393919,-82685,427,-11,80,0,1 +2,8305,5,0,393919,-82685,427,-11,80,0,1 +1,8306,5,0,393919,-82685,426,-11,80,0,1 +2,8306,5,0,393919,-82685,426,-11,80,0,1 +1,8308,5,0,393919,-82685,423,-11,80,0,1 +2,8308,5,0,393919,-82685,423,-11,80,0,1 +1,8310,5,0,393919,-82685,421,-11,80,0,1 +2,8310,5,0,393919,-82685,421,-11,80,0,1 +1,8311,5,0,393920,-82685,420,-11,80,0,1 +2,8311,5,0,393920,-82685,420,-11,80,0,1 +1,8312,5,0,393920,-82685,418,-11,80,0,1 +2,8312,5,0,393920,-82685,418,-11,80,0,1 +1,8313,5,0,393920,-82685,417,-12,80,0,1 +2,8313,5,0,393920,-82685,417,-12,80,0,1 +1,8314,5,0,393920,-82685,415,-12,80,0,1 +2,8314,5,0,393920,-82685,415,-12,80,0,1 +1,8315,5,0,393920,-82685,414,-12,80,0,1 +2,8315,5,0,393920,-82685,414,-12,80,0,1 +1,8317,5,0,393920,-82685,410,-13,80,0,1 +2,8317,5,0,393920,-82685,410,-13,80,0,1 +1,8318,5,0,393920,-82685,408,-14,80,0,1 +2,8318,5,0,393920,-82685,408,-14,80,0,1 +1,8319,5,0,393921,-82686,407,-14,80,0,1 +2,8319,5,0,393921,-82686,407,-14,80,0,1 +1,8320,5,0,393921,-82686,406,-13,80,0,1 +2,8320,5,0,393921,-82686,406,-13,80,0,1 +2,8322,5,0,393921,-82686,404,-12,80,0,1 +1,8324,5,0,393921,-82686,401,-12,80,0,1 +2,8324,5,0,393921,-82686,401,-12,80,0,1 +1,8325,5,0,393921,-82686,400,-13,80,0,1 +2,8325,5,0,393921,-82686,400,-13,80,0,1 +1,8326,5,0,393921,-82686,398,-13,80,0,1 +2,8326,5,0,393921,-82686,398,-13,80,0,1 +1,8327,5,0,393922,-82686,396,-14,80,0,1 +2,8327,5,0,393922,-82686,396,-14,80,0,1 +1,8328,5,0,393922,-82686,394,-14,80,0,1 +2,8330,5,0,393922,-82686,391,-15,80,0,1 +1,8331,5,0,393922,-82686,390,-14,80,0,1 +2,8331,5,0,393922,-82686,390,-14,80,0,1 +1,8332,5,0,393922,-82686,390,-13,80,0,1 +1,8333,5,0,393922,-82686,389,-12,80,0,1 +2,8333,5,0,393922,-82686,389,-12,80,0,1 +1,8334,5,0,393922,-82686,388,-12,80,0,1 +1,8335,5,0,393923,-82686,387,-12,80,0,1 +2,8335,5,0,393923,-82686,387,-12,80,0,1 +1,8336,5,0,393923,-82686,387,-11,80,0,1 +2,8336,5,0,393923,-82686,387,-11,80,0,1 +1,8338,5,0,393923,-82686,384,-12,81,0,1 +2,8338,5,0,393923,-82686,384,-12,81,0,1 +1,8340,5,0,393923,-82685,382,-11,81,0,1 +1,8341,5,0,393923,-82685,381,-11,80,0,1 +2,8341,5,0,393923,-82685,381,-11,80,0,1 +1,8342,5,0,393923,-82685,380,-11,80,0,1 +2,8342,5,0,393923,-82685,380,-11,80,0,1 +1,8343,5,0,393923,-82685,379,-11,80,0,1 +2,8343,5,0,393923,-82685,379,-11,80,0,1 +1,8345,5,0,393924,-82685,375,-12,80,0,1 +2,8345,5,0,393924,-82685,375,-12,80,0,1 +1,8346,5,0,393924,-82685,375,-11,80,0,1 +2,8346,5,0,393924,-82685,375,-11,80,0,1 +1,8347,5,0,393924,-82685,374,-11,80,0,1 +2,8347,5,0,393924,-82685,374,-11,80,0,1 +1,8348,5,0,393924,-82685,372,-12,80,0,1 +2,8348,5,0,393924,-82685,372,-12,80,0,1 +1,8349,5,0,393924,-82685,371,-11,80,0,1 +2,8349,5,0,393924,-82685,371,-11,80,0,1 +1,8350,5,0,393924,-82685,370,-11,80,0,1 +1,8352,5,0,393924,-82685,365,-13,80,0,1 +2,8352,5,0,393924,-82685,365,-13,80,0,1 +1,8353,5,0,393924,-82685,364,-13,80,0,1 +1,8354,5,0,393924,-82685,364,-12,80,0,1 +2,8354,5,0,393924,-82685,364,-12,80,0,1 +1,8355,5,0,393924,-82685,363,-11,80,0,1 +2,8355,5,0,393924,-82685,363,-11,80,0,1 +1,8356,5,0,393924,-82685,362,-11,80,0,1 +2,8356,5,0,393924,-82685,362,-11,80,0,1 +1,8358,5,0,393924,-82685,360,-12,80,0,1 +1,8359,5,0,393924,-82685,358,-12,80,0,1 +2,8359,5,0,393924,-82685,358,-12,80,0,1 +1,8360,5,0,393924,-82685,356,-12,81,0,1 +2,8360,5,0,393924,-82685,356,-12,81,0,1 +1,8361,5,0,393924,-82685,355,-12,80,0,1 +2,8361,5,0,393924,-82685,355,-12,80,0,1 +1,8362,5,0,393924,-82685,354,-12,80,0,1 +1,8363,5,0,393925,-82685,353,-11,80,0,1 +2,8363,5,0,393925,-82685,353,-11,80,0,1 +1,8366,5,0,393925,-82685,352,-10,81,0,1 +2,8366,5,0,393925,-82685,352,-10,81,0,1 +2,8367,5,0,393925,-82685,351,-10,81,0,1 +1,8368,5,0,393925,-82685,349,-10,80,0,1 +2,8368,5,0,393925,-82685,349,-10,80,0,1 +1,8369,5,0,393925,-82685,347,-11,80,0,1 +2,8369,5,0,393925,-82685,347,-11,80,0,1 +1,8370,5,0,393925,-82685,345,-12,80,0,1 +2,8370,5,0,393925,-82685,345,-12,80,0,1 +1,8371,6,0,393925,-82685,342,-13,81,0,0 +2,8371,6,0,393925,-82685,342,-13,81,0,0 +1,8374,6,0,393925,-82685,336,-15,81,0,0 +2,8374,6,0,393925,-82685,336,-15,81,0,0 +1,8375,6,0,393926,-82685,334,-16,80,0,0 +2,8375,6,0,393926,-82685,334,-16,80,0,0 +1,8376,6,0,393926,-82685,332,-17,80,0,0 +2,8376,6,0,393926,-82685,332,-17,80,0,0 +1,8377,6,0,393926,-82685,330,-17,80,0,0 +2,8377,6,0,393926,-82685,330,-17,80,0,0 +1,8378,6,0,393926,-82685,328,-17,80,0,0 +2,8378,6,0,393926,-82685,328,-17,80,0,0 +1,8380,6,0,393926,-82685,325,-16,80,0,0 +2,8380,6,0,393926,-82685,325,-16,80,0,0 +1,8381,6,0,393926,-82685,325,-15,81,0,0 +2,8381,6,0,393926,-82685,325,-15,81,0,0 +1,8382,6,0,393926,-82685,325,-13,80,0,0 +2,8382,6,0,393926,-82685,325,-13,80,0,0 +1,8383,6,0,393927,-82685,324,-13,81,0,0 +2,8383,6,0,393927,-82685,324,-13,81,0,0 +1,8384,6,0,393927,-82685,323,-12,80,0,0 +2,8384,6,0,393927,-82685,323,-12,80,0,0 +1,8387,6,0,393927,-82685,319,-12,80,0,0 +2,8387,6,0,393927,-82685,319,-12,80,0,0 +1,8388,6,0,393927,-82685,318,-12,81,0,0 +2,8388,6,0,393927,-82685,318,-12,81,0,0 +1,8389,6,0,393927,-82685,317,-12,80,0,0 +2,8389,6,0,393927,-82685,317,-12,80,0,0 +1,8390,6,0,393927,-82685,315,-13,80,0,0 +2,8390,6,0,393927,-82685,315,-13,80,0,0 +1,8391,6,0,393928,-82685,314,-13,80,0,0 +1,8394,6,0,393928,-82685,312,-11,81,0,0 +1,8395,6,0,393928,-82685,311,-11,81,0,0 +2,8395,6,0,393928,-82685,311,-11,81,0,0 +1,8396,6,0,393928,-82685,310,-11,80,0,0 +2,8396,6,0,393928,-82685,310,-11,80,0,0 +1,8397,6,0,393928,-82685,309,-10,81,0,0 +2,8397,6,0,393928,-82685,309,-10,81,0,0 +1,8398,6,0,393928,-82685,308,-10,81,0,0 +2,8398,6,0,393928,-82685,308,-10,81,0,0 +1,8399,6,0,393929,-82684,306,-11,80,0,0 +2,8399,6,0,393929,-82684,306,-11,80,0,0 +1,8401,6,0,393929,-82684,303,-12,80,0,0 +2,8401,6,0,393929,-82684,303,-12,80,0,0 +1,8402,6,0,393929,-82684,303,-11,80,0,0 +2,8402,6,0,393929,-82684,303,-11,80,0,0 +1,8403,6,0,393929,-82684,302,-11,80,0,0 +2,8403,6,0,393929,-82684,302,-11,80,0,0 +1,8404,6,0,393929,-82684,301,-11,80,0,0 +1,8405,6,0,393929,-82684,300,-11,80,0,0 +2,8405,6,0,393929,-82684,300,-11,80,0,0 +1,8409,6,0,393929,-82684,294,-11,81,0,0 +1,8410,6,0,393929,-82684,292,-13,80,0,0 +2,8410,6,0,393929,-82684,292,-13,80,0,0 +1,8411,6,0,393930,-82684,290,-13,81,0,0 +1,8412,6,0,393930,-82684,289,-13,81,0,0 +2,8412,6,0,393930,-82684,289,-13,81,0,0 +1,8413,6,0,393930,-82684,289,-12,81,0,0 +1,8414,6,0,393930,-82684,288,-11,81,0,0 +2,8414,6,0,393930,-82684,288,-11,81,0,0 +1,8415,6,0,393930,-82684,287,-11,81,0,0 +1,8417,6,0,393930,-82684,284,-12,80,0,0 +2,8417,6,0,393930,-82684,284,-12,80,0,0 +2,8418,6,0,393930,-82684,282,-12,81,0,0 +2,8419,6,0,393930,-82684,282,-12,80,0,0 +1,8420,6,0,393930,-82684,281,-11,81,0,0 +2,8420,6,0,393930,-82684,281,-11,81,0,0 +1,8421,6,0,393930,-82684,280,-11,80,0,0 +2,8421,6,0,393930,-82684,280,-11,80,0,0 +1,8422,6,0,393930,-82684,278,-12,81,0,0 +2,8422,6,0,393930,-82684,278,-12,81,0,0 +1,8424,6,0,393930,-82684,272,-15,81,0,0 +2,8424,6,0,393930,-82684,272,-15,81,0,0 +2,8425,6,0,393930,-82684,270,-16,80,0,0 +1,8426,6,0,393930,-82684,268,-16,81,0,0 +2,8426,6,0,393930,-82684,268,-16,81,0,0 +1,8427,6,0,393930,-82684,266,-16,80,0,0 +1,8428,6,0,393930,-82684,264,-16,81,0,0 +2,8428,6,0,393930,-82684,264,-16,81,0,0 +1,8429,6,0,393931,-82684,263,-16,80,0,0 +2,8429,6,0,393931,-82684,263,-16,80,0,0 +1,8432,6,0,393931,-82684,258,-16,80,0,0 +2,8432,6,0,393931,-82684,258,-16,80,0,0 +1,8434,6,0,393931,-82684,255,-16,81,0,0 +2,8434,6,0,393931,-82684,255,-16,81,0,0 +1,8435,6,0,393931,-82684,253,-16,80,0,0 +2,8435,6,0,393931,-82684,253,-16,80,0,0 +1,8436,6,0,393931,-82684,252,-16,81,0,0 +2,8436,6,0,393931,-82684,252,-16,81,0,0 +1,8437,6,0,393931,-82684,251,-15,81,0,0 +2,8437,6,0,393931,-82684,251,-15,81,0,0 +1,8440,6,0,393932,-82684,247,-14,81,0,0 +2,8440,6,0,393932,-82684,247,-14,81,0,0 +1,8441,6,0,393932,-82684,246,-14,80,0,0 +2,8441,6,0,393932,-82684,246,-14,80,0,0 +2,8442,6,0,393932,-82684,244,-14,81,0,0 +1,8443,6,0,393932,-82684,243,-13,81,0,0 +2,8443,6,0,393932,-82684,243,-13,81,0,0 +1,8444,6,0,393932,-82684,242,-13,81,0,0 +2,8444,6,0,393932,-82684,242,-13,81,0,0 +1,8445,6,0,393932,-82684,241,-13,81,0,0 +2,8445,6,0,393932,-82684,241,-13,81,0,0 +2,8447,6,0,393933,-82684,241,-10,80,0,0 +2,8448,6,0,393933,-82684,241,-9,81,0,0 +1,8450,6,0,393933,-82684,240,-9,81,0,0 +2,8450,6,0,393933,-82684,240,-9,81,0,0 +1,8451,6,0,393933,-82684,239,-9,81,0,0 +2,8451,6,0,393933,-82684,239,-9,81,0,0 +1,8452,6,0,393933,-82684,239,-8,81,0,0 +2,8452,6,0,393933,-82684,239,-8,81,0,0 +1,8453,6,0,393933,-82684,238,-8,80,0,0 +1,8455,6,0,393933,-82684,235,-9,81,0,0 +2,8455,6,0,393933,-82684,235,-9,81,0,0 +1,8456,6,0,393933,-82684,233,-10,81,0,0 +2,8456,6,0,393933,-82684,233,-10,81,0,0 +1,8457,6,0,393934,-82684,233,-10,81,0,0 +2,8457,6,0,393934,-82684,233,-10,81,0,0 +1,8458,6,0,393934,-82684,232,-9,80,0,0 +2,8458,6,0,393934,-82684,232,-9,80,0,0 +1,8459,6,0,393934,-82683,231,-10,80,0,0 +2,8459,6,0,393934,-82683,231,-10,80,0,0 +1,8462,6,0,393934,-82683,228,-9,80,0,0 +2,8462,6,0,393934,-82683,228,-9,80,0,0 +1,8463,6,0,393934,-82683,227,-10,80,0,0 +2,8463,6,0,393934,-82683,227,-10,80,0,0 +1,8464,6,0,393934,-82683,225,-11,81,0,0 +2,8464,6,0,393934,-82683,225,-11,81,0,0 +1,8465,6,0,393934,-82683,222,-12,81,0,0 +2,8465,6,0,393934,-82683,222,-12,81,0,0 +1,8466,6,0,393934,-82683,220,-13,80,0,0 +2,8466,6,0,393934,-82683,220,-13,80,0,0 +1,8467,6,0,393934,-82683,218,-14,80,0,0 +2,8467,6,0,393934,-82683,218,-14,80,0,0 +1,8469,6,0,393934,-82683,216,-13,80,0,0 +2,8469,6,0,393934,-82683,216,-13,80,0,0 +1,8470,6,0,393934,-82683,215,-12,81,0,0 +2,8470,6,0,393934,-82683,215,-12,81,0,0 +1,8471,6,0,393934,-82683,215,-11,81,0,0 +1,8472,6,0,393934,-82683,214,-11,81,0,0 +2,8472,6,0,393934,-82683,214,-11,81,0,0 +1,8474,6,0,393934,-82683,211,-12,80,0,0 +2,8476,6,0,393934,-82682,208,-12,81,0,0 +1,8477,6,0,393935,-82682,206,-12,81,0,0 +2,8477,6,0,393935,-82682,206,-12,81,0,0 +1,8478,6,0,393935,-82682,205,-12,81,0,0 +2,8478,6,0,393935,-82682,205,-12,81,0,0 +1,8479,6,0,393935,-82682,204,-12,81,0,0 +2,8479,6,0,393935,-82682,204,-12,81,0,0 +1,8480,6,0,393935,-82682,202,-13,81,0,0 +2,8480,6,0,393935,-82682,202,-13,81,0,0 +1,8481,6,0,393935,-82682,200,-13,81,0,0 +2,8481,6,0,393935,-82682,200,-13,81,0,0 +1,8485,6,0,393935,-82682,194,-14,81,0,0 +2,8485,6,0,393935,-82682,194,-14,81,0,0 +1,8486,6,0,393935,-82682,193,-14,81,0,0 +2,8486,6,0,393935,-82682,193,-14,81,0,0 +1,8487,6,0,393935,-82682,191,-14,81,0,0 +2,8487,6,0,393935,-82682,191,-14,81,0,0 +1,8488,6,0,393935,-82682,190,-13,80,0,0 +2,8488,6,0,393935,-82682,190,-13,80,0,0 +1,8489,6,0,393935,-82682,189,-13,81,0,0 +1,8491,6,0,393935,-82682,188,-12,81,0,0 +2,8491,6,0,393935,-82682,188,-12,81,0,0 +1,8492,6,0,393935,-82682,188,-11,81,0,0 +2,8492,6,0,393935,-82682,188,-11,81,0,0 +1,8493,6,0,393935,-82683,187,-10,81,0,0 +2,8493,6,0,393935,-82683,187,-10,81,0,0 +1,8494,6,0,393935,-82683,187,-9,81,0,0 +2,8494,6,0,393935,-82683,187,-9,81,0,0 +1,8495,6,0,393935,-82683,186,-9,81,0,0 +2,8495,6,0,393935,-82683,186,-9,81,0,0 +1,8498,6,0,393935,-82683,179,-13,80,0,0 +2,8498,6,0,393935,-82683,179,-13,80,0,0 +1,8499,6,0,393935,-82683,176,-14,81,0,0 +2,8499,6,0,393935,-82683,176,-14,81,0,0 +1,8500,6,0,393935,-82683,175,-15,80,0,0 +2,8500,6,0,393935,-82683,175,-15,80,0,0 +1,8501,6,0,393936,-82683,173,-14,81,0,0 +2,8501,6,0,393936,-82683,173,-14,81,0,0 +1,8502,6,0,393936,-82683,172,-14,81,0,0 +2,8502,6,0,393936,-82683,172,-14,81,0,0 +1,8505,6,0,393936,-82683,168,-14,80,0,0 +2,8505,6,0,393936,-82683,168,-14,80,0,0 +1,8506,6,0,393936,-82683,167,-14,81,0,0 +2,8506,6,0,393936,-82683,167,-14,81,0,0 +2,8507,6,0,393936,-82683,165,-14,80,0,0 +1,8507,6,0,393936,-82683,165,-14,80,0,0 +1,8508,6,0,393936,-82683,165,-12,80,0,0 +2,8508,6,0,393936,-82683,165,-12,80,0,0 +1,8509,6,0,393936,-82683,165,-11,81,0,0 +2,8509,6,0,393936,-82683,165,-11,81,0,0 +1,8512,6,0,393937,-82683,160,-13,81,0,0 +2,8512,6,0,393937,-82683,160,-13,81,0,0 +1,8514,6,0,393937,-82682,157,-13,80,0,0 +2,8514,6,0,393937,-82682,157,-13,80,0,0 +1,8515,6,0,393937,-82682,157,-12,80,0,0 +2,8515,6,0,393937,-82682,157,-12,80,0,0 +1,8516,6,0,393937,-82682,156,-12,81,0,0 +2,8516,6,0,393937,-82682,156,-12,81,0,0 +1,8517,6,0,393937,-82682,154,-12,80,0,0 +2,8517,6,0,393937,-82682,154,-12,80,0,0 +1,8519,6,0,393937,-82682,152,-12,81,0,0 +1,8520,6,0,393937,-82682,151,-12,80,0,0 +2,8520,6,0,393937,-82682,151,-12,80,0,0 +1,8521,6,0,393937,-82682,150,-11,81,0,0 +2,8521,6,0,393937,-82682,150,-11,81,0,0 +1,8522,6,0,393937,-82682,150,-11,80,0,0 +2,8522,6,0,393937,-82682,150,-11,80,0,0 +1,8523,6,0,393937,-82682,148,-11,80,0,0 +2,8523,6,0,393937,-82682,148,-11,80,0,0 +1,8524,6,0,393937,-82682,146,-12,81,0,0 +1,8526,6,0,393937,-82682,141,-14,81,0,0 +2,8526,6,0,393937,-82682,141,-14,81,0,0 +1,8527,6,0,393937,-82682,139,-15,80,0,0 +1,8529,6,0,393937,-82682,135,-15,81,0,0 +2,8529,6,0,393937,-82682,135,-15,81,0,0 +1,8530,6,0,393937,-82682,134,-15,81,0,0 +2,8530,6,0,393937,-82682,134,-15,81,0,0 +2,8531,6,0,393937,-82682,134,-14,81,0,0 +2,8532,6,0,393937,-82682,133,-12,80,0,0 +1,8533,6,0,393937,-82682,133,-12,81,0,0 +1,8536,6,0,393937,-82682,129,-12,81,0,0 +2,8536,6,0,393937,-82682,129,-12,81,0,0 +1,8537,6,0,393937,-82682,128,-12,81,0,0 +2,8537,6,0,393937,-82682,128,-12,81,0,0 +1,8538,6,0,393937,-82682,127,-11,81,0,0 +2,8538,6,0,393937,-82682,127,-11,81,0,0 +1,8539,6,0,393937,-82682,126,-11,80,0,0 +2,8539,6,0,393937,-82682,126,-11,80,0,0 +1,8540,6,0,393937,-82682,126,-10,81,0,0 +2,8540,6,0,393937,-82682,126,-10,81,0,0 +1,8543,6,0,393938,-82682,120,-12,81,0,0 +2,8543,6,0,393938,-82682,120,-12,81,0,0 +1,8544,6,0,393938,-82682,118,-14,81,0,0 +2,8544,6,0,393938,-82682,118,-14,81,0,0 +1,8545,6,0,393938,-82683,115,-15,80,0,0 +2,8545,6,0,393938,-82683,115,-15,80,0,0 +1,8546,6,0,393938,-82683,114,-15,81,0,0 +2,8546,6,0,393938,-82683,114,-15,81,0,0 +1,8547,6,0,393938,-82683,113,-14,81,0,0 +1,8548,6,0,393938,-82683,113,-12,81,0,0 +2,8548,6,0,393938,-82683,113,-12,81,0,0 +1,8549,6,0,393938,-82683,113,-11,81,0,0 +2,8552,6,0,393938,-82683,111,-10,80,0,0 +2,8554,6,0,393938,-82683,108,-11,81,0,0 +1,8555,6,0,393938,-82683,106,-11,80,0,0 +2,8555,6,0,393938,-82683,106,-11,80,0,0 +1,8556,6,0,393938,-82683,105,-12,81,0,0 +2,8556,6,0,393938,-82683,105,-12,81,0,0 +1,8557,6,0,393938,-82683,103,-12,80,0,0 +2,8557,6,0,393938,-82683,103,-12,80,0,0 +1,8560,6,0,393938,-82683,100,-11,81,0,0 +2,8560,6,0,393938,-82683,100,-11,81,0,0 +1,8561,6,0,393939,-82683,99,-11,80,0,0 +2,8561,6,0,393939,-82683,99,-11,80,0,0 +2,8562,6,0,393939,-82683,98,-11,81,0,0 +2,8563,6,0,393939,-82683,97,-11,80,0,0 +1,8564,6,0,393939,-82683,96,-11,81,0,0 +2,8564,6,0,393939,-82683,96,-11,81,0,0 +1,8565,6,0,393939,-82683,95,-11,81,0,0 +2,8565,6,0,393939,-82683,95,-11,81,0,0 +1,8567,6,0,393939,-82683,93,-10,81,0,0 +2,8567,6,0,393939,-82683,93,-10,81,0,0 +1,8568,6,0,393939,-82683,92,-10,80,0,0 +2,8568,6,0,393939,-82683,92,-10,80,0,0 +1,8569,6,0,393939,-82683,91,-11,81,0,0 +2,8569,6,0,393939,-82683,91,-11,81,0,0 +2,8570,6,0,393939,-82683,88,-12,81,0,0 +1,8571,6,0,393939,-82683,86,-13,81,0,0 +2,8571,6,0,393939,-82683,86,-13,81,0,0 +1,8572,6,0,393939,-82683,83,-14,80,0,0 +2,8572,6,0,393939,-82683,83,-14,80,0,0 +2,8573,6,0,393939,-82682,82,-14,80,0,0 +1,8576,6,0,393939,-82682,80,-12,81,0,0 +2,8576,6,0,393939,-82682,80,-12,81,0,0 +1,8577,6,0,393939,-82682,79,-12,81,0,0 +2,8577,6,0,393939,-82682,79,-12,81,0,0 +1,8578,6,0,393939,-82682,77,-12,81,0,0 +2,8578,6,0,393939,-82682,77,-12,81,0,0 +1,8579,6,0,393939,-82682,76,-12,81,0,0 +2,8579,6,0,393939,-82682,76,-12,81,0,0 +2,8582,6,0,393939,-82682,74,-11,81,0,0 +1,8583,6,0,393939,-82682,73,-10,81,0,0 +2,8583,6,0,393939,-82682,73,-10,81,0,0 +1,8584,6,0,393939,-82682,73,-10,81,0,0 +2,8584,6,0,393939,-82682,73,-10,81,0,0 +1,8585,6,0,393939,-82682,72,-10,81,0,0 +2,8585,6,0,393939,-82682,72,-10,81,0,0 +1,8586,6,0,393939,-82682,70,-11,81,0,0 +2,8586,6,0,393939,-82682,70,-11,81,0,0 +1,8587,6,0,393939,-82682,68,-11,80,0,0 +1,8589,6,0,393939,-82682,64,-13,80,0,0 +2,8589,6,0,393939,-82682,64,-13,80,0,0 +2,8590,6,0,393939,-82682,62,-13,80,0,0 +2,8591,6,0,393939,-82682,61,-13,80,0,0 +1,8592,6,0,393939,-82682,60,-13,81,0,0 +2,8592,6,0,393939,-82682,60,-13,81,0,0 +2,8593,6,0,393939,-82682,59,-12,81,0,0 +1,8594,6,0,393939,-82682,58,-12,80,0,0 +2,8594,6,0,393939,-82682,58,-12,80,0,0 +1,8595,6,0,393939,-82682,57,-11,80,0,0 +2,8595,6,0,393939,-82682,57,-11,80,0,0 +1,8597,6,0,393939,-82682,57,-10,80,0,0 +1,8598,6,0,393939,-82682,55,-10,80,0,0 +2,8598,6,0,393939,-82682,55,-10,80,0,0 +1,8599,6,0,393939,-82682,54,-10,81,0,0 +2,8599,6,0,393939,-82682,54,-10,81,0,0 +1,8600,6,0,393939,-82682,53,-11,80,0,0 +2,8600,6,0,393939,-82682,53,-11,80,0,0 +1,8601,6,0,393939,-82682,51,-12,80,0,0 +2,8601,6,0,393939,-82682,51,-12,80,0,0 +1,8603,6,0,393939,-82682,47,-13,81,0,0 +2,8603,6,0,393939,-82682,47,-13,81,0,0 +1,8604,6,0,393939,-82682,45,-13,81,0,0 +2,8604,6,0,393939,-82682,45,-13,81,0,0 +1,8605,6,0,393939,-82682,44,-13,81,0,0 +2,8605,6,0,393939,-82682,44,-13,81,0,0 +1,8606,6,0,393939,-82682,42,-14,80,0,0 +2,8606,6,0,393939,-82682,42,-14,80,0,0 +1,8607,6,0,393939,-82683,40,-14,80,0,0 +2,8607,6,0,393939,-82683,40,-14,80,0,0 +1,8610,6,0,393939,-82683,36,-14,80,0,0 +2,8610,6,0,393939,-82683,36,-14,80,0,0 +1,8611,6,0,393939,-82683,35,-14,81,0,0 +2,8611,6,0,393939,-82683,35,-14,81,0,0 +1,8612,6,0,393939,-82683,34,-14,81,0,0 +2,8612,6,0,393939,-82683,34,-14,81,0,0 +1,8613,6,0,393939,-82683,32,-13,81,0,0 +1,8614,6,0,393939,-82683,31,-13,81,0,0 +1,8615,6,0,393939,-82683,31,-12,80,0,0 +2,8615,6,0,393939,-82683,31,-12,80,0,0 +1,8618,6,0,393939,-82683,29,-11,81,0,0 +2,8618,6,0,393939,-82683,29,-11,81,0,0 +1,8619,6,0,393939,-82683,28,-10,80,0,0 +2,8619,6,0,393939,-82683,28,-10,80,0,0 +1,8620,6,0,393939,-82683,26,-11,81,0,0 +2,8620,6,0,393939,-82683,26,-11,81,0,0 +1,8621,6,0,393940,-82683,25,-11,81,0,0 +2,8621,6,0,393940,-82683,25,-11,81,0,0 +1,8622,6,0,393940,-82683,23,-12,81,0,0 +2,8622,6,0,393940,-82683,23,-12,81,0,0 +1,8623,6,0,393940,-82683,21,-13,80,0,0 +1,8625,6,0,393940,-82684,20,-11,81,0,0 +2,8625,6,0,393940,-82684,20,-11,81,0,0 +1,8626,6,0,393940,-82684,19,-10,80,0,0 +2,8626,6,0,393940,-82684,19,-10,80,0,0 +1,8627,6,0,393940,-82684,19,-10,81,0,0 +2,8627,6,0,393940,-82684,19,-10,81,0,0 +1,8628,6,0,393940,-82684,18,-10,81,0,0 +2,8628,6,0,393940,-82684,18,-10,81,0,0 +1,8629,6,0,393940,-82684,16,-10,81,0,0 diff --git a/ground_station/simulator/web/fixture-runtime.js b/ground_station/simulator/web/fixture-runtime.js new file mode 100644 index 00000000..1d1fd6b0 --- /dev/null +++ b/ground_station/simulator/web/fixture-runtime.js @@ -0,0 +1,124 @@ +export const CSV_HEADER = 'link,ts[deciseconds],state,errors,lat[deg/10000],lon[deg/10000],altitude[m],velocity[m/s],battery[decivolts],pyro1,pyro2\n'; +export const DECISECOND_MS = 100; + +export async function loadFixtureManifest(url = './fixtures.json') { + const response = await fetch(url); + if (!response.ok) throw new Error(`fixtures: HTTP ${response.status}`); + const manifest = await response.json(); + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.fixtures)) { + throw new Error('Unsupported simulator fixture manifest.'); + } + return manifest; +} + +export function fixtureById(manifest, id) { + const fixture = manifest.fixtures.find(candidate => candidate.id === id); + if (!fixture) throw new Error(`Unknown simulator fixture: ${id}`); + return fixture; +} + +export function parseReplayCsv(csv) { + const rows = []; + for (const [order, line] of csv.split(/\r?\n/).entries()) { + if (!line.trim() || line.toLowerCase().startsWith('link,')) continue; + const fields = line.split(',').map(field => field.trim()); + if (fields.length < 11) continue; + const values = fields.slice(0, 11).map(Number); + if (values.some(value => !Number.isFinite(value)) || ![1, 2].includes(values[0])) continue; + rows.push({ + link: values[0], timestampDs: values[1], state: values[2], errors: values[3], + latitude: values[4] / 10000, longitude: values[5] / 10000, + altitudeM: values[6], velocityMps: values[7], voltage: values[8] / 10, + pyroContinuity: (values[9] ? 1 : 0) | (values[10] ? 2 : 0), + linkQuality: 100, rssi: -50, snr: 10, connected: true, order + }); + } + rows.sort((left, right) => left.timestampDs - right.timestampDs || left.order - right.order); + if (!rows.length) return rows; + const firstTimestamp = rows[0].timestampDs; + return rows.map(({ order, ...row }) => ({ + ...row, + elapsedMs: (row.timestampDs - firstTimestamp) * DECISECOND_MS + })); +} + +export function injectReplayRow(wasm, row) { + const { link, elapsedMs, ...value } = row; + wasm.ccall('gs_set_link_json', null, ['string', 'string'], [String(link), JSON.stringify(value)]); +} + +function sendJson(wasm, name, value) { + wasm.ccall(name, null, ['string'], [JSON.stringify(value)]); +} + +function completeLog(name, offset, spec) { + const rows = []; + for (const link of [1, 2]) { + const latitude = spec.baseLatitudeE4 + offset + link * 5; + const longitude = spec.baseLongitudeE4 + offset + link * 5; + rows.push(`${link},10,3,0,${latitude},${longitude},100,30,42,1,1`); + rows.push(`${link},20,5,0,${latitude},${longitude},1000,0,41,1,1`); + rows.push(`${link},30,6,0,${latitude},${longitude},400,-25,40,1,1`); + rows.push(`${link},50,7,0,${latitude},${longitude},5,0,39,1,1`); + } + return { name, csv: CSV_HEADER + rows.join('\n') + '\n' }; +} + +async function resolveLogs(spec, lightMode) { + if (Array.isArray(spec)) return spec; + if (spec.files) { + const files = lightMode && spec.lightFiles ? spec.lightFiles : spec.files; + return Promise.all(files.map(async file => { + const response = await fetch(`./${file}`); + if (!response.ok) throw new Error(`${file}: HTTP ${response.status}`); + return { name: file.split('/').pop(), csv: await response.text() }; + })); + } + if (spec.generated) { + const generated = spec.generated; + return Array.from({ length: generated.count }, (_, offset) => + completeLog(`log_${String(generated.startIndex - offset).padStart(3, '0')}.csv`, offset, generated)); + } + if (spec.entries) { + return spec.entries.map(entry => ({ + name: entry.name, + csv: CSV_HEADER + entry.rows.join('\n') + '\n' + })); + } + throw new Error('Fixture logs must be an array, file list, generated set, or entry list.'); +} + +export async function applyFixtureToWasm(wasm, fixture, { lightMode = false, reset = true } = {}) { + if (reset) wasm.ccall('gs_reset', null, [], []); + let logs = []; + for (const step of fixture.operations) { + const entries = Object.entries(step); + if (entries.length !== 1) throw new Error(`Fixture ${fixture.id} has an invalid operation.`); + const [operation, value] = entries[0]; + if (operation === 'logs') { + logs = await resolveLogs(value, lightMode); + sendJson(wasm, 'gs_set_logs_json', logs); + } else if (operation === 'configuration') { + sendJson(wasm, 'gs_set_configuration_json', value); + } else if (operation === 'navigation') { + sendJson(wasm, 'gs_set_navigation_json', value); + } else if (operation === 'deviceStatus') { + sendJson(wasm, 'gs_set_device_status_json', value); + } else if (operation === 'link') { + wasm.ccall('gs_set_link_json', null, ['string', 'string'], [String(value.index), JSON.stringify(value.value)]); + } else if (operation === 'tap') { + wasm.ccall('gs_press', null, ['string'], [value]); + wasm.ccall('gs_release', null, ['string'], [value]); + } else if (operation === 'hold') { + wasm.ccall('gs_hold', null, ['string', 'number'], [value.button, value.ms]); + } else if (operation === 'advance') { + wasm.ccall('gs_advance', null, ['number'], [value]); + } else { + throw new Error(`Fixture ${fixture.id} uses unknown operation: ${operation}`); + } + } + if (lightMode && fixture.lightAdvanceMs) { + wasm.ccall('gs_advance', null, ['number'], [fixture.lightAdvanceMs]); + } + return { logs }; +} diff --git a/ground_station/simulator/web/fixtures.json b/ground_station/simulator/web/fixtures.json new file mode 100644 index 00000000..fa0cc19a --- /dev/null +++ b/ground_station/simulator/web/fixtures.json @@ -0,0 +1,143 @@ +{ + "schemaVersion": 1, + "fixtures": [ + { + "id": "demo", + "label": "Complete and partial logs", + "description": "Loads one complete mission log and one damaged or partial log. Use it to check valid statistics, unavailable values shown as --, and safe malformed-row handling.", + "operations": [ + {"logs": {"files": ["demo-logs/log_001.csv", "demo-logs/log_002.csv"]}}, + {"navigation": {"homeLatitude": 33.6838649, "homeLongitude": 132.6331541, "rocketLatitude": 33.7365, "rocketLongitude": 132.4881, "northRad": 0.35}}, + {"link": {"index": 1, "value": {"state": 2, "timestampDs": 10, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 18, "velocityMps": 0, "voltage": 4.1, "pyroContinuity": 3, "connected": true}}} + ] + }, + { + "id": "light-demo", + "label": "Recorded demo flight", + "description": "Replays a recorded dual-link flight at its original timestamp cadence.", + "lightAdvanceMs": 3500, + "lightPlaybackSpeed": 1, + "operations": [ + {"logs": {"files": ["demo-logs/gs_log_main_1_1.csv"]}}, + {"configuration": {"dualReceiver": true}}, + {"navigation": {"homeLatitude": 39.3899, "homeLongitude": -8.2895, "rocketLatitude": 39.3899, "rocketLongitude": -8.2895, "northRad": 0.35}}, + {"link": {"index": 1, "value": {"state": 2, "timestampDs": 5929, "latitude": 39.3899, "longitude": -8.2895, "altitudeM": 0, "velocityMps": 0, "voltage": 8.1, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 2, "timestampDs": 5929, "latitude": 39.3899, "longitude": -8.2895, "altitudeM": 0, "velocityMps": 0, "voltage": 8.1, "pyroContinuity": 3, "connected": true}}} + ] + }, + { + "id": "dual", + "label": "Nominal dual-link mission", + "description": "Runs a completed two-rocket mission. Both links record liftoff through touchdown and both recovery targets have valid locations.", + "operations": [ + {"logs": []}, + {"configuration": {"dualReceiver": true, "neverStopLogging": false}}, + {"navigation": {"homeLatitude": 33.6838649, "homeLongitude": 132.6331541, "northRad": 0.35}}, + {"link": {"index": 1, "value": {"state": 3, "timestampDs": 10, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 120, "velocityMps": 42, "voltage": 4.1, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 3, "timestampDs": 10, "latitude": 33.7369, "longitude": 132.4874, "altitudeM": 110, "velocityMps": 39, "voltage": 4.1, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 1, "value": {"state": 5, "timestampDs": 20, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 1000, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 5, "timestampDs": 20, "latitude": 33.7369, "longitude": 132.4874, "altitudeM": 1000, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 1, "value": {"state": 6, "timestampDs": 30, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 400, "velocityMps": -25, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 6, "timestampDs": 30, "latitude": 33.7369, "longitude": 132.4874, "altitudeM": 400, "velocityMps": -25, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 1, "value": {"state": 7, "timestampDs": 50, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 5, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 7, "timestampDs": 50, "latitude": 33.7369, "longitude": 132.4874, "altitudeM": 5, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}} + ] + }, + { + "id": "many", + "label": "More than 11 logs", + "description": "Loads 24 completed logs. Use it to check list scrolling, viewport edge arrows, and selection at the first and last log.", + "operations": [ + {"logs": {"generated": {"count": 24, "startIndex": 124, "baseLatitudeE4": 337367, "baseLongitudeE4": 1324878}}} + ] + }, + { + "id": "missing-fix", + "label": "One missing recovery fix", + "description": "Runs Dual Recovery with a valid location for Link 1 and no location for Link 2. Use it to check target selection and No location behavior.", + "operations": [ + {"logs": []}, + {"configuration": {"dualReceiver": true, "neverStopLogging": false}}, + {"navigation": {"homeLatitude": 33.6838649, "homeLongitude": 132.6331541, "northRad": 0.35}}, + {"link": {"index": 1, "value": {"state": 3, "timestampDs": 10, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 120, "velocityMps": 42, "voltage": 4.1, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 2, "timestampDs": 10, "latitude": 0, "longitude": 0, "altitudeM": 110, "velocityMps": 39, "voltage": 4.1, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 1, "value": {"state": 5, "timestampDs": 20, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 1000, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 2, "timestampDs": 20, "latitude": 0, "longitude": 0, "altitudeM": 1000, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 1, "value": {"state": 6, "timestampDs": 30, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 400, "velocityMps": -25, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 2, "timestampDs": 30, "latitude": 0, "longitude": 0, "altitudeM": 400, "velocityMps": -25, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 1, "value": {"state": 7, "timestampDs": 50, "latitude": 33.7365, "longitude": 132.4881, "altitudeM": 5, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 2, "timestampDs": 50, "latitude": 0, "longitude": 0, "altitudeM": 5, "velocityMps": 0, "voltage": 4.0, "pyroContinuity": 3, "connected": true}}} + ] + }, + { + "id": "zero-coordinate", + "label": "Equator and prime meridian fixes", + "description": "Loads valid locations on the prime meridian and equator. Both must remain available as map QR codes.", + "operations": [ + {"navigation": {"homeLatitude": 0.0, "homeLongitude": -30.0, "rocketLatitude": 1.0, "rocketLongitude": -30.0}}, + {"logs": {"entries": [{"name": "zero-coordinate.csv", "rows": ["1,10,3,0,5000,0,100,20,42,0,0", "2,11,3,0,0,-300000,100,20,42,0,0"]}]}} + ] + }, + { + "id": "never", + "label": "Active Never recording", + "description": "Creates an active dual-link log with Stop Logging set to Never. Recording remains active after touchdown until Finalize Log is confirmed.", + "operations": [ + {"logs": []}, + {"configuration": {"dualReceiver": true, "neverStopLogging": true}}, + {"link": {"index": 1, "value": {"state": 7, "timestampDs": 50, "latitude": 33.7365, "longitude": 132.4881, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 7, "timestampDs": 50, "latitude": 33.7369, "longitude": 132.4874, "connected": true}}} + ] + }, + { + "id": "delete-failure", + "label": "Deletion failure", + "description": "Loads a finalized log and injects a storage deletion error. The log must remain in the catalog and an error message should appear.", + "operations": [ + {"logs": {"generated": {"count": 1, "startIndex": 105, "baseLatitudeE4": 337367, "baseLongitudeE4": 1324878}}}, + {"deviceStatus": {"deleteFailure": true}} + ] + }, + { + "id": "usb-delete", + "label": "USB deletion block", + "description": "Loads a finalized log while USB mass storage is connected. Delete Log must be blocked until USB is disconnected.", + "operations": [ + {"logs": {"generated": {"count": 1, "startIndex": 106, "baseLatitudeE4": 337367, "baseLongitudeE4": 1324878}}}, + {"deviceStatus": {"usb": true, "usbStorageState": "host"}} + ] + }, + { + "id": "usb-storage", + "label": "USB Drive active", + "description": "Connects USB while idle, automatically shares the logs, and opens Settings > System > USB Drive. Press B to return without disconnecting, or press A to return storage to the Ground Station.", + "operations": [ + {"deviceStatus": {"usb": true, "usbStorageState": "firmware"}}, + {"tap": "right"}, {"tap": "right"}, {"tap": "down"}, {"tap": "ok"}, + {"tap": "right"}, {"tap": "right"}, {"tap": "down"}, {"tap": "down"}, {"tap": "ok"} + ] + }, + { + "id": "finalize-failure", + "label": "Finalization failure", + "description": "Creates an active Never-mode log and injects a finalization error. The log must remain active after the failed attempt.", + "operations": [ + {"logs": []}, + {"configuration": {"dualReceiver": true, "neverStopLogging": true}}, + {"link": {"index": 1, "value": {"state": 7, "timestampDs": 50, "latitude": 33.7365, "longitude": 132.4881, "connected": true}}}, + {"link": {"index": 2, "value": {"state": 7, "timestampDs": 50, "latitude": 33.7369, "longitude": 132.4874, "connected": true}}}, + {"deviceStatus": {"finalizeFailure": true}} + ] + }, + { + "id": "recorder-fault", + "label": "Recorder write fault", + "description": "Injects a storage write failure as recording starts. Use it to check the recorder fault state and crossed logging indicator.", + "operations": [ + {"logs": []}, + {"deviceStatus": {"recorderWriteFailure": true}}, + {"link": {"index": 1, "value": {"state": 3, "timestampDs": 10, "latitude": 33.7365, "longitude": 132.4881, "connected": true}}} + ] + } + ] +} diff --git a/ground_station/simulator/web/index.html b/ground_station/simulator/web/index.html index 06493585..bdc5f293 100644 --- a/ground_station/simulator/web/index.html +++ b/ground_station/simulator/web/index.html @@ -4,12 +4,29 @@ CATS Ground Station Simulator - + +

CATS Ground Station Simulator

-
+
+ + +
+ +
@@ -24,49 +41,51 @@

CATS Ground Station Simulator

- +
-

Pause freezes virtual time. Advance time runs exactly the selected number of milliseconds.

-

-
- - -
- -
-
-
- - - - - -
-
- - +

Pause freezes virtual time. Advance time runs exactly the selected number of milliseconds.

+

+
+
+ + +
+
+
+
+ + + + + +
+
+ + +
+
-
+

Loading WebAssembly simulator...

-

Keyboard: arrows move · A, Enter, or Space selects · B or Escape goes back

-

+    

Keyboard: arrows move · A, Enter, or Space selects · B or Escape goes back

+

+    
+ Test coverage matrix +

Golden checks compare a stable framebuffer hash. Deterministic checks compare two independent runs byte for byte.

+
+ + + + + +
GS screen or pathScenario / fixtureSnapshotFramebufferBrowser/WASM
+
+
- + diff --git a/ground_station/simulator/web/style.css b/ground_station/simulator/web/style.css index 18de2ead..a8938cdd 100644 --- a/ground_station/simulator/web/style.css +++ b/ground_station/simulator/web/style.css @@ -1,6 +1,8 @@ :root { color-scheme: dark; font-family: system-ui, sans-serif; } body { margin: 0; background: #101214; color: #e8edf2; } main { width: min(960px, calc(100% - 16px)); margin: 24px auto; } +[hidden] { display: none !important; } +.light-only { display: none; } .toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0; align-items: stretch; } .toolbar-group { display: flex; align-items: center; gap: 7px; padding: 6px; background: #171b1f; border: 1px solid #35414a; border-radius: 8px; } .time-group { flex: 0 1 auto; } @@ -17,43 +19,49 @@ button:active { background: #384752; } .icon-button { display: grid; place-items: center; width: 40px; height: 40px; padding: 0; color: #f4b740; font-family: "Segoe UI Symbol", system-ui, sans-serif; font-size: 20px; line-height: 1; } .icon-button:hover { background: #29323a; border-color: #82919c; } .icon-button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid #f4b740; outline-offset: 2px; } -.device { - position: relative; +.device-wrap { + container-type: inline-size; width: min(720px, 100%); margin: 58px auto 24px; - padding: 76px 112px 72px; +} +.device { + position: relative; + width: 100%; + aspect-ratio: 720 / 652.36; + margin: 0; + padding: 10.5556cqw 15.5556cqw 10cqw; box-sizing: border-box; background: linear-gradient(145deg, #2b2e2d 0%, #1a1c1b 58%, #111312 100%); border: 1px solid #3d4240; - border-radius: 14px 14px 22px 22px; + border-radius: 1.9444cqw 1.9444cqw 3.0556cqw 3.0556cqw; box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%), inset -7px -8px 14px rgb(0 0 0 / 25%), 0 18px 30px rgb(0 0 0 / 38%); } .antenna { position: absolute; - top: -48px; - width: 34px; - height: 53px; - border-radius: 3px 3px 1px 1px; + top: -6.6667cqw; + width: 4.7222cqw; + height: 7.3611cqw; + border-radius: .4167cqw .4167cqw .1389cqw .1389cqw; background: repeating-linear-gradient(to bottom, #b8842f 0 4px, #74501d 4px 7px); box-shadow: inset 2px 0 2px rgb(255 227 132 / 45%), 0 1px 3px rgb(0 0 0 / 70%); } .antenna::after { content: ''; position: absolute; - left: -5px; + left: -.6944cqw; bottom: 0; - width: 44px; - height: 10px; - border-radius: 3px 3px 1px 1px; + width: 6.1111cqw; + height: 1.3889cqw; + border-radius: .4167cqw .4167cqw .1389cqw .1389cqw; background: linear-gradient(90deg, #72501e, #d0a04a 45%, #76531f); } .antenna-left { left: 17%; transform: translateX(-50%); } .antenna-right { right: 17%; transform: translateX(50%); } .screen-bezel { - padding: 11px 12px 13px; + padding: 1.5278cqw 1.6667cqw 1.8056cqw; background: #070808; border: 1px solid #303331; - border-radius: 5px; + border-radius: .6944cqw; box-shadow: inset 0 2px 5px #000, 0 1px 0 rgb(255 255 255 / 7%); } #display { @@ -66,31 +74,66 @@ button:active { background: #384752; } } #display:focus { outline: 2px solid #f4b740; outline-offset: 3px; } #status { color: #ffcc66; min-height: 1.4em; } -.controls { display: flex; align-items: center; justify-content: space-between; margin-top: 67px; padding: 0 0 0 24px; } -.dpad { display: grid; grid-template: repeat(3, 43px) / repeat(3, 43px); filter: drop-shadow(0 5px 3px rgb(0 0 0 / 35%)); } -.dpad button { min-width: 43px; min-height: 43px; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#c86b30, #a84d20); color: #50200e; font-size: 17px; font-weight: 800; text-shadow: 0 1px rgb(255 184 111 / 28%); } +.controls { display: flex; align-items: center; justify-content: space-between; margin-top: 9.3056cqw; padding: 0 0 0 3.3333cqw; } +.dpad { display: grid; grid-template: repeat(3, 5.9722cqw) / repeat(3, 5.9722cqw); filter: drop-shadow(0 .6944cqw .4167cqw rgb(0 0 0 / 35%)); } +.dpad button { min-width: 5.9722cqw; min-height: 5.9722cqw; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#c86b30, #a84d20); color: #50200e; font-size: 2.3611cqw; font-weight: 800; text-shadow: 0 1px rgb(255 184 111 / 28%); } .dpad button:active, .face-button:active { background: #dd7b3b; transform: translateY(1px); } -.dpad-up { grid-area: 1 / 2; border-radius: 7px 7px 0 0 !important; } -.dpad-left { grid-area: 2 / 1; border-radius: 7px 0 0 7px !important; } -.dpad-right { grid-area: 2 / 3; border-radius: 0 7px 7px 0 !important; } -.dpad-down { grid-area: 3 / 2; border-radius: 0 0 7px 7px !important; } +.dpad-up { grid-area: 1 / 2; border-radius: .9722cqw .9722cqw 0 0 !important; } +.dpad-left { grid-area: 2 / 1; border-radius: .9722cqw 0 0 .9722cqw !important; } +.dpad-right { grid-area: 2 / 3; border-radius: 0 .9722cqw .9722cqw 0 !important; } +.dpad-down { grid-area: 3 / 2; border-radius: 0 0 .9722cqw .9722cqw !important; } .dpad-center { grid-area: 2 / 2; background: linear-gradient(#c86b30, #a84d20); } -.face-buttons { position: relative; top: 5px; display: flex; align-items: center; gap: 28px; } -.face-button { width: 72px; height: 72px; padding: 0; border: 1px solid #7d3518; border-radius: 50%; background: linear-gradient(145deg, #cb6f35, #a84d21); color: #642810; font-size: 24px; font-weight: 800; text-shadow: 0 1px rgb(255 187 116 / 32%); box-shadow: inset 2px 2px 2px rgb(255 180 101 / 25%), inset -3px -4px 2px rgb(91 31 7 / 25%), 0 5px 5px rgb(0 0 0 / 35%); } +.face-buttons { position: relative; top: .6944cqw; display: flex; align-items: center; gap: 3.8889cqw; } +.face-button { width: 10cqw; height: 10cqw; padding: 0; border: 1px solid #7d3518; border-radius: 50%; background: linear-gradient(145deg, #cb6f35, #a84d21); color: #642810; font-size: 3.3333cqw; font-weight: 800; text-shadow: 0 1px rgb(255 187 116 / 32%); box-shadow: inset 2px 2px 2px rgb(255 180 101 / 25%), inset -3px -4px 2px rgb(91 31 7 / 25%), 0 5px 5px rgb(0 0 0 / 35%); } pre { max-height: 350px; overflow: auto; background: #171b1f; padding: 12px; } +.coverage { margin: 18px 0 40px; padding: 12px; background: #171b1f; border: 1px solid #35414a; border-radius: 8px; } +.coverage summary { cursor: pointer; color: #f4b740; font-weight: 700; } +.coverage > p { color: #9ca8b3; font-size: 14px; } +.coverage-scroll { overflow-x: auto; } +.coverage table { width: 100%; min-width: 850px; border-collapse: collapse; font-size: 13px; } +.coverage th, .coverage td { padding: 7px 8px; border: 1px solid #35414a; text-align: left; vertical-align: top; } +.coverage thead th, .coverage-group th { background: #20262c; color: #d9e1e8; } +.coverage-group th { color: #f4b740; } +.coverage-gap td { color: #ff9f80; } + +html.light-mode .engineering-only { display: none !important; } +html.light-mode .light-only { display: flex; } +html.light-mode main { width: min(760px, calc(100% - 24px)); margin: 0 auto; padding: 16px 0 24px; } +html.light-mode h1 { margin: 0 0 10px; font-size: clamp(22px, 4vw, 32px); } +html.light-mode .light-toolbar { + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; + color: #cbd4dc; +} +html.light-mode .light-toolbar p { margin: 0; } +html.light-mode .light-toolbar button { flex: 0 0 auto; border-color: #a85625; color: #ffd09c; } +html.light-mode .phone-sensor-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 0 0 18px; + padding: 14px 16px; + border: 1px solid #35414a; + border-radius: 10px; + background: #171b1f; +} +html.light-mode .phone-sensor-panel strong { display: block; margin-bottom: 4px; } +html.light-mode .phone-sensor-panel p { margin: 0; color: #cbd4dc; font-size: 14px; line-height: 1.4; } +html.light-mode .phone-sensor-panel .phone-sensor-privacy { margin-top: 3px; color: #8f9ba5; font-size: 12px; } +html.light-mode .phone-sensor-panel button { flex: 0 0 auto; border-color: #a85625; color: #ffd09c; } +html.light-mode .device-wrap { margin-top: 58px; } +html.light-mode #status { margin: 10px 0 0; font-size: 14px; } @media (max-width: 620px) { .toolbar-group, .fixture-group { flex: 1 1 100%; } .fixture-group select { min-width: 0; } - .device { margin-top: 40px; padding: 34px 22px 34px; border-radius: 13px 13px 25px 25px; } - .antenna { top: -32px; width: 22px; height: 36px; } - .antenna::after { left: -4px; width: 30px; height: 8px; } - .antenna-left { left: 8%; } - .antenna-right { right: 8%; } - .screen-bezel { padding: 7px 8px 9px; } - .controls { margin-top: 30px; padding: 0 0 0 12px; } - .dpad { grid-template: repeat(3, 36px) / repeat(3, 36px); } - .dpad button { min-width: 36px; min-height: 36px; font-size: 14px; } - .face-buttons { top: 2px; gap: 12px; } - .face-button { width: 46px; height: 46px; font-size: 17px; } + html.light-mode main { width: min(100% - 16px, 760px); padding-top: 10px; } + html.light-mode .light-toolbar { align-items: flex-start; } + html.light-mode .light-toolbar p { max-width: 62%; } + html.light-mode .phone-sensor-panel { align-items: flex-start; flex-direction: column; gap: 12px; } + html.light-mode .phone-sensor-panel button { width: 100%; } + html.light-mode .device-wrap { margin-top: 40px; } } diff --git a/ground_station/simulator/window_hmi_renderer.cpp b/ground_station/simulator/window_hmi_renderer.cpp index 614fac78..a1c5709e 100644 --- a/ground_station/simulator/window_hmi_renderer.cpp +++ b/ground_station/simulator/window_hmi_renderer.cpp @@ -34,6 +34,7 @@ void WindowHmiRenderer::syncNavigation(const NavigationSnapshot& source) { navigation_.setPointA(source.homeLatitude, source.homeLongitude); navigation_.setPointB(source.rocketLatitude, source.rocketLongitude); navigation_.setOrientation(source.northRad, source.azimuthRad, source.elevationRad); + navigation_.setAttitude(source.pitchRad, source.rollRad); navigation_.setDistance(source.distanceM); navigation_.setAcceleration(source.ax, source.ay, source.az); navigation_.setGyroscope(source.gx, source.gy, source.gz); @@ -45,16 +46,16 @@ void WindowHmiRenderer::syncLink(const LinkSnapshot& source, size_t index) { telemetry_[index].set(std::min(7, source.telemetry.state), source.telemetry.errors, source.telemetry.altitudeM, source.telemetry.velocityMps, source.telemetry.latitude, source.telemetry.longitude, source.telemetry.voltage, source.telemetry.pyroContinuity, - source.telemetry.testingMode); + source.telemetry.testingMode, static_cast(source.telemetry.lastUpdateMs)); linkInfo_[index].set(source.info.linkQuality, source.info.rssi, source.info.snr); } void WindowHmiRenderer::syncStatistics(const FlightStatisticsSnapshot& source, size_t index) { - statistics_[index].set(source.maxAltitudeM, source.timeToApogeeS, source.maxVelocityMps, - source.drogueDescentRateMps, source.mainDescentRateMps, source.lastLatitude, - source.lastLongitude, source.flightTimeS, source.maxAltitudeValid, - source.timeToApogeeValid, source.maxVelocityValid, source.drogueRateValid, - source.mainRateValid, source.lastLocationValid, source.flightTimeValid); + statistics_[index].set(source.maxAltitudeM, source.timeToApogeeS, source.maxVelocityMps, source.drogueDescentRateMps, + source.mainDescentRateMps, source.lastLatitude, source.lastLongitude, source.flightTimeS, + source.maxAltitudeValid, source.timeToApogeeValid, source.maxVelocityValid, + source.drogueRateValid, source.mainRateValid, source.lastLocationValid, + source.flightTimeValid); } void WindowHmiRenderer::drawStatusBar(const DeviceStatusSnapshot& status) { @@ -70,9 +71,11 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { syncStatistics(state.flightStatistics[0], 0); syncStatistics(state.flightStatistics[1], 1); - // A complete draw from the same production Window methods keeps resets and - // repeated scenario runs independent of prior framebuffer history. - window_.begin(); + // Preserve the actual framebuffer on exit from self-test. Clearing it here + // would hide stale-header bugs in the production transition. + const bool leavingSelfTest = selfTestWasRendered_ && state.screen != "self_test"; + selfTestWasRendered_ = state.screen == "self_test"; + if (!leavingSelfTest) window_.begin(); if (state.screen == "logo") { if (state.configuration.startupAnimation) { @@ -87,6 +90,10 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { return; } + if (state.screen == "self_test") { + window_.drawSelfTest(state.selfTest, static_cast(state.virtualTimeMs), state.selfTestResultIndex); + return; + } window_.initBar(); if (state.screen == "menu") { @@ -101,12 +108,12 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { window_.updateLive(&telemetry_[index], &navigation_, &linkInfo_[index], static_cast(index)); } } + window_.updateLiveNavigation(telemetry_[0], telemetry_[1], &navigation_); } else if (state.screen == "recovery") { if (state.qrView == "recovery_link_1") { - window_.showLocationQr(state.recoveryLocations[0].lastLatitude, state.recoveryLocations[0].lastLongitude, - "[Link 1] Last Location", true, - LocationQr::IsValid(state.recoveryLocations[1].lastLatitude, - state.recoveryLocations[1].lastLongitude)); + window_.showLocationQr( + state.recoveryLocations[0].lastLatitude, state.recoveryLocations[0].lastLongitude, "[Link 1] Last Location", + true, LocationQr::IsValid(state.recoveryLocations[1].lastLatitude, state.recoveryLocations[1].lastLongitude)); } else if (state.qrView == "recovery_link_2") { window_.showLocationQr(state.recoveryLocations[1].lastLatitude, state.recoveryLocations[1].lastLongitude, "[Link 2] Last Location", true, false); @@ -120,9 +127,8 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { window_.initRecovery(hasLastLocation); if (state.configuration.dualReceiver) { const EarthPoint3D target(state.recoverySolution.latitude, state.recoverySolution.longitude); - window_.updateRecoveryTarget(&navigation_, target, - LocationQr::IsValid(target.lat, target.lon), state.selectedRecoveryLink, true, - hasLastLocation); + window_.updateRecoveryTarget(&navigation_, target, LocationQr::IsValid(target.lat, target.lon), + state.selectedRecoveryLink, true, hasLastLocation); } else { window_.updateRecovery(&navigation_, hasLastLocation); } @@ -137,11 +143,13 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { } else if (state.testingState == "waiting") { window_.initTestingWait(); } else if (state.testingState == "failed") { - const bool connectionLost = std::any_of(state.actions.begin(), state.actions.end(), [](const PlatformAction& action) { - return action.type == "testing_connection_lost"; - }); - if (connectionLost) window_.initTestingLost(); - else window_.initTestingFailed(); + const bool connectionLost = + std::any_of(state.actions.begin(), state.actions.end(), + [](const PlatformAction& action) { return action.type == "testing_connection_lost"; }); + if (connectionLost) + window_.initTestingLost(); + else + window_.initTestingFailed(); } else { window_.initTestingReady(); window_.updateTesting(state.testingSelection); @@ -149,24 +157,22 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { } } else if (state.screen == "data") { if (state.qrView == "log_link_1") { - window_.showLocationQr(state.flightStatistics[0].lastLatitude, state.flightStatistics[0].lastLongitude, - "[Link 1] Last Location", true, - LocationQr::IsValid(state.flightStatistics[1].lastLatitude, - state.flightStatistics[1].lastLongitude)); + window_.showLocationQr( + state.flightStatistics[0].lastLatitude, state.flightStatistics[0].lastLongitude, "[Link 1] Last Location", + true, LocationQr::IsValid(state.flightStatistics[1].lastLatitude, state.flightStatistics[1].lastLongitude)); } else if (state.qrView == "log_link_2") { window_.showLocationQr(state.flightStatistics[1].lastLatitude, state.flightStatistics[1].lastLongitude, "[Link 2] Last Location", true, false); } else if (state.currentDataSubview == "details" && !state.logs.empty()) { - const size_t selected = std::min(static_cast(std::max(0, state.dataSelection)), - state.logs.size() - 1U); - window_.dataShowFlightStatistics(statistics_[0], statistics_[1], state.logs[selected].name.c_str(), - LocationQr::IsValid(state.flightStatistics[0].lastLatitude, - state.flightStatistics[0].lastLongitude) || - LocationQr::IsValid(state.flightStatistics[1].lastLatitude, - state.flightStatistics[1].lastLongitude)); + const size_t selected = + std::min(static_cast(std::max(0, state.dataSelection)), state.logs.size() - 1U); + window_.dataShowFlightStatistics( + statistics_[0], statistics_[1], state.logs[selected].name.c_str(), + LocationQr::IsValid(state.flightStatistics[0].lastLatitude, state.flightStatistics[0].lastLongitude) || + LocationQr::IsValid(state.flightStatistics[1].lastLatitude, state.flightStatistics[1].lastLongitude)); } else if (state.currentDataSubview == "options" && !state.logs.empty()) { - const size_t selected = std::min(static_cast(std::max(0, state.dataSelection)), - state.logs.size() - 1U); + const size_t selected = + std::min(static_cast(std::max(0, state.dataSelection)), state.logs.size() - 1U); window_.initDataOptions(state.logs[selected].name.c_str(), state.logs[selected].active); } else if ((state.currentDataSubview == "confirm_finalize" || state.currentDataSubview == "confirm_delete") && !state.logs.empty()) { @@ -182,12 +188,14 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { window_.listFileName(label.c_str(), static_cast(index - state.logScrollOffset)); } if (!state.logs.empty()) { - const size_t selected = std::min(static_cast(std::max(0, state.dataSelection)), state.logs.size() - 1U); + const size_t selected = + std::min(static_cast(std::max(0, state.dataSelection)), state.logs.size() - 1U); std::string label = state.logs[selected].name + (state.logs[selected].active ? " [ACTIVE]" : ""); window_.dataHighlight(label.c_str(), static_cast(selected - state.logScrollOffset), true); } - window_.dataScrollIndicators(state.logScrollOffset > 0U, end < state.logs.size(), - state.logs.empty() ? -1 : static_cast(state.dataSelection - state.logScrollOffset)); + window_.dataScrollIndicators( + state.logScrollOffset > 0U, end < state.logs.size(), + state.logs.empty() ? -1 : static_cast(state.dataSelection - state.logScrollOffset)); window_.refresh(); } } else if (state.screen == "usb_storage") { @@ -196,6 +204,21 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { } else { window_.initUsbStorage(state.device.usbStorageState == "host"); } + } else if (state.screen == "firmware_update") { + window_.initFirmwareUpdate(state.firmwareUpdateSelection); + } else if (state.screen == "radio_update") { + const char* filename = state.radioFirmware.empty() ? "" : state.radioFirmware[0].c_str(); + if (state.radioUpdateState == "browse") { + window_.initRadioUpdateList(); + if (!state.radioFirmware.empty()) window_.radioUpdateFileName(filename, 0, true); + window_.refresh(); + } else if (state.radioUpdateState == "confirm") { + window_.initRadioUpdateConfirm(filename, 37120, 0x11223344); + } else if (state.radioUpdateState == "complete") { + window_.radioUpdateResult(true, "Update verified", "1.2.0", "1.2.0"); + } else { + window_.radioUpdateProgress("Writing", state.radioUpdateLink, state.radioUpdatePercent); + } } else if (state.screen == "sensors") { if (state.calibrationState == "prepare") { window_.initSensorPrepareCalibrate(); @@ -205,8 +228,13 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { } else if (state.calibrationState == "concluded") { window_.initSensorCalibrateDone(); } else { - window_.initSensors(); - window_.updateSensors(&navigation_); + if (state.sensorView == "orientation") { + window_.initSensorOrientation(); + window_.updateSensorOrientation(&navigation_); + } else { + window_.initSensors(); + window_.updateSensors(&navigation_); + } } } else if (state.screen == "settings") { if (state.settingsState == "keyboard") { @@ -219,6 +247,12 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { } else { window_.initSettings(state.settingsPage); window_.updateSettings(state.settingsSelection); + if (state.settingsSelection >= 0 && + settingsTable[state.settingsPage][state.settingsSelection].type == BUTTON && + settingsTable[state.settingsPage][state.settingsSelection].config.buttonAction == BUTTON_ACTION_VERSION) { + window_.settingsVersions(state.settingsVersions[0].c_str(), state.settingsVersions[1].c_str()); + window_.refresh(); + } } } diff --git a/ground_station/simulator/window_hmi_renderer.hpp b/ground_station/simulator/window_hmi_renderer.hpp index 060837a0..0dd80217 100644 --- a/ground_station/simulator/window_hmi_renderer.hpp +++ b/ground_station/simulator/window_hmi_renderer.hpp @@ -21,6 +21,7 @@ class WindowHmiRenderer final : public IHmiRenderer { SimulatorDisplay& display_; Window window_; + bool selfTestWasRendered_{false}; Navigation navigation_; TelemetryData telemetry_[2]{}; TelemetryInfo linkInfo_[2]{}; diff --git a/ground_station/src/console.cpp b/ground_station/src/console.cpp index 1a024420..afe7499b 100644 --- a/ground_station/src/console.cpp +++ b/ground_station/src/console.cpp @@ -160,6 +160,7 @@ void Console::printStartupMessage() { stream.println("* CATS Ground Station *"); stream.println("****************************************************"); stream.println(CONSOLE_LOG); + stream.println(FIRMWARE_VERSION); } void Console::usbEventCallback(void* arg [[maybe_unused]], esp_event_base_t event_base, int32_t event_id, diff --git a/ground_station/src/hmi/hmi.cpp b/ground_station/src/hmi/hmi.cpp index ccd06750..8e6a3bac 100644 --- a/ground_station/src/hmi/hmi.cpp +++ b/ground_station/src/hmi/hmi.cpp @@ -39,6 +39,12 @@ void Hmi::begin() { recorder.enable(); link1.setPacketSink(&recorder, 1); link2.setPacketSink(&recorder, 2); + static RadioUpdate::RadioFirmwareUpdater updater(recorder, link1, link2); + if (updater.begin()) { + radioUpdater = &updater; + } else { + console.warning.println("[HMI] Could not start radio update worker"); + } window.begin(); initialized = true; @@ -84,6 +90,18 @@ void Hmi::fsm() { usbStorage(); break; + case SELF_TEST: + selfTestStep(); + break; + + case FIRMWARE_UPDATE: + firmwareUpdate(); + break; + + case RADIO_UPDATE: + radioUpdate(); + break; + default: break; } @@ -135,14 +153,48 @@ void Hmi::menu() { /* LIVE */ -void Hmi::initLive() { window.initLive(); } +void Hmi::initLive() { + window.initLive(); + lastLiveRefresh = millis(); +} + +bool Hmi::liveButtonPending() const { + return (digitalRead(kLeftButtonPin) == LOW) != leftButton.isPressed() || + (digitalRead(kRightButtonPin) == LOW) != rightButton.isPressed() || + (digitalRead(kBackButtonPin) == LOW) != backButton.isPressed(); +} void Hmi::live() { - bool updated = false; + // Honour navigation before a full LCD transfer can delay button polling. + if (backButton.wasPressed()) { + state = MENU; + window.initMenu(menuIndex); + return; + } + if (rightButton.wasPressed()) { + window.UpdateLiveState(&link1.data, &link2.data, &navigation, Window::LiveState::kShowDownRange); + lastLiveRefresh = millis(); + return; + } + if (leftButton.wasPressed()) { + window.UpdateLiveState(&link1.data, &link2.data, &navigation, Window::LiveState::kShowGnss); + lastLiveRefresh = millis(); + return; + } + // Leave time to debounce a press or release before another blocking LCD transfer. + // Coalesce telemetry and navigation updates so packet arrivals cannot starve polling. + if (liveButtonPending() || millis() - lastLiveRefresh < kLiveRefreshIntervalMs) { + return; + } + + bool updated = false; if (link1.data.isUpdated() && link1.info.isUpdated()) { window.updateLive(&link1.data, &navigation, &link1.info, 0); updated = true; + } else if (link1.data.isUpdated()) { + window.updateLive(&link1.data, &navigation, 0); + updated = true; } else if (link1.info.isUpdated()) { window.updateLive(&link1.info, 0); updated = true; @@ -151,29 +203,18 @@ void Hmi::live() { if (link2.data.isUpdated() && link2.info.isUpdated()) { window.updateLive(&link2.data, &navigation, &link2.info, 1); updated = true; + } else if (link2.data.isUpdated()) { + window.updateLive(&link2.data, &navigation, 1); + updated = true; } else if (link2.info.isUpdated()) { window.updateLive(&link2.info, 1); updated = true; } + updated |= window.updateLiveNavigation(link1.data, link2.data, &navigation); isLogging = recorder.getStatus().state == RecorderState::Recording; - - if (updated) { - window.refresh(); - } - - if (rightButton.wasPressed()) { - window.UpdateLiveState(&link1.data, &link2.data, &navigation, Window::LiveState::kShowDownRange); - } - - if (leftButton.wasPressed()) { - window.UpdateLiveState(&link1.data, &link2.data, &navigation, Window::LiveState::kShowGnss); - } - - if (backButton.wasPressed()) { - state = MENU; - window.initMenu(menuIndex); - } + if (updated) window.refresh(); + lastLiveRefresh = millis(); } /* RECOVERY */ @@ -652,6 +693,7 @@ void Hmi::data() { /* SENSORS */ void Hmi::initSensors() { + sensorView = SensorView::Readings; lastSensorRefresh = 0; window.initSensors(); } @@ -671,11 +713,28 @@ void Hmi::sensors() { return; } + if (rightButton.wasPressed() || downButton.wasPressed()) { + sensorView = SensorView::Orientation; + window.initSensorOrientation(); + lastSensorRefresh = 0; + return; + } + if (leftButton.wasPressed() || upButton.wasPressed()) { + sensorView = SensorView::Readings; + window.initSensors(); + lastSensorRefresh = 0; + return; + } + const uint32_t now = millis(); if (now - lastSensorRefresh >= kSensorRefreshIntervalMs) { // A full framebuffer transfer blocks this task. Leave a // quiet interval between sensor redraws so button polling stays fast. - window.updateSensors(&navigation); + if (sensorView == SensorView::Orientation) { + window.updateSensorOrientation(&navigation); + } else { + window.updateSensors(&navigation); + } lastSensorRefresh = millis(); } break; @@ -688,6 +747,7 @@ void Hmi::sensors() { } if (backButton.wasPressed()) { calibrationState = IDLE; + sensorView = SensorView::Readings; window.initSensors(); } break; @@ -707,6 +767,7 @@ void Hmi::sensors() { case CONCLUDED: if (okButton.wasPressed() || backButton.wasPressed()) { calibrationState = IDLE; + sensorView = SensorView::Readings; if (claimStorageForFirmware()) { systemConfig.save(); automaticUsbSharePending = Utils::isConnected(); @@ -722,6 +783,7 @@ void Hmi::sensors() { /* SETTINGS */ void Hmi::initSettings() { + versionsSelected = false; settingSubMenu = 0; settingIndex = -1; window.initSettings(settingSubMenu); @@ -828,7 +890,8 @@ void Hmi::settings() { break; } case STRING: { - if (okButton.wasPressed()) { + if (okButton.wasPressed() && + (data_ptr != systemConfig.config.linkPhrase2 || systemConfig.config.receiverMode != SINGLE)) { memcpy(keyboardString, static_cast(data_ptr), kMaxPhraseLen); keyboardString[kMaxPhraseLen] = '\0'; @@ -843,10 +906,14 @@ void Hmi::settings() { case BUTTON_ACTION_USB_STORAGE: initUsbStorage(); return; - case BUTTON_ACTION_START_BOOTLOADER: - window.Bootloader(); - Utils::startBootloader(); + case BUTTON_ACTION_UPDATE_FIRMWARE: + initFirmwareUpdate(); + return; + case BUTTON_ACTION_SELF_TEST: + initSelfTest(); return; + case BUTTON_ACTION_VERSION: + break; case BUTTON_ACTION_NONE: break; } @@ -892,6 +959,33 @@ void Hmi::settings() { window.initMenu(menuIndex); } } + if (state == SETTINGS && !keyboardActive && settingIndex >= 0 && + settingsTable[settingSubMenu][settingIndex].type == BUTTON && + settingsTable[settingSubMenu][settingIndex].config.buttonAction == BUTTON_ACTION_VERSION) { + updateSettingVersions(); + } else { + versionsSelected = false; + } +} + +void Hmi::updateSettingVersions() { + Telemetry *links[] = {&link1, &link2}; + bool redraw = !versionsSelected; + versionsSelected = true; + for (size_t i = 0; i < 2; ++i) { + const auto observation = links[i]->diagnostics(); + const char *text = observation.versionReplies != 0 + ? observation.version + : (links[i]->versionReadComplete() ? "No response" : "Reading..."); + if (std::strcmp(displayedVersions[i], text) != 0) { + std::snprintf(displayedVersions[i], sizeof(displayedVersions[i]), "%s", text); + redraw = true; + } + } + if (redraw) { + window.settingsVersions(displayedVersions[0], displayedVersions[1]); + window.refresh(); + } } void Hmi::initUsbStorage() { @@ -984,7 +1078,145 @@ void Hmi::usbStorage() { } } +void Hmi::initFirmwareUpdate() { + state = FIRMWARE_UPDATE; + firmwareUpdateIndex = 0; + window.initFirmwareUpdate(firmwareUpdateIndex); +} + +void Hmi::firmwareUpdate() { + const int16_t oldIndex = firmwareUpdateIndex; + if (downButton.wasPressed() && firmwareUpdateIndex < 1) { + ++firmwareUpdateIndex; + } + if (upButton.wasPressed() && firmwareUpdateIndex > 0) { + --firmwareUpdateIndex; + } + if (backButton.wasPressed()) { + state = SETTINGS; + settingSubMenu = SETTING_SYSTEM; + settingIndex = 3; + window.initSettings(settingSubMenu); + window.updateSettings(settingIndex); + return; + } + if (okButton.wasPressed()) { + if (firmwareUpdateIndex == 0) { + window.Bootloader(); + Utils::startBootloader(); + } else { + initRadioUpdate(); + } + return; + } + if (firmwareUpdateIndex != oldIndex) { + window.initFirmwareUpdate(firmwareUpdateIndex); + } +} + +void Hmi::initRadioUpdate() { + state = RADIO_UPDATE; + automaticUsbSharePending = false; + leaveRadioUpdate = false; + radioUpdateRevision = UINT32_MAX; + radioUpdateIndex = 0; + radioUpdateWindowStart = 0; + if (radioUpdater == nullptr || !radioUpdater->browse()) { + window.initDataMessage("Radio Update", "Update worker unavailable"); + } +} + +void Hmi::drawRadioUpdateList(const RadioUpdate::Snapshot &snapshot) { + window.initRadioUpdateList(); + const size_t end = std::min(snapshot.fileCount, radioUpdateWindowStart + 8U); + char filename[RadioUpdate::kNameSize]{}; + for (size_t index = radioUpdateWindowStart; index < end; ++index) { + if (radioUpdater->filename(index, filename, sizeof(filename))) { + window.radioUpdateFileName(filename, static_cast(index - radioUpdateWindowStart), + index == radioUpdateIndex); + } + } + window.refresh(); +} + +void Hmi::radioUpdate() { + if (radioUpdater == nullptr) { + if (backButton.wasPressed()) { + state = FIRMWARE_UPDATE; + firmwareUpdateIndex = 1; + window.initFirmwareUpdate(firmwareUpdateIndex); + } + return; + } + RadioUpdate::Snapshot snapshot = radioUpdater->snapshot(); + if (snapshot.phase == RadioUpdate::Phase::Browsing && !snapshot.busy && snapshot.fileCount > 0) { + if (downButton.wasPressed() && radioUpdateIndex + 1U < snapshot.fileCount) { + ++radioUpdateIndex; + if (radioUpdateIndex >= radioUpdateWindowStart + 8U) { + radioUpdateWindowStart = radioUpdateIndex - 7U; + } + drawRadioUpdateList(snapshot); + return; + } + if (upButton.wasPressed() && radioUpdateIndex > 0) { + --radioUpdateIndex; + if (radioUpdateIndex < radioUpdateWindowStart) { + radioUpdateWindowStart = radioUpdateIndex; + } + drawRadioUpdateList(snapshot); + return; + } + if (okButton.wasPressed()) { + (void)radioUpdater->select(radioUpdateIndex); + return; + } + } else if (snapshot.phase == RadioUpdate::Phase::Confirm && !snapshot.busy && okButton.wasPressed()) { + (void)radioUpdater->program(); + return; + } + + const bool mayLeave = + !snapshot.busy && + (snapshot.phase == RadioUpdate::Phase::Browsing || snapshot.phase == RadioUpdate::Phase::Confirm || + snapshot.phase == RadioUpdate::Phase::Failed || snapshot.phase == RadioUpdate::Phase::Complete); + if (mayLeave && backButton.wasPressed()) { + leaveRadioUpdate = true; + (void)radioUpdater->close(); + return; + } + if (leaveRadioUpdate && snapshot.phase == RadioUpdate::Phase::Idle && !snapshot.busy) { + leaveRadioUpdate = false; + automaticUsbSharePending = Utils::isConnected(); + state = FIRMWARE_UPDATE; + firmwareUpdateIndex = 1; + window.initFirmwareUpdate(firmwareUpdateIndex); + return; + } + if (snapshot.revision == radioUpdateRevision) { + return; + } + radioUpdateRevision = snapshot.revision; + if (snapshot.phase == RadioUpdate::Phase::Browsing) { + drawRadioUpdateList(snapshot); + } else if (snapshot.phase == RadioUpdate::Phase::Confirm) { + window.initRadioUpdateConfirm(snapshot.filename, snapshot.image.size, snapshot.image.crc); + } else if (snapshot.phase == RadioUpdate::Phase::Complete || snapshot.phase == RadioUpdate::Phase::Failed) { + const char *link1Status = snapshot.results[0].success + ? snapshot.results[0].version + : (snapshot.results[0].entryRequested ? "QUARANTINED" : "not updated"); + const char *link2Status = snapshot.results[1].success + ? snapshot.results[1].version + : (snapshot.results[1].entryRequested ? "QUARANTINED" : "not updated"); + window.radioUpdateResult(snapshot.phase == RadioUpdate::Phase::Complete, snapshot.error, link1Status, link2Status); + } else { + window.radioUpdateProgress(RadioUpdate::phaseName(snapshot.phase), snapshot.link, snapshot.percent); + } +} + void Hmi::updateAutomaticUsbStorage(const RecorderStatus &recorderStatus) { + if (state == SELF_TEST || state == RADIO_UPDATE) { + return; + } const bool connected = Utils::isConnected(); if (!connected) { usbPreviouslyConnected = false; @@ -1063,7 +1295,7 @@ void Hmi::update(void *pvParameter) { // ref->window.updateBar(link1.data.ts()); } - if (millis() - barUpdate >= 1000) { + if (ref->state != SELF_TEST && millis() - barUpdate >= 1000 && (ref->state != LIVE || !ref->liveButtonPending())) { barUpdate = millis(); const float voltage = static_cast(analogRead(18)) * 0.00062F; // 0.00059154929F; if (!ref->isLogging && Utils::isFilesystemAvailable()) { diff --git a/ground_station/src/hmi/hmi.hpp b/ground_station/src/hmi/hmi.hpp index 6f4f1d81..e305e6b3 100644 --- a/ground_station/src/hmi/hmi.hpp +++ b/ground_station/src/hmi/hmi.hpp @@ -8,6 +8,7 @@ #include "clock.hpp" #include "config.hpp" #include "logging/recorder.hpp" +#include "update/radio_firmware_updater.hpp" #include "window.hpp" #include @@ -18,20 +19,29 @@ class Hmi { : recorder(dir), upButton(kUpButtonPin, kButtonDebounceMs), downButton(kDownButtonPin, kButtonDebounceMs), - leftButton(2, kButtonDebounceMs), - rightButton(5, kButtonDebounceMs), + leftButton(kLeftButtonPin, kButtonDebounceMs), + rightButton(kRightButtonPin, kButtonDebounceMs), centerButton(1, kButtonDebounceMs), okButton(7, kButtonDebounceMs), - backButton(6, kButtonDebounceMs), + backButton(kBackButtonPin, kButtonDebounceMs), window(display, systemConfig, clock) {} void begin(); + ReceiverTelemetryMode_e receiverMode() const { + const int8_t mode = selfTestMode.load(); + return mode < 0 ? systemConfig.config.receiverMode : (mode == 0 ? SINGLE : DUAL); + } + void noteCombinedTelemetry() { ++combinedTelemetryPackets; } private: static constexpr uint8_t kUpButtonPin = 3; static constexpr uint8_t kDownButtonPin = 4; + static constexpr uint8_t kLeftButtonPin = 2; + static constexpr uint8_t kRightButtonPin = 5; + static constexpr uint8_t kBackButtonPin = 6; static constexpr uint32_t kButtonDebounceMs = 15; static constexpr uint32_t kSensorRefreshIntervalMs = 200; + static constexpr uint32_t kLiveRefreshIntervalMs = 200; enum State { MENU = 0, @@ -42,6 +52,9 @@ class Hmi { SENSORS = 5, SETTINGS = 6, USB_STORAGE = 7, + SELF_TEST = 8, + FIRMWARE_UPDATE = 9, + RADIO_UPDATE = 10, }; enum TestingState { @@ -60,6 +73,8 @@ class Hmi { CONCLUDED = 3, }; + enum class SensorView : uint8_t { Readings, Orientation }; + State state = MENU; TestingState testingState = DISCLAIMER; @@ -67,9 +82,12 @@ class Hmi { int16_t testingIndex = 0; CalibrationState calibrationState = IDLE; + SensorView sensorView = SensorView::Readings; uint32_t lastSensorRefresh = 0; + uint32_t lastLiveRefresh = 0; Recorder recorder; + RadioUpdate::RadioFirmwareUpdater* radioUpdater{nullptr}; uint16_t oldTimeStamp = 0; @@ -84,6 +102,7 @@ class Hmi { void menu(); void initLive(); void live(); + bool liveButtonPending() const; void initRecovery(); void recovery(); void updateRecoveryLocations(); @@ -100,10 +119,32 @@ class Hmi { void sensors(); void initSettings(); void settings(); + void updateSettingVersions(); + bool versionsSelected{false}; + char displayedVersions[2][17]{}; void initUsbStorage(); void usbStorage(); + void initFirmwareUpdate(); + void firmwareUpdate(); + void initRadioUpdate(); + void radioUpdate(); + void drawRadioUpdateList(const RadioUpdate::Snapshot& snapshot); void updateAutomaticUsbStorage(const RecorderStatus& recorderStatus); bool claimStorageForFirmware(); + void initSelfTest(); + void startSelfTest(); + void selfTestStep(); + SelfTestInput selfTestInput(); + void applySelfTestRadio(SelfTest::RadioConfiguration configuration); + bool testStorage(); + void leaveSelfTest(); + SelfTest selfTest{}; + std::atomic selfTestMode{-1}; + std::atomic combinedTelemetryPackets{0}; + uint32_t selfTestLastDraw{0}; + uint32_t selfTestBackSince{0}; + int16_t selfTestResultIndex{0}; + bool selfTestControlsOk{true}; bool initialized = false; bool isLogging = false; @@ -152,4 +193,9 @@ class Hmi { bool usbPreviouslyConnected = false; bool automaticUsbSharePending = false; uint32_t lastAutomaticUsbShareAttempt = 0; + uint32_t radioUpdateRevision = UINT32_MAX; + int16_t firmwareUpdateIndex = 0; + size_t radioUpdateIndex = 0; + size_t radioUpdateWindowStart = 0; + bool leaveRadioUpdate = false; }; diff --git a/ground_station/src/hmi/hmi_self_test.cpp b/ground_station/src/hmi/hmi_self_test.cpp new file mode 100644 index 00000000..d41fa80a --- /dev/null +++ b/ground_station/src/hmi/hmi_self_test.cpp @@ -0,0 +1,180 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later +#include "hmi.hpp" +#include "telemetry/telemetry.hpp" + +#include +#include + +extern Telemetry link1; +extern Telemetry link2; +extern Navigation navigation; + +SelfTestInput Hmi::selfTestInput() { + SelfTestInput input{}; + input.links = {link1.diagnostics(), link2.diagnostics()}; + input.linksReady = selfTestControlsOk && link1.selfTestReady() && link2.selfTestReady(); + input.sensors = navigation.selfTestSensors(); + // The operator starts on battery with USB unplugged. An active bus invalidates + // that check; suspension alone cannot establish the board's power source. + input.usbConnected = Utils::isUsbActive(); + input.usbStorageReady = Utils::getMassStorageState() == UsbStorageState::HostOwned; + input.usbStorageFault = Utils::getMassStorageState() == UsbStorageState::Fault; + input.usbReadCount = Utils::usbReadCount(); + input.batteryVoltage = static_cast(analogRead(18)) * 0.00062F; + input.combinedPackets = combinedTelemetryPackets.load(); + Button* buttons[] = {&upButton, &downButton, &leftButton, &rightButton, ¢erButton, &okButton, &backButton}; + for (size_t i = 0; i < 7; ++i) { + if (buttons[i]->isPressed()) input.buttonsHeld |= static_cast(1U << i); + if (buttons[i]->wasPressed()) input.buttonsPressed |= static_cast(1U << i); + } + return input; +} + +void Hmi::initSelfTest() { + state = SELF_TEST; + selfTest = SelfTest{}; + selfTestResultIndex = 0; + selfTestBackSince = 0; + window.drawSelfTest(selfTest, millis(), 0); +} + +void Hmi::startSelfTest() { + if (recorder.getStatus().state != RecorderState::Idle) { + window.initDataMessage("Self-test unavailable", "Finalize the active log first."); + return; + } + // The queue barrier drains any in-flight packet before touching storage. + recorder.disable(); + if (!recorder.sync() || recorder.getStatus().state != RecorderState::Idle) { + recorder.enable(); + window.initDataMessage("Self-test unavailable", "Recorder is busy. Try again."); + return; + } + automaticUsbSharePending = false; + selfTestControlsOk = true; + selfTestResultIndex = 0; + selfTest.start(millis(), selfTestInput()); + navigation.enableSelfTestSampling(true); + const bool storageOk = claimStorageForFirmware() && testStorage(); + selfTest.setResult(SelfTest::Check::Storage, storageOk ? SelfTest::Result::Pass : SelfTest::Result::Fail, + storageOk ? "Written, reopened and verified" : "Storage write/read check failed"); + window.drawSelfTest(selfTest, millis(), 0); + selfTestLastDraw = millis(); +} + +void Hmi::applySelfTestRadio(SelfTest::RadioConfiguration configuration) { + using Radio = SelfTest::RadioConfiguration; + const char* first = SelfTestProfile::kPhrase1; + const char* second = SelfTestProfile::kPhrase2; + bool firstEnabled = true; + bool secondEnabled = true; + if (configuration == Radio::Restore) { + // Include any ordinary Settings edits made before starting the test. + link1.setLinkPhrase(systemConfig.config.linkPhrase1, kMaxPhraseLen); + link2.setLinkPhrase( + systemConfig.config.receiverMode == SINGLE ? systemConfig.config.linkPhrase1 : systemConfig.config.linkPhrase2, + kMaxPhraseLen); + first = second = nullptr; + selfTestMode = -1; + } else if (configuration == Radio::Swapped) { + std::swap(first, second); + selfTestMode = 1; + } else if (configuration == Radio::Dual) { + selfTestMode = 1; + } else { + second = first; + firstEnabled = configuration != Radio::Receiver2Only; + secondEnabled = configuration != Radio::Receiver1Only; + selfTestMode = 0; + } + const bool firstOk = link1.setSelfTestOverride(first, firstEnabled); + const bool secondOk = link2.setSelfTestOverride(second, secondEnabled); + selfTestControlsOk = firstOk && secondOk; +} + +bool Hmi::testStorage() { + if (!Utils::isFilesystemAvailable()) return false; + constexpr const char* path = "/self-test.tmp"; + uint8_t expected[256]{}; + for (size_t i = 0; i < sizeof(expected); ++i) expected[i] = static_cast(i ^ 0xa5U); + auto file = fatfs.open(path, O_WRONLY | O_CREAT | O_EXCL); + if (!file) return false; + const bool written = file.write(expected, sizeof(expected)) == sizeof(expected) && file.sync(); + file.close(); + uint8_t actual[256]{}; + file = fatfs.open(path, O_RDONLY); + const bool read = file && file.read(actual, sizeof(actual)) == sizeof(actual) && file.size() == sizeof(actual); + file.close(); + const bool removed = fatfs.remove(path); + return written && read && removed && std::memcmp(expected, actual, sizeof(expected)) == 0; +} + +void Hmi::leaveSelfTest() { + navigation.enableSelfTestSampling(false); + recorder.enable(); + automaticUsbSharePending = Utils::isConnected(); + state = SETTINGS; + window.initBar(); + window.initSettings(settingSubMenu); + window.updateSettings(settingIndex); +} + +void Hmi::selfTestStep() { + const uint32_t now = millis(); + if (!selfTest.started()) { + if (backButton.wasPressed()) { + leaveSelfTest(); + return; + } + if (okButton.wasPressed()) startSelfTest(); + return; + } + if (selfTest.finished()) { + if (backButton.wasPressed()) { + leaveSelfTest(); + return; + } + if (okButton.wasPressed()) { + startSelfTest(); + return; + } + if (upButton.wasPressed() && selfTestResultIndex > 0) --selfTestResultIndex; + if (downButton.wasPressed() && selfTestResultIndex < static_cast(SelfTest::Check::Count) - 1) + ++selfTestResultIndex; + if (upButton.wasPressed() || downButton.wasPressed()) window.drawSelfTest(selfTest, now, selfTestResultIndex); + return; + } + if (backButton.isPressed()) { + if (selfTestBackSince == 0) selfTestBackSince = now; + if (now - selfTestBackSince >= 2000U && !selfTest.cancelled) selfTest.cancel(now); + } else + selfTestBackSince = 0; + const auto previousPhase = selfTest.phase; + const bool wasWaiting = selfTest.awaitingConfirmation; + selfTest.update(now, selfTestInput()); + std::array gyroBias{}; + if (selfTest.takeGyroCalibration(gyroBias)) + selfTest.completeGyroCalibration(navigation.saveGyroCalibration(gyroBias), now, selfTestInput()); + if (selfTest.takeUsbStorageRequest() && !recorder.shareWithMassStorage()) + selfTest.setResult(SelfTest::Check::Usb, SelfTest::Result::Fail, "Could not share USB storage"); + const auto radio = selfTest.takeRadioConfiguration(); + if (radio != SelfTest::RadioConfiguration::None) applySelfTestRadio(radio); + if (selfTest.takeVersionRequest()) { + link1.requestVersion(); + link2.requestVersion(); + } + if (selfTest.takeRadioReset()) { + link1.resetReceptionStats(now); + link2.resetReceptionStats(now); + } + if (selfTest.finished()) { + navigation.enableSelfTestSampling(false); + if (Utils::isConnected()) recorder.shareWithMassStorage(); + } + if (previousPhase != selfTest.phase || wasWaiting != selfTest.awaitingConfirmation || + now - selfTestLastDraw >= 200U) { + window.drawSelfTest(selfTest, now, selfTestResultIndex); + selfTestLastDraw = now; + } +} diff --git a/ground_station/src/hmi/settings.cpp b/ground_station/src/hmi/settings.cpp index 1a9ffb57..751a6c98 100644 --- a/ground_station/src/hmi/settings.cpp +++ b/ground_station/src/hmi/settings.cpp @@ -6,82 +6,78 @@ #include "config.hpp" // NOLINTNEXTLINE(cppcoreguidelines-interfaces-global-init) -const device_settings_t settingsTable[][4] = {{ - {"Stop Logging", - "Down: Stop the log at touchdown", - "Never: Never stop logging after liftoff", - TOGGLE, - {.lookup = TABLE_LOGGING}, - &systemConfig.config.neverStopLogging}, - - { - "Version", - "Firmware Version: " FIRMWARE_VERSION, - "", - BUTTON, - {.buttonAction = BUTTON_ACTION_NONE}, - nullptr, - }, - { - "USB Drive", - "Shared automatically while not logging", - "Press A to view or disconnect the drive", - BUTTON, - {.buttonAction = BUTTON_ACTION_USB_STORAGE}, - nullptr, - }, - { - "Start Bootloader", - "Press A to start the bootloader", - "Make sure you are connected to a computer", - BUTTON, - {.buttonAction = BUTTON_ACTION_START_BOOTLOADER}, - nullptr, - }, - }, - { - {"Mode", - "Single: Use both receiver to track one rocket", - "Dual: Use both receivers individually", - TOGGLE, - {.lookup = TABLE_MODE}, - &systemConfig.config.receiverMode}, - {"Link Phrase 1", - "Single Mode: Set phrase for both receivers", - "Dual Mode: Set phrase for the left receiver", - STRING, - {.stringLength = kMaxPhraseLen}, - systemConfig.config.linkPhrase1}, - {"Link Phrase 2", - "Single Mode: No functionality", - "Dual Mode: Set phrase for the right receiver", - STRING, - {.stringLength = kMaxPhraseLen}, - systemConfig.config.linkPhrase2}, - {"Testing Phrase", - "Set the phrase for the testing mode", - "", - STRING, - {.stringLength = kMaxPhraseLen}, - systemConfig.config.testingPhrase}, - }, - { - {"Time Zone", - "Time offset relative to UTC", - "", - NUMBER, - {.minmax = {.min = -12, .max = 12}}, - &systemConfig.config.timeZoneOffset}, - {"Unit System", - "Select unit system to display", - "Note: Data is still logged using metric", - TOGGLE, - {.lookup = TABLE_UNIT}, - &systemConfig.config.unitSystem}, - {"Startup Animation", - "Show animated startup sequence", - "Off: Show static CATS logo", - TOGGLE, - {.lookup = TABLE_STARTUP_ANIMATION}, - &systemConfig.config.startupAnimation}, - }}; +const device_settings_t settingsTable[][4] = { + { + {"Receiver Mode", + "Single: Both receivers track one rocket", + "Dual: Each receiver tracks its own rocket", + TOGGLE, + {.lookup = TABLE_MODE}, + &systemConfig.config.receiverMode}, + {"Link Phrase 1", + "Single: Set the phrase for both receivers", + "Dual: Set the left receiver's phrase", + STRING, + {.stringLength = kMaxPhraseLen}, + systemConfig.config.linkPhrase1}, + {"Link Phrase 2", + "Available in Dual mode for the right receiver", + "Single mode uses Link Phrase 1 for both", + STRING, + {.stringLength = kMaxPhraseLen}, + systemConfig.config.linkPhrase2}, + {"Test Phrase", + "Set the phrase for Vega testing mode", + "", + STRING, + {.stringLength = kMaxPhraseLen}, + systemConfig.config.testingPhrase}, + }, + { + {"Stop Logging", + "Landing: Stop the log at touchdown", + "Never: Never stop logging after liftoff", + TOGGLE, + {.lookup = TABLE_LOGGING}, + &systemConfig.config.neverStopLogging}, + {"Time Zone", + "Time offset relative to UTC", + "", + NUMBER, + {.minmax = {.min = -12, .max = 12}}, + &systemConfig.config.timeZoneOffset}, + {"Units", + "Select units to display", + "Data is always logged using metric units", + TOGGLE, + {.lookup = TABLE_UNIT}, + &systemConfig.config.unitSystem}, + {"Startup Animation", + "Show animated startup sequence", + "Off: Show static CATS logo", + TOGGLE, + {.lookup = TABLE_STARTUP_ANIMATION}, + &systemConfig.config.startupAnimation}, + }, + { + {"Firmware Versions", "", "", BUTTON, {.buttonAction = BUTTON_ACTION_VERSION}, nullptr}, + {"USB Drive", + "Shared automatically while not logging", + "Press A to view or disconnect the drive", + BUTTON, + {.buttonAction = BUTTON_ACTION_USB_STORAGE}, + nullptr}, + {"Self-Test", + "Factory checks with two test Vegas", + "Automatic checks, then guided checks", + BUTTON, + {.buttonAction = BUTTON_ACTION_SELF_TEST}, + nullptr}, + {"Update Firmware", + "Update the Ground Station or its radios", + "Press A to choose an update target", + BUTTON, + {.buttonAction = BUTTON_ACTION_UPDATE_FIRMWARE}, + nullptr}, + }, +}; diff --git a/ground_station/src/hmi/settings.hpp b/ground_station/src/hmi/settings.hpp index bcdf1668..c5a6a068 100644 --- a/ground_station/src/hmi/settings.hpp +++ b/ground_station/src/hmi/settings.hpp @@ -20,7 +20,9 @@ enum settings_type_e { enum settings_button_action_e { BUTTON_ACTION_NONE = 0, BUTTON_ACTION_USB_STORAGE, - BUTTON_ACTION_START_BOOTLOADER, + BUTTON_ACTION_UPDATE_FIRMWARE, + BUTTON_ACTION_SELF_TEST, + BUTTON_ACTION_VERSION, }; struct settings_min_max_t { @@ -63,7 +65,7 @@ const char* const unit_map[2] = { }; const char* const logging_map[2] = { - "DOWN", + "LANDING", "NEVER", }; @@ -89,11 +91,14 @@ const lookup_table_entry_t lookup_tables[] = { }; enum { - kSettingPages = 3, + SETTING_TELEMETRY = 0, + SETTING_PREFERENCES, + SETTING_SYSTEM, + kSettingPages, }; -const char* const settingPageName[kSettingPages] = {"General", "Telemetry", "Other"}; +const char* const settingPageName[kSettingPages] = {"Telemetry", "Preferences", "System"}; extern const device_settings_t settingsTable[][4]; -const uint16_t settingsTableValueCount[kSettingPages] = {4, 4, 3}; +const uint16_t settingsTableValueCount[kSettingPages] = {4, 4, 4}; diff --git a/ground_station/src/hmi/window.cpp b/ground_station/src/hmi/window.cpp index 728b7999..5d4eb5fe 100644 --- a/ground_station/src/hmi/window.cpp +++ b/ground_station/src/hmi/window.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include uint16_t GetNegativeColor(uint16_t color) { if (color == BLACK) { @@ -23,6 +25,15 @@ uint16_t GetNegativeColor(uint16_t color) { return BLACK; } +void Window::drawCoordinate(float value, char positiveHemisphere, char negativeHemisphere, int16_t x, int16_t baseline, + uint16_t color) { + display.setTextColor(color); + display.setCursor(x, baseline); + display.print(std::fabs(value), 4); + display.print(' '); + display.print(value < 0.0F ? negativeHemisphere : positiveHemisphere); +} + void Window::begin() { surface.begin(); surface.clear(); @@ -36,10 +47,8 @@ void Window::begin() { barBlinkStatus = false; oldMenuHighlight = 0; oldTestingIndex = 0; - oldCalibrationPercentage = 0.0F; livestate = LiveState::kShowGnss; - old_bearing[0] = old_bearing[1] = 0.0F; - old_downrange[0] = old_downrange[1] = 0; + std::memset(liveNavigationText, 0, sizeof(liveNavigationText)); connected[0] = connected[1] = false; lastTeleData[0] = lastTeleData[1] = 0; dataAge[0] = dataAge[1] = 0; @@ -70,7 +79,11 @@ void Window::drawIntroRocket(int16_t centerX, int16_t centerY, uint32_t frameNum int16_t x; int16_t y; }; + constexpr int16_t kScaleNumerator = 6; + constexpr int16_t kScaleDenominator = 5; const auto transform = [centerX, centerY](int16_t forward, int16_t sideways) -> Point { + forward = static_cast((forward * kScaleNumerator) / kScaleDenominator); + sideways = static_cast((sideways * kScaleNumerator) / kScaleDenominator); return { static_cast(centerX + (5 * forward + 9 * sideways) / 10), static_cast(centerY + (-9 * forward + 5 * sideways) / 10), @@ -113,13 +126,24 @@ void Window::drawIntroRocket(int16_t centerX, int16_t centerY, uint32_t frameNum display.fillTriangle(finRight.x, finRight.y, finRightTip.x, finRightTip.y, finRightFront.x, finRightFront.y, BLACK); const int16_t flameWobble = (frameNumber % 2U == 0U) ? 2 : -2; - const Point flameRoot = transform(-16, 0); - const Point flameTip = transform(-34, flameWobble); - const Point flameFork = transform(-28, static_cast(-5 - flameWobble)); - const Point flameForkRight = transform(-27, static_cast(5 + flameWobble)); - display.drawLine(flameRoot.x, flameRoot.y, flameTip.x, flameTip.y, BLACK); - display.drawLine(flameRoot.x, flameRoot.y, flameFork.x, flameFork.y, BLACK); - display.drawLine(flameRoot.x, flameRoot.y, flameForkRight.x, flameForkRight.y, BLACK); + const Point flameBaseLeft = transform(-15, -4); + const Point flameBaseRight = transform(-15, 4); + const Point flameTip = transform(-38, flameWobble); + const Point flameShoulder = transform(-22, 0); + const Point flameForkLeft = transform(-29, static_cast(-7 - flameWobble)); + const Point flameForkRight = transform(-28, static_cast(7 - flameWobble)); + display.fillTriangle(flameBaseLeft.x, flameBaseLeft.y, flameBaseRight.x, flameBaseRight.y, flameTip.x, flameTip.y, + BLACK); + display.fillTriangle(flameBaseLeft.x, flameBaseLeft.y, flameShoulder.x, flameShoulder.y, flameForkLeft.x, + flameForkLeft.y, BLACK); + display.fillTriangle(flameBaseRight.x, flameBaseRight.y, flameShoulder.x, flameShoulder.y, flameForkRight.x, + flameForkRight.y, BLACK); + + const Point innerFlameLeft = transform(-17, -2); + const Point innerFlameRight = transform(-17, 2); + const Point innerFlameTip = transform(-29, flameWobble / 2); + display.fillTriangle(innerFlameLeft.x, innerFlameLeft.y, innerFlameRight.x, innerFlameRight.y, innerFlameTip.x, + innerFlameTip.y, WHITE); } void Window::drawIntroLogo(int16_t y) { @@ -229,6 +253,10 @@ void Window::drawCentreString(String &buf, int16_t x, int16_t y) { } void Window::initBar() { + // Full-screen modes draw over this area. Rebuild both its background and + // cached values when returning to the normal status bar. + display.fillRect(0, 0, 400, 19, WHITE); + barNeedsRedraw = true; // Memory display.setFont(nullptr); display.drawBitmap(5, 1, bar_memory, 16, 16, BLACK); @@ -245,7 +273,7 @@ void Window::initBar() { void Window::updateBar(float batteryVoltage, bool usb, bool logging, bool location, bool time, uint32_t free_memory, bool recorderFault) { // Logging - if (logging != oldBarLoggingStatus || recorderFault != oldBarRecorderFault) { + if (barNeedsRedraw || logging != oldBarLoggingStatus || recorderFault != oldBarRecorderFault) { display.fillRect(74, 0, 19, 18, WHITE); oldBarLoggingStatus = logging; oldBarRecorderFault = recorderFault; @@ -266,7 +294,7 @@ void Window::updateBar(float batteryVoltage, bool usb, bool logging, bool locati } // Memory Usage - if (free_memory != oldBarFreeMemory) { + if (barNeedsRedraw || free_memory != oldBarFreeMemory) { display.setFont(nullptr); display.setTextSize(2); @@ -282,7 +310,7 @@ void Window::updateBar(float batteryVoltage, bool usb, bool logging, bool locati drawCentreString(t, 50, 2); } - if ((clock.minute() != oldBarMinute || clock.hour() != oldBarHour) && time) { + if ((barNeedsRedraw || clock.minute() != oldBarMinute || clock.hour() != oldBarHour) && time) { display.setFont(nullptr); display.setTextSize(2); @@ -310,7 +338,7 @@ void Window::updateBar(float batteryVoltage, bool usb, bool logging, bool locati } // USB - if (usb != oldBarUsbStatus) { + if (barNeedsRedraw || usb != oldBarUsbStatus) { oldBarUsbStatus = usb; display.fillRect(373, 5, 6, 8, WHITE); @@ -335,6 +363,7 @@ void Window::updateBar(float batteryVoltage, bool usb, bool logging, bool locati } barBlinkStatus = !barBlinkStatus; + barNeedsRedraw = false; surface.present(); } @@ -410,6 +439,8 @@ void Window::drawMenuHighlight(int16_t index, bool highlight) { void Window::initLive() { clearMainScreen(); + livestate = LiveState::kShowGnss; + std::memset(liveNavigationText, 0, sizeof(liveNavigationText)); display.drawLine(199, 18, 199, 240, BLACK); display.drawLine(200, 18, 200, 240, BLACK); @@ -466,117 +497,86 @@ void Window::initLive() { } void Window::UpdateLiveState(TelemetryData *data1, TelemetryData *data2, Navigation *navigation, LiveState state) { - if (livestate == state) { - return; + if (livestate == state) return; + const bool showGnss = state == LiveState::kShowGnss; + TelemetryData data[] = {*data1, *data2}; // Presentation getters only consume these copies. + for (int16_t index = 0; index < 2; ++index) { + const int16_t x = index * 200; + display.fillRect(x + 28, 99, 130, 50, WHITE); + display.fillRect(x + 3, 100, 24, 49, WHITE); + display.fillRect(x + 158, 100, 24, 49, WHITE); + display.drawBitmap(x + 3, 100, showGnss ? live_lat : down_range, 24, 24, BLACK); + display.drawBitmap(x + 3, 125, showGnss ? live_lon : compass, 24, 24, BLACK); + display.drawBitmap(x + 158, 100, showGnss ? right_arrow : left_arrow, 24, 24, BLACK); + display.drawBitmap(x + 158, 125, showGnss ? right_arrow : left_arrow, 24, 24, BLACK); + if (showGnss && !data[index].testingMode()) { + display.setFont(&FreeSans12pt7b); + drawCoordinate(data[index].lat(), 'N', 'S', x + 28, 120, BLACK); + drawCoordinate(data[index].lon(), 'E', 'W', x + 28, 145, BLACK); + } } - livestate = state; - const auto xOffset1 = static_cast(0 * 200); - const auto xOffset2 = static_cast(1 * 200); - const int32_t first_row_offset = 28; - - display.setFont(&FreeSans12pt7b); - display.setTextSize(1); - display.setTextColor(WHITE); + std::memset(liveNavigationText, 0, sizeof(liveNavigationText)); + updateLiveNavigation(*data1, *data2, navigation); + surface.present(); +} - if (livestate == LiveState::kShowGnss) { - // Set Downrange text of FC 1 to white - auto downrange_m = static_cast(std::roundf(navigation->getDistance())); - display.setCursor(static_cast(xOffset1 + first_row_offset), 120); - if (config.config.unitSystem == UnitSystem::kMetric) { - display.print(old_downrange[0]); - display.print(" m"); - } else { - display.print(Utils::MetersToFeet(old_downrange[0])); - display.print(" ft"); +bool Window::updateLiveNavigation(const TelemetryData &data1, const TelemetryData &data2, Navigation *navigation) { + if (livestate != LiveState::kShowDownRange) return false; + // Copy before using presentation getters, preserving incoming packet flags. + TelemetryData data[] = {data1, data2}; + const EarthPoint3D targets[] = {EarthPoint3D(data[0].lat(), data[0].lon()), + EarthPoint3D(data[1].lat(), data[1].lon())}; + const bool validTarget[] = {LocationQr::IsValid(targets[0].lat, targets[0].lon), + LocationQr::IsValid(targets[1].lat, targets[1].lon)}; + const auto home = navigation->getPointA(); + const bool validHome = LocationQr::IsValid(home.lat, home.lon); + const float north = navigation->getNorth(); + // Single mode tracks one rocket through the most recent receiver with GPS. + size_t singleTarget = data1.getLastUpdateTime() > data2.getLastUpdateTime() ? 0 : 1; + if (!validTarget[singleTarget]) singleTarget = 1 - singleTarget; + bool changed = false; + for (size_t index = 0; index < 2; ++index) { + const size_t targetIndex = config.config.receiverMode == DUAL ? index : singleTarget; + char values[2][24]{}; + if (validHome && validTarget[targetIndex] && !data[index].testingMode()) { + const auto target = targets[targetIndex]; + constexpr float radians = PI_F / 180.0F; + constexpr float earthRadiusM = 6378100.0F; + const float dy = (target.lat - home.lat) * radians * earthRadiusM; + const float dx = + std::remainder(target.lon - home.lon, 360.0F) * radians * std::cos(home.lat * radians) * earthRadiusM; + const auto distance = static_cast(std::round(std::hypot(dx, dy))); + const bool metric = config.config.unitSystem == UnitSystem::kMetric; + std::snprintf(values[0], sizeof(values[0]), "%ld %s", + static_cast(metric ? distance : Utils::MetersToFeet(distance)), metric ? "m" : "ft"); + // Screen drawing uses a -90 degree offset; a turn angle starts straight ahead at zero. + if ((dx != 0 || dy != 0) && std::isfinite(north)) { + float bearing = std::round(std::remainder((std::atan2(dx, dy) + north) / radians, 360.0F) * 10.0F) / 10.0F; + if (bearing <= -180.0F) bearing = 180.0F; + if (bearing == 0) bearing = 0.0F; // Avoid displaying negative zero after rounding. + std::snprintf(values[1], sizeof(values[1]), "%.1f", static_cast(bearing)); + } } - old_downrange[0] = downrange_m; - - float bearing = navigation->computeBearing(); - - display.setCursor(static_cast(xOffset1 + first_row_offset), 145); - display.print(old_bearing[0]); - display.print(" deg"); - old_bearing[0] = bearing; - - // Set Downrange text of FC 2 to white - downrange_m = static_cast(std::roundf(navigation->getDistance())); - display.setCursor(static_cast(xOffset2 + first_row_offset), 120); - if (config.config.unitSystem == UnitSystem::kMetric) { - display.print(old_downrange[1]); - display.print(" m"); - } else { - display.print(Utils::MetersToFeet(old_downrange[1])); - display.print(" ft"); + for (size_t row = 0; row < 2; ++row) { + if (std::strcmp(values[row], liveNavigationText[index][row]) == 0) continue; + const auto x = static_cast(index * 200 + 28); + const auto y = static_cast(99 + row * 25); + display.fillRect(x, y, 130, 25, WHITE); + display.setFont(&FreeSans12pt7b); + display.setTextSize(1); + display.setTextColor(BLACK); + display.setCursor(x, y + 21); + display.print(values[row]); + if (row == 1 && values[row][0] != '\0') { + // Keep the larger degree symbol inside the cleared row. + display.drawCircle(static_cast(display.getCursorX() + 4), y + 6, 3, BLACK); + } + std::strcpy(liveNavigationText[index][row], values[row]); + changed = true; } - old_downrange[1] = downrange_m; - - bearing = navigation->computeBearing(); - - display.setCursor(static_cast(xOffset2 + first_row_offset), 145); - display.print(old_bearing[1]); - display.print(" deg"); - old_bearing[1] = bearing; - - // Set DownRange to white - display.drawBitmap(3, 100, down_range, 24, 24, WHITE); - display.drawBitmap(3, 125, compass, 24, 24, WHITE); - display.drawBitmap(203, 100, down_range, 24, 24, WHITE); - display.drawBitmap(203, 125, compass, 24, 24, WHITE); - display.drawBitmap(158, 100, left_arrow, 24, 24, WHITE); - display.drawBitmap(158, 125, left_arrow, 24, 24, WHITE); - display.drawBitmap(358, 100, left_arrow, 24, 24, WHITE); - display.drawBitmap(358, 125, left_arrow, 24, 24, WHITE); - - // Set GNSS to black - display.drawBitmap(3, 100, live_lat, 24, 24, BLACK); - display.drawBitmap(3, 125, live_lon, 24, 24, BLACK); - display.drawBitmap(203, 100, live_lat, 24, 24, BLACK); - display.drawBitmap(203, 125, live_lon, 24, 24, BLACK); - display.drawBitmap(158, 100, right_arrow, 24, 24, BLACK); - display.drawBitmap(158, 125, right_arrow, 24, 24, BLACK); - display.drawBitmap(358, 100, right_arrow, 24, 24, BLACK); - display.drawBitmap(358, 125, right_arrow, 24, 24, BLACK); - } else { - // Set GNSS text to white - display.setCursor(static_cast(xOffset1 + first_row_offset), 120); - display.print(data1->lat(), 4); - display.print(" N"); - - display.setCursor(static_cast(xOffset1 + first_row_offset), 145); - display.print(data1->lon(), 4); - display.print(" E"); - - display.setCursor(static_cast(xOffset2 + first_row_offset), 120); - display.print(data2->lat(), 4); - display.print(" N"); - - display.setCursor(static_cast(xOffset2 + first_row_offset), 145); - display.print(data2->lon(), 4); - display.print(" E"); - - // Set GNSS to white - display.drawBitmap(3, 100, live_lat, 24, 24, WHITE); - display.drawBitmap(3, 125, live_lon, 24, 24, WHITE); - display.drawBitmap(203, 100, live_lat, 24, 24, WHITE); - display.drawBitmap(203, 125, live_lon, 24, 24, WHITE); - display.drawBitmap(158, 100, right_arrow, 24, 24, WHITE); - display.drawBitmap(158, 125, right_arrow, 24, 24, WHITE); - display.drawBitmap(358, 100, right_arrow, 24, 24, WHITE); - display.drawBitmap(358, 125, right_arrow, 24, 24, WHITE); - - // Set DownRange to black - display.drawBitmap(3, 100, down_range, 24, 24, BLACK); - display.drawBitmap(3, 125, compass, 24, 24, BLACK); - display.drawBitmap(203, 100, down_range, 24, 24, BLACK); - display.drawBitmap(203, 125, compass, 24, 24, BLACK); - display.drawBitmap(158, 100, left_arrow, 24, 24, BLACK); - display.drawBitmap(158, 125, left_arrow, 24, 24, BLACK); - display.drawBitmap(358, 100, left_arrow, 24, 24, BLACK); - display.drawBitmap(358, 125, left_arrow, 24, 24, BLACK); } - - surface.present(); + return changed; } void Window::updateLive(TelemetryInfo *info, int16_t index) { @@ -605,7 +605,7 @@ void Window::updateLive(TelemetryData *data, Navigation *navigation, TelemetryIn data->clear(); info->clear(); - updateLiveData(&teleData[index], navigation, index, WHITE); + updateLiveData(&teleData[index], index, WHITE); updateLiveInfo(&infoData[index], index, BLACK); // display.fillRect(10,19,190,200, WHITE); @@ -615,7 +615,8 @@ void Window::updateLive(TelemetryData *data, Navigation *navigation, TelemetryIn dataAge[index] = 0; - updateLiveData(&teleData[index], navigation, index, BLACK); + updateLiveData(&teleData[index], index, BLACK); + updateLiveNavigation(teleData[0], teleData[1], navigation); updateLiveInfo(&infoData[index], index, WHITE); } @@ -629,7 +630,7 @@ void Window::updateLive(TelemetryData *data, Navigation *navigation, int16_t ind // Clear update flag data->clear(); - updateLiveData(&teleData[index], navigation, index, WHITE); + updateLiveData(&teleData[index], index, WHITE); // display.fillRect(10,19,190,200, WHITE); @@ -637,7 +638,8 @@ void Window::updateLive(TelemetryData *data, Navigation *navigation, int16_t ind dataAge[index] = 0; - updateLiveData(&teleData[index], navigation, index, BLACK); + updateLiveData(&teleData[index], index, BLACK); + updateLiveNavigation(teleData[0], teleData[1], navigation); } const char *const stateName[] = {"INVALID", "CALIB", "READY", "THRUST", "COAST", "DROGUE", "MAIN", "DOWN"}; @@ -645,7 +647,7 @@ const char *const stateName[] = {"INVALID", "CALIB", "READY", "THRUST", "COAST", const char *const errorName[] = {"No Config", "Log Full", "Filter Error", "Overheating", "Continuity Error", "Calibration Error"}; -void Window::updateLiveData(TelemetryData *data, Navigation *navigation, int16_t index, uint16_t color) { +void Window::updateLiveData(TelemetryData *data, int16_t index, uint16_t color) { const auto xOffset = static_cast(index * 200); display.setFont(&FreeSans12pt7b); @@ -686,50 +688,12 @@ void Window::updateLiveData(TelemetryData *data, Navigation *navigation, int16_t } if (livestate == LiveState::kShowGnss) { - display.setCursor(static_cast(xOffset + first_row_offset), 120); - display.print(data->lat(), 4); - display.print(" N"); - - display.setCursor(static_cast(xOffset + first_row_offset), 145); - display.print(data->lon(), 4); - display.print(" E"); - } else { - const auto downrange_m = static_cast(std::roundf(navigation->getDistance())); - display.setCursor(static_cast(xOffset + first_row_offset), 120); - if (config.config.unitSystem == UnitSystem::kMetric) { - display.setTextColor(WHITE); - display.print(old_downrange[index]); - display.print(" m"); - display.setTextColor(color); - display.setCursor(static_cast(xOffset + first_row_offset), 120); - display.print(downrange_m); - display.print(" m"); - } else { - display.setTextColor(WHITE); - display.print(Utils::MetersToFeet(old_downrange[index])); - display.print(" ft"); - display.setTextColor(color); - display.setCursor(static_cast(xOffset + first_row_offset), 120); - display.print(Utils::MetersToFeet(downrange_m)); - display.print(" ft"); - } - old_downrange[index] = downrange_m; - - const float bearing = navigation->computeBearing(); - - display.setCursor(static_cast(xOffset + first_row_offset), 145); - display.setTextColor(WHITE); - display.print(old_bearing[index]); - display.print(" deg"); - display.setTextColor(color); - display.setCursor(static_cast(xOffset + first_row_offset), 145); - display.print(bearing); - display.print(" deg"); - old_bearing[index] = bearing; + drawCoordinate(data->lat(), 'N', 'S', static_cast(xOffset + first_row_offset), 120, color); + drawCoordinate(data->lon(), 'E', 'W', static_cast(xOffset + first_row_offset), 145, color); } display.setCursor(static_cast(xOffset + first_row_offset), 170); - display.print(data->voltage()); + display.print(data->voltage(), 1); display.print(" V"); if (static_cast(data->pyroContinuity() & 0x01U)) { @@ -1296,6 +1260,129 @@ void Window::initUsbStorage(bool active) { surface.present(); } +void Window::initFirmwareUpdate(int16_t index) { + clearMainScreen(); + drawPageHeader("Update Firmware", false, false); + + const char *names[] = {"Ground Station", "Radio Receivers"}; + for (int16_t row = 0; row < 2; ++row) { + const int16_t y = static_cast(52 + 30 * row); + display.fillRect(0, y, 400, 30, row == index ? BLACK : WHITE); + display.setFont(&FreeSans12pt7b); + display.setTextColor(row == index ? WHITE : BLACK); + display.setCursor(10, y + 23); + display.print(names[row]); + } + + display.drawLine(0, 112, 400, 112, BLACK); + display.setFont(&FreeSans9pt7b); + display.setTextColor(BLACK); + if (index == 0) { + display.setCursor(10, 143); + display.print("Restart into the USB updater"); + display.setCursor(10, 163); + display.print("Connect to a computer before continuing"); + } else { + display.setCursor(10, 143); + display.print("Install a telemetry .bin on both receivers"); + display.setCursor(10, 163); + display.print("Copy it to /telemetry_firmware and eject USB"); + } + display.setCursor(10, 230); + display.print("Back (B)"); + display.setCursor(260, 230); + display.print("Select (A)"); + surface.present(); +} + +void Window::initRadioUpdateList() { + clearMainScreen(); + drawPageHeader("Update GS Radios", false, false); + display.setFont(&FreeSans9pt7b); + display.setTextColor(BLACK); + display.setCursor(10, 230); + display.print("Back (B)"); + display.setCursor(260, 230); + display.print("Select (A)"); +} + +void Window::radioUpdateFileName(const char *filename, uint8_t row, bool selected) { + const int16_t y = static_cast(50 + 20 * row); + display.fillRect(0, y, 400, 20, selected ? BLACK : WHITE); + display.setFont(&FreeSans9pt7b); + display.setTextColor(selected ? WHITE : BLACK); + display.setCursor(10, y + 15); + display.print(filename); +} + +void Window::initRadioUpdateConfirm(const char *filename, uint32_t size, uint32_t crc) { + clearMainScreen(); + drawPageHeader("Confirm Radio Update", false, false); + char visibleName[43]{}; + strncpy(visibleName, filename, sizeof(visibleName) - 1); + if (strlen(filename) >= sizeof(visibleName)) { + memcpy(visibleName + sizeof(visibleName) - 5, "...", 4); + } + display.setTextColor(BLACK); + display.setFont(&FreeSans9pt7b); + drawCentreString(visibleName, 200, 70); + char identity[64]{}; + snprintf(identity, sizeof(identity), "%lu bytes CRC32 %08lX", static_cast(size), + static_cast(crc)); + drawCentreString(identity, 200, 91); + drawCentreString("Only use production telemetry firmware", 200, 117); + drawCentreString("intended for this Ground Station.", 200, 137); + drawCentreString("Wrong firmware may require ST-Link recovery.", 200, 157); + display.setFont(&FreeSansBold9pt7b); + drawCentreString("Do not disconnect power during this update.", 200, 184); + display.setFont(&FreeSans9pt7b); + drawCentreString("An interruption may require ST-Link recovery.", 200, 204); + display.setCursor(10, 230); + display.print("Back (B)"); + display.setCursor(263, 230); + display.print("Install (A)"); + surface.present(); +} + +void Window::radioUpdateProgress(const char *phase, uint8_t link, uint8_t percent) { + clearMainScreen(); + drawPageHeader("Updating GS Radios", false, false); + display.setTextColor(BLACK); + display.setFont(&FreeSansBold12pt7b); + char label[64]{}; + if (link > 0) { + snprintf(label, sizeof(label), "Link %u: %s", link, phase); + } else { + strncpy(label, phase, sizeof(label) - 1); + } + drawCentreString(label, 200, 105); + display.drawRect(38, 132, 324, 26, BLACK); + display.fillRect(42, 136, static_cast(316U * percent / 100U), 18, BLACK); + display.setFont(&FreeSans9pt7b); + snprintf(label, sizeof(label), "%u%%", percent); + drawCentreString(label, 200, 184); + drawCentreString("Do not disconnect power", 200, 224); + surface.present(); +} + +void Window::radioUpdateResult(bool success, const char *message, const char *version1, const char *version2) { + clearMainScreen(); + drawPageHeader(success ? "Radio Update Complete" : "Radio Update Stopped", false, false); + display.setTextColor(BLACK); + display.setFont(&FreeSansBold12pt7b); + drawCentreString(success ? "Both radios verified" : "See recovery status below", 200, 88); + display.setFont(&FreeSans9pt7b); + drawCentreString(message, 200, 118); + char value[80]{}; + snprintf(value, sizeof(value), "Link 1: %s", version1[0] != '\0' ? version1 : "not updated"); + drawCentreString(value, 200, 146); + snprintf(value, sizeof(value), "Link 2: %s", version2[0] != '\0' ? version2 : "not updated"); + drawCentreString(value, 200, 168); + display.setCursor(10, 230); + display.print("Back (B)"); + surface.present(); +} + bool Window::showLocationQr(float latitude, float longitude, const char *label, bool hasPreviousPage, bool hasNextPage) { if (!LocationQr::IsValid(latitude, longitude)) { @@ -1373,6 +1460,109 @@ void Window::initSensors() { display.print("Press A to calibrate"); display.setCursor(220, 225); display.print("the compass"); + display.setCursor(12, 225); + display.print("Right: compass"); + + surface.present(); +} + +void Window::initSensorOrientation() { + clearMainScreen(); + display.setTextSize(1); + display.setTextColor(WHITE); + display.setFont(&FreeSansBold12pt7b); + display.fillRect(0, 19, 400, 30, BLACK); + drawCentreString("Compass / 3D Orientation", 200, 42); + display.setTextColor(BLACK); + display.setFont(&FreeSans9pt7b); + display.setCursor(8, 233); + display.print("Left: sensors"); + display.setCursor(285, 233); + display.print("A: calibrate"); + + surface.present(); +} + +void Window::updateSensorOrientation(Navigation *navigation) { + constexpr int16_t centerX = 105; + constexpr int16_t centerY = 132; + constexpr int16_t radius = 60; + float northAngle = navigation->getNorth(); + while (northAngle < 0.0F) { + northAngle += 2.0F * PI_F; + } + while (northAngle >= 2.0F * PI_F) { + northAngle -= 2.0F * PI_F; + } + // The north pointer rotates opposite to the clockwise heading of the device. + const float headingDegrees = (northAngle > 0.0F ? 2.0F * PI_F - northAngle : 0.0F) * 180.0F / PI_F; + static constexpr const char *directions[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; + const uint8_t direction = static_cast((headingDegrees + 22.5F) / 45.0F) & 7U; + + display.fillRect(0, 49, 400, 163, WHITE); + display.setTextSize(1); + display.setTextColor(BLACK); + display.drawCircle(centerX, centerY, radius, BLACK); + + for (uint8_t index = 0; index < 8; ++index) { + const float angle = static_cast(index) * PI_F / 4.0F; + const float tickDepth = index % 2U == 0U ? 5.0F : 3.0F; + const int16_t outerX = static_cast(centerX + sinf(angle) * radius); + const int16_t outerY = static_cast(centerY - cosf(angle) * radius); + const int16_t innerX = static_cast(centerX + sinf(angle) * (radius - tickDepth)); + const int16_t innerY = static_cast(centerY - cosf(angle) * (radius - tickDepth)); + display.drawLine(innerX, innerY, outerX, outerY, BLACK); + } + + display.setFont(&FreeSansBold9pt7b); + drawCentreString("N", centerX, 68); + drawCentreString("S", centerX, 208); + drawCentreString("W", 31, 139); + drawCentreString("E", 179, 139); + + const float sine = sinf(northAngle); + const float cosine = cosf(northAngle); + const int16_t northTipX = static_cast(centerX + sine * 50.0F); + const int16_t northTipY = static_cast(centerY - cosine * 50.0F); + const int16_t northLeftX = static_cast(centerX - cosine * 6.0F); + const int16_t northLeftY = static_cast(centerY - sine * 6.0F); + const int16_t northRightX = static_cast(centerX + cosine * 6.0F); + const int16_t northRightY = static_cast(centerY + sine * 6.0F); + const int16_t southTipX = static_cast(centerX - sine * 30.0F); + const int16_t southTipY = static_cast(centerY + cosine * 30.0F); + display.drawLine(centerX, centerY, southTipX, southTipY, BLACK); + display.fillTriangle(northTipX, northTipY, northLeftX, northLeftY, northRightX, northRightY, BLACK); + display.fillCircle(centerX, centerY, 4, WHITE); + display.drawCircle(centerX, centerY, 4, BLACK); + display.fillCircle(centerX, centerY, 1, BLACK); + + display.drawFastVLine(195, 62, 137, BLACK); + + display.setFont(&FreeSans9pt7b); + display.setCursor(215, 78); + display.print("Mag heading"); + display.setFont(&FreeSansBold12pt7b); + display.setCursor(215, 105); + display.print(headingDegrees, 1); + display.print(" deg "); + display.print(directions[direction]); + + display.setFont(&FreeSans9pt7b); + display.setCursor(215, 137); + display.print("Pitch: "); + display.print(navigation->getPitch() * 180.0F / PI_F, 1); + display.print(" deg"); + display.setCursor(215, 164); + display.print("Roll: "); + display.print(navigation->getRoll() * 180.0F / PI_F, 1); + display.print(" deg"); + const float magneticMagnitude = + sqrtf(navigation->getMX() * navigation->getMX() + navigation->getMY() * navigation->getMY() + + navigation->getMZ() * navigation->getMZ()) / + 1000.0F; + display.setCursor(215, 191); + display.print("|M|: "); + display.print(magneticMagnitude, 2); surface.present(); } @@ -1533,13 +1723,9 @@ void Window::updateSensorCalibrate(Navigation *navigation) { display.setFont(&FreeSansBold9pt7b); display.setTextSize(1); - // Show Progress - display.setTextColor(WHITE); - String t = "Progress: " + String(oldCalibrationPercentage) + "%"; - drawCentreString(t, 200, 160); - oldCalibrationPercentage = navigation->getCalibrationPercentage(); + display.fillRect(0, 140, 400, 35, WHITE); display.setTextColor(BLACK); - t = "Progress: " + String(oldCalibrationPercentage) + "%"; + String t = "Progress: " + String(navigation->getCalibrationPercentage()) + "%"; drawCentreString(t, 200, 160); } @@ -1641,6 +1827,13 @@ void Window::updateSettings(int16_t index) { } if (index >= 0) { + if (settingsTable[subMenuSettingIndex][index].dataPtr == &systemConfig.config.receiverMode) { + for (int16_t row = 0; row < settingsTableValueCount[subMenuSettingIndex]; ++row) { + if (settingsTable[subMenuSettingIndex][row].dataPtr == systemConfig.config.linkPhrase2) { + highlightSetting(row, BLACK, false); + } + } + } highlightSetting(index, WHITE, true); } else { drawSettingsTriangles(subMenuSettingIndex, WHITE); @@ -1648,6 +1841,10 @@ void Window::updateSettings(int16_t index) { } oldSettingsIndex = index; + // The HMI fills in cached versions before presenting this selection. + if (index >= 0 && settingsTable[subMenuSettingIndex][index].type == BUTTON && + settingsTable[subMenuSettingIndex][index].config.buttonAction == BUTTON_ACTION_VERSION) + return; surface.present(); } @@ -1672,6 +1869,11 @@ void Window::highlightSetting(int16_t index, uint16_t color, bool updateDescript return; } + const auto &setting = settingsTable[subMenuSettingIndex][index]; + if (setting.type == BUTTON && setting.config.buttonAction == BUTTON_ACTION_VERSION) { + return; + } + display.fillRect(0, 178, 400, 62, WHITE); display.setFont(&FreeSans9pt7b); display.setTextColor(BLACK); @@ -1681,6 +1883,22 @@ void Window::highlightSetting(int16_t index, uint16_t color, bool updateDescript display.print(settingsTable[subMenuSettingIndex][index].description2); } +void Window::settingsVersions(const char *telemetry1, const char *telemetry2) { + display.fillRect(0, 178, 400, 62, WHITE); + display.setFont(&FreeSans9pt7b); + display.setTextSize(1); + display.setTextColor(BLACK); + const char *labels[] = {"Ground Station:", "Telemetry 1:", "Telemetry 2:"}; + const char *versions[] = {FIRMWARE_VERSION, telemetry1, telemetry2}; + for (int16_t row = 0; row < 3; ++row) { + const auto y = static_cast(193 + row * 20); + display.setCursor(10, y); + display.print(labels[row]); + display.setCursor(165, y); + display.print(versions[row]); + } +} + const uint8_t kNumKeyboardChars = 38; const uint32_t kBackspaceCoordX = 330; const uint32_t kBackspaceCoordY = 62; diff --git a/ground_station/src/hmi/window.hpp b/ground_station/src/hmi/window.hpp index 2eeeb01c..0b9ba3e3 100644 --- a/ground_station/src/hmi/window.hpp +++ b/ground_station/src/hmi/window.hpp @@ -11,6 +11,7 @@ #include "display.hpp" #include "logging/flightStatistics.hpp" #include "navigation.hpp" +#include "self_test.hpp" #include "settings.hpp" #include "startup_intro.hpp" #include "telemetry/telemetryData.hpp" @@ -56,6 +57,7 @@ class Window { void updateLive(TelemetryData *data, Navigation *navigation, TelemetryInfo *info, int16_t index); void updateLive(TelemetryData *data, Navigation *navigation, int16_t index); void UpdateLiveState(TelemetryData *data1, TelemetryData *data2, Navigation *navigation, LiveState state); + bool updateLiveNavigation(const TelemetryData &data1, const TelemetryData &data2, Navigation *navigation); void initRecovery(bool hasLastLocation); void updateRecovery(Navigation *navigation, bool hasLastLocation); @@ -77,6 +79,12 @@ class Window { void initDataOptions(const char *logName, bool active); void initDataMessage(const char *title, const char *message); void initUsbStorage(bool active); + void initFirmwareUpdate(int16_t index); + void initRadioUpdateList(); + void radioUpdateFileName(const char *filename, uint8_t row, bool selected); + void initRadioUpdateConfirm(const char *filename, uint32_t size, uint32_t crc); + void radioUpdateProgress(const char *phase, uint8_t link, uint8_t percent); + void radioUpdateResult(bool success, const char *message, const char *version1, const char *version2); void initSensors(); void initSensorPrepareCalibrate(); @@ -84,9 +92,13 @@ class Window { void updateSensorCalibrate(Navigation *navigation); void initSensorCalibrateDone(); void updateSensors(Navigation *navigation); + void initSensorOrientation(); + void updateSensorOrientation(Navigation *navigation); void initSettings(int16_t submenuIdx); + void drawSelfTest(const SelfTest &test, uint32_t now, int16_t selectedResult); void updateSettings(int16_t index); + void settingsVersions(const char *telemetry1, const char *telemetry2); void drawSettingsTriangles(int16_t submenuIdx, int16_t color); void initBox(const char *text); @@ -106,8 +118,10 @@ class Window { static constexpr uint8_t kUnderscoreIdx = 37; private: - void updateLiveData(TelemetryData *data, Navigation *navigation, int16_t index, uint16_t color); + void updateLiveData(TelemetryData *data, int16_t index, uint16_t color); void updateLiveInfo(TelemetryInfo *info, int16_t index, uint16_t color); + void drawCoordinate(float value, char positiveHemisphere, char negativeHemisphere, int16_t x, int16_t baseline, + uint16_t color); void drawCentreString(const char *buf, int16_t x, int16_t y); void drawCentreString(String &buf, int16_t x, int16_t y); void drawPageHeader(const char *title, bool hasPreviousPage, bool hasNextPage); @@ -143,11 +157,11 @@ class Window { bool oldBarRecorderFault = false; uint32_t oldBarFreeMemory = 0; bool barBlinkStatus = false; + bool barNeedsRedraw = false; TelemetryData teleData[2]{}; TelemetryInfo infoData[2]{}; LiveState livestate{LiveState::kShowGnss}; - float old_bearing[2] = {0.0F, 0.0F}; - int32_t old_downrange[2] = {0, 0}; + char liveNavigationText[2][2][24]{}; int16_t oldSettingsIndex{0}; int16_t subMenuSettingIndex{0}; @@ -157,7 +171,5 @@ class Window { bool upperCase{false}; int32_t oldKey{0}; uint32_t keyboardTextMaxLength{0}; - float oldCalibrationPercentage = 0.0F; - const char *eventName[9] = {"Ready", "Liftoff", "Burnout", "Apogee", "Main", "Touchdown", "Custom 1", "Custom 2"}; }; diff --git a/ground_station/src/hmi/window_self_test.cpp b/ground_station/src/hmi/window_self_test.cpp new file mode 100644 index 00000000..1f87f435 --- /dev/null +++ b/ground_station/src/hmi/window_self_test.cpp @@ -0,0 +1,210 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include +#include +#include +#include +#include "window.hpp" + +void Window::drawSelfTest(const SelfTest& test, uint32_t now, int16_t selectedResult) { + const uint8_t pattern = test.displayPattern(now); + surface.clearBuffer(); + display.setTextWrap(false); + if (pattern != 0) { + display.fillRect(0, 0, 400, 240, pattern == 1 ? BLACK : WHITE); + if (pattern >= 3) { + for (int16_t y = 0; y < 240; y += 10) { + for (int16_t x = 0; x < 400; x += 10) { + if (((x / 10 + y / 10) % 2 == 0) == (pattern == 3)) display.fillRect(x, y, 10, 10, BLACK); + } + } + } + surface.present(); + return; + } + display.setTextSize(1); + display.setTextColor(WHITE); + display.setFont(&FreeSans12pt7b); + display.fillRect(0, 0, 400, 30, BLACK); + drawCentreString(test.title(), 200, 23); + display.setFont(&FreeSans9pt7b); + display.setTextColor(BLACK); + const auto text = [this](int16_t y, const char* value) { + display.setCursor(10, y); + display.print(value); + }; + const auto rightText = [this](int16_t y, const char* value) { + int16_t x1 = 0, y1 = 0; + uint16_t width = 0, height = 0; + display.getTextBounds(value, 0, y, &x1, &y1, &width, &height); + display.setCursor(static_cast(390 - width - x1), y); + display.print(value); + }; + // Result reasons wrap at words, keeping the same readable font and margins. + const auto reason = [this, &text](const char* value) { + for (int16_t y = 148; *value != '\0' && y <= 168; y += 20) { + char row[96]{}; + std::snprintf(row, sizeof(row), "%s", value); + size_t length = std::strlen(row); + int16_t x1 = 0, y1 = 0; + uint16_t width = 0, height = 0; + display.getTextBounds(row, 0, y, &x1, &y1, &width, &height); + while (width > 380 && length > 0) { + row[--length] = '\0'; + display.getTextBounds(row, 0, y, &x1, &y1, &width, &height); + } + if (value[length] != '\0') { + const char* space = std::strrchr(row, ' '); + if (space != nullptr) length = static_cast(space - row); + row[length] = '\0'; + } + text(y, row); + value += length; + while (*value == ' ') ++value; + } + }; + const char* leftHint = "Cancel: hold B 2s"; + const char* rightHint = ""; + char line[80]{}; + if (!test.finished()) text(51, test.instruction()); + if (!test.started()) { + text(78, "0. Gyro calibration: keep still"); + text(102, "1. Ground Station: GNSS, sensors, storage"); + text(126, "2. Telemetry: two test Vegas and LEDs"); + text(150, "3. Manual: confirm before each check"); + text(181, "Gyro offsets are saved for future starts."); + text(202, "Settings and compass calibration are kept."); + leftHint = "Back (B)"; + rightHint = "Start (A)"; + } else if (test.finished()) { + std::snprintf(line, sizeof(line), "%s%s", SelfTest::resultName(test.overall()), + test.cancelled ? " - cancelled" : ""); + display.setFont(&FreeSansBold12pt7b); + text(57, line); + display.setFont(&FreeSans9pt7b); + const int16_t first = std::max(0, selectedResult - 1); + for (int16_t row = 0; row < 3 && first + row < static_cast(SelfTest::Check::Count); ++row) { + const auto item = static_cast(first + row); + const auto baseline = static_cast(84 + row * 20); + const bool selected = first + row == selectedResult; + if (selected) display.fillRect(0, baseline - 16, 400, 20, BLACK); + display.setTextColor(selected ? WHITE : BLACK); + text(baseline, SelfTest::checkName(item)); + rightText(baseline, SelfTest::resultName(test.check(item).result)); + } + display.setTextColor(BLACK); + reason(test.check(static_cast(selectedResult)).reason); + text(189, "All checks must pass for an overall PASS."); + text(209, test.check(SelfTest::Check::Restoration).result == SelfTest::Result::Pass + ? "Normal receiver settings restored." + : "Check receiver settings before use."); + drawCentreString("Up/down", 200, 232); + leftHint = "Back (B)"; + rightHint = "Rerun (A)"; + } else if (test.awaitingConfirmation) { + rightHint = "Start (A)"; + switch (test.phase) { + case SelfTest::Phase::Telemetry: + text(84, "Power both test Vegas."); + text(109, "Vega 1: cats_test_1"); + text(134, "Vega 2: cats_test_2"); + text(168, "Single, dual and each receiver separately."); + text(194, "Radio limits: provisional bench profile"); + break; + case SelfTest::Phase::Motion: + text(90, "Pick up the Ground Station."); + text(117, "Tilt and rotate around all three axes."); + break; + case SelfTest::Phase::Buttons: + text(90, "Press and release all six buttons."); + text(117, "The Start press does not count."); + break; + case SelfTest::Phase::Display: + text(90, "Four patterns will fill the display."); + text(117, "Each pattern stays for four seconds."); + text(154, "Afterward: A = good, B = fault."); + break; + case SelfTest::Phase::Usb: + text(90, "After Start, connect USB to the PC."); + text(117, "The GS will share its USB drive."); + text(154, "Open the CATS GS drive on the computer."); + break; + default: + break; + } + text(211, "Waiting for your confirmation."); + } else if (test.phase == SelfTest::Phase::GyroCalibration) { + text(87, "Measuring the three stationary offsets."); + text(115, "Keep still for five seconds."); + text(143, "Movement restarts the measurement."); + const int16_t progress = static_cast(std::min(test.gyroCalibrationSamples, 250U) * 360U / 250U); + display.drawRect(19, 164, 362, 18, BLACK); + display.fillRect(20, 165, progress, 16, BLACK); + text(211, "Offsets are saved when measurement passes."); + } else if (test.phase == SelfTest::Phase::Hardware || test.phase == SelfTest::Phase::Telemetry) { + const SelfTest::Check hardware[] = {SelfTest::Check::Gnss, SelfTest::Check::Stationary, SelfTest::Check::Magnetic, + SelfTest::Check::Storage, SelfTest::Check::Battery}; + const SelfTest::Check telemetry[] = {SelfTest::Check::Firmware1, SelfTest::Check::Firmware2, + SelfTest::Check::Dual, SelfTest::Check::Swapped, + SelfTest::Check::Single, SelfTest::Check::Receiver1Only, + SelfTest::Check::Receiver2Only}; + const bool isHardware = test.phase == SelfTest::Phase::Hardware; + const auto* checks = isHardware ? hardware : telemetry; + const size_t count = isHardware ? 5 : 7; + for (size_t row = 0; row < count; ++row) { + const auto baseline = static_cast(77 + row * 19); + text(baseline, SelfTest::checkName(checks[row])); + rightText(baseline, SelfTest::resultName(test.check(checks[row]).result)); + } + if (isHardware) { + std::snprintf(line, sizeof(line), "GNSS satellites: %d", test.satellites); + text(185, line); + } + std::snprintf(line, sizeof(line), "Elapsed: %lu s", static_cast(test.phaseElapsedMs(now) / 1000)); + text(211, line); + } else { + if (test.phase == SelfTest::Phase::Motion) { + const uint8_t masks[] = {test.accelerationAxes, test.gyroAxes, test.magneticAxes}; + const char* names[] = {"Acceleration", "Gyroscope", "Magnetometer"}; + for (size_t i = 0; i < 3; ++i) { + const auto baseline = static_cast(87 + i * 32); + text(baseline, names[i]); + std::snprintf(line, sizeof(line), "X[%c] Y[%c] Z[%c]", masks[i] & 1 ? '+' : ' ', masks[i] & 2 ? '+' : ' ', + masks[i] & 4 ? '+' : ' '); + rightText(baseline, line); + } + text(187, "Completes when every axis responds."); + rightHint = "Skip (A)"; + } else if (test.phase == SelfTest::Phase::Buttons) { + const char* names[] = {"Up", "Down", "Left", "Right", "Center", "A", "B"}; + int16_t y = 76; + for (size_t i = 0; i < 7; ++i) { + if ((SelfTest::kRequiredButtons & (1U << i)) == 0) continue; + std::snprintf(line, sizeof(line), "[%c] %s", test.completedButtons & (1U << i) ? '+' : ' ', names[i]); + text(y, line); + y += 18; + } + text(207, "Next check waits for confirmation."); + } else if (test.phase == SelfTest::Phase::Display) { + text(95, "Watch four full-screen patterns."); + text(120, "Check for missing pixels, lines or noise."); + if (test.phaseElapsedMs(now) >= SelfTestProfile::kDisplayEndMs) { + text(190, "Hold B for 2 seconds to cancel."); + leftHint = "Display fault (B)"; + rightHint = "Looks good (A)"; + } + } else if (test.phase == SelfTest::Phase::Usb) { + text(100, "Open the CATS GS drive on the computer."); + text(130, "Pass requires a read from the computer."); + text(160, "The connection alone does not pass."); + rightHint = "Skip (A)"; + } + } + text(232, leftHint); + rightText(232, rightHint); + display.setTextWrap(true); + surface.present(); +} diff --git a/ground_station/src/logging/recorder.cpp b/ground_station/src/logging/recorder.cpp index ff6d4efd..8bc215a8 100644 --- a/ground_station/src/logging/recorder.cpp +++ b/ground_station/src/logging/recorder.cpp @@ -42,7 +42,7 @@ bool Recorder::begin() { } void Recorder::onTelemetryPacket(const packedRXMessage& packet, uint8_t source) { - if (!initialized || !enabled || source < 1 || source > 2) { + if (!initialized || !enabled || radioUpdatePaused || source < 1 || source > 2) { return; } Command command{}; @@ -250,6 +250,26 @@ bool Recorder::shareWithMassStorage() { return submitAndWait(command); } +bool Recorder::pauseForRadioUpdate() { + portENTER_CRITICAL(&statusMux); + radioPauseRequested = true; + portEXIT_CRITICAL(&statusMux); + Command command{}; + command.type = CommandType::PauseRadioUpdate; + const bool result = submitAndWait(command); + if (!result) { + resumeAfterRadioUpdate(); + } + return result; +} + +void Recorder::resumeAfterRadioUpdate() { + portENTER_CRITICAL(&statusMux); + radioPauseRequested = false; + radioUpdatePaused = false; + portEXIT_CRITICAL(&statusMux); +} + bool Recorder::sync() { Command command{}; command.type = CommandType::Sync; @@ -274,7 +294,7 @@ void Recorder::recordTask(void* pvParameter) { } bool result = true; if (command.type == CommandType::Sample) { - if (!ref->enabled) { + if (!ref->enabled || ref->radioUpdatePaused) { continue; } const auto bit = static_cast(1U << (command.source - 1U)); @@ -310,6 +330,15 @@ void Recorder::recordTask(void* pvParameter) { } } } + } else if (command.type == CommandType::PauseRadioUpdate) { + // All earlier samples have been processed. A queued liftoff can make this + // fail, instead of allowing an apparently idle UI to interrupt recording. + portENTER_CRITICAL(&ref->statusMux); + result = ref->radioPauseRequested && !ref->fileCreated && ref->status.state == RecorderState::Idle; + ref->radioUpdatePaused = result; + portEXIT_CRITICAL(&ref->statusMux); + } else if (ref->radioUpdatePaused) { + result = false; } else if (command.type == CommandType::Finalize) { result = ref->finalizeFile(command.reason); } else if (command.type == CommandType::Sync) { diff --git a/ground_station/src/logging/recorder.hpp b/ground_station/src/logging/recorder.hpp index f4c30fd9..aaa72c23 100644 --- a/ground_station/src/logging/recorder.hpp +++ b/ground_station/src/logging/recorder.hpp @@ -47,6 +47,8 @@ class Recorder : public ITelemetryPacketSink { void onTelemetryPacket(const packedRXMessage& packet, uint8_t source) override; RecorderStatus getStatus() const; bool shareWithMassStorage(); + bool pauseForRadioUpdate(); + void resumeAfterRadioUpdate(); bool sync(); bool finalize(FinalizeReason reason = FinalizeReason::UserRequested); bool deleteLog(const char* name); @@ -55,7 +57,15 @@ class Recorder : public ITelemetryPacketSink { const char* getDirectory() const { return directory; } private: - enum class CommandType : uint8_t { Sample, Finalize, Sync, CatalogRefresh, Delete, ShareMassStorage }; + enum class CommandType : uint8_t { + Sample, + Finalize, + Sync, + CatalogRefresh, + Delete, + ShareMassStorage, + PauseRadioUpdate + }; struct Command { CommandType type{CommandType::Sample}; packedRXMessage data{}; @@ -71,6 +81,8 @@ class Recorder : public ITelemetryPacketSink { bool initialized{false}; std::atomic enabled{false}; + std::atomic radioUpdatePaused{false}; + bool radioPauseRequested{false}; bool fileCreated{false}; bool armed{true}; bool neverStopLogging{false}; diff --git a/ground_station/src/main.cpp b/ground_station/src/main.cpp index 28acd674..75216abe 100644 --- a/ground_station/src/main.cpp +++ b/ground_station/src/main.cpp @@ -82,14 +82,16 @@ void loop() { const float link2Lat = static_cast(link2Data.lat) / 10000.0F; const float link2Lon = static_cast(link2Data.lon) / 10000.0F; - // In single mode, both antennas track the same rocket - if (systemConfig.config.receiverMode == SINGLE) { + // Self-test exercises this same selection path with a temporary runtime mode. + // The stored configuration remains untouched. + if (hmi.receiverMode() == SINGLE) { const bool link1GpsValid = (link1Lat != 0) && (link1Lon != 0); const bool link2GpsValid = (link2Lat != 0) && (link2Lon != 0); // Check if data from link 1 is newer than link 2 if (link1.data.getLastUpdateTime() > link2.data.getLastUpdateTime()) { // Stream data from Link 1 if (link1LastTs != link1Data.timestamp) { + hmi.noteCombinedTelemetry(); Utils::streamUsb(&link1, 1); } @@ -102,6 +104,7 @@ void loop() { } else { // Stream data from Link 2 if (link2LastTs != link2Data.timestamp) { + hmi.noteCombinedTelemetry(); Utils::streamUsb(&link2, 2); } diff --git a/ground_station/src/navigation.cpp b/ground_station/src/navigation.cpp index 6c517f4b..baf00cf8 100644 --- a/ground_station/src/navigation.cpp +++ b/ground_station/src/navigation.cpp @@ -3,12 +3,60 @@ /// SPDX-License-Identifier: GPL-3.0-or-later #include "navigation.hpp" + +#include #include "config.hpp" #include "console.hpp" #include "utils.hpp" constexpr uint8_t NAVIGATION_TASK_FREQUENCY = 50; +namespace { +// The compass library intentionally remains unchanged. Factory diagnostics use +// checked reads on the navigation task, the sole owner of the sensor bus. +bool readSensorRegisters(uint8_t address, uint8_t reg, uint8_t *bytes, uint8_t count) { + Wire.beginTransmission(address); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom(address, count) != count) return false; + for (uint8_t i = 0; i < count; ++i) bytes[i] = static_cast(Wire.read()); + return true; +} +uint8_t detectSelfTestCompass() { + uint8_t id = 0; + if (readSensorRegisters(0x0d, 0x0d, &id, 1) && id == 0xff) return 0x0d; + if (readSensorRegisters(0x2c, 0x00, &id, 1) && id == 0x80) return 0x2c; + return 0; +} +} // namespace + +SelfTestSensorObservation Navigation::selfTestSensors() const { + portENTER_CRITICAL(&sensorMux); + const auto copy = sensorObservation; + portEXIT_CRITICAL(&sensorMux); + return copy; +} + +bool Navigation::saveGyroCalibration(const std::array &bias) { + if (!std::all_of(bias.begin(), bias.end(), [](float value) { + return std::isfinite(value) && std::fabs(value) <= SelfTestProfile::kMaximumGyroBias; + })) + return false; + // Factory offsets live in NVS, independently of the user-editable USB filesystem. + Preferences preferences; + if (!preferences.begin("cats-gyro", false)) return false; + std::array verified{}; + const bool saved = preferences.putBytes("bias-v1", bias.data(), sizeof(bias)) == sizeof(bias) && + preferences.getBytes("bias-v1", verified.data(), sizeof(verified)) == sizeof(verified) && + verified == bias; + preferences.end(); + if (!saved) return false; + portENTER_CRITICAL(&sensorMux); + gyroBias = bias; + portEXIT_CRITICAL(&sensorMux); + return true; +} + bool Navigation::begin() { initialized = true; @@ -16,10 +64,23 @@ bool Navigation::begin() { compass->init(); - if (imu.begin(Wire, 0x6A) != 1) { + imuDetected = imu.begin(Wire, 0x6A) == 1; + if (!imuDetected) { console.warning.println("IMU init failed!"); } + Preferences preferences; + if (preferences.begin("cats-gyro", true)) { + std::array saved{}; + if (preferences.getBytesLength("bias-v1") == sizeof(saved) && + preferences.getBytes("bias-v1", saved.data(), sizeof(saved)) == sizeof(saved) && + std::all_of(saved.begin(), saved.end(), [](float value) { + return std::isfinite(value) && std::fabs(value) <= SelfTestProfile::kMaximumGyroBias; + })) + gyroBias = saved; + preferences.end(); + } + filter.begin(NAVIGATION_TASK_FREQUENCY); calibration = CALIB_CONCLUDED; @@ -161,20 +222,65 @@ void Navigation::navigationTask(void *pvParameter) { TickType_t task_last_tick = xTaskGetTickCount(); uint32_t count = 0; + bool wasSampling = false; + uint8_t compassAddress = 0; + uint32_t sensorSequence = 0; ref->get_saved_calib(); ref->initFibonacciSphere(); while (ref->initialized) { + const bool sampling = ref->selfTestSampling.load(); + SelfTestSensorObservation sample{}; + if (sampling) { + if (!wasSampling) compassAddress = detectSelfTestCompass(); + sample.imuDetected = ref->imuDetected; + sample.magnetometer = compassAddress; + sample.accelerationFresh = ref->imu.accelerationAvailable() != 0; + sample.gyroFresh = ref->imu.gyroscopeAvailable() != 0; + uint8_t bytes[6]{}; + sample.magnetometerFresh = + compassAddress != 0 && + readSensorRegisters(compassAddress, compassAddress == 0x0d ? 0x00 : 0x01, bytes, sizeof(bytes)); + if (sample.magnetometerFresh) { + for (size_t i = 0; i < 3; ++i) { + sample.magnetic[i] = static_cast(static_cast(bytes[i * 2] | (bytes[i * 2 + 1] << 8U))); + } + } + sample.readError = !sample.magnetometerFresh; + } + wasSampling = sampling; ref->compass->read(); ref->compass->readRaw(ref->raw_m); ref->transform(ref->raw_m, ref->m); - ref->imu.readAcceleration(ref->ax, ref->ay, ref->az); - ref->imu.readGyroscope(ref->gx, ref->gy, ref->gz); + const bool accelerationRead = ref->imu.readAcceleration(ref->ax, ref->ay, ref->az) != 0; + const bool gyroRead = ref->imu.readGyroscope(ref->gx, ref->gy, ref->gz) != 0; + const std::array rawGyro{ref->gx, ref->gy, ref->gz}; + portENTER_CRITICAL(&ref->sensorMux); + const auto bias = ref->gyroBias; + portEXIT_CRITICAL(&ref->sensorMux); + ref->gx -= bias[0]; + ref->gy -= bias[1]; + ref->gz -= bias[2]; + if (sampling) { + sample.accelerationFresh &= accelerationRead; + sample.gyroFresh &= gyroRead; + sample.readError |= !accelerationRead || !gyroRead; + sample.acceleration = {ref->ax, ref->ay, ref->az}; + sample.gyro = {ref->gx, ref->gy, ref->gz}; + sample.rawGyro = rawGyro; + sample.sequence = ++sensorSequence; + sample.sampledAtMs = millis(); + portENTER_CRITICAL(&ref->sensorMux); + ref->sensorObservation = sample; + portEXIT_CRITICAL(&ref->sensorMux); + } - ref->filter.update(ref->gy, ref->gx, -ref->gz, ref->ay, ref->ax, -ref->az, -ref->m[0], ref->m[1], -ref->m[2]); + // Align magnetic axes with the IMU before fusion. The quarter-turn belongs + // after calibration, which remains in the magnetometer's native axes. + ref->filter.update(ref->gy, ref->gx, -ref->gz, ref->ay, ref->ax, -ref->az, -ref->m[1], -ref->m[0], -ref->m[2]); ref->filter.getQuaternion(&ref->q0, &ref->q1, &ref->q2, &ref->q3); diff --git a/ground_station/src/navigation.hpp b/ground_station/src/navigation.hpp index 8ff5fd22..5ebfed97 100644 --- a/ground_station/src/navigation.hpp +++ b/ground_station/src/navigation.hpp @@ -5,6 +5,7 @@ #pragma once #include "console.hpp" +#include "self_test.hpp" #include "utils.hpp" // clang-format off @@ -14,6 +15,7 @@ #include // clang-format on +#include #include #include @@ -50,6 +52,9 @@ class EarthPoint3D { class Navigation { public: bool begin(); + void enableSelfTestSampling(bool enabled) { selfTestSampling = enabled; } + SelfTestSensorObservation selfTestSensors() const; + bool saveGyroCalibration(const std::array &bias); void setPointA(EarthPoint3D point) { pointA = point; } void setPointA(float lat, float lon, float height = 0) { pointA = EarthPoint3D(lat, lon, height); } @@ -142,6 +147,11 @@ class Navigation { private: bool initialized = false; + std::atomic selfTestSampling{false}; + bool imuDetected{false}; + mutable portMUX_TYPE sensorMux = portMUX_INITIALIZER_UNLOCKED; + SelfTestSensorObservation sensorObservation{}; + std::array gyroBias{}; // Degrees/s, protected by sensorMux. bool updated = false; calibration_state_e calibration = INV_CALIB; diff --git a/ground_station/src/self_test.cpp b/ground_station/src/self_test.cpp new file mode 100644 index 00000000..55a9e4a4 --- /dev/null +++ b/ground_station/src/self_test.cpp @@ -0,0 +1,502 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later +#include "self_test.hpp" + +#include +#include + +namespace { +constexpr uint8_t kA = 1U << 5; +constexpr uint8_t kB = 1U << 6; +constexpr SelfTest::Check kRadioChecks[] = {SelfTest::Check::Dual, SelfTest::Check::Swapped, SelfTest::Check::Single, + SelfTest::Check::Receiver1Only, SelfTest::Check::Receiver2Only}; +bool finite(const std::array& v) { return std::isfinite(v[0]) && std::isfinite(v[1]) && std::isfinite(v[2]); } +float magnitude(const std::array& v) { return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); } +bool plausibleBattery(float voltage) { return std::isfinite(voltage) && voltage >= 3.0F && voltage <= 4.35F; } +} // namespace + +void SelfTest::start(uint32_t now, const SelfTestInput& input) { + *this = SelfTest{}; + startedAt = now; + initialFixCount = input.links[1].fixCount; + initialUtcSeconds = input.links[1].utcSeconds; + sensorSequence = input.sensors.sequence; + enter(Phase::GyroCalibration, now); +} + +void SelfTest::setResult(Check item, Result result, const char* reason) { + results[static_cast(item)] = {result, reason}; +} +const SelfTest::CheckResult& SelfTest::check(Check item) const { return results[static_cast(item)]; } + +SelfTest::Result SelfTest::overall() const { + if (!finished()) return Result::Pending; + for (const auto& item : results) + if (item.result != Result::Pass) return Result::Fail; + return cancelled ? Result::Fail : Result::Pass; +} + +void SelfTest::enter(Phase next, uint32_t now) { + phase = next; + phaseStartedAt = now; + blockedButtons = 0x7f; + awaitingConfirmation = next == Phase::Telemetry || next == Phase::Motion || next == Phase::Buttons || + next == Phase::Display || next == Phase::Usb; +} + +void SelfTest::observeVersions(uint32_t now, const SelfTestInput& input) { + for (size_t i = 0; i < 2; ++i) { + const auto item = i == 0 ? Check::Firmware1 : Check::Firmware2; + if (check(item).result != Result::Pending) continue; + if (input.links[i].versionReplies != initialVersionReplies[i]) { + const bool present = input.links[i].version[0] != '\0'; + setResult(item, present ? Result::Pass : Result::Fail, present ? "Fresh version reply" : "Empty version reply"); + } else if (now - phaseStartedAt >= 15000U) { + setResult(item, Result::Fail, "No version reply after retries"); + } + } + if (now - lastVersionRequest >= 1000U && now - phaseStartedAt < 15000U) { + versionRequest = true; + lastVersionRequest = now; + } +} + +void SelfTest::observeGnss(uint32_t now, const SelfTestInput& input) { + const auto& gps = input.links[1]; + satellites = gps.satellites; + if (check(Check::Gnss).result == Result::Pending) { + const bool valid = std::isfinite(gps.latitude) && std::isfinite(gps.longitude) && std::fabs(gps.latitude) <= 90 && + std::fabs(gps.longitude) <= 180 && (gps.latitude != 0 || gps.longitude != 0); + if (valid && gps.fixCount - initialFixCount >= 5 && now - gps.lastFixMs < 1500U && gps.timeUpdates > 0 && + now - gps.lastTimeMs < 2000U && gps.utcSeconds != initialUtcSeconds) { + setResult(Check::Gnss, Result::Pass, "Fresh fixes and advancing GNSS time"); + } else if (now - phaseStartedAt >= SelfTestProfile::kGnssTimeoutMs) { + setResult(Check::Gnss, Result::Fail, "No fresh fix: retest with open sky"); + } + } +} + +void SelfTest::resetStationary() { + stationarySamples = 0; + accelSum = accelSquareSum = 0; + gyroSum.fill(0); + gyroSquareSum.fill(0); +} + +void SelfTest::resetGyroCalibration() { + gyroCalibrationSamples = 0; + gyroCalibrationBias.fill(0); +} + +void SelfTest::observeGyroCalibration(uint32_t now, const SelfTestSensorObservation& s) { + if (gyroCalibrationPending) return; + if (now - s.sampledAtMs > 100U) { + resetGyroCalibration(); + return; + } + if (s.sequence == sensorSequence) return; + sensorSequence = s.sequence; + const bool valid = s.imuDetected && s.accelerationFresh && s.gyroFresh && finite(s.acceleration) && finite(s.rawGyro); + const float gravity = magnitude(s.acceleration); + if (!valid || gravity < 0.8F || gravity > 1.2F || !std::all_of(s.rawGyro.begin(), s.rawGyro.end(), [](float value) { + return std::fabs(value) <= SelfTestProfile::kMaximumGyroBias; + })) { + resetGyroCalibration(); + return; + } + if (gyroCalibrationSamples != 0 && s.sampledAtMs - gyroCalibrationLastSample > 100U) resetGyroCalibration(); + gyroCalibrationLastSample = s.sampledAtMs; + if (gyroCalibrationSamples == 0) { + gyroCalibrationStartedAt = now; + calibrationAccelMin = calibrationAccelMax = s.acceleration; + calibrationGyroMin = calibrationGyroMax = s.rawGyro; + } + for (size_t i = 0; i < 3; ++i) { + calibrationAccelMin[i] = std::min(calibrationAccelMin[i], s.acceleration[i]); + calibrationAccelMax[i] = std::max(calibrationAccelMax[i], s.acceleration[i]); + calibrationGyroMin[i] = std::min(calibrationGyroMin[i], s.rawGyro[i]); + calibrationGyroMax[i] = std::max(calibrationGyroMax[i], s.rawGyro[i]); + // Reject movement throughout the window, including tilt at constant total gravity. + if (calibrationAccelMax[i] - calibrationAccelMin[i] > 0.1F || + calibrationGyroMax[i] - calibrationGyroMin[i] > 0.5F) { + resetGyroCalibration(); + return; + } + } + for (size_t i = 0; i < 3; ++i) gyroCalibrationBias[i] += s.rawGyro[i]; + ++gyroCalibrationSamples; + if (now - gyroCalibrationStartedAt >= SelfTestProfile::kGyroCalibrationMs && gyroCalibrationSamples >= 200U) { + for (auto& value : gyroCalibrationBias) value /= static_cast(gyroCalibrationSamples); + gyroCalibrationPending = gyroCalibrationRequested = true; + } +} + +bool SelfTest::takeGyroCalibration(std::array& bias) { + if (!gyroCalibrationRequested) return false; + bias = gyroCalibrationBias; + gyroCalibrationRequested = false; + return true; +} + +void SelfTest::completeGyroCalibration(bool saved, uint32_t now, const SelfTestInput& input) { + if (phase != Phase::GyroCalibration) return; + setResult(Check::GyroCalibration, saved ? Result::Pass : Result::Fail, + saved ? "Stationary offsets saved for future starts" + : gyroCalibrationPending ? "Could not save gyro offsets" + : "No stable gyro calibration within 30 seconds"); + gyroCalibrationPending = gyroCalibrationRequested = false; + resetStationary(); + sensorSequence = input.sensors.sequence; + initialFixCount = input.links[1].fixCount; + initialUtcSeconds = input.links[1].utcSeconds; + batteryVoltageValid = plausibleBattery(input.batteryVoltage); + batteryUsbSeen = input.usbConnected; + enter(Phase::Hardware, now); +} + +bool SelfTest::takeUsbStorageRequest() { + const bool requested = usbStorageRequest; + usbStorageRequest = false; + return requested; +} + +void SelfTest::observeSensors(uint32_t now, const SelfTestSensorObservation& s) { + if (s.sequence == sensorSequence || now - s.sampledAtMs > 500U) return; + sensorSequence = s.sequence; + if (s.readError) ++sensorErrors; + const bool goodImu = s.imuDetected && s.accelerationFresh && s.gyroFresh && finite(s.acceleration) && finite(s.gyro); + const bool goodMag = + s.magnetometer != 0 && s.magnetometerFresh && finite(s.magnetic) && magnitude(s.magnetic) > 10 && + std::all_of(s.magnetic.begin(), s.magnetic.end(), [](float value) { return std::fabs(value) < 32000; }); + if (phase == Phase::Hardware && goodMag && check(Check::Magnetic).result == Result::Pending) + setResult(Check::Magnetic, Result::Pass, "Detected; fresh, unsaturated raw data"); + if (phase == Phase::Hardware && check(Check::Stationary).result == Result::Pending) { + const float gravity = magnitude(s.acceleration); + const bool still = goodImu && gravity >= 0.8F && gravity <= 1.2F && + std::all_of(s.gyro.begin(), s.gyro.end(), [](float value) { return std::fabs(value) <= 5.0F; }); + if (!still) { + resetStationary(); + return; + } + if (stationarySamples == 0) stationaryStartedAt = now; + ++stationarySamples; + accelSum += gravity; + accelSquareSum += gravity * gravity; + for (size_t i = 0; i < 3; ++i) { + gyroSum[i] += s.gyro[i]; + gyroSquareSum[i] += s.gyro[i] * s.gyro[i]; + } + if (now - stationaryStartedAt >= 2000U && stationarySamples >= 50) { + const float count = static_cast(stationarySamples); + const float accelerationMean = accelSum / count; + const float accelerationNoise = + std::sqrt(std::max(0.0F, accelSquareSum / count - accelerationMean * accelerationMean)); + float gyroNoise = 0; + for (size_t i = 0; i < 3; ++i) { + const float mean = gyroSum[i] / count; + gyroNoise = std::max(gyroNoise, std::sqrt(std::max(0.0F, gyroSquareSum[i] / count - mean * mean))); + } + if (accelerationNoise <= 0.05F && gyroNoise <= 1.5F) { + setResult(Check::Stationary, Result::Pass, "Gravity, gyro bias and noise within limits"); + } else + resetStationary(); + } + } + if (phase != Phase::Motion) return; + for (size_t i = 0; i < 3; ++i) { + const auto bit = static_cast(1U << i); + if (goodImu) { + accelerationMin[i] = std::min(accelerationMin[i], s.acceleration[i]); + accelerationMax[i] = std::max(accelerationMax[i], s.acceleration[i]); + if (accelerationMax[i] - accelerationMin[i] >= 0.7F) accelerationAxes |= bit; + if (std::fabs(s.gyro[i]) >= 15 && std::fabs(s.gyro[i]) < 1900) gyroAxes |= bit; + } + if (goodMag) { + magneticMin[i] = std::min(magneticMin[i], s.magnetic[i]); + magneticMax[i] = std::max(magneticMax[i], s.magnetic[i]); + if (magneticMax[i] - magneticMin[i] >= 100) magneticAxes |= bit; + } + } +} + +void SelfTest::updateRadio(uint32_t now, const SelfTestInput& input) { + if (!measuring) { + if (now - radioStartedAt >= SelfTestProfile::kSettleMs && input.linksReady) { + radioReset = true; + measuring = true; + measurementStartedAt = now; + combinedBaseline = lastCombinedCount = input.combinedPackets; + lastCombinedAt = now; + combinedMaxGap = 0; + } else if (now - radioStartedAt >= 10000U) { + setResult(kRadioChecks[radioStage], Result::Fail, "Receiver configuration did not complete"); + completeRadio(now); + } + return; + } + if (input.combinedPackets != lastCombinedCount) { + combinedMaxGap = std::max(combinedMaxGap, now - lastCombinedAt); + lastCombinedAt = now; + lastCombinedCount = input.combinedPackets; + } + const uint32_t duration = radioStage < 3 ? SelfTestProfile::kRadioWindowMs : SelfTestProfile::kFailoverWindowMs; + if (now - measurementStartedAt < duration) return; + const uint32_t measuredMs = now - measurementStartedAt; + const uint32_t minimumPackets = (measuredMs * SelfTestProfile::kMinimumPacketsPerSecond + 999U) / 1000U; + const uint32_t combinedPackets = input.combinedPackets - combinedBaseline; + bool passed = true; + for (size_t i = 0; i < 2; ++i) { + if ((radioStage == 3 && i == 1) || (radioStage == 4 && i == 0)) continue; + auto stats = input.links[i].radio; + stats.maxGapMs = std::max(stats.maxGapMs, stats.packets == 0 ? measuredMs : now - stats.lastPacketMs); + passed &= stats.packets >= minimumPackets && stats.maxGapMs <= SelfTestProfile::kMaximumGapMs && + stats.infoSamples > 0 && stats.lqSum / stats.infoSamples >= SelfTestProfile::kMinimumMeanLq && + stats.minimumSnr > SelfTestProfile::kSnrThreshold; + } + if (radioStage >= 2) { + combinedMaxGap = std::max(combinedMaxGap, now - lastCombinedAt); + passed &= combinedPackets >= minimumPackets && combinedMaxGap <= SelfTestProfile::kMaximumGapMs; + } + setResult( + kRadioChecks[radioStage], passed ? Result::Pass : Result::Fail, + passed ? "Packet rate, gaps, quality and SNR within limits" : "Packet rate, quality, SNR or gap outside limits"); + completeRadio(now); +} + +void SelfTest::completeRadio(uint32_t now) { + ++radioAttempts; + measuring = false; + if (check(kRadioChecks[radioStage]).result == Result::Fail && radioAttempts < 2) { + setResult(kRadioChecks[radioStage], Result::Pending, "Retrying receiver test"); + } else if (++radioStage >= 5) { + radioStage = 4; + radioRequest = RadioConfiguration::Restore; + afterRestore = Phase::Motion; + enter(Phase::Restoring, now); + return; + } else { + radioAttempts = 0; + } + radioStartedAt = now; + radioRequest = static_cast(radioStage); +} + +void SelfTest::update(uint32_t now, const SelfTestInput& input) { + if (!started() || finished()) return; + blockedButtons &= input.buttonsHeld; + const uint8_t pressed = input.buttonsPressed & static_cast(~blockedButtons); + // A confirmation starts exactly one check. Ignore its press/release as test + // input, and leave every measurement and timeout stopped while waiting. + if (awaitingConfirmation) { + if ((pressed & kA) != 0) { + awaitingConfirmation = false; + phaseStartedAt = now; + blockedButtons = input.buttonsHeld; + sensorSequence = input.sensors.sequence; + if (phase == Phase::Telemetry) { + for (size_t i = 0; i < 2; ++i) initialVersionReplies[i] = input.links[i].versionReplies; + versionRequest = true; + lastVersionRequest = radioStartedAt = now; + radioRequest = RadioConfiguration::Dual; + radioChanged = true; + } + } + return; + } + if (phase == Phase::Hardware || phase == Phase::Motion) observeSensors(now, input.sensors); + switch (phase) { + case Phase::GyroCalibration: + observeGyroCalibration(now, input.sensors); + if (!gyroCalibrationPending && now - phaseStartedAt >= 30000U) completeGyroCalibration(false, now, input); + break; + case Phase::Hardware: + observeGnss(now, input); + if (check(Check::Battery).result == Result::Pending) { + batteryVoltageValid &= plausibleBattery(input.batteryVoltage); + batteryUsbSeen |= input.usbConnected; + if (now - phaseStartedAt >= 5000U) { + const bool passed = batteryVoltageValid && !batteryUsbSeen; + setResult(Check::Battery, passed ? Result::Pass : Result::Fail, + batteryUsbSeen ? "USB active during battery check: rerun unplugged" + : passed ? "Five seconds on battery" + : "Battery voltage outside 3.0-4.35 V"); + } + } + if (now - phaseStartedAt >= 30000U) { + if (check(Check::Stationary).result == Result::Pending) + setResult(Check::Stationary, Result::Fail, "No stable fresh IMU measurement"); + if (check(Check::Magnetic).result == Result::Pending) + setResult(Check::Magnetic, Result::Fail, "No valid magnetometer samples"); + } + if (check(Check::Gnss).result != Result::Pending && check(Check::Stationary).result != Result::Pending && + check(Check::Magnetic).result != Result::Pending && check(Check::Battery).result != Result::Pending) + enter(Phase::Telemetry, now); + break; + case Phase::Telemetry: + observeVersions(now, input); + updateRadio(now, input); + break; + case Phase::Motion: + if (accelerationAxes == 7 && gyroAxes == 7 && magneticAxes == 7) { + setResult(Check::Motion, Result::Pass, "All accelerometer, gyro and magnetic axes responded"); + enter(Phase::Buttons, now); + } else if ((pressed & kA) != 0 || now - phaseStartedAt >= 60000U) { + setResult(Check::Motion, (pressed & kA) != 0 ? Result::Incomplete : Result::Fail, + "Not all sensor axes demonstrated motion"); + enter(Phase::Buttons, now); + } + break; + case Phase::Buttons: + pressedButtons |= pressed & kRequiredButtons; + completedButtons |= pressedButtons & static_cast(~input.buttonsHeld); + if (completedButtons == kRequiredButtons) { + setResult(Check::Buttons, Result::Pass, "All six buttons pressed and released"); + enter(Phase::Display, now); + } else if (now - phaseStartedAt >= 60000U) { + setResult(Check::Buttons, Result::Fail, "Missing button press or release"); + enter(Phase::Display, now); + } + break; + case Phase::Display: + if (now - phaseStartedAt >= SelfTestProfile::kDisplayEndMs && (pressed & (kA | kB)) != 0) { + const bool passed = (pressed & kA) != 0; + setResult(Check::Display, passed ? Result::Pass : Result::Fail, + passed ? "Operator confirmed all patterns" : "Operator reported display fault"); + enter(Phase::Usb, now); + } + break; + case Phase::Restoring: + if (input.linksReady && now - phaseStartedAt >= SelfTestProfile::kSettleMs) { + setResult(Check::Restoration, Result::Pass, "Normal receiver settings reapplied"); + radioChanged = false; + if (afterRestore == Phase::Finished) + finish(now); + else + enter(afterRestore, now); + } else if (now - phaseStartedAt >= 10000U) { + setResult(Check::Restoration, Result::Fail, "Receiver restoration timed out"); + finish(now); + } + break; + case Phase::Usb: + if (input.usbConnected && !usbStorageRequested) { + usbReadBaseline = input.usbReadCount; + usbStorageRequest = usbStorageRequested = true; + } + if (input.usbStorageFault || check(Check::Usb).result == Result::Fail) { + setResult(Check::Usb, Result::Fail, "Could not share USB storage"); + finish(now); + } else if (usbStorageRequested && input.usbConnected && input.usbStorageReady && + input.usbReadCount != usbReadBaseline) { + setResult(Check::Usb, Result::Pass, "Computer read the shared USB drive"); + finish(now); + } else if (now - phaseStartedAt >= 30000U) { + setResult(Check::Usb, Result::Fail, "No USB drive read within 30 seconds"); + finish(now); + } else if ((pressed & kA) != 0) { + setResult(Check::Usb, Result::Incomplete, "USB drive check skipped"); + finish(now); + } + break; + default: + break; + } +} + +void SelfTest::finish(uint32_t now) { + if (sensorErrors > 5) setResult(Check::Motion, Result::Fail, "Repeated sensor read errors; inspect sensor bus"); + for (auto& item : results) + if (item.result == Result::Pending) item = {Result::Incomplete, "Not completed"}; + enter(Phase::Finished, now); +} +void SelfTest::cancel(uint32_t now) { + cancelled = true; + if (!radioChanged) { + if (check(Check::Restoration).result == Result::Pending) + setResult(Check::Restoration, Result::Pass, "Normal receiver settings retained"); + finish(now); + return; + } + afterRestore = Phase::Finished; + radioRequest = RadioConfiguration::Restore; + enter(Phase::Restoring, now); +} +SelfTest::RadioConfiguration SelfTest::takeRadioConfiguration() { + const auto value = radioRequest; + radioRequest = RadioConfiguration::None; + return value; +} +bool SelfTest::takeRadioReset() { + const bool value = radioReset; + radioReset = false; + return value; +} +bool SelfTest::takeVersionRequest() { + const bool value = versionRequest; + versionRequest = false; + return value; +} +uint8_t SelfTest::displayPattern(uint32_t now) const { + if (awaitingConfirmation || phase != Phase::Display || now - phaseStartedAt < SelfTestProfile::kDisplayLeadInMs || + now - phaseStartedAt >= SelfTestProfile::kDisplayEndMs) + return 0; + return static_cast(1U + (now - phaseStartedAt - SelfTestProfile::kDisplayLeadInMs) / + SelfTestProfile::kDisplayPatternMs); +} +const char* SelfTest::resultName(Result result) { + static constexpr const char* names[] = {"WAIT", "PASS", "FAIL", "NOT TESTED"}; + return names[static_cast(result)]; +} +const char* SelfTest::checkName(Check item) { + static constexpr const char* names[] = {"Gyroscope calibration", + "Telemetry 1 firmware", + "Telemetry 2 firmware", + "Onboard GNSS", + "Stationary IMU", + "Magnetometer", + "Dual reception", + "Dual swapped", + "Single reception", + "Receiver 1 alone", + "Receiver 2 alone", + "Storage", + "Sensor movement", + "Buttons", + "Display", + "Battery operation", + "USB drive", + "Settings restored"}; + return names[static_cast(item)]; +} +const char* SelfTest::title() const { + static constexpr const char* names[] = { + "Factory self-test", "0. Gyro calibration", "1. Ground Station", "2. Telemetry", "3.1 Sensor movement", + "3.2 Buttons", "3.3 Display", "Restoring settings", "3.4 USB drive", "Self-test results"}; + return names[static_cast(phase)]; +} +const char* SelfTest::instruction() const { + if (awaitingConfirmation) return "Ready to start - press A when ready."; + switch (phase) { + case Phase::Ready: + return "Start on battery with USB disconnected."; + case Phase::GyroCalibration: + return "Keep completely still on the bench."; + case Phase::Hardware: + return "Keep still. Checks run automatically."; + case Phase::Telemetry: + return "Watch receiver LEDs during reception."; + case Phase::Motion: + return "Tilt and turn around each axis."; + case Phase::Buttons: + return "Press and release each button, including B."; + case Phase::Display: + return "Inspect black, white and checker patterns."; + case Phase::Restoring: + return "Restoring normal receiver settings."; + case Phase::Usb: + return usbStorageRequested ? "Waiting for the computer to read the drive." : "Connect USB to the computer."; + case Phase::Finished: + return "Use up/down to view each result."; + } + return ""; +} diff --git a/ground_station/src/self_test.hpp b/ground_station/src/self_test.hpp new file mode 100644 index 00000000..ce05d0b7 --- /dev/null +++ b/ground_station/src/self_test.hpp @@ -0,0 +1,224 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +// Shared by the firmware and simulator. Hardware adapters provide observations; +// this state machine owns timing, acceptance and the order of operator prompts. +struct SelfTestRadioStats { + uint32_t packets{0}; + uint32_t infoSamples{0}; + uint32_t lqSum{0}; + int8_t minimumSnr{127}; + uint32_t lastPacketMs{0}; + uint32_t maxGapMs{0}; +}; + +struct SelfTestLinkObservation { + SelfTestRadioStats radio{}; + char version[17]{}; + uint32_t versionReplies{0}; + uint32_t fixCount{0}; + uint32_t lastFixMs{0}; + uint32_t timeUpdates{0}; + uint32_t lastTimeMs{0}; + uint32_t utcSeconds{0}; + float latitude{0}; + float longitude{0}; + int16_t satellites{-1}; +}; + +struct SelfTestSensorObservation { + uint32_t sequence{0}; + uint32_t sampledAtMs{0}; + bool imuDetected{false}; + uint8_t magnetometer{0}; // I2C address: 0x0d (L), 0x2c (P), or zero. + bool accelerationFresh{false}; + bool gyroFresh{false}; + bool magnetometerFresh{false}; + bool readError{false}; + std::array acceleration{}; + std::array gyro{}; + std::array rawGyro{}; // Degrees/s before the saved zero-rate offsets. + std::array magnetic{}; // Uncalibrated ADC counts, for both variants. +}; + +struct SelfTestInput { + std::array links{}; + SelfTestSensorObservation sensors{}; + bool linksReady{false}; + bool usbConnected{false}; + bool usbStorageReady{false}; + bool usbStorageFault{false}; + uint32_t usbReadCount{0}; + float batteryVoltage{0}; + uint32_t combinedPackets{0}; + uint8_t buttonsHeld{0}; // up, down, left, right, center, A, B + uint8_t buttonsPressed{0}; +}; + +struct SelfTestProfile { + // Initial bench limits: qualify on the actual fixture before accepting a batch. + static constexpr const char* kPhrase1 = "cats_test_1"; + static constexpr const char* kPhrase2 = "cats_test_2"; + static constexpr uint32_t kSettleMs = 2500; + static constexpr uint32_t kRadioWindowMs = 15000; + static constexpr uint32_t kFailoverWindowMs = 5000; + static constexpr uint32_t kGnssTimeoutMs = 120000; + static constexpr uint32_t kMinimumPacketsPerSecond = 8; + static constexpr uint32_t kMaximumGapMs = 1000; + static constexpr uint32_t kMinimumMeanLq = 80; + static constexpr int8_t kSnrThreshold = -5; // Every report must be strictly above this value, in dB. + static constexpr uint32_t kDisplayLeadInMs = 2000; + static constexpr uint32_t kDisplayPatternMs = 4000; + static constexpr uint32_t kDisplayEndMs = kDisplayLeadInMs + 4 * kDisplayPatternMs; + static constexpr uint32_t kGyroCalibrationMs = 5000; + static constexpr float kMaximumGyroBias = 5.0F; +}; + +class SelfTest { + public: + static constexpr uint8_t kRequiredButtons = 0x6f; // Up, down, left, right, A, B; center (bit 4) is not fitted. + enum class Result : uint8_t { Pending, Pass, Fail, Incomplete }; + enum class Check : uint8_t { + GyroCalibration, + Firmware1, + Firmware2, + Gnss, + Stationary, + Magnetic, + Dual, + Swapped, + Single, + Receiver1Only, + Receiver2Only, + Storage, + Motion, + Buttons, + Display, + Battery, + Usb, + Restoration, + Count + }; + enum class Phase : uint8_t { + Ready, + GyroCalibration, + Hardware, + Telemetry, + Motion, + Buttons, + Display, + Restoring, + Usb, + Finished + }; + enum class RadioConfiguration : int8_t { + None = -1, + Dual = 0, + Swapped = 1, + Single = 2, + Receiver1Only = 3, + Receiver2Only = 4, + Restore = 5 + }; + struct CheckResult { + Result result{Result::Pending}; + const char* reason{"Not tested"}; + }; + void start(uint32_t now, const SelfTestInput& input); + void update(uint32_t now, const SelfTestInput& input); + void cancel(uint32_t now); + void setResult(Check check, Result result, const char* reason); + [[nodiscard]] const CheckResult& check(Check check) const; + [[nodiscard]] Result overall() const; + [[nodiscard]] bool started() const { return phase != Phase::Ready; } + [[nodiscard]] bool finished() const { return phase == Phase::Finished; } + [[nodiscard]] const char* title() const; + [[nodiscard]] const char* instruction() const; + [[nodiscard]] uint32_t elapsedMs(uint32_t now) const { return now - startedAt; } + [[nodiscard]] uint32_t phaseElapsedMs(uint32_t now) const { return now - phaseStartedAt; } + [[nodiscard]] uint8_t displayPattern(uint32_t now) const; + [[nodiscard]] RadioConfiguration takeRadioConfiguration(); + bool takeRadioReset(); + bool takeVersionRequest(); + bool takeGyroCalibration(std::array& bias); + void completeGyroCalibration(bool saved, uint32_t now, const SelfTestInput& input); + bool takeUsbStorageRequest(); + static const char* resultName(Result result); + static const char* checkName(Check check); + + Phase phase{Phase::Ready}; + std::array(Check::Count)> results{}; + uint8_t radioStage{0}; + bool measuring{false}; + bool awaitingConfirmation{false}; + uint8_t completedButtons{0}; + uint8_t accelerationAxes{0}; + uint8_t gyroAxes{0}; + uint8_t magneticAxes{0}; + int16_t satellites{-1}; + uint32_t sensorErrors{0}; + bool cancelled{false}; + uint32_t gyroCalibrationSamples{0}; + + private: + void enter(Phase next, uint32_t now); + void observeVersions(uint32_t now, const SelfTestInput& input); + void observeGnss(uint32_t now, const SelfTestInput& input); + void observeSensors(uint32_t now, const SelfTestSensorObservation& input); + void updateRadio(uint32_t now, const SelfTestInput& input); + void completeRadio(uint32_t now); + void resetStationary(); + void observeGyroCalibration(uint32_t now, const SelfTestSensorObservation& input); + void resetGyroCalibration(); + void finish(uint32_t now); + RadioConfiguration radioRequest{RadioConfiguration::None}; + bool radioReset{false}; + bool versionRequest{false}; + bool gyroCalibrationPending{false}; + bool gyroCalibrationRequested{false}; + bool usbStorageRequest{false}; + bool usbStorageRequested{false}; + uint32_t usbReadBaseline{0}; + uint32_t gyroCalibrationStartedAt{0}; + uint32_t gyroCalibrationLastSample{0}; + std::array gyroCalibrationBias{}; + std::array calibrationAccelMin{}; + std::array calibrationAccelMax{}; + std::array calibrationGyroMin{}; + std::array calibrationGyroMax{}; + uint32_t startedAt{0}; + uint32_t phaseStartedAt{0}; + uint32_t radioStartedAt{0}; + uint32_t measurementStartedAt{0}; + uint32_t lastVersionRequest{0}; + uint32_t sensorSequence{0}; + uint32_t stationaryStartedAt{0}; + uint32_t stationarySamples{0}; + uint32_t initialFixCount{0}; + uint32_t initialUtcSeconds{0}; + uint32_t initialVersionReplies[2]{}; + uint32_t combinedBaseline{0}; + uint32_t lastCombinedCount{0}; + uint32_t lastCombinedAt{0}; + uint32_t combinedMaxGap{0}; + uint8_t pressedButtons{0}; + uint8_t blockedButtons{0x7f}; + uint8_t radioAttempts{0}; + bool radioChanged{false}; + bool batteryVoltageValid{true}; + bool batteryUsbSeen{false}; + Phase afterRestore{Phase::Motion}; + float accelSum{0}; + float accelSquareSum{0}; + std::array gyroSum{}; + std::array gyroSquareSum{}; + std::array accelerationMin{{100, 100, 100}}; + std::array accelerationMax{{-100, -100, -100}}; + std::array magneticMin{{32768, 32768, 32768}}; + std::array magneticMax{{-32768, -32768, -32768}}; +}; diff --git a/ground_station/src/systemParser.cpp b/ground_station/src/systemParser.cpp index 144cc487..c3abc9e9 100644 --- a/ground_station/src/systemParser.cpp +++ b/ground_station/src/systemParser.cpp @@ -93,6 +93,9 @@ SystemParser::SystemParser() = default; * @return false on error */ bool SystemParser::loadFile(const char* path) { + if (!Utils::isFilesystemAvailable()) { + return false; + } if (path == nullptr || path[0] == '\0') { console.warning.println("[PARSER] Invalid file path"); return false; @@ -289,6 +292,9 @@ bool SystemParser::getUnitSystem(UnitSystem& unit_system) { * @return false on error */ bool SystemParser::saveFile(const char* path) { + if (!Utils::isFilesystemAvailable()) { + return false; + } if (path != nullptr) { filePath = path; } diff --git a/ground_station/src/telemetry/parser.cpp b/ground_station/src/telemetry/parser.cpp index ea9e94d7..e2d44071 100644 --- a/ground_station/src/telemetry/parser.cpp +++ b/ground_station/src/telemetry/parser.cpp @@ -4,6 +4,8 @@ #include "parser.hpp" +#include +#include #include "console.hpp" #include "crc.hpp" @@ -36,6 +38,9 @@ int32_t Parser::getOpCodeIndex(uint8_t opCode) { } void Parser::process(uint8_t ch) { + const uint32_t now = millis(); + if (state != STATE_OP && now - lastByteMs > 100U) reset(); + lastByteMs = now; switch (state) { case STATE_OP: opCodeIndex = getOpCodeIndex(ch); @@ -52,6 +57,8 @@ void Parser::process(uint8_t ch) { } else { state = STATE_CRC; } + } else { + reset(); } break; case STATE_DATA: @@ -84,26 +91,94 @@ void Parser::cmdRX(uint8_t *args, uint32_t length) { return; } data->commit(args, sizeof(packedRXMessage)); + const uint32_t now = millis(); + portENTER_CRITICAL(&diagnosticsMux); + auto &stats = observation.radio; + ++stats.packets; + stats.maxGapMs = std::max(stats.maxGapMs, now - stats.lastPacketMs); + stats.lastPacketMs = now; + portEXIT_CRITICAL(&diagnosticsMux); if (sink != nullptr) { sink->onTelemetryPacket(data->getRxData(), source); } } -void Parser::cmdInfo(uint8_t *args, uint32_t length) { info->commit(args, length); } +void Parser::cmdInfo(uint8_t *args, uint32_t length) { + if (length != sizeof(TelemetryInfoData) || args[0] > 100) return; + info->commit(args, length); + portENTER_CRITICAL(&diagnosticsMux); + auto &stats = observation.radio; + ++stats.infoSamples; + stats.lqSum += args[0]; + stats.minimumSnr = std::min(stats.minimumSnr, static_cast(args[2])); + portEXIT_CRITICAL(&diagnosticsMux); +} void Parser::cmdGNSSLoc(uint8_t *args, uint32_t length) { + if (length != sizeof(TelemetryLocationData)) return; + TelemetryLocationData value{}; + memcpy(&value, args, sizeof(value)); + if (!std::isfinite(value.lat) || !std::isfinite(value.lon) || std::fabs(value.lat) > 90 || + std::fabs(value.lon) > 180 || (value.lat == 0 && value.lon == 0)) + return; + const uint32_t now = millis(); + portENTER_CRITICAL(&diagnosticsMux); + ++observation.fixCount; + observation.lastFixMs = now; + observation.latitude = value.lat; + observation.longitude = value.lon; + portEXIT_CRITICAL(&diagnosticsMux); if (location != nullptr) { location->commit(args, length); } } void Parser::cmdGNSSTime(uint8_t *args, uint32_t length) { + if (length != 3 || args[0] > 59 || args[1] > 59 || args[2] > 23) return; + const uint32_t now = millis(); + portENTER_CRITICAL(&diagnosticsMux); + ++observation.timeUpdates; + observation.lastTimeMs = now; + observation.utcSeconds = args[2] * 3600U + args[1] * 60U + args[0]; + portEXIT_CRITICAL(&diagnosticsMux); if (time != nullptr) { time->commit(args, length); } } -// NOLINTNEXTLINE(readability-convert-member-functions-to-static) function ptr doesn't work if it's static -void Parser::cmdGNSSInfo(uint8_t *args [[maybe_unused]], uint32_t length [[maybe_unused]]) { - // TODO: Do Something +void Parser::cmdGNSSInfo(uint8_t *args, uint32_t length) { + if (length != 1) return; + portENTER_CRITICAL(&diagnosticsMux); + observation.satellites = args[0]; + portEXIT_CRITICAL(&diagnosticsMux); +} + +void Parser::cmdVersion(uint8_t *args, uint32_t length) { + if (length == 0 || length > 16 || + !std::all_of(args, args + length, [](uint8_t value) { return value >= 32 && value <= 126; }) || + !std::any_of(args, args + length, [](uint8_t value) { return value > 32; })) + return; + portENTER_CRITICAL(&diagnosticsMux); + memset(observation.version, 0, sizeof(observation.version)); + memcpy(observation.version, args, length); + ++observation.versionReplies; + portEXIT_CRITICAL(&diagnosticsMux); +} + +void Parser::cmdTemperature(uint8_t *args [[maybe_unused]], uint32_t length [[maybe_unused]]) { + // Consume the existing periodic frame without treating its payload as opcodes. +} + +SelfTestLinkObservation Parser::diagnostics() const { + portENTER_CRITICAL(&diagnosticsMux); + const auto copy = observation; + portEXIT_CRITICAL(&diagnosticsMux); + return copy; +} + +void Parser::resetReceptionStats(uint32_t now) { + portENTER_CRITICAL(&diagnosticsMux); + observation.radio = {}; + observation.radio.lastPacketMs = now; + portEXIT_CRITICAL(&diagnosticsMux); } diff --git a/ground_station/src/telemetry/parser.hpp b/ground_station/src/telemetry/parser.hpp index 245d7ac7..57ba7bd8 100644 --- a/ground_station/src/telemetry/parser.hpp +++ b/ground_station/src/telemetry/parser.hpp @@ -5,6 +5,7 @@ #pragma once #include "packetSink.hpp" +#include "self_test.hpp" #include "telemetryData.hpp" #include "telemetry_reg.hpp" @@ -52,6 +53,10 @@ class Parser { void cmdGNSSLoc(uint8_t* args, uint32_t length); void cmdGNSSTime(uint8_t* args, uint32_t length); void cmdGNSSInfo(uint8_t* args, uint32_t length); + void cmdVersion(uint8_t* args, uint32_t length); + void cmdTemperature(uint8_t* args, uint32_t length); + SelfTestLinkObservation diagnostics() const; + void resetReceptionStats(uint32_t now); private: static int32_t getOpCodeIndex(uint8_t opCode); @@ -62,6 +67,9 @@ class Parser { TelemetryTime* time{nullptr}; ITelemetryPacketSink* sink{nullptr}; uint8_t source{0}; + mutable portMUX_TYPE diagnosticsMux = portMUX_INITIALIZER_UNLOCKED; + SelfTestLinkObservation observation{}; + uint32_t lastByteMs{0}; uint8_t buffer[MAX_CMD_BUFFER]{}; uint32_t dataIndex = 0; @@ -86,12 +94,14 @@ class Parser { }; enum { - CMD_NUMBER = 5, + CMD_NUMBER = 7, }; using cmd_fn = void (Parser::*)(uint8_t* args, uint32_t length); -const cmd_fn commandFunction[] = {&Parser::cmdRX, &Parser::cmdInfo, &Parser::cmdGNSSLoc, &Parser::cmdGNSSTime, - &Parser::cmdGNSSInfo}; +const cmd_fn commandFunction[] = {&Parser::cmdRX, &Parser::cmdInfo, &Parser::cmdGNSSLoc, + &Parser::cmdGNSSTime, &Parser::cmdGNSSInfo, &Parser::cmdVersion, + &Parser::cmdTemperature}; -const uint8_t cmdIndex[] = {CMD_RX, CMD_INFO, CMD_GNSS_LOC, CMD_GNSS_TIME, CMD_GNSS_INFO}; +const uint8_t cmdIndex[] = {CMD_RX, CMD_INFO, CMD_GNSS_LOC, CMD_GNSS_TIME, + CMD_GNSS_INFO, CMD_VERSION_INFO, CMD_TEMP_INFO}; diff --git a/ground_station/src/telemetry/telemetry.cpp b/ground_station/src/telemetry/telemetry.cpp index f5475b87..ba919ae6 100644 --- a/ground_station/src/telemetry/telemetry.cpp +++ b/ground_station/src/telemetry/telemetry.cpp @@ -7,16 +7,32 @@ #include "console.hpp" #include "crc.hpp" +#include + constexpr uint8_t TASK_TELE_FREQ = 100; void Telemetry::begin() { + uartMutex = xSemaphoreCreateMutex(); + if (uartMutex == nullptr) { + return; + } serial.begin(115200, SERIAL_8N1, rxPin, txPin); parser.init(&data, &info, &location, &time); + controlQueue = xQueueCreate(1, sizeof(SelfTestControl)); initialized = true; xTaskCreate(update, "task_telemetry", 2048, this, 1, nullptr); } +bool Telemetry::setSelfTestOverride(const char* phrase, bool enabled) { + SelfTestControl control{}; + control.active = phrase != nullptr; + control.enabled = enabled; + if (phrase != nullptr) strncpy(control.phrase, phrase, kMaxPhraseLen); + control.generation = ++controlRequested; + return controlQueue != nullptr && xQueueOverwrite(controlQueue, &control) == pdPASS; +} + void Telemetry::setLinkPhrase(const char* phrase, uint32_t length) { memset(linkPhrase, 0, kMaxPhraseLen + 1); memcpy(linkPhrase, phrase, length); @@ -48,28 +64,31 @@ void Telemetry::setMode(transmission_mode_e mode) { } void Telemetry::initLink() { + const auto direction = selfTestControl.active ? RX_DIR : transmissionDirection; + const auto mode = selfTestControl.active ? UNIDIRECTIONAL : transmissionMode; + const char* phrase = selfTestControl.active ? selfTestControl.phrase : reinterpret_cast(linkPhrase); // The receiver MCU can remain active across an ESP reset, so its state is // unknown even during the first initialization. sendDisable(); linkInitialized = false; vTaskDelay(100); - sendSetting(CMD_DIRECTION, transmissionDirection); + sendSetting(CMD_DIRECTION, direction); vTaskDelay(100); - sendSetting(CMD_MODE, transmissionMode); + sendSetting(CMD_MODE, mode); vTaskDelay(100); sendSetting(CMD_PA_GAIN, 0); vTaskDelay(100); - if (linkPhrase[0] != 0) { + if (phrase[0] != 0 && (!selfTestControl.active || selfTestControl.enabled)) { // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) uint8 to char is OK - const uint32_t phraseCrc = crc32(linkPhrase, strlen(reinterpret_cast(linkPhrase))); + const uint32_t phraseCrc = crc32(reinterpret_cast(phrase), strlen(phrase)); sendLinkPhraseCrc(phraseCrc, 4); vTaskDelay(100); // Keep this next to ENABLE. If the receiver became ready partway through // startup, it must still see the intended direction before starting. - sendSetting(CMD_DIRECTION, transmissionDirection); + sendSetting(CMD_DIRECTION, direction); sendEnable(); linkInitialized = true; } @@ -81,6 +100,9 @@ void Telemetry::initLink() { } void Telemetry::exitTesting() { + if (!lockNormalWriter()) { + return; + } testingMsg.header = 0x72; testingMsg.passcode = testingCrc; testingMsg.enable_pyros = 0; @@ -90,9 +112,14 @@ void Telemetry::exitTesting() { vTaskDelay(50); setMode(BIDIRECTIONAL); requestExitTesting = true; + xSemaphoreGive(uartMutex); } void Telemetry::enterTesting() { + if (!lockNormalWriter()) { + return; + } + testingActive = true; testingMsg.header = 0x72; testingMsg.passcode = testingCrc; testingMsg.enable_pyros = 1; @@ -101,9 +128,13 @@ void Telemetry::enterTesting() { sendTXPayload(reinterpret_cast(&testingMsg), 15); vTaskDelay(50); setMode(BIDIRECTIONAL); + xSemaphoreGive(uartMutex); } void Telemetry::triggerEvent(uint8_t event) { + if (!lockNormalWriter()) { + return; + } testingMsg.header = 0x72; testingMsg.passcode = testingCrc; testingMsg.enable_pyros = 1; @@ -112,23 +143,73 @@ void Telemetry::triggerEvent(uint8_t event) { sendTXPayload(reinterpret_cast(&testingMsg), 15); triggerAction = true; triggerActionStart = xTaskGetTickCount(); + xSemaphoreGive(uartMutex); } void Telemetry::update(void* pvParameter) { auto* ref = static_cast(pvParameter); + const uint32_t versionReadStarted = millis(); + uint32_t versionLastRequest = versionReadStarted - 1000U; while (ref->initialized) { TickType_t task_last_tick = xTaskGetTickCount(); + if (xSemaphoreTake(ref->uartMutex, pdMS_TO_TICKS(20)) != pdTRUE) { + continue; + } + if (ref->updateRequested || ref->quarantined) { + if (ref->updateRequested && !ref->updateGranted) { + for (size_t count = 0; count < 512 && ref->serial.available(); ++count) { + ref->parser.process(ref->serial.read()); + } + if (!ref->safeForUpdateLocked()) { + xSemaphoreGive(ref->uartMutex); + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + ref->sendDisable(); + ref->serial.flush(); + ref->linkInitialized = false; + ref->updateGranted = true; + } + xSemaphoreGive(ref->uartMutex); + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + if (ref->controlQueue != nullptr && xQueueReceive(ref->controlQueue, &ref->selfTestControl, 0) == pdPASS) { + ref->newSetting = true; + } + if (ref->newSetting) { ref->newSetting = false; ref->initLink(); + ref->controlApplied = ref->selfTestControl.generation; + } + + // The telemetry MCU waits 4 seconds for GNSS at boot; allow 8 seconds for its reply. + // Explicit requests remain available to self-test. + if (!ref->versionReadDone.load()) { + const uint32_t now = millis(); + if (ref->diagnostics().versionReplies != 0 || now - versionReadStarted >= 8000U) { + ref->versionReadDone = true; + } else if (now - versionLastRequest >= 1000U) { + ref->versionRequested = true; + versionLastRequest = now; + } + } + + if (ref->versionRequested.exchange(false)) { + const uint8_t header[] = {CMD_VERSION_INFO, 0}; + uint8_t request[] = {CMD_VERSION_INFO, 0, crc8(header, sizeof(header))}; + ref->serial.write(request, sizeof(request)); } if (ref->requestExitTesting) { ref->requestExitTesting = false; vTaskDelay(1000); ref->setMode(UNIDIRECTIONAL); + ref->testingActive = false; } if (ref->triggerAction && (ref->triggerActionStart + 1000) < xTaskGetTickCount()) { @@ -141,14 +222,129 @@ void Telemetry::update(void* pvParameter) { ref->sendTXPayload(reinterpret_cast(&ref->testingMsg), 15); } - while (ref->serial.available()) { + for (size_t count = 0; count < 512 && ref->serial.available(); ++count) { ref->parser.process(ref->serial.read()); } + xSemaphoreGive(ref->uartMutex); + vTaskDelayUntil(&task_last_tick, static_cast(1000) / TASK_TELE_FREQ); } } +bool Telemetry::lockNormalWriter() { + if (uartMutex == nullptr || xSemaphoreTake(uartMutex, pdMS_TO_TICKS(1500)) != pdTRUE) { + return false; + } + if (updateRequested || quarantined) { + xSemaphoreGive(uartMutex); + return false; + } + return true; +} + +void Telemetry::disable() { + if (!lockNormalWriter()) { + return; + } + if (linkInitialized) { + sendDisable(); + linkInitialized = false; + } + xSemaphoreGive(uartMutex); +} + +void Telemetry::enable() { + if (!lockNormalWriter()) { + return; + } + if (!linkInitialized && linkPhrase[0] != 0) { + sendEnable(); + linkInitialized = true; + } + xSemaphoreGive(uartMutex); +} + +bool Telemetry::safeForUpdateLocked() const { + const auto packet = data.snapshot(); + const bool recent = (xTaskGetTickCount() - data.getLastUpdateTime()) <= pdMS_TO_TICKS(2000); + const bool airborne = packet.state > 2 && packet.state < 7; + return initialized && !quarantined && !testingActive && !requestExitTesting && !triggerAction && + !(recent && (airborne || packet.testing_mode)); +} + +bool Telemetry::safeForUpdate() { + if (uartMutex == nullptr || xSemaphoreTake(uartMutex, pdMS_TO_TICKS(1500)) != pdTRUE) { + return false; + } + const bool safe = safeForUpdateLocked(); + xSemaphoreGive(uartMutex); + return safe; +} + +bool Telemetry::beginUpdate() { + if (uartMutex == nullptr || xSemaphoreTake(uartMutex, pdMS_TO_TICKS(1500)) != pdTRUE) { + return false; + } + const bool safe = !updateRequested && safeForUpdateLocked(); + if (safe) { + updateGranted = false; + updateRequested = true; + } + xSemaphoreGive(uartMutex); + if (!safe) { + return false; + } + const uint32_t started = millis(); + while (!updateGranted && millis() - started < 2000) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (!updateGranted) { + // Cancellation is serialized with the grant; no delayed grant can escape. + if (xSemaphoreTake(uartMutex, pdMS_TO_TICKS(1500)) == pdTRUE) { + updateRequested = false; + updateGranted = false; + newSetting = true; + xSemaphoreGive(uartMutex); + } + return false; + } + return true; +} + +void Telemetry::configureUpdateUart(bool rom) { + /* HardwareSerial::begin() updates data bits/parity/stop bits in place when + * the UART is already running. Keeping the pins attached prevents a low + * pulse on TX from poisoning the STM32 ROM's autobaud detection. */ + serial.begin(115200, rom ? SERIAL_8E1 : SERIAL_8N1, rxPin, txPin); +} + +void Telemetry::finishUpdate(bool healthy, const char* verifiedVersion) { + if (xSemaphoreTake(uartMutex, pdMS_TO_TICKS(1500)) != pdTRUE) { + quarantined = true; + updateRequested = false; + return; + } + // The normal task has acknowledged and no longer touches the UART/parser. + configureUpdateUart(false); + parser.reset(); + if (healthy && verifiedVersion != nullptr) { + // Publish the verified reply before storage is shared again. The updater + // consumed it while the normal parser was paused; startup reads are over. + uint8_t version[16]{}; + const size_t length = std::min(strlen(verifiedVersion), sizeof(version)); + memcpy(version, verifiedVersion, length); + parser.cmdVersion(version, length); + versionReadDone = true; + } + linkInitialized = false; + quarantined = !healthy; + newSetting = healthy; + updateGranted = false; + updateRequested = false; + xSemaphoreGive(uartMutex); +} + // NOLINTNEXTLINE(readability-convert-member-functions-to-static) uses serial void Telemetry::sendLinkPhraseCrc(uint32_t crc, uint32_t length) { uint8_t out[7]; // 1 OP + 1 LEN + 4 DATA + 1 CRC diff --git a/ground_station/src/telemetry/telemetry.hpp b/ground_station/src/telemetry/telemetry.hpp index 5b476636..eb270f87 100644 --- a/ground_station/src/telemetry/telemetry.hpp +++ b/ground_station/src/telemetry/telemetry.hpp @@ -4,6 +4,8 @@ #pragma once +#include + #include "config.hpp" #include "parser.hpp" #include "telemetryData.hpp" @@ -28,20 +30,24 @@ class Telemetry { void exitTesting(); void enterTesting(); void triggerEvent(uint8_t event); - - void disable() { - if (linkInitialized) { - sendDisable(); - linkInitialized = false; - } - } - - void enable() { - if (!linkInitialized) { - sendEnable(); - linkInitialized = true; - } - } + bool setSelfTestOverride(const char* phrase, bool enabled = true); + bool selfTestReady() const { return controlApplied.load() == controlRequested.load(); } + void requestVersion() { versionRequested = true; } + bool versionReadComplete() const { return versionReadDone.load(); } + SelfTestLinkObservation diagnostics() const { return parser.diagnostics(); } + void resetReceptionStats(uint32_t now) { parser.resetReceptionStats(now); } + + void disable(); + void enable(); + + // Acknowledged handoff: only the update worker may use this UART until + // finishUpdate(). No task suspension while a parser/setting write is active. + bool safeForUpdate(); + bool beginUpdate(); + void finishUpdate(bool healthy, const char* verifiedVersion = nullptr); + HardwareSerial& updateSerial() { return serial; } + void configureUpdateUart(bool rom); + bool isQuarantined() const { return quarantined; } // NOLINTBEGIN(cppcoreguidelines-non-private-member-variables-in-classes) TelemetryData data{}; @@ -52,6 +58,25 @@ class Telemetry { private: HardwareSerial serial; + struct SelfTestControl { + char phrase[kMaxPhraseLen + 1]{}; + bool active{false}; + bool enabled{true}; + uint32_t generation{0}; + }; + SelfTestControl selfTestControl{}; + QueueHandle_t controlQueue{nullptr}; + std::atomic controlRequested{0}; + std::atomic controlApplied{0}; + std::atomic versionRequested{false}; + std::atomic versionReadDone{false}; + SemaphoreHandle_t uartMutex{nullptr}; + std::atomic updateRequested{false}; + std::atomic updateGranted{false}; + std::atomic quarantined{false}; + bool testingActive{false}; + bool safeForUpdateLocked() const; + bool lockNormalWriter(); void initLink(); diff --git a/ground_station/src/telemetry/telemetry_reg.hpp b/ground_station/src/telemetry/telemetry_reg.hpp index fd809166..9e2c3a40 100644 --- a/ground_station/src/telemetry/telemetry_reg.hpp +++ b/ground_station/src/telemetry/telemetry_reg.hpp @@ -30,7 +30,9 @@ enum transmission_mode_e { #define CMD_RX 0x31 #define CMD_INFO 0x32 -#define CMD_GNSS_LOC 0x40 -#define CMD_GNSS_TIME 0x41 -#define CMD_GNSS_INFO 0x42 +#define CMD_GNSS_LOC 0x40 +#define CMD_GNSS_TIME 0x41 +#define CMD_GNSS_INFO 0x42 +#define CMD_TEMP_INFO 0x50 +#define CMD_VERSION_INFO 0x60 // NOLINTEND(cppcoreguidelines-macro-usage) diff --git a/ground_station/src/update/radio_firmware_updater.cpp b/ground_station/src/update/radio_firmware_updater.cpp new file mode 100644 index 00000000..89a96cca --- /dev/null +++ b/ground_station/src/update/radio_firmware_updater.cpp @@ -0,0 +1,322 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later + +#include "radio_firmware_updater.hpp" + +#include +#include + +#include "console.hpp" + +namespace RadioUpdate { +namespace { +constexpr const char* kDirectory = "/telemetry_firmware"; + +class FileSource final : public Source { + public: + explicit FileSource(File& file) : file(file) {} + uint32_t size() override { return file.size(); } + bool read(uint32_t offset, uint8_t* data, size_t length) override { + return file.seek(offset) && file.read(data, length) == static_cast(length); + } + + private: + File& file; +}; + +class UartPort final : public Port { + public: + UartPort(Telemetry& link, RadioFirmwareUpdater& updater) : link(link), updater(updater) {} + bool write(const uint8_t* data, size_t size) override { + // ESP-IDF bounds the physical TX drain as well as the RX waits below. + return link.updateSerial().write(data, size) == size; + } + bool read(uint8_t* data, size_t size, uint32_t timeoutMs) override { + const uint32_t started = millis(); + size_t received = 0; + while (received < size && millis() - started < timeoutMs) { + const int byte = link.updateSerial().read(); + if (byte >= 0) { + data[received++] = static_cast(byte); + } else { + vTaskDelay(pdMS_TO_TICKS(1)); + } + } + return received == size; + } + void configure(bool rom) override { link.configureUpdateUart(rom); } + void drain() override { + for (size_t count = 0; count < 1024 && link.updateSerial().available(); ++count) { + (void)link.updateSerial().read(); + } + } + void wait(uint32_t ms) override { vTaskDelay(pdMS_TO_TICKS(ms)); } + uint32_t now() override { return millis(); } + void progress(Phase phase, uint8_t percent) override { updater.progress(phase, percent); } + + private: + Telemetry& link; + RadioFirmwareUpdater& updater; +}; +} // namespace + +bool RadioFirmwareUpdater::begin() { + queue = xQueueCreate(1, sizeof(Request)); + if (queue == nullptr) { + return false; + } + if (xTaskCreate(worker, "radio_update", 6144, this, 1, &task) != pdPASS) { + vQueueDelete(queue); + queue = nullptr; + return false; + } + return true; +} + +Snapshot RadioFirmwareUpdater::snapshot() const { + portENTER_CRITICAL(&stateMux); + const Snapshot result = state; + portEXIT_CRITICAL(&stateMux); + return result; +} + +bool RadioFirmwareUpdater::filename(size_t index, char* destination, size_t capacity) const { + portENTER_CRITICAL(&stateMux); + const bool valid = !state.busy && state.phase == Phase::Browsing && index < catalog.size() && capacity > 0; + if (valid) { + strncpy(destination, catalog[index].value, capacity - 1); + destination[capacity - 1] = '\0'; + } + portEXIT_CRITICAL(&stateMux); + return valid; +} + +bool RadioFirmwareUpdater::submit(const Request& request, Phase phase) { + portENTER_CRITICAL(&stateMux); + const bool allowed = queue != nullptr && !state.busy && + (request.action != Action::Program || state.phase == Phase::Confirm) && + (request.action != Action::Select || state.phase == Phase::Browsing); + if (allowed) { + if (request.action == Action::Browse) { + const uint32_t revision = state.revision; + state = Snapshot{}; + state.revision = revision; + } + state.busy = true; + state.phase = phase; + state.error[0] = '\0'; + ++state.revision; + } + portEXIT_CRITICAL(&stateMux); + if (!allowed) { + return false; + } + // State changes BEFORE enqueueing; the UI cannot mistake the previous result + // for this request's completion. One outstanding request owns all resources. + if (xQueueSend(queue, &request, 0) != pdPASS) { + finish(Phase::Failed, "Update queue unavailable"); + return false; + } + return true; +} + +bool RadioFirmwareUpdater::browse() { return submit(Request{}, Phase::Preparing); } +bool RadioFirmwareUpdater::program() { return submit(Request{Action::Program, {}}, Phase::Preparing); } +bool RadioFirmwareUpdater::close() { return submit(Request{Action::Close, {}}, Phase::Closing); } + +bool RadioFirmwareUpdater::select(size_t index) { + Request request{Action::Select, {}}; + return filename(index, request.filename, sizeof(request.filename)) && submit(request, Phase::Validating); +} + +void RadioFirmwareUpdater::progress(Phase phase, uint8_t percent) { + portENTER_CRITICAL(&stateMux); + if (state.phase != phase || state.percent != percent) { + state.phase = phase; + state.percent = percent; + ++state.revision; + } + portEXIT_CRITICAL(&stateMux); +} + +void RadioFirmwareUpdater::finish(Phase phase, const char* error) { + portENTER_CRITICAL(&stateMux); + state.phase = phase; + state.busy = false; + strncpy(state.error, error, sizeof(state.error) - 1); + state.stackFreeBytes = uxTaskGetStackHighWaterMark(nullptr); + ++state.revision; + portEXIT_CRITICAL(&stateMux); +} + +void RadioFirmwareUpdater::release() { + file.close(); + if (storageOwned) { + Utils::endRadioUpdateStorage(); + storageOwned = false; + } + if (recorderPaused) { + recorder.resumeAfterRadioUpdate(); + recorderPaused = false; + } +} + +void RadioFirmwareUpdater::scan() { + release(); + catalog.clear(); + if (!Utils::isFilesystemAvailable()) { + finish(Phase::Failed, "Safely eject USB drive, then retry"); + return; + } + if (!links[0]->safeForUpdate() || !links[1]->safeForUpdate() || !recorder.pauseForRadioUpdate()) { + finish(Phase::Failed, "Need idle recorder and safe, non-testing links"); + return; + } + recorderPaused = true; + storageOwned = Utils::beginRadioUpdateStorage(); + if (!storageOwned) { + release(); + finish(Phase::Failed, "Safely eject USB drive, then retry"); + return; + } + auto directory = fatfs.open(kDirectory); + const char* error = ""; + if (!directory || !directory.isDirectory()) { + error = "Copy .bin files into /telemetry_firmware"; + } else { + auto entry = directory.openNextFile(); + while (entry) { + // FatFs long names can exceed our bound. Never silently select a truncated name. + char name[256]{}; + const size_t length = entry.getName(name, sizeof(name)); + if (!entry.isDirectory() && length > 4) { + const char* suffix = name + length - 4; + const bool binSuffix = suffix[0] == '.' && (suffix[1] == 'b' || suffix[1] == 'B') && + (suffix[2] == 'i' || suffix[2] == 'I') && (suffix[3] == 'n' || suffix[3] == 'N'); + if (binSuffix && length >= kNameSize) { + error = "Firmware filename too long (max 127)"; + } else if (isBinName(name)) { + if (catalog.size() == kMaxFiles) { + error = "More than 64 .bin files; remove extras"; + } else { + Filename item{}; + memcpy(item.value, name, length + 1); + catalog.push_back(item); + } + } + } + entry.close(); + if (error[0] != '\0') { + break; + } + entry = directory.openNextFile(); + } + } + directory.close(); + if (error[0] != '\0' || catalog.empty()) { + release(); + finish(Phase::Failed, error[0] != '\0' ? error : "No .bin files in /telemetry_firmware"); + return; + } + std::sort(catalog.begin(), catalog.end(), + [](const Filename& a, const Filename& b) { return strcmp(a.value, b.value) < 0; }); + portENTER_CRITICAL(&stateMux); + state.fileCount = catalog.size(); + portEXIT_CRITICAL(&stateMux); + finish(Phase::Browsing); +} + +void RadioFirmwareUpdater::open(const char* name) { + file.close(); + char path[kNameSize + 24]{}; + snprintf(path, sizeof(path), "%s/%s", kDirectory, name); + file = fatfs.open(path, FILE_READ); + FileSource source(file); + ImageInfo info{}; + if (!storageOwned || !file || file.isDirectory() || !isBinName(name) || !inspect(source, info)) { + release(); + finish(Phase::Failed, "Invalid .bin size, vectors, or file read"); + return; + } + portENTER_CRITICAL(&stateMux); + strncpy(state.filename, name, sizeof(state.filename) - 1); + state.image = info; + portEXIT_CRITICAL(&stateMux); + finish(Phase::Confirm); +} + +void RadioFirmwareUpdater::updateBoth() { + const ImageInfo expected = snapshot().image; + bool held[2]{}; + LinkResult results[2]{}; + const char* error = ""; + if (!storageOwned || !recorderPaused || !file || !links[0]->safeForUpdate() || !links[1]->safeForUpdate()) { + error = "Safety state changed; update not started"; + } else { + held[0] = links[0]->beginUpdate(); + held[1] = held[0] && links[1]->beginUpdate(); + if (!held[1] || !links[0]->safeForUpdate() || !links[1]->safeForUpdate()) { + error = "UART handoff/safety check failed"; + } else { + FileSource source(file); + for (size_t index = 0; index < 2; ++index) { + portENTER_CRITICAL(&stateMux); + state.link = static_cast(index + 1); + state.percent = 0; + ++state.revision; + portEXIT_CRITICAL(&stateMux); + UartPort port(*links[index], *this); + RomBootloader bootloader(port); + const bool success = bootloader.run(source, expected, results[index]); + portENTER_CRITICAL(&stateMux); + state.results[index] = results[index]; + portEXIT_CRITICAL(&stateMux); + if (!success) { + error = bootloader.error(); + break; + } + } + } + } + for (size_t index = 0; index < 2; ++index) { + if (held[index]) { + // A missing entry ACK is ambiguous: that application may already be in + // ROM. Quarantine it, while restoring untouched and verified links. + links[index]->finishUpdate(!results[index].entryRequested || results[index].success, + results[index].success ? results[index].version : nullptr); + } + } + release(); + finish(error[0] == '\0' ? Phase::Complete : Phase::Failed, error); + const auto status = snapshot(); + console.log.printf("[RADIO UPDATE] %s; link1=%s link2=%s; ROM=%02x/%02x; stack free=%lu bytes\n", + phaseName(status.phase), results[0].version, results[1].version, results[0].romRevision, + results[1].romRevision, static_cast(status.stackFreeBytes)); +} + +void RadioFirmwareUpdater::worker(void* parameter) { + auto* self = static_cast(parameter); + Request request{}; + for (;;) { + if (xQueueReceive(self->queue, &request, portMAX_DELAY) != pdPASS) { + continue; + } + switch (request.action) { + case Action::Browse: + self->scan(); + break; + case Action::Select: + self->open(request.filename); + break; + case Action::Program: + self->updateBoth(); + break; + case Action::Close: + self->release(); + self->finish(Phase::Idle); + break; + } + } +} + +} // namespace RadioUpdate diff --git a/ground_station/src/update/radio_firmware_updater.hpp b/ground_station/src/update/radio_firmware_updater.hpp new file mode 100644 index 00000000..d51d4a2f --- /dev/null +++ b/ground_station/src/update/radio_firmware_updater.hpp @@ -0,0 +1,69 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include "logging/recorder.hpp" +#include "rom_bootloader.hpp" +#include "telemetry/telemetry.hpp" + +namespace RadioUpdate { + +struct Snapshot { + Phase phase{Phase::Idle}; + bool busy{false}; + uint32_t revision{0}; + char filename[kNameSize]{}; + ImageInfo image{}; + size_t fileCount{0}; + uint8_t link{0}; + uint8_t percent{0}; + LinkResult results[2]{}; + char error[96]{}; + uint32_t stackFreeBytes{0}; +}; + +class RadioFirmwareUpdater { + public: + RadioFirmwareUpdater(Recorder& recorder, Telemetry& first, Telemetry& second) + : recorder(recorder), links{&first, &second} {} + bool begin(); + bool browse(); + bool select(size_t index); + bool program(); + bool close(); + Snapshot snapshot() const; + bool filename(size_t index, char* destination, size_t capacity) const; + void progress(Phase phase, uint8_t percent); + + private: + enum class Action : uint8_t { Browse, Select, Program, Close }; + struct Request { + Action action{Action::Browse}; + char filename[kNameSize]{}; + }; + struct Filename { + char value[kNameSize]{}; + }; + Recorder& recorder; + Telemetry* links[2]; + QueueHandle_t queue{nullptr}; + TaskHandle_t task{nullptr}; + mutable portMUX_TYPE stateMux = portMUX_INITIALIZER_UNLOCKED; + Snapshot state{}; + std::vector catalog{}; + File file{}; + bool recorderPaused{false}; + bool storageOwned{false}; + bool submit(const Request& request, Phase phase); + void scan(); + void open(const char* name); + void updateBoth(); + void release(); + void finish(Phase phase, const char* error = ""); + static void worker(void* parameter); +}; + +} // namespace RadioUpdate diff --git a/ground_station/src/update/rom_bootloader.cpp b/ground_station/src/update/rom_bootloader.cpp new file mode 100644 index 00000000..b1b6f615 --- /dev/null +++ b/ground_station/src/update/rom_bootloader.cpp @@ -0,0 +1,405 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later + +#include "rom_bootloader.hpp" + +#include +#include + +#include "telemetry/crc.hpp" + +namespace RadioUpdate { +namespace { +constexpr uint8_t kAck = 0x79; +constexpr uint8_t kVersionCommand = 0x60; +constexpr uint8_t kBootCommand = 0x80; + +uint32_t littleWord(const uint8_t* bytes) { + return uint32_t{bytes[0]} | (uint32_t{bytes[1]} << 8U) | (uint32_t{bytes[2]} << 16U) | (uint32_t{bytes[3]} << 24U); +} + +bool sameImage(const ImageInfo& lhs, const ImageInfo& rhs) { + return lhs.size == rhs.size && lhs.crc == rhs.crc && memcmp(lhs.vectors, rhs.vectors, 8) == 0; +} +} // namespace + +uint32_t crcUpdate(uint32_t state, const uint8_t* data, size_t size) { + for (size_t i = 0; i < size; ++i) { + state ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + state = (state >> 1U) ^ ((state & 1U) != 0U ? 0xEDB88320U : 0U); + } + } + return state; +} + +bool validVectors(const uint8_t* vectors, uint32_t size) { + const uint32_t sp = littleWord(vectors); + const uint32_t reset = littleWord(vectors + 4); + return size >= kBlockSize && size <= kFlashSize && (sp & 7U) == 0U && sp >= 0x20000000U && sp <= 0x20009000U && + (reset & 1U) != 0U && (reset & ~1U) >= kFlashBase && (reset & ~1U) < kFlashBase + size; +} + +bool isBinName(const char* name) { + if (name == nullptr) { + return false; + } + const size_t length = strlen(name); + if (length <= 4 || length >= kNameSize || strchr(name, '/') != nullptr || strchr(name, '\\') != nullptr) { + return false; + } + const char* suffix = name + length - 4; + return suffix[0] == '.' && (suffix[1] == 'b' || suffix[1] == 'B') && (suffix[2] == 'i' || suffix[2] == 'I') && + (suffix[3] == 'n' || suffix[3] == 'N'); +} + +bool inspect(Source& source, ImageInfo& info) { + info = ImageInfo{}; + info.size = source.size(); + if (info.size < kBlockSize || info.size > kFlashSize || !source.read(0, info.vectors, 8) || + !validVectors(info.vectors, info.size)) { + return false; + } + uint8_t block[kBlockSize]{}; + uint32_t crc = ~0U; + for (uint32_t offset = 0; offset < info.size; offset += sizeof(block)) { + const size_t count = std::min(sizeof(block), info.size - offset); + if (!source.read(offset, block, count)) { + return false; + } + crc = crcUpdate(crc, block, count); + } + info.crc = crc ^ ~0U; + return source.size() == info.size; +} + +const char* phaseName(Phase phase) { + switch (phase) { + case Phase::Idle: + return "Idle"; + case Phase::Preparing: + return "Preparing"; + case Phase::Browsing: + return "Choose firmware"; + case Phase::Validating: + return "Checking file"; + case Phase::Confirm: + return "Confirm update"; + case Phase::Entering: + return "Entering bootloader"; + case Phase::Identifying: + return "Checking device"; + case Phase::Erasing: + return "Erasing"; + case Phase::Writing: + return "Writing"; + case Phase::Verifying: + return "Verifying"; + case Phase::Activating: + return "Activating"; + case Phase::Starting: + return "Starting radio"; + case Phase::Complete: + return "Both radios updated"; + case Phase::Failed: + return "Update stopped"; + case Phase::Closing: + return "Restoring operation"; + } + return "Unknown"; +} + +bool RomBootloader::fail(const char* message) { + failure = message; + return false; +} + +bool RomBootloader::ack(uint32_t timeoutMs) { + uint8_t byte = 0; + return port.read(&byte, 1, timeoutMs) && byte == kAck; +} + +bool RomBootloader::command(uint8_t value) { + const uint8_t bytes[] = {value, static_cast(value ^ 0xFFU)}; + return port.write(bytes, sizeof(bytes)) && ack(); +} + +bool RomBootloader::address(uint32_t value) { + uint8_t bytes[] = {static_cast(value >> 24U), static_cast(value >> 16U), + static_cast(value >> 8U), static_cast(value), 0}; + bytes[4] = bytes[0] ^ bytes[1] ^ bytes[2] ^ bytes[3]; + return port.write(bytes, sizeof(bytes)) && ack(); +} + +bool RomBootloader::frame(uint8_t expectedCommand, uint8_t* payload, size_t capacity, size_t& size, + uint32_t timeoutMs) { + uint8_t buffer[67]{}; + size_t used = 0; + const uint32_t started = port.now(); + while (port.now() - started < timeoutMs) { + uint8_t byte = 0; + if (!port.read(&byte, 1, 20)) { + used = 0; + continue; + } + if (used == 0 && byte != expectedCommand) { + continue; + } + buffer[used++] = byte; + if (used == 2 && (byte == 0 || byte > capacity || byte > sizeof(buffer) - 3)) { + used = 0; + } else if (used >= 3 && used == static_cast(buffer[1]) + 3) { + if (crc8(buffer, used - 1) == byte) { + size = buffer[1]; + memcpy(payload, buffer + 2, size); + return true; + } + used = 0; + } + } + return false; +} + +bool RomBootloader::enter(LinkResult& result) { + port.progress(Phase::Entering, 0); + port.configure(false); + port.drain(); + uint8_t disable[] = {0x21, 0, 0}; + disable[2] = crc8(disable, 2); + if (!port.write(disable, sizeof(disable))) { + return fail("UART write failed"); + } + port.wait(100); + port.drain(); + uint8_t request[] = {kBootCommand, 0, 0}; + request[2] = crc8(request, 2); + result.entryRequested = true; // An ACK can be lost after the application has jumped. + uint8_t response[2]{}; + size_t length = 0; + if (!port.write(request, sizeof(request)) || !frame(kBootCommand, response, 2, length, 1000) || length != 2 || + response[0] != 1 || response[1] != kAck) { + return fail("No entry ACK; ST-Link bootstrap/recovery"); + } + port.wait(50); + port.configure(true); + port.drain(); + constexpr uint8_t sync = 0x7F; + for (uint8_t attempt = 0; attempt < 3; ++attempt) { + if (port.write(&sync, 1) && ack(500)) { + return true; + } + port.wait(50); + port.drain(); + } + return fail("ROM sync failed; ST-Link recovery"); +} + +bool RomBootloader::readMemory(uint32_t location, uint8_t* data, size_t size) { + if (size == 0 || size > kBlockSize || !command(0x11) || !address(location)) { + return false; + } + const auto n = static_cast(size - 1); + const uint8_t bytes[] = {n, static_cast(n ^ 0xFFU)}; + return port.write(bytes, sizeof(bytes)) && ack() && port.read(data, size, 1000); +} + +bool RomBootloader::identify(LinkResult& result) { + port.progress(Phase::Identifying, 2); + uint8_t count = 0; + uint8_t capabilities[32]{}; + if (!command(0x00) || !port.read(&count, 1, 1000) || count >= sizeof(capabilities) || + !port.read(capabilities, static_cast(count) + 1, 1000) || !ack() || capabilities[0] != 0x31) { + return fail("Unsupported ROM capabilities"); + } + const auto has = [&](uint8_t value) { + return std::find(capabilities + 1, capabilities + count + 1, value) != capabilities + count + 1; + }; + extendedErase = has(0x44); + if (!has(0x01) || !has(0x02) || !has(0x11) || !has(0x21) || !has(0x31) || (!extendedErase && !has(0x43))) { + return fail("ROM commands unavailable/protected"); + } + uint8_t versionInfo[3]{}; + if (!command(0x01) || !port.read(versionInfo, 3, 1000) || !ack() || versionInfo[0] != 0x31) { + return fail("Unsupported ROM protocol version"); + } + uint8_t id[3]{}; + if (!command(0x02) || !port.read(id, 3, 1000) || !ack() || id[0] != 1 || id[1] != 4 || id[2] != 0x60) { + return fail("Wrong MCU; expected STM32G071/081"); + } + uint8_t geometry[2]{}; + if (!readMemory(0x1FFF75E0U, geometry, 2) || geometry[0] != 128 || geometry[1] != 0) { + return fail("Wrong or unreadable flash size"); + } + if (!readMemory(0x1FFF6FFEU, &result.romRevision, 1) || result.romRevision < 0xB1 || result.romRevision > 0xB4) { + return fail("Unsupported ROM revision (need B1-B4)"); + } + // RM0444: option words are spaced eight bytes apart (word + complement). + // Read only: never issue write/read unprotect or change option bytes. + uint8_t options[56]{}; + uint8_t security[4]{}; + if (!readMemory(0x1FFF7800U, options, sizeof(options)) || !readMemory(0x1FFF7870U, security, 4)) { + return fail("Cannot read protection settings"); + } + const auto wrpActive = [&](size_t offset) { return (options[offset] & 0x3FU) <= (options[offset + 2] & 0x3FU); }; + if (options[0] != 0xAA || wrpActive(24) || wrpActive(32) || options[8] <= options[16] || options[40] <= options[48] || + (security[0] & 0x7FU) != 0) { + return fail("Protected flash; separate provisioning needed"); + } + return true; +} + +bool RomBootloader::erasePage(uint16_t page) { + if (!command(extendedErase ? 0x44 : 0x43)) { + return false; + } + uint8_t extended[] = {0, 0, static_cast(page >> 8U), static_cast(page), 0}; + extended[4] = extended[2] ^ extended[3]; + const uint8_t basic[] = {0, static_cast(page), static_cast(page)}; + const bool sent = extendedErase ? port.write(extended, sizeof(extended)) : port.write(basic, sizeof(basic)); + if (!sent || !ack(5000)) { + return false; + } + // AN2606: B3 does not wait for erase BUSY. Never batch pages; wait >=40ms. + port.wait(40); + uint8_t block[kBlockSize]{}; + for (uint32_t offset = 0; offset < kPageSize; offset += sizeof(block)) { + if (!readMemory(kFlashBase + uint32_t{page} * kPageSize + offset, block, sizeof(block)) || + !std::all_of(block, block + sizeof(block), [](uint8_t value) { return value == 0xFF; })) { + return false; + } + } + return true; +} + +bool RomBootloader::writeMemory(uint32_t location, const uint8_t* data, size_t size) { + if (size == 0 || size > kBlockSize || (size & 7U) != 0 || (location & 7U) != 0 || !command(0x31) || + !address(location)) { + return false; + } + uint8_t buffer[kBlockSize + 2]{}; + buffer[0] = static_cast(size - 1); + memcpy(buffer + 1, data, size); + uint8_t checksum = buffer[0]; + for (size_t index = 0; index < size; ++index) { + checksum ^= data[index]; + } + buffer[size + 1] = checksum; + if (!port.write(buffer, size + 2)) { + return false; + } + if (!ack()) { + // A lost final ACK may still have programmed the double words. Never write + // them again: G0 ECC programming is not safely repeatable without erase. + port.wait(40); + port.drain(); + } + uint8_t actual[kBlockSize]{}; + return readMemory(location, actual, size) && memcmp(actual, data, size) == 0; +} + +bool RomBootloader::writeRange(Source& source, uint32_t start, uint32_t end, uint8_t low, uint8_t high) { + uint8_t bytes[kBlockSize]{}; + for (uint32_t offset = start; offset < end; offset += sizeof(bytes)) { + const size_t length = std::min(sizeof(bytes), end - offset); + memset(bytes, 0xFF, sizeof(bytes)); + if (!source.read(offset, bytes, length) || !writeMemory(kFlashBase + offset, bytes, (length + 7U) & ~7U)) { + return false; + } + port.progress(Phase::Writing, static_cast(low + (high - low) * (offset + length - start) / (end - start))); + } + return true; +} + +bool RomBootloader::verify(Source& source, const ImageInfo& info, bool activated) { + uint8_t expected[kBlockSize]{}; + uint8_t actual[kBlockSize]{}; + uint32_t crc = ~0U; + const uint32_t start = activated ? 0 : 8; + if (!activated) { + crc = crcUpdate(crc, info.vectors, 8); + if (!readMemory(kFlashBase, actual, 8) || + !std::all_of(actual, actual + 8, [](uint8_t byte) { return byte == 0xFF; })) { + return false; + } + } + for (uint32_t offset = start; offset < info.size; offset += sizeof(actual)) { + const size_t count = std::min(sizeof(actual), info.size - offset); + if (!source.read(offset, expected, count) || !readMemory(kFlashBase + offset, actual, count) || + memcmp(expected, actual, count) != 0) { + return false; + } + crc = crcUpdate(crc, actual, count); + const uint8_t low = activated ? 90 : 72; + port.progress(Phase::Verifying, static_cast(low + 8U * (offset + count) / info.size)); + } + return source.size() == info.size && (crc ^ ~0U) == info.crc; +} + +bool RomBootloader::version(char* output, size_t capacity) { + const uint32_t started = port.now(); + uint8_t request[] = {kVersionCommand, 0, 0}; + request[2] = crc8(request, 2); + uint8_t payload[31]{}; + while (port.now() - started < 8000) { + size_t size = 0; + if (!port.write(request, sizeof(request))) { + return false; + } + if (frame(kVersionCommand, payload, sizeof(payload), size, 200)) { + if (size > 0 && payload[size - 1] == 0) { + --size; + } + if (size != 0 && size < capacity && + std::all_of(payload, payload + size, [](uint8_t byte) { return byte >= 0x20 && byte <= 0x7E; })) { + memcpy(output, payload, size); + output[size] = '\0'; + return true; + } + } + } + return false; +} + +bool RomBootloader::run(Source& source, const ImageInfo& expected, LinkResult& result) { + result = LinkResult{}; + result.attempted = true; + failure = ""; + ImageInfo current{}; + if (!inspect(source, current) || !sameImage(expected, current)) { + return fail("Source file changed or invalid"); + } + if (!enter(result) || !identify(result)) { + return false; + } + const uint32_t pages = (expected.size + kPageSize - 1) / kPageSize; + for (uint32_t page = 0; page < pages; ++page) { + port.progress(Phase::Erasing, static_cast(5 + 15U * page / pages)); + result.destructive = true; // Set BEFORE sending the first erase command. + if (!erasePage(static_cast(page))) { + return fail("Erase/blank check failed; ST-Link recovery"); + } + } + if (!writeRange(source, 256, expected.size, 20, 68) || !writeRange(source, 8, 256, 68, 72)) { + return fail("Write/readback failed; ST-Link recovery"); + } + if (!verify(source, expected, false) || !inspect(source, current) || !sameImage(expected, current)) { + return fail("Verification/source changed; ST-Link recovery"); + } + port.progress(Phase::Activating, 85); + if (!writeMemory(kFlashBase, expected.vectors, 8) || !verify(source, expected, true)) { + return fail("Activation/verification failed; ST-Link recovery"); + } + port.progress(Phase::Starting, 99); + if (!command(0x21) || !address(kFlashBase)) { + return fail("Application start failed; ST-Link recovery"); + } + port.configure(false); + port.drain(); + if (!version(result.version, sizeof(result.version))) { + return fail("No application version; ST-Link recovery"); + } + result.success = true; + return true; +} + +} // namespace RadioUpdate diff --git a/ground_station/src/update/rom_bootloader.hpp b/ground_station/src/update/rom_bootloader.hpp new file mode 100644 index 00000000..1a65947b --- /dev/null +++ b/ground_station/src/update/rom_bootloader.hpp @@ -0,0 +1,104 @@ +/// Copyright (C) 2026 Control and Telemetry Systems GmbH +/// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace RadioUpdate { + +constexpr uint32_t kFlashBase = 0x08000000U; +constexpr uint32_t kFlashSize = 128U * 1024U; +constexpr uint32_t kPageSize = 2048U; +constexpr size_t kBlockSize = 256U; +constexpr size_t kNameSize = 128U; +constexpr size_t kMaxFiles = 64U; + +enum class Phase : uint8_t { + Idle, + Preparing, + Browsing, + Validating, + Confirm, + Entering, + Identifying, + Erasing, + Writing, + Verifying, + Activating, + Starting, + Complete, + Failed, + Closing +}; + +struct ImageInfo { + uint32_t size{0}; + uint32_t crc{0}; + uint8_t vectors[8]{}; +}; + +struct LinkResult { + bool attempted{false}; + bool success{false}; + bool entryRequested{false}; + bool destructive{false}; + uint8_t romRevision{0}; + char version[32]{}; +}; + +// Only the byte transport and file reads are substituted in native tests. The +// production protocol, validation, erase/write ordering and verification run unchanged. +class Port { + public: + virtual ~Port() = default; + virtual bool write(const uint8_t* data, size_t size) = 0; + virtual bool read(uint8_t* data, size_t size, uint32_t timeoutMs) = 0; + virtual void configure(bool rom) = 0; + virtual void drain() = 0; + virtual void wait(uint32_t ms) = 0; + virtual uint32_t now() = 0; + virtual void progress(Phase phase, uint8_t percent) = 0; +}; + +class Source { + public: + virtual ~Source() = default; + virtual uint32_t size() = 0; + virtual bool read(uint32_t offset, uint8_t* data, size_t length) = 0; +}; + +uint32_t crcUpdate(uint32_t state, const uint8_t* data, size_t size); +bool validVectors(const uint8_t* vectors, uint32_t size); +bool isBinName(const char* name); +bool inspect(Source& source, ImageInfo& info); +const char* phaseName(Phase phase); + +class RomBootloader { + public: + explicit RomBootloader(Port& port) : port(port) {} + // No retries of an entire update and no automatic reset/recovery after failure. + bool run(Source& source, const ImageInfo& expected, LinkResult& result); + const char* error() const { return failure; } + + private: + Port& port; + bool extendedErase{false}; + const char* failure{""}; + bool fail(const char* message); + bool ack(uint32_t timeoutMs = 1000); + bool command(uint8_t value); + bool address(uint32_t value); + bool enter(LinkResult& result); + bool identify(LinkResult& result); + bool readMemory(uint32_t address, uint8_t* data, size_t size); + bool writeMemory(uint32_t address, const uint8_t* data, size_t size); + bool erasePage(uint16_t page); + bool writeRange(Source& source, uint32_t start, uint32_t end, uint8_t low, uint8_t high); + bool verify(Source& source, const ImageInfo& info, bool activated); + bool version(char* output, size_t capacity); + bool frame(uint8_t command, uint8_t* payload, size_t capacity, size_t& size, uint32_t timeoutMs); +}; + +} // namespace RadioUpdate diff --git a/ground_station/src/utils.cpp b/ground_station/src/utils.cpp index 2397fcfe..3f69f762 100644 --- a/ground_station/src/utils.cpp +++ b/ground_station/src/utils.cpp @@ -16,6 +16,8 @@ #include "systemParser.hpp" #include "telemetry/telemetry.hpp" +#include + // clang-format off: diskio.h uses the FatFs types declared by ff.h. // NOLINTNEXTLINE(llvm-include-order) #include "ff.h" @@ -27,6 +29,7 @@ #include #include +#include #include constexpr uint8_t BOOT_BUTTON = 0; @@ -54,12 +57,65 @@ static volatile UsbConnectionState usbConnectionState = UsbConnectionState::Disc static volatile UsbStorageState usbStorageState = UsbStorageState::FirmwareOwned; static volatile bool filesystemMounted = true; static SemaphoreHandle_t storageAccessMutex = nullptr; +static std::atomic radioUpdateStorage{false}; +static std::atomic successfulUsbReads{0}; USBMSC usb_msc; Adafruit_FlashTransport_ESP32 flashTransport; Adafruit_SPIFlash flash(&flashTransport); FatFileSystem fatfs; +extern Telemetry link1; +extern Telemetry link2; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) +// Wait for startup reads without holding the storage mutex, so recording can cancel USB preparation. +static bool readCachedVersions(JsonDocument &versions) { + Telemetry *links[] = {&link1, &link2}; + constexpr const char *keys[] = {"telemetry_1", "telemetry_2"}; + versions["ground_station"] = FIRMWARE_VERSION; + versions["telemetry_1"] = nullptr; + versions["telemetry_2"] = nullptr; + while (usbStorageState == UsbStorageState::Preparing && usbConnectionState == UsbConnectionState::Active) { + if (link1.versionReadComplete() && link2.versionReadComplete()) { + for (size_t i = 0; i < 2; ++i) { + const auto observation = links[i]->diagnostics(); + if (observation.versionReplies != 0) versions[keys[i]] = JsonString(observation.version); + } + return true; + } + vTaskDelay(pdMS_TO_TICKS(20)); + } + return false; +} + +// Called with storageAccessMutex held, before handing the volume to USB. +static bool refreshVersionFile(const JsonDocument &versions) { + constexpr const char *path = "/version.json"; + String version; + serializeJsonPretty(versions, version); + version += '\n'; + auto file = fatfs.open(path, O_RDONLY); + bool matches = false; + bool readOnly = false; + if (file) { + matches = file.size() == version.length(); + for (size_t i = 0; matches && i < version.length(); ++i) matches = file.read() == version[i]; + readOnly = (file.attrib() & FS_ATTRIB_READ_ONLY) != 0; + file.close(); + } + if (!matches) { + if (readOnly && !fatfs.attrib(path, 0)) return false; + file = fatfs.open(path, O_WRONLY | O_CREAT | O_TRUNC); + if (!file) return false; + const bool written = file.write(version.c_str(), version.length()) == version.length(); + const bool closed = file.close(); // Flush the file before setting read-only. + if (!written || !closed) return false; + readOnly = false; + } + if (!readOnly && !fatfs.attrib(path, FS_ATTRIB_READ_ONLY)) return false; + constexpr const char *oldPath = "/version.txt"; + return !fatfs.exists(oldPath) || (fatfs.attrib(oldPath, 0) && fatfs.remove(oldPath)); +} + bool Utils::begin(uint32_t watchdogTimeout, const char *labelName, bool forceFormat) { bool status = true; @@ -223,23 +279,27 @@ void Utils::update(void *pvParameter) { if (usbStorageState == UsbStorageState::Preparing) { if (currentState != UsbConnectionState::Active || storageAccessMutex == nullptr) { usbStorageState = UsbStorageState::Reclaiming; - } else if (xSemaphoreTake(storageAccessMutex, portMAX_DELAY) == pdTRUE) { - // FatFS::end() flushes its own sector cache into the flash driver's - // cache. Sync the flash only after that flush, before exposing raw - // sectors to the host. - const bool unmounted = fatfs.end() != nullptr; - filesystemMounted = false; - const bool synced = unmounted && flash.syncBlocks(); - if (synced) { - usbStorageState = UsbStorageState::HostOwned; - ref->mscReady = true; - usb_msc.mediaPresent(true); - } else { - // Reclaiming attempts a clean remount so a failed handoff does not - // leave firmware storage permanently unavailable. - usbStorageState = UsbStorageState::Reclaiming; + } else { + JsonDocument versions; + if (readCachedVersions(versions) && xSemaphoreTake(storageAccessMutex, portMAX_DELAY) == pdTRUE) { + if (usbStorageState == UsbStorageState::Preparing && usbConnectionState == UsbConnectionState::Active) { + if (!refreshVersionFile(versions)) console.warning.println("[UTILS] Could not refresh version.json"); + // Flush and unmount before exposing raw sectors to the host. + const bool unmounted = fatfs.end() != nullptr; + filesystemMounted = false; + const bool synced = unmounted && flash.syncBlocks(); + if (synced && usbStorageState == UsbStorageState::Preparing && + usbConnectionState == UsbConnectionState::Active) { + usbStorageState = UsbStorageState::HostOwned; + ref->mscReady = true; + usb_msc.mediaPresent(true); + } else { + usbStorageState = UsbStorageState::Reclaiming; + } + } + xSemaphoreGive(storageAccessMutex); } - xSemaphoreGive(storageAccessMutex); + if (usbStorageState == UsbStorageState::Preparing) usbStorageState = UsbStorageState::Reclaiming; } } @@ -277,12 +337,16 @@ bool Utils::isUpdated(bool clearFlag) { bool Utils::isConnected() { return usbConnectionState != UsbConnectionState::Disconnected; } +bool Utils::isUsbActive() { return usbConnectionState == UsbConnectionState::Active; } + +uint32_t Utils::usbReadCount() { return successfulUsbReads.load(); } + bool Utils::requestMassStorage() { if (storageAccessMutex == nullptr || xSemaphoreTake(storageAccessMutex, pdMS_TO_TICKS(100)) != pdTRUE) { return false; } const bool available = usbConnectionState == UsbConnectionState::Active && - usbStorageState == UsbStorageState::FirmwareOwned && filesystemMounted; + usbStorageState == UsbStorageState::FirmwareOwned && filesystemMounted && !radioUpdateStorage; if (available) { usbStorageState = UsbStorageState::Preparing; } @@ -310,9 +374,29 @@ bool Utils::claimFirmwareStorage(uint32_t timeoutMs) { UsbStorageState Utils::getMassStorageState() { return usbStorageState; } -bool Utils::isFilesystemAvailable() { return usbStorageState == UsbStorageState::FirmwareOwned && filesystemMounted; } +bool Utils::isFilesystemAvailable() { + return usbStorageState == UsbStorageState::FirmwareOwned && filesystemMounted && !radioUpdateStorage; +} + +bool Utils::beginRadioUpdateStorage() { + if (storageAccessMutex == nullptr || xSemaphoreTake(storageAccessMutex, pdMS_TO_TICKS(500)) != pdTRUE) { + return false; + } + // Never reclaim a host-owned volume implicitly. The user must eject it first. + const bool available = isFilesystemAvailable(); + if (available) { + radioUpdateStorage = true; + } + xSemaphoreGive(storageAccessMutex); + return available; +} + +void Utils::endRadioUpdateStorage() { radioUpdateStorage = false; } bool Utils::format(const char *labelName) { + if (!isFilesystemAvailable()) { + return false; + } static FATFS elmchanFatfs; static uint8_t workbuf[4096]; // Working buffer for f_fdisk function. @@ -491,7 +575,11 @@ static int32_t coreMscRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t return -1; } const uint64_t endAddress = static_cast(lba) * MSC_BLOCK_SIZE + offset + bufsize; - const bool read = endAddress <= flash.size() && msc_read_range(lba, offset, static_cast(buffer), bufsize); + // Ownership can change while this callback waits for the mutex. A request + // queued by the host must not access flash after firmware has reclaimed it. + const bool read = usbStorageState == UsbStorageState::HostOwned && endAddress <= flash.size() && + msc_read_range(lba, offset, static_cast(buffer), bufsize); + if (read && bufsize != 0) ++successfulUsbReads; xSemaphoreGive(storageAccessMutex); return read ? static_cast(bufsize) : -1; } @@ -503,7 +591,8 @@ static int32_t coreMscWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint return -1; } const uint64_t endAddress = static_cast(lba) * MSC_BLOCK_SIZE + offset + bufsize; - const bool written = endAddress <= flash.size() && msc_write_range(lba, offset, buffer, bufsize); + const bool written = usbStorageState == UsbStorageState::HostOwned && endAddress <= flash.size() && + msc_write_range(lba, offset, buffer, bufsize); xSemaphoreGive(storageAccessMutex); return written ? static_cast(bufsize) : -1; } @@ -515,7 +604,7 @@ static void msc_flush_cb() { xSemaphoreTake(storageAccessMutex, pdMS_TO_TICKS(100)) != pdTRUE) { return; } - flash.syncBlocks(); + if (usbStorageState == UsbStorageState::HostOwned) flash.syncBlocks(); xSemaphoreGive(storageAccessMutex); updated = true; } diff --git a/ground_station/src/utils.hpp b/ground_station/src/utils.hpp index 40d4eea8..8acdd25e 100644 --- a/ground_station/src/utils.hpp +++ b/ground_station/src/utils.hpp @@ -35,11 +35,15 @@ class Utils { static void feedWatchdog(); static bool isUpdated(bool clearFlag = true); static bool isConnected(); + static bool isUsbActive(); + static uint32_t usbReadCount(); static bool requestMassStorage(); static void requestFirmwareStorage(); static bool claimFirmwareStorage(uint32_t timeoutMs = 3000); static UsbStorageState getMassStorageState(); static bool isFilesystemAvailable(); + static bool beginRadioUpdateStorage(); + static void endRadioUpdateStorage(); static int32_t getFlashMemoryUsage(); static void streamUsb(Telemetry *link, uint8_t link_idx); static bool format(const char *labelName); diff --git a/ground_station/tests/rom_bootloader_test.cpp b/ground_station/tests/rom_bootloader_test.cpp new file mode 100644 index 00000000..9988047a --- /dev/null +++ b/ground_station/tests/rom_bootloader_test.cpp @@ -0,0 +1,202 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "telemetry/crc.hpp" +#include "update/rom_bootloader.hpp" + +using namespace RadioUpdate; + +class MemorySource final : public Source { + public: + explicit MemorySource(size_t size = 3000) : bytes(size, 0xA5) { + const uint32_t sp = 0x20009000; + const uint32_t reset = kFlashBase + 0x101; + memcpy(bytes.data(), &sp, 4); + memcpy(bytes.data() + 4, &reset, 4); + } + uint32_t size() override { return bytes.size(); } + bool read(uint32_t offset, uint8_t* destination, size_t length) override { + if (offset + length > bytes.size()) return false; + memcpy(destination, bytes.data() + offset, length); + return true; + } + std::vector bytes; +}; + +class FakePort final : public Port { + public: + bool write(const uint8_t* data, size_t size) override { + if (!rom) { + if (size == 3 && data[0] == 0x80 && data[1] == 0 && data[2] == crc8(data, 2)) { + const uint8_t frame[] = {0x80, 2, 1, 0x79, 0x42}; + queue(frame, sizeof(frame) - 1); + const uint8_t content[] = {0x80, 2, 1, 0x79}; + rx.push_back(crc8(content, sizeof(content))); + } else if (size == 3 && data[0] == 0x60) { + std::vector response{0x60, 5, '1', '.', '2', '.', '0'}; + response.push_back(crc8(response.data(), response.size())); + queue(response.data(), response.size()); + } + return true; + } + if (size == 1 && data[0] == 0x7F) { + rx.push_back(0x79); + return true; + } + if (stage == 0 && size == 2 && data[1] == static_cast(data[0] ^ 0xFFU)) { + command = data[0]; + stage = 1; + rx.push_back(0x79); + if (command == 0x00) { + const uint8_t response[] = {7, 0x31, 0x00, 0x01, 0x02, 0x11, 0x21, 0x31, 0x43, 0x79}; + queue(response, sizeof(response)); + stage = 0; + } else if (command == 0x01) { + const uint8_t response[] = {0x31, 0, 0, 0x79}; + queue(response, sizeof(response)); + stage = 0; + } else if (command == 0x02) { + const uint8_t response[] = {1, 4, 0x60, 0x79}; + queue(response, sizeof(response)); + stage = 0; + } + return true; + } + if ((command == 0x11 || command == 0x31 || command == 0x21) && stage == 1 && size == 5) { + address = (uint32_t{data[0]} << 24U) | (uint32_t{data[1]} << 16U) | (uint32_t{data[2]} << 8U) | data[3]; + rx.push_back(0x79); + stage = 2; + return true; + } + if (command == 0x11 && stage == 2 && size == 2) { + const size_t count = size_t{data[0]} + 1; + rx.push_back(0x79); + std::vector result(count, 0); + if (address >= kFlashBase && address + count <= kFlashBase + flash.size()) { + memcpy(result.data(), flash.data() + address - kFlashBase, count); + } else if (address == 0x1FFF75E0U) { + result[0] = 128; + } else if (address == 0x1FFF6FFEU) { + result[0] = revision; + } else if (address == 0x1FFF7870U) { + result[0] = protectedFlash ? 1 : 0; + } else if (address >= 0x1FFF7800U && address + count <= 0x1FFF7800U + options.size()) { + memcpy(result.data(), options.data() + address - 0x1FFF7800U, count); + } else + return false; + queue(result.data(), result.size()); + stage = 0; + return true; + } + if (command == 0x31 && stage == 2 && size >= 10) { + const size_t count = size_t{data[0]} + 1; + assert(count % 8 == 0 && count <= kBlockSize); + memcpy(flash.data() + address - kFlashBase, data + 1, count); + writes.push_back(address); + if (!dropWriteAck) rx.push_back(0x79); + dropWriteAck = false; + stage = 0; + return true; + } + if (command == 0x43 && stage == 1 && size == 3) { + const size_t page = data[1]; + std::fill(flash.begin() + page * kPageSize, flash.begin() + (page + 1) * kPageSize, 0xFF); + rx.push_back(0x79); + stage = 0; + return true; + } + if (command == 0x21 && stage == 2) { + stage = 0; + return true; + } + return false; + } + bool read(uint8_t* data, size_t size, uint32_t) override { + if (rx.size() < size) { + time += 25; + return false; + } + for (size_t index = 0; index < size; ++index) { + data[index] = rx.front(); + rx.pop_front(); + } + return true; + } + void configure(bool inRom) override { rom = inRom; } + void drain() override { rx.clear(); } + void wait(uint32_t ms) override { time += ms; } + uint32_t now() override { return time++; } + void progress(Phase, uint8_t) override {} + void queue(const uint8_t* data, size_t size) { + for (size_t i = 0; i < size; ++i) rx.push_back(data[i]); + } + + FakePort() : flash(kFlashSize, 0), options(0x74, 0) { + options[0] = 0xAA; + options[8] = 0xFF; + options[16] = 0; + options[24] = 0x3F; + options[26] = 0; + options[32] = 0x3F; + options[34] = 0; + options[40] = 0xFF; + options[48] = 0; + } + std::vector flash; + std::vector options; + std::deque rx; + std::vector writes; + uint32_t time{0}; + uint32_t address{0}; + uint8_t command{0}; + uint8_t stage{0}; + uint8_t revision{0xB2}; + bool rom{false}; + bool dropWriteAck{true}; + bool protectedFlash{false}; +}; + +int main() { + MemorySource image; + ImageInfo info{}; + assert(inspect(image, info)); + assert(info.size == image.bytes.size()); + assert(validVectors(info.vectors, info.size)); + assert(isBinName("telemetry.bin") && isBinName("A.BIN")); + assert(!isBinName("telemetry.bin.old") && !isBinName("folder/telemetry.bin")); + + FakePort port; + LinkResult result{}; + RomBootloader updater(port); + if (!updater.run(image, info, result)) { + std::cerr << updater.error() << '\n'; + return 1; + } + assert(result.success && result.destructive && result.romRevision == 0xB2); + assert(std::string(result.version) == "1.2.0"); + assert(port.writes.back() == kFlashBase); // Activation vectors are last. + assert(std::equal(image.bytes.begin(), image.bytes.end(), port.flash.begin())); + + FakePort oldRom; + oldRom.revision = 0xB0; + RomBootloader rejected(oldRom); + assert(!rejected.run(image, info, result)); + assert(!result.destructive && oldRom.writes.empty()); + + FakePort protectedRom; + protectedRom.protectedFlash = true; + RomBootloader protectedUpdater(protectedRom); + assert(!protectedUpdater.run(image, info, result)); + assert(!result.destructive && protectedRom.writes.empty()); + + image.bytes[0] = 1; + assert(!inspect(image, info)); + return 0; +} diff --git a/ground_station/tests/self_test.cpp b/ground_station/tests/self_test.cpp new file mode 100644 index 00000000..ebd43729 --- /dev/null +++ b/ground_station/tests/self_test.cpp @@ -0,0 +1,386 @@ +// Contract tests for the production state machine, including manual start gates. +#include "self_test.hpp" +#include +#include +#include +#include +#include + +using Phase = SelfTest::Phase; +using Check = SelfTest::Check; +using Result = SelfTest::Result; +using Radio = SelfTest::RadioConfiguration; +constexpr uint8_t A = 1U << 5; +constexpr uint8_t B = 1U << 6; + +struct Rig { + SelfTest test; + SelfTestInput input; + uint32_t now{1000}; + uint32_t sinceStart{0}; + bool versions{true}; + bool gps{true}; + bool packets{true}; + uint8_t lq{90}; + int8_t snr{10}; + bool saveCalibration{true}; + unsigned calibrationSaves{0}; + std::array savedBias{}; + Radio configuration{Radio::Restore}; + std::array requests{}; + + Rig() { + input.linksReady = true; + input.batteryVoltage = 3.8F; + input.sensors.imuDetected = true; + input.sensors.magnetometer = 0x2c; + input.sensors.accelerationFresh = input.sensors.gyroFresh = input.sensors.magnetometerFresh = true; + input.sensors.acceleration = {0, 0, 1}; + input.sensors.magnetic = {500, 300, 700}; + } + void start() { + test.start(now, input); + test.setResult(Check::Storage, Result::Pass, "fixture"); + } + void tick() { + now += 20; + sinceStart += 20; + ++input.sensors.sequence; + input.sensors.sampledAtMs = now; + for (size_t i = 0; i < 3; ++i) input.sensors.gyro[i] = input.sensors.rawGyro[i] - savedBias[i]; + if (packets && sinceStart % 100 == 0) { + for (auto& link : input.links) { + ++link.radio.packets; + ++link.radio.infoSamples; + link.radio.lqSum += lq; + link.radio.minimumSnr = std::min(link.radio.minimumSnr, snr); + link.radio.lastPacketMs = now; + link.radio.maxGapMs = 100; + } + ++input.combinedPackets; + } + if (gps && sinceStart % 1000 == 0) { + auto& gnss = input.links[1]; + gnss.fixCount += 5; + gnss.latitude = 46; + gnss.longitude = 8; + gnss.lastFixMs = gnss.lastTimeMs = now; + ++gnss.timeUpdates; + ++gnss.utcSeconds; + } + test.update(now, input); + std::array bias{}; + if (test.takeGyroCalibration(bias)) { + if (saveCalibration) { savedBias = bias; ++calibrationSaves; } + test.completeGyroCalibration(saveCalibration, now, input); + } + if (test.takeUsbStorageRequest()) input.usbStorageReady = true; + input.buttonsPressed = 0; + const auto request = test.takeRadioConfiguration(); + if (request != Radio::None) { configuration = request; ++requests[static_cast(request)]; } + if (test.takeVersionRequest() && versions) { + for (auto& link : input.links) { std::strcpy(link.version, "any-version"); ++link.versionReplies; } + } + if (test.takeRadioReset()) for (auto& link : input.links) { link.radio = {}; link.radio.lastPacketMs = now; } + } + void advance(uint32_t duration) { for (uint32_t elapsed = 0; elapsed < duration; elapsed += 20) tick(); } + void press(uint8_t button) { input.buttonsHeld |= button; input.buttonsPressed = button; tick(); } + void release(uint8_t button) { input.buttonsHeld &= ~button; tick(); } + void tap(uint8_t button) { press(button); release(button); } + void hardware() { + start(); advance(12000); + assert(test.phase == Phase::Telemetry && test.awaitingConfirmation); + assert(requests[0] == 0 && input.links[0].versionReplies == 0); + } + void radio() { + hardware(); tap(A); advance(72000); + assert(test.phase == Phase::Motion && test.awaitingConfirmation); + } + void gate(Phase phase) { + assert(test.phase == phase && test.awaitingConfirmation); + const auto before = test.results; + const auto radioRequests = requests; + advance(120000); // Waiting must never consume the test timeout. + tap(1U); // An unrelated button must not start the check. + assert(test.phase == phase && test.awaitingConfirmation); + assert(test.displayPattern(now) == 0 && requests == radioRequests); + for (size_t i = 0; i < before.size(); ++i) assert(test.results[i].result == before[i].result); + tap(A); + assert(test.phase == phase && !test.awaitingConfirmation); + } + void move() { + for (size_t axis = 0; axis < 3; ++axis) for (float sign : {-1.0F, 1.0F}) { + input.sensors.acceleration = {}; + input.sensors.acceleration[axis] = sign; + input.sensors.rawGyro = {}; + input.sensors.rawGyro[axis] = 30; + input.sensors.magnetic = {sign * 1000, sign * 800, sign * 600}; + tick(); + } + } +}; + +int main() { + { + Rig rig; + rig.hardware(); + assert(rig.test.check(Check::Battery).result == Result::Pass); + rig.gate(Phase::Telemetry); + rig.advance(72000); + for (size_t i = 0; i <= static_cast(Check::Storage); ++i) + assert(rig.test.check(static_cast(i)).result == Result::Pass); + rig.move(); // Sensor activity before confirmation does not count. + assert(rig.test.accelerationAxes == 0 && rig.test.gyroAxes == 0 && rig.test.magneticAxes == 0); + rig.gate(Phase::Motion); + rig.move(); + rig.gate(Phase::Buttons); + assert(rig.test.completedButtons == 0); // Start press AND release do not count as button A. + rig.press(1U); + assert(rig.test.completedButtons == 0); + rig.release(1U); + assert(rig.test.completedButtons == 1); + for (unsigned bit : {1U, 2U, 3U, 5U}) rig.tap(1U << bit); + assert(rig.test.phase == Phase::Buttons && rig.test.check(Check::Buttons).result == Result::Pending); + rig.press(B); + assert(rig.test.phase == Phase::Buttons); + rig.release(B); + assert(rig.test.check(Check::Buttons).result == Result::Pass); // All six physical buttons; no center input. + rig.gate(Phase::Display); + for (uint8_t pattern = 1; pattern <= 4; ++pattern) { + rig.advance(pattern == 1 ? 2000 : 4000); + assert(rig.test.displayPattern(rig.now) == pattern); + } + rig.advance(4100); + rig.press(A); // Keep the approval button held through the next prompt. + assert(rig.test.phase == Phase::Usb && rig.test.awaitingConfirmation); + const auto requests = rig.requests; + rig.advance(15000); + assert(rig.test.awaitingConfirmation && rig.requests == requests); + rig.release(A); + assert(rig.test.phase == Phase::Usb && rig.test.awaitingConfirmation); + rig.gate(Phase::Usb); + rig.advance(1000); + assert(!rig.test.finished() && !rig.input.usbStorageReady); + rig.input.usbConnected = true; rig.tick(); + assert(!rig.test.finished() && rig.input.usbStorageReady); // Enumeration alone cannot pass. + ++rig.input.usbReadCount; rig.tick(); + assert(rig.test.finished() && rig.test.overall() == Result::Pass); + for (const auto& result : rig.test.results) assert(result.result == Result::Pass); + } + { + Rig rig; + rig.versions = false; + rig.gps = false; + for (auto& link : rig.input.links) { std::strcpy(link.version, "cached"); link.versionReplies = 1; } + rig.input.links[1].fixCount = 10; + rig.start(); rig.advance(127000); + assert(rig.test.check(Check::Gnss).result == Result::Fail); + assert(rig.test.phase == Phase::Telemetry && rig.test.awaitingConfirmation); + rig.tap(A); rig.advance(72000); + assert(rig.test.check(Check::Firmware1).result == Result::Fail); + assert(rig.test.check(Check::Firmware2).result == Result::Fail); + assert(rig.test.phase == Phase::Motion && rig.test.awaitingConfirmation); + } + { + Rig rig; + rig.lq = 10; + rig.hardware(); rig.tap(A); rig.advance(140000); + assert(rig.test.check(Check::Dual).result == Result::Fail && rig.requests[0] == 2); + assert(rig.test.check(Check::Receiver2Only).result == Result::Fail); + assert(rig.test.phase == Phase::Motion && rig.test.awaitingConfirmation); + } + { + Rig rig; + rig.hardware(); rig.tap(A); rig.advance(3000); + rig.test.cancel(rig.now); + rig.input.linksReady = false; + rig.advance(5000); + assert(!rig.test.finished() && rig.configuration == Radio::Restore); + rig.input.linksReady = true; rig.tick(); + assert(rig.test.finished() && rig.test.overall() == Result::Fail); + assert(rig.test.check(Check::Motion).result == Result::Incomplete); + } + { + Rig rig; + rig.start(); rig.test.cancel(rig.now); + assert(rig.test.finished() && rig.test.takeRadioConfiguration() == Radio::None); + assert(rig.test.check(Check::Restoration).result == Result::Pass); + } + { + Rig rig; + rig.now = UINT32_MAX - 3000; + rig.radio(); // millis() wrap remains safe. + rig.gate(Phase::Motion); rig.advance(60000); + assert(rig.test.check(Check::Motion).result == Result::Fail); + assert(rig.test.phase == Phase::Buttons && rig.test.awaitingConfirmation); + } + { + Rig rig; + rig.hardware(); rig.tap(A); rig.input.linksReady = false; rig.advance(115000); + assert(rig.test.finished() && rig.test.check(Check::Restoration).result == Result::Fail); + assert(rig.test.overall() == Result::Fail); + } + { + Rig rig; + rig.start(); rig.advance(5100); rig.input.sensors.readError = true; rig.advance(200); + rig.input.sensors.readError = false; rig.advance(3000); + rig.test.cancel(rig.now); + assert(rig.test.finished() && rig.test.check(Check::Motion).result == Result::Fail); + } + { + Rig rig; + rig.radio(); rig.gate(Phase::Motion); rig.move(); rig.gate(Phase::Buttons); + constexpr uint8_t center = 1U << 4; // Retained input must not affect the physical button checklist. + rig.tap(center); + assert(rig.test.completedButtons == 0); + rig.press(center); + for (unsigned bit : {0U, 1U, 2U, 3U, 5U, 6U}) rig.tap(1U << bit); + assert(rig.test.phase == Phase::Display && rig.test.check(Check::Buttons).result == Result::Pass); + } + { + SelfTest test; + test.phase = Phase::Display; + assert(test.displayPattern(1999) == 0); + for (uint32_t pattern = 1; pattern <= 4; ++pattern) { + const uint32_t begins = 2000 + (pattern - 1) * 4000; + assert(test.displayPattern(begins) == pattern); + assert(test.displayPattern(begins + 3999) == pattern); + } + assert(test.displayPattern(18000) == 0); + } + const auto radioWindowPasses = [](uint32_t packets, uint32_t lq, int8_t snr, uint32_t gap, uint32_t reports = 1, + uint32_t elapsedMs = 15000) { + Rig rig; + rig.hardware(); rig.tap(A); rig.advance(2480); + assert(rig.test.measuring); + const uint32_t end = rig.now + elapsedMs; + for (auto& link : rig.input.links) { + link.radio.packets = packets; + link.radio.infoSamples = reports; + link.radio.lqSum = lq * reports; + link.radio.minimumSnr = snr; + link.radio.lastPacketMs = end; + link.radio.maxGapMs = gap; + } + rig.test.update(end, rig.input); + return rig.test.check(Check::Dual).result == Result::Pass; + }; + assert(radioWindowPasses(120, 80, -4, 1000)); + assert(!radioWindowPasses(119, 80, -4, 1000)); + assert(!radioWindowPasses(120, 79, -4, 1000)); + assert(!radioWindowPasses(120, 80, -5, 1000)); + assert(!radioWindowPasses(120, 80, -6, 1000)); + assert(!radioWindowPasses(120, 80, -4, 1001)); + assert(!radioWindowPasses(120, 80, -4, 1000, 0)); + assert(!radioWindowPasses(120, 80, -4, 1000, 1, 15001)); // Do not round a rate below 8 packets/s into a pass. + assert(radioWindowPasses(121, 80, -4, 1000, 1, 15001)); + { + Rig rig; + const std::array bias{1.2F, -0.6F, 0.3F}; + rig.input.sensors.rawGyro = bias; + rig.hardware(); + assert(rig.calibrationSaves == 1 && rig.test.check(Check::GyroCalibration).result == Result::Pass); + for (size_t i = 0; i < 3; ++i) { + assert(std::fabs(rig.savedBias[i] - bias[i]) < 0.0001F); + assert(std::fabs(rig.input.sensors.gyro[i]) < 0.0001F); + } + rig.hardware(); // A rerun measures raw bias, rather than subtracting the saved correction twice. + assert(rig.calibrationSaves == 2); + for (size_t i = 0; i < 3; ++i) assert(std::fabs(rig.savedBias[i] - bias[i]) < 0.0001F); + rig.saveCalibration = false; + rig.input.sensors.rawGyro = {2, 1, 0}; + rig.hardware(); + assert(rig.calibrationSaves == 2 && rig.test.check(Check::GyroCalibration).result == Result::Fail); + for (size_t i = 0; i < 3; ++i) assert(std::fabs(rig.savedBias[i] - bias[i]) < 0.0001F); + } + { + Rig rig; + rig.start(); rig.advance(4000); + assert(rig.calibrationSaves == 0); + rig.input.sensors.acceleration = {1, 0, 0}; rig.tick(); // Tilt at constant 1 g restarts collection. + assert(rig.test.gyroCalibrationSamples == 0); + rig.advance(4000); + assert(rig.calibrationSaves == 0); + rig.now += 200; rig.tick(); // A sampling interruption also restarts the window. + assert(rig.test.gyroCalibrationSamples == 1); + rig.advance(5100); + assert(rig.calibrationSaves == 1); + } + { + Rig rig; + rig.start(); rig.input.sensors.rawGyro = {6, 0, 0}; rig.advance(30100); + assert(rig.calibrationSaves == 0 && rig.test.check(Check::GyroCalibration).result == Result::Fail); + assert(rig.savedBias == (std::array{})); + rig.gps = false; + rig.advance(90000); + assert(rig.test.check(Check::Gnss).result == Result::Pending); // Calibration time does not consume GNSS acquisition. + rig.advance(31000); + assert(rig.test.check(Check::Gnss).result == Result::Fail); + } + { + Rig rig; + rig.start(); rig.advance(4000); rig.test.cancel(rig.now); + rig.advance(2000); + assert(rig.calibrationSaves == 0 && rig.test.finished()); + } + { + Rig rig; + rig.start(); rig.advance(5020); + assert(rig.test.phase == Phase::Hardware); + rig.advance(4980); + assert(rig.test.check(Check::Battery).result == Result::Pending); + rig.tick(); + assert(rig.test.check(Check::Battery).result == Result::Pass); + assert(rig.test.phase == Phase::Telemetry && rig.test.awaitingConfirmation); + } + for (float voltage : {3.0F, 4.35F, 2.99F, 4.36F, std::numeric_limits::quiet_NaN()}) { + Rig rig; + rig.input.batteryVoltage = voltage; + rig.start(); rig.advance(5020); + assert(rig.test.phase == Phase::Hardware); + rig.input.batteryVoltage = 3.8F; // A later valid reading cannot hide an invalid first sample. + rig.advance(5000); + const auto expected = voltage >= 3.0F && voltage <= 4.35F ? Result::Pass : Result::Fail; + assert(rig.test.check(Check::Battery).result == expected); + } + for (bool usb : {false, true}) { + Rig rig; + rig.start(); rig.advance(7000); + assert(rig.test.phase == Phase::Hardware); + rig.input.usbConnected = usb; + if (!usb) rig.input.batteryVoltage = 4.5F; + rig.tick(); // Even a transient USB connection or invalid voltage fails the window. + rig.input.usbConnected = false; + rig.input.batteryVoltage = 3.8F; + rig.advance(5000); + assert(rig.test.check(Check::Battery).result == Result::Fail); + } + for (bool storageFault : {false, true}) { + Rig rig; + rig.start(); + rig.test.phase = Phase::Usb; + rig.test.awaitingConfirmation = true; + rig.input.usbConnected = false; + rig.input.usbReadCount = 10; // Reads from before this test must not count. + rig.tick(); // Release the entry gate before pressing Start. + rig.tap(A); + rig.input.usbConnected = true; rig.tick(); + assert(rig.input.usbStorageReady && !rig.test.finished()); + rig.input.usbStorageFault = storageFault; + rig.advance(30100); + assert(rig.test.check(Check::Usb).result == Result::Fail); + } + { + Rig rig; + rig.start(); rig.test.phase = Phase::Usb; rig.test.awaitingConfirmation = true; + rig.input.usbConnected = true; + rig.input.usbReadCount = 10; + rig.tick(); + assert(!rig.input.usbStorageReady); // An early connection cannot start the gated check. + rig.tap(A); // Already connected at Start: share without an unnecessary unplug/reconnect cycle. + assert(rig.input.usbStorageReady && !rig.test.finished()); // Old reads cannot pass. + ++rig.input.usbReadCount; rig.tick(); + assert(rig.test.check(Check::Usb).result == Result::Pass); + } + std::cout << "Three-phase self-test and all manual confirmation gates passed\n"; +} diff --git a/telemetry/platformio.ini b/telemetry/platformio.ini index 87b1548a..5910fa1a 100644 --- a/telemetry/platformio.ini +++ b/telemetry/platformio.ini @@ -13,7 +13,7 @@ extra_scripts = pre:pre_config.py build_flags = - -D FIRMWARE_VERSION='"1.1.3"' + -D FIRMWARE_VERSION='"1.2.0"' check_tool = clangtidy check_src_filters = diff --git a/telemetry/src/Common.cpp b/telemetry/src/Common.cpp index 04620633..88a92e83 100644 --- a/telemetry/src/Common.cpp +++ b/telemetry/src/Common.cpp @@ -7,4 +7,5 @@ // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) Transmission link; bool send_version_num = false; +bool bootloader_requested = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/telemetry/src/Common.hpp b/telemetry/src/Common.hpp index 32ad0bb7..0f6f5fc6 100644 --- a/telemetry/src/Common.hpp +++ b/telemetry/src/Common.hpp @@ -9,4 +9,5 @@ // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) extern Transmission link; extern bool send_version_num; +extern bool bootloader_requested; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/telemetry/src/Main.cpp b/telemetry/src/Main.cpp index 59d0a1c2..aca90803 100644 --- a/telemetry/src/Main.cpp +++ b/telemetry/src/Main.cpp @@ -41,6 +41,7 @@ static void MX_USART1_UART_Init(); static void MX_USART2_UART_Init(); static void MX_DMA_Init(); static void MX_TIM2_Init(); +[[noreturn]] static void EnterSystemBootloader(); /** * @brief The application entry point. @@ -51,6 +52,13 @@ int main() { */ HAL_Init(); + /* ROM bootloader revisions before B4 can leave RCC configuration behind when + * launching the application with GO. Start from the reset clock state in both + * reset and bootloader-launch paths. */ + if (HAL_RCC_DeInit() != HAL_OK) { + Error_Handler(); + } + /* Configure the system clock */ SystemClock_Config(); @@ -69,18 +77,20 @@ int main() { // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) fine to cast since char is 8 bits on this platform const auto *code_version = reinterpret_cast(telemetry_code_version); + Parser parser; + + /* Start both circular DMA receivers before the GNSS startup delay. Vega can + * already be transmitting during this period, and leaving USART2 idle would + * latch an overrun before the application reaches its main loop. */ + Serial<64> serial2(&huart2); + Serial<256> serial1(&huart1); + /* Wait for the GNSS module to initialize*/ HAL_Delay(4000); /* Set the GNSS module to 115200 baud and put in airbourne mode*/ gpsSetup(); - Parser parser; - - /* Initalize the communication to Host */ - Serial<64> serial2(&huart2); - /* Initalize the communication to GNSS Module */ - Serial<256> serial1(&huart1); /* Initalize Thermistor */ Thermistor<3434> thermistor(&hadc1); @@ -109,6 +119,25 @@ int main() { parser.process(serial2.read()); } + if (bootloader_requested) { + link.disableTransmission(); + + uartOutBuffer[0] = CMD_BOOTLOADER; + uartOutBuffer[1] = BOOTLOADER_ACK_LENGTH; + uartOutBuffer[2] = BOOTLOADER_PROTOCOL_VERSION; + uartOutBuffer[3] = 0x79; + uartOutBuffer[4] = crc8(uartOutBuffer, 4); + + if (HAL_UART_Transmit(&huart2, uartOutBuffer, 5, 100) != HAL_OK) { + bootloader_requested = false; + continue; + } + while (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) == RESET) { + } + HAL_Delay(10); + EnterSystemBootloader(); + } + /* Transmit Link Information*/ if (link.infoAvailable()) { linkInfo_t info{}; @@ -214,6 +243,52 @@ int main() { } } +[[noreturn]] static void EnterSystemBootloader() { + constexpr uint32_t kSystemMemoryBase = 0x1FFF0000U; + + HAL_UART_DMAStop(&huart1); + HAL_UART_DMAStop(&huart2); + HAL_ADC_Stop_DMA(&hadc1); + HAL_TIM_Base_Stop_IT(&htim2); + + HAL_UART_DeInit(&huart1); + HAL_UART_DeInit(&huart2); + HAL_ADC_DeInit(&hadc1); + HAL_SPI_DeInit(&hspi1); + HAL_TIM_Base_DeInit(&htim2); + + SysTick->CTRL = 0; + SysTick->LOAD = 0; + SysTick->VAL = 0; + + HAL_RCC_DeInit(); + HAL_DeInit(); + + __disable_irq(); + NVIC->ICER[0] = 0xFFFFFFFFU; + NVIC->ICPR[0] = 0xFFFFFFFFU; + + /* Jump through the physical system-memory vectors. B3/B4 ROM revisions + * reset SYSCFG during startup, so their entry must not depend on remapping + * system memory at address zero. */ + + // NOLINTBEGIN(cppcoreguidelines-pro-type-reinterpret-cast) + const uint32_t system_stack = *reinterpret_cast(kSystemMemoryBase); + const uint32_t system_entry = *reinterpret_cast(kSystemMemoryBase + 4U); + auto system_bootloader = reinterpret_cast(system_entry); + // NOLINTEND(cppcoreguidelines-pro-type-reinterpret-cast) + + __set_CONTROL(0U); + __set_MSP(system_stack); + __DSB(); + __ISB(); + __enable_irq(); + system_bootloader(); + + while (true) { + } +} + /** * @brief System Clock Configuration * @retval None diff --git a/telemetry/src/SerialComm/Parser.cpp b/telemetry/src/SerialComm/Parser.cpp index 8e45cc29..6daafc4a 100644 --- a/telemetry/src/SerialComm/Parser.cpp +++ b/telemetry/src/SerialComm/Parser.cpp @@ -140,8 +140,12 @@ void Parser::cmdVersionNum([[maybe_unused]] uint8_t *args, [[maybe_unused]] uint send_version_num = true; } -void Parser::cmdBootloader(uint8_t *args, uint32_t length) { - // UNUSED +void Parser::cmdBootloader([[maybe_unused]] uint8_t *args, uint32_t length) { + if (length != 0) { + return; + } + + bootloader_requested = true; } void Parser::cmdRX(uint8_t *args, uint32_t length) { diff --git a/telemetry/src/Telemetry_reg.h b/telemetry/src/Telemetry_reg.h index 36421e94..762bdaa8 100644 --- a/telemetry/src/Telemetry_reg.h +++ b/telemetry/src/Telemetry_reg.h @@ -57,3 +57,6 @@ typedef struct modulation_settings_s { #define CMD_VERSION_INFO 0x60 #define CMD_BOOTLOADER 0x80 + +#define BOOTLOADER_ACK_LENGTH 2U +#define BOOTLOADER_PROTOCOL_VERSION 1U