From 8b9687dfad6d91cb865e964e96f6a2f193aef900 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Tue, 1 Sep 2026 20:31:00 +0200 Subject: [PATCH 01/10] feat(gs): improve startup animation Enlarge the startup rocket and draw a fuller animated flame with a contrasting inner core. Update the matching deterministic renderer and snapshots while preserving the existing startup timing. --- .../simulator/golden/snapshots.json | 4 +-- ground_station/simulator/gs_sim.py | 11 +++++-- ground_station/src/hmi/window.cpp | 29 ++++++++++++++----- 3 files changed, 32 insertions(+), 12 deletions(-) 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.py b/ground_station/simulator/gs_sim.py index ad77ab95..e3eab852 100644 --- a/ground_station/simulator/gs_sim.py +++ b/ground_station/simulator/gs_sim.py @@ -368,6 +368,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 +385,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): diff --git a/ground_station/src/hmi/window.cpp b/ground_station/src/hmi/window.cpp index 728b7999..ae90a328 100644 --- a/ground_station/src/hmi/window.cpp +++ b/ground_station/src/hmi/window.cpp @@ -70,7 +70,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 +117,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) { From de5777fceea670804f8f331c83816ed52a1541a7 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Tue, 1 Sep 2026 20:34:44 +0200 Subject: [PATCH 02/10] feat(gs): add compass Add a Sensors orientation view showing magnetic heading, pitch, roll, and field magnitude while retaining the existing calibration algorithm. Include the related coordinate and numeric display fixes, atomic calibration-progress redraw, and simulator support for the shared screen. --- ground_station/simulator/compat/Arduino.h | 7 +- ground_station/simulator/compat/Print.h | 2 +- .../simulator/compat/navigation.hpp | 8 + ground_station/simulator/gs_sim.py | 20 ++- ground_station/simulator/hmi_controller.cpp | 13 ++ ground_station/simulator/hmi_controller.hpp | 4 + .../scenarios/sensors-orientation.json | 29 ++++ ground_station/simulator/wasm_api.cpp | 28 +++ .../simulator/window_hmi_renderer.cpp | 10 +- ground_station/src/hmi/hmi.cpp | 22 ++- ground_station/src/hmi/hmi.hpp | 3 + ground_station/src/hmi/window.cpp | 160 ++++++++++++++---- ground_station/src/hmi/window.hpp | 6 +- 13 files changed, 268 insertions(+), 44 deletions(-) create mode 100644 ground_station/simulator/scenarios/sensors-orientation.json 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/gs_sim.py b/ground_station/simulator/gs_sim.py index e3eab852..76a02c26 100644 --- a/ground_station/simulator/gs_sim.py +++ b/ground_station/simulator/gs_sim.py @@ -237,7 +237,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,6 +289,7 @@ 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 @@ -479,7 +481,7 @@ 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) @@ -676,6 +678,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" @@ -697,6 +700,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" @@ -898,6 +903,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: @@ -909,6 +920,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" @@ -919,6 +931,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") @@ -1173,6 +1186,7 @@ def snapshot(self) -> dict[str, Any]: "activeScreen": self.screen, "testingState": self.testing_state, "calibrationState": self.calibration_state, + "sensorView": self.sensor_view, "settingsState": self.settings_state, "inputState": "held" if any(self.held.values()) else "idle", "menuSelection": self.menu_selection, @@ -1307,7 +1321,7 @@ def deterministic_test(root: Path) -> int: 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", + "sensors-orientation.json", "settings.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"): diff --git a/ground_station/simulator/hmi_controller.cpp b/ground_station/simulator/hmi_controller.cpp index 32165707..24549047 100644 --- a/ground_station/simulator/hmi_controller.cpp +++ b/ground_station/simulator/hmi_controller.cpp @@ -43,6 +43,7 @@ void HmiController::start() { recoveryLocations_ = {}; clearQr(); liveDownrange_ = false; + sensorOrientation_ = false; keyboardActive_ = false; keyboardSelection_ = 0; keyboardUppercase_ = false; @@ -198,6 +199,7 @@ void HmiController::enter(Screen screen) { } if (screen_ == Screen::Sensors) { calibrationState_ = CalibrationState::Idle; + sensorOrientation_ = false; } if (screen_ == Screen::Settings) { settingsPage_ = 0; @@ -520,6 +522,14 @@ bool HmiController::showRecoveryLocation(size_t linkIndex) { 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 +545,7 @@ void HmiController::sensorsStep(uint64_t nowMs) { } if (pressed(HmiButton::Back)) { calibrationState_ = CalibrationState::Idle; + sensorOrientation_ = false; } break; case CalibrationState::Calibrating: @@ -551,6 +562,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; @@ -759,6 +771,7 @@ HmiSnapshot HmiController::snapshot() const { result.settingsState = keyboardActive_ ? "keyboard" : "list"; result.inputState = "idle"; result.liveView = liveDownrange_ ? "downrange" : "gnss"; + result.sensorView = sensorOrientation_ ? "orientation" : "readings"; for (const bool held : input_.held) { if (held) { result.inputState = "held"; diff --git a/ground_station/simulator/hmi_controller.hpp b/ground_station/simulator/hmi_controller.hpp index 130905a3..588741f8 100644 --- a/ground_station/simulator/hmi_controller.hpp +++ b/ground_station/simulator/hmi_controller.hpp @@ -51,6 +51,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; @@ -209,6 +211,7 @@ struct HmiSnapshot { std::string settingsState = "list"; std::string inputState = "idle"; std::string liveView = "gnss"; + std::string sensorView = "readings"; int16_t menuSelection = 0; int16_t testingSelection = 0; int16_t keyboardSelection = 0; @@ -329,6 +332,7 @@ class HmiController { std::array recoveryLocations_{}; int8_t selectedRecoveryLink_ = 0; bool liveDownrange_ = false; + bool sensorOrientation_ = false; bool keyboardActive_ = false; int16_t keyboardSelection_ = 0; bool keyboardUppercase_ = false; diff --git a/ground_station/simulator/scenarios/sensors-orientation.json b/ground_station/simulator/scenarios/sensors-orientation.json new file mode 100644 index 00000000..cce20158 --- /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/wasm_api.cpp b/ground_station/simulator/wasm_api.cpp index 9077f290..e800b5c7 100644 --- a/ground_station/simulator/wasm_api.cpp +++ b/ground_station/simulator/wasm_api.cpp @@ -472,6 +472,7 @@ void rebuildSnapshot() { const HmiSnapshot state = controller.snapshot(); snapshot = "{\"activeScreen\":" + quote(state.screen) + ",\"liveView\":" + quote(state.liveView) + + ",\"sensorView\":" + quote(state.sensorView) + ",\"testingState\":" + quote(state.testingState) + ",\"testingSelection\":" + std::to_string(state.testingSelection) + ",\"keyboardSelection\":" + std::to_string(state.keyboardSelection) + @@ -513,6 +514,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) { @@ -600,11 +617,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(); } diff --git a/ground_station/simulator/window_hmi_renderer.cpp b/ground_station/simulator/window_hmi_renderer.cpp index 614fac78..9495cdd4 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); @@ -205,8 +206,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") { diff --git a/ground_station/src/hmi/hmi.cpp b/ground_station/src/hmi/hmi.cpp index ccd06750..4f18bcc6 100644 --- a/ground_station/src/hmi/hmi.cpp +++ b/ground_station/src/hmi/hmi.cpp @@ -652,6 +652,7 @@ void Hmi::data() { /* SENSORS */ void Hmi::initSensors() { + sensorView = SensorView::Readings; lastSensorRefresh = 0; window.initSensors(); } @@ -671,11 +672,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 +706,7 @@ void Hmi::sensors() { } if (backButton.wasPressed()) { calibrationState = IDLE; + sensorView = SensorView::Readings; window.initSensors(); } break; @@ -707,6 +726,7 @@ void Hmi::sensors() { case CONCLUDED: if (okButton.wasPressed() || backButton.wasPressed()) { calibrationState = IDLE; + sensorView = SensorView::Readings; if (claimStorageForFirmware()) { systemConfig.save(); automaticUsbSharePending = Utils::isConnected(); diff --git a/ground_station/src/hmi/hmi.hpp b/ground_station/src/hmi/hmi.hpp index 6f4f1d81..38b5da72 100644 --- a/ground_station/src/hmi/hmi.hpp +++ b/ground_station/src/hmi/hmi.hpp @@ -60,6 +60,8 @@ class Hmi { CONCLUDED = 3, }; + enum class SensorView : uint8_t { Readings, Orientation }; + State state = MENU; TestingState testingState = DISCLAIMER; @@ -67,6 +69,7 @@ class Hmi { int16_t testingIndex = 0; CalibrationState calibrationState = IDLE; + SensorView sensorView = SensorView::Readings; uint32_t lastSensorRefresh = 0; Recorder recorder; diff --git a/ground_station/src/hmi/window.cpp b/ground_station/src/hmi/window.cpp index ae90a328..90cd42e7 100644 --- a/ground_station/src/hmi/window.cpp +++ b/ground_station/src/hmi/window.cpp @@ -23,6 +23,17 @@ 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); + const int16_t degreeX = static_cast(display.getCursorX() + 3); + display.drawCircle(degreeX, static_cast(baseline - 15), 2, color); + display.setCursor(static_cast(degreeX + 7), baseline); + display.print(value < 0.0F ? negativeHemisphere : positiveHemisphere); +} + void Window::begin() { surface.begin(); surface.clear(); @@ -36,7 +47,6 @@ 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; @@ -510,7 +520,7 @@ void Window::UpdateLiveState(TelemetryData *data1, TelemetryData *data2, Navigat float bearing = navigation->computeBearing(); display.setCursor(static_cast(xOffset1 + first_row_offset), 145); - display.print(old_bearing[0]); + display.print(old_bearing[0], 1); display.print(" deg"); old_bearing[0] = bearing; @@ -529,7 +539,7 @@ void Window::UpdateLiveState(TelemetryData *data1, TelemetryData *data2, Navigat bearing = navigation->computeBearing(); display.setCursor(static_cast(xOffset2 + first_row_offset), 145); - display.print(old_bearing[1]); + display.print(old_bearing[1], 1); display.print(" deg"); old_bearing[1] = bearing; @@ -554,21 +564,10 @@ void Window::UpdateLiveState(TelemetryData *data1, TelemetryData *data2, Navigat 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"); + drawCoordinate(data1->lat(), 'N', 'S', static_cast(xOffset1 + first_row_offset), 120, WHITE); + drawCoordinate(data1->lon(), 'E', 'W', static_cast(xOffset1 + first_row_offset), 145, WHITE); + drawCoordinate(data2->lat(), 'N', 'S', static_cast(xOffset2 + first_row_offset), 120, WHITE); + drawCoordinate(data2->lon(), 'E', 'W', static_cast(xOffset2 + first_row_offset), 145, WHITE); // Set GNSS to white display.drawBitmap(3, 100, live_lat, 24, 24, WHITE); @@ -701,13 +700,8 @@ 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"); + 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); } else { const auto downrange_m = static_cast(std::roundf(navigation->getDistance())); display.setCursor(static_cast(xOffset + first_row_offset), 120); @@ -734,17 +728,17 @@ void Window::updateLiveData(TelemetryData *data, Navigation *navigation, int16_t display.setCursor(static_cast(xOffset + first_row_offset), 145); display.setTextColor(WHITE); - display.print(old_bearing[index]); + display.print(old_bearing[index], 1); display.print(" deg"); display.setTextColor(color); display.setCursor(static_cast(xOffset + first_row_offset), 145); - display.print(bearing); + display.print(bearing, 1); display.print(" deg"); old_bearing[index] = bearing; } 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)) { @@ -1388,6 +1382,108 @@ 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 heading = navigation->getNorth(); + while (heading < 0.0F) { + heading += 2.0F * PI_F; + } + while (heading >= 2.0F * PI_F) { + heading -= 2.0F * PI_F; + } + const float headingDegrees = heading * 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(heading); + const float cosine = cosf(heading); + 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(); } @@ -1548,13 +1644,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); } diff --git a/ground_station/src/hmi/window.hpp b/ground_station/src/hmi/window.hpp index 2eeeb01c..c0af1b52 100644 --- a/ground_station/src/hmi/window.hpp +++ b/ground_station/src/hmi/window.hpp @@ -84,6 +84,8 @@ class Window { void updateSensorCalibrate(Navigation *navigation); void initSensorCalibrateDone(); void updateSensors(Navigation *navigation); + void initSensorOrientation(); + void updateSensorOrientation(Navigation *navigation); void initSettings(int16_t submenuIdx); void updateSettings(int16_t index); @@ -108,6 +110,8 @@ class Window { private: void updateLiveData(TelemetryData *data, Navigation *navigation, 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); @@ -157,7 +161,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"}; }; From d0f372703c74489ed19980f9739d6af494fcc8ae Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Tue, 1 Sep 2026 20:34:45 +0200 Subject: [PATCH 03/10] feat(gs-sim): improve simulator Add the simplified browser demo, real-time recorded flight replay, optional phone sensor input, declarative fixtures, and browser regression coverage. Expand deterministic scenarios and document the simulator controls and coverage gaps. --- ground_station/simulator/API.md | 5 +- ground_station/simulator/COVERAGE.md | 46 + ground_station/simulator/README.md | 31 +- ground_station/simulator/gs-sim.ps1 | 76 +- ground_station/simulator/gs_sim.py | 162 +- .../simulator/scenarios/bootloader.json | 12 + ground_station/simulator/scenarios/live.json | 13 + .../simulator/scenarios/sensors.json | 16 + ground_station/simulator/web/app.js | 379 +- .../simulator/web/browser-golden.json | 9 + .../simulator/web/browser-tests.html | 12 + ground_station/simulator/web/browser-tests.js | 212 ++ ground_station/simulator/web/coverage.json | 42 + .../web/demo-logs/gs_log_main_1_1.csv | 3301 +++++++++++++++++ .../simulator/web/fixture-runtime.js | 124 + ground_station/simulator/web/fixtures.json | 143 + ground_station/simulator/web/index.html | 95 +- ground_station/simulator/web/style.css | 111 +- 18 files changed, 4593 insertions(+), 196 deletions(-) create mode 100644 ground_station/simulator/COVERAGE.md create mode 100644 ground_station/simulator/scenarios/bootloader.json create mode 100644 ground_station/simulator/scenarios/live.json create mode 100644 ground_station/simulator/scenarios/sensors.json create mode 100644 ground_station/simulator/web/browser-golden.json create mode 100644 ground_station/simulator/web/browser-tests.html create mode 100644 ground_station/simulator/web/browser-tests.js create mode 100644 ground_station/simulator/web/coverage.json create mode 100644 ground_station/simulator/web/demo-logs/gs_log_main_1_1.csv create mode 100644 ground_station/simulator/web/fixture-runtime.js create mode 100644 ground_station/simulator/web/fixtures.json 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..1b9724e9 100644 --- a/ground_station/simulator/README.md +++ b/ground_station/simulator/README.md @@ -16,16 +16,38 @@ 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. +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 > Other > Startup Animation defaults to ON. When switched OFF and saved, Reset shows the legacy static CATS logo for two seconds instead. @@ -47,7 +69,14 @@ 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 diff --git a/ground_station/simulator/gs-sim.ps1 b/ground_station/simulator/gs-sim.ps1 index f940a787..1a5d0acc 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)] @@ -155,6 +155,7 @@ function Build-WasmDirect { '-o', (Join-Path $SimulatorRoot 'web\gs-sim.js'), '-sWASM=1', + '-sSTACK_SIZE=262144', '-sMODULARIZE=1', '-sEXPORT_ES6=1', '-sENVIRONMENT=web', @@ -181,6 +182,74 @@ 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" + } + Write-Host 'browser/WASM: 9 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 +302,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 76a02c26..8cd99a78 100644 --- a/ground_station/simulator/gs_sim.py +++ b/ground_station/simulator/gs_sim.py @@ -39,6 +39,7 @@ BUTTONS = ("up", "down", "left", "right", "center", "ok", "back") MENU_SCREENS = ("live", "recovery", "testing", "data", "sensors", "settings") SETTING_COUNTS = (4, 4, 3) +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 @@ -1286,6 +1287,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")) @@ -1320,8 +1442,8 @@ 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", - "sensors-orientation.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", "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"): @@ -1333,6 +1455,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))) @@ -1365,7 +1494,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 @@ -1376,12 +1525,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/scenarios/bootloader.json b/ground_station/simulator/scenarios/bootloader.json new file mode 100644 index 00000000..defc76f6 --- /dev/null +++ b/ground_station/simulator/scenarios/bootloader.json @@ -0,0 +1,12 @@ +{ + "initial": {"ready": true}, + "steps": [ + {"press": "right"}, {"release": "right"}, {"press": "right"}, {"release": "right"}, + {"press": "down"}, {"release": "down"}, {"press": "ok"}, {"release": "ok"}, + {"press": "down"}, {"release": "down"}, {"press": "down"}, {"release": "down"}, + {"press": "down"}, {"release": "down"}, {"press": "down"}, {"release": "down"}, + {"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/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/web/app.js b/ground_station/simulator/web/app.js index 92be2c2c..071db772 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 = 'compass-view-20260823-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..0502db56 --- /dev/null +++ b/ground_station/simulator/web/browser-golden.json @@ -0,0 +1,9 @@ +{ + "menu": "c2aed4b98256a29885ed9454cba3dbd9b99169437030693cecb935817e6ff334", + "startupRocket": "c8db9d72adcd669e7f60cb33e213e1f05c9f4eecead98bf8d2510d254163ae64", + "lightDemo": "fa391a09283a47cd42db9fc338647c1f9eafdbc7d9d2ab7185001787ab603ab1", + "recovery": "7d7386739f96b1615d6364c912a52d2b9c45ab5cbe7bd03d3b90d7dcbb463d61", + "compassOrientation": "2a0623d35eaac6f98c310079a41d74d71d975f3e28d6d8de654963e089312d92", + "usbStorage": "cd4a76e556b38614ce3194c15fb40de92c012ab78f128d9624d4c3043047c089", + "recorderFault": "9dec4d072234546324d9894eca09c85259c42bca150208ddf97f540789b78762" +} 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..5b9ef3f6 --- /dev/null +++ b/ground_station/simulator/web/browser-tests.js @@ -0,0 +1,212 @@ +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').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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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}`); + } + }); + + 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..fde8d681 --- /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 > General > 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": "down"}, {"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; } } From d5bbb7d475a067915da6bee674ee9491cd2ef454 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Thu, 3 Sep 2026 17:33:11 +0200 Subject: [PATCH 04/10] ci: include firmware versions in artifact names --- .github/workflows/merge-criteria.yml | 41 ++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) 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 From 42b425157f1dfa2f39f43d00431de6a4234ca434 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Thu, 3 Sep 2026 19:02:10 +0200 Subject: [PATCH 05/10] feat(gs): add factory self-test and firmware details Add the three-phase factory self-test with telemetry, sensor, radio, storage, and gated manual checks. Report Ground Station and telemetry versions through Settings and version.json, reorganize the Settings pages, and cover the workflows in the simulator. --- ground_station/AGENTS.md | 6 +- ground_station/SELF-TEST.md | 61 +++ ground_station/simulator/README.md | 4 +- ground_station/simulator/gs-sim.ps1 | 3 + ground_station/simulator/gs_sim.py | 53 ++- ground_station/simulator/hmi_controller.cpp | 82 +++- ground_station/simulator/hmi_controller.hpp | 25 +- .../simulator/scenarios/bootloader.json | 14 +- .../scenarios/settings-versions.json | 142 ++++++ .../simulator/scenarios/settings.json | 40 +- .../simulator/scenarios/usb-storage.json | 12 +- .../simulator/self_test_fixture.cpp | 118 +++++ ground_station/simulator/wasm_api.cpp | 35 ++ .../simulator/web/browser-golden.json | 10 +- ground_station/simulator/web/browser-tests.js | 300 +++++++++++- ground_station/simulator/web/fixtures.json | 4 +- .../simulator/window_hmi_renderer.cpp | 18 +- .../simulator/window_hmi_renderer.hpp | 1 + ground_station/src/console.cpp | 1 + ground_station/src/hmi/hmi.cpp | 42 +- ground_station/src/hmi/hmi.hpp | 23 + ground_station/src/hmi/hmi_self_test.cpp | 170 +++++++ ground_station/src/hmi/settings.cpp | 154 +++--- ground_station/src/hmi/settings.hpp | 13 +- ground_station/src/hmi/window.cpp | 54 ++- ground_station/src/hmi/window.hpp | 4 + ground_station/src/hmi/window_self_test.cpp | 222 +++++++++ ground_station/src/main.cpp | 7 +- ground_station/src/navigation.cpp | 68 ++- ground_station/src/navigation.hpp | 8 + ground_station/src/self_test.cpp | 443 ++++++++++++++++++ ground_station/src/self_test.hpp | 197 ++++++++ ground_station/src/telemetry/parser.cpp | 82 +++- ground_station/src/telemetry/parser.hpp | 18 +- ground_station/src/telemetry/telemetry.cpp | 48 +- ground_station/src/telemetry/telemetry.hpp | 19 + .../src/telemetry/telemetry_reg.hpp | 8 +- ground_station/src/utils.cpp | 99 +++- ground_station/tests/self_test.cpp | 226 +++++++++ 39 files changed, 2619 insertions(+), 215 deletions(-) create mode 100644 ground_station/SELF-TEST.md create mode 100644 ground_station/simulator/scenarios/settings-versions.json create mode 100644 ground_station/simulator/self_test_fixture.cpp create mode 100644 ground_station/src/hmi/hmi_self_test.cpp create mode 100644 ground_station/src/hmi/window_self_test.cpp create mode 100644 ground_station/src/self_test.cpp create mode 100644 ground_station/src/self_test.hpp create mode 100644 ground_station/tests/self_test.cpp 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/SELF-TEST.md b/ground_station/SELF-TEST.md new file mode 100644 index 00000000..3b941efa --- /dev/null +++ b/ground_station/SELF-TEST.md @@ -0,0 +1,61 @@ +# Ground Station self-test + +Open **Settings > System > Self-Test**, rest the Ground Station on the bench with +access to the sky, and press **A: Start**. Results stay on the screen; no reports +or CSV files are saved. Saved settings, flight logs and compass calibration are retained. + +## 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. +- 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. + +The initial limits are at least 5 packets/s, mean link quality at least 50%, and +no gap longer than 1.5 seconds. These provisional 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 seven buttons. The Start press does not count. | +| 3.3 Display | Inspect black, white and two checkerboard patterns. Then A = good, B = fault. | +| 3.4 Receiver LEDs | Keep both test Vegas powered. Watch receiver 1, then receiver 2 blink. Then A = good, B = fault. | +| 3.5 Battery | Unplug USB with the power switch on. The GS checks five seconds of battery operation at 3.0-4.35 V. | +| 3.6 USB reconnect | Reconnect USB to the computer. A connection made before Start does not complete this check. | + +Receiver settings are restored again after the LED check. USB storage sharing +resumes when the run ends and USB is connected. 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/simulator/README.md b/ground_station/simulator/README.md index 1b9724e9..b002008a 100644 --- a/ground_station/simulator/README.md +++ b/ground_station/simulator/README.md @@ -48,7 +48,7 @@ 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 > Other > Startup Animation defaults to ON. When switched OFF and +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 @@ -80,6 +80,6 @@ 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/gs-sim.ps1 b/ground_station/simulator/gs-sim.ps1 index 1a5d0acc..a4f405d6 100644 --- a/ground_station/simulator/gs-sim.ps1 +++ b/ground_station/simulator/gs-sim.ps1 @@ -149,6 +149,9 @@ 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'), diff --git a/ground_station/simulator/gs_sim.py b/ground_station/simulator/gs_sim.py index 8cd99a78..17903502 100644 --- a/ground_station/simulator/gs_sim.py +++ b/ground_station/simulator/gs_sim.py @@ -38,7 +38,7 @@ 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 @@ -295,6 +295,11 @@ def __init__(self, initial: dict[str, Any] | None = None, ready: bool = True, ba 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" @@ -487,6 +492,13 @@ def render_screen(self) -> None: 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": @@ -539,6 +551,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))) @@ -563,7 +584,7 @@ def tick(self, explicit_pressed: set[str]) -> None: self.settings_step(explicit_pressed) elif self.screen == "recovery": self.recovery_step(explicit_pressed) - elif self.screen == "bootloader" and "back" in explicit_pressed: + elif self.screen in ("bootloader", "self_test") and "back" in explicit_pressed: self.screen = "settings" elif self.screen == "usb_storage": self.usb_storage_step(explicit_pressed) @@ -962,10 +983,10 @@ def settings_step(self, pressed: set[str]) -> None: 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): @@ -982,22 +1003,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: + elif self.settings_page == 2 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 == 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) @@ -1018,23 +1041,17 @@ def usb_storage_step(self, pressed: set[str]) -> None: 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) @@ -1189,6 +1206,8 @@ def snapshot(self) -> dict[str, Any]: "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, @@ -1443,7 +1462,7 @@ def deterministic_test(root: Path) -> int: 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", "live.json", "testing-timeout.json", - "sensors.json", "sensors-orientation.json", "settings.json", "bootloader.json", "replay.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"): diff --git a/ground_station/simulator/hmi_controller.cpp b/ground_station/simulator/hmi_controller.cpp index 24549047..21a5b99a 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; @@ -47,6 +51,8 @@ void HmiController::start() { keyboardActive_ = false; keyboardSelection_ = 0; keyboardUppercase_ = false; + usbStorageSession_ = false; + usbStorageMessage_.clear(); usbPreviouslyConnected_ = false; automaticUsbSharePending_ = false; previousRecorderState_ = logs_.recorderStatus().state; @@ -55,6 +61,9 @@ void HmiController::start() { heldSince_.fill(0); lastRepeat_.fill(0); actions_.clear(); + selfTest_ = SelfTest{}; + selfTestInput_ = {}; + selfTestResultIndex_ = 0; renderer_.begin(); render(); } @@ -64,9 +73,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]) { @@ -103,6 +132,9 @@ void HmiController::step(const HmiInput& input, uint64_t nowMs) { case Screen::Settings: settingsStep(nowMs_); break; + case Screen::SelfTest: + selfTestStep(nowMs_); + break; case Screen::Recovery: if (qrView_ == "none" && config_.config().dualReceiver && (pressed(HmiButton::Up) || pressed(HmiButton::Down))) { @@ -144,24 +176,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; } @@ -191,6 +217,7 @@ 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; clearQr(); if (screen_ == Screen::Testing) { @@ -202,8 +229,10 @@ void HmiController::enter(Screen screen) { sensorOrientation_ = false; } if (screen_ == Screen::Settings) { - settingsPage_ = 0; - settingsSelection_ = -1; + if (!returningToSettings) { + settingsPage_ = SETTING_TELEMETRY; + settingsSelection_ = -1; + } keyboardActive_ = false; } if (screen_ == Screen::Recovery) { @@ -574,7 +603,7 @@ void HmiController::sensorsStep(uint64_t nowMs) { } void HmiController::settingsStep(uint64_t nowMs) { - const int16_t counts[3] = {4, 4, 3}; + const int16_t counts[3] = {4, 4, 4}; if (keyboardActive_) { const bool moveRight = pressed(HmiButton::Right) || repeated(HmiButton::Right, nowMs); const bool moveLeft = pressed(HmiButton::Left) || repeated(HmiButton::Left, nowMs); @@ -642,28 +671,31 @@ void HmiController::settingsStep(uint64_t nowMs) { --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_USB_STORAGE) { usbStorageMessage_.clear(); const DeviceStatusSnapshot device = device_.snapshot(); if (!device.usb) { @@ -681,7 +713,14 @@ void HmiController::settingsStep(uint64_t nowMs) { enter(Screen::UsbStorage); return; } - if (pressed(HmiButton::Ok) && settingsPage_ == 0 && settingsSelection_ == 3) { + if (pressed(HmiButton::Ok) && action == BUTTON_ACTION_SELF_TEST) { + selfTest_ = SelfTest{}; + selfTestInput_ = {}; + selfTestResultIndex_ = 0; + enter(Screen::SelfTest); + return; + } + if (pressed(HmiButton::Ok) && action == BUTTON_ACTION_START_BOOTLOADER) { enter(Screen::Bootloader); emit("bootloader_requested"); } @@ -744,7 +783,7 @@ void HmiController::render() { std::string HmiController::screenName() const { static constexpr const char* names[] = {"logo", "menu", "live", "recovery", "testing", "data", "sensors", - "settings", "bootloader", "usb_storage"}; + "settings", "bootloader", "usb_storage", "self_test"}; return names[static_cast(screen_)]; } @@ -766,9 +805,12 @@ std::string HmiController::dataSubviewName() const { 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"; diff --git a/ground_station/simulator/hmi_controller.hpp b/ground_station/simulator/hmi_controller.hpp index 588741f8..a21d7c9e 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 { @@ -81,6 +84,11 @@ 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; }; struct GsConfigSnapshot { @@ -166,6 +174,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 { @@ -209,6 +218,7 @@ 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"; @@ -243,6 +253,8 @@ struct HmiSnapshot { RecoverySolutionSnapshot recoverySolution; std::vector actions; uint32_t framebufferRevision = 0; + SelfTest selfTest; + int16_t selfTestResultIndex = 0; }; class IHmiRenderer { @@ -265,7 +277,7 @@ 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 }; 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 }; @@ -286,6 +298,8 @@ 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 updateRecoveryLocations(); bool showRecoveryLocation(size_t linkIndex); void emit(const char* type, uint8_t link = 0, int32_t value = 0, const std::string& text = {}); @@ -334,10 +348,19 @@ class HmiController { 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; }; diff --git a/ground_station/simulator/scenarios/bootloader.json b/ground_station/simulator/scenarios/bootloader.json index defc76f6..c6ab4ce3 100644 --- a/ground_station/simulator/scenarios/bootloader.json +++ b/ground_station/simulator/scenarios/bootloader.json @@ -1,10 +1,16 @@ { "initial": {"ready": true}, "steps": [ - {"press": "right"}, {"release": "right"}, {"press": "right"}, {"release": "right"}, - {"press": "down"}, {"release": "down"}, {"press": "ok"}, {"release": "ok"}, - {"press": "down"}, {"release": "down"}, {"press": "down"}, {"release": "down"}, - {"press": "down"}, {"release": "down"}, {"press": "down"}, {"release": "down"}, + {"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": "bootloader", "emittedAction": "bootloader_requested"}}, {"snapshot": "bootloader-requested"} 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..a001d438 --- /dev/null +++ b/ground_station/simulator/self_test_fixture.cpp @@ -0,0 +1,118 @@ +// 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_.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; + 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.gyro = {nav.gx, nav.gy, nav.gz}; + 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_); + 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 e800b5c7..ba8413b2 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 { @@ -435,6 +442,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; @@ -471,6 +481,19 @@ 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) + + ",\"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) + @@ -479,6 +502,8 @@ void rebuildSnapshot() { ",\"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) + @@ -558,6 +583,8 @@ void resetState(bool skipStartup, bool preserveConfiguration = false) { device = Device{}; controller.start(); if (skipStartup) { + virtualClock.now = 20; + step(); virtualClock.now = StartupIntro::kDurationMs; step(); } @@ -649,6 +676,11 @@ 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 (booleanField(json, "recorderWriteFailure", boolean)) logs.failWrite = boolean; if (booleanField(json, "deleteFailure", boolean)) logs.failDelete = boolean; if (booleanField(json, "finalizeFailure", boolean)) logs.failFinalize = boolean; @@ -663,6 +695,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/browser-golden.json b/ground_station/simulator/web/browser-golden.json index 0502db56..3f46af96 100644 --- a/ground_station/simulator/web/browser-golden.json +++ b/ground_station/simulator/web/browser-golden.json @@ -1,9 +1,9 @@ { - "menu": "c2aed4b98256a29885ed9454cba3dbd9b99169437030693cecb935817e6ff334", + "menu": "2102f0f91cd0c03176ee70f679063cdc9fb95593e64c401b88149c4316c5c117", "startupRocket": "c8db9d72adcd669e7f60cb33e213e1f05c9f4eecead98bf8d2510d254163ae64", - "lightDemo": "fa391a09283a47cd42db9fc338647c1f9eafdbc7d9d2ab7185001787ab603ab1", - "recovery": "7d7386739f96b1615d6364c912a52d2b9c45ab5cbe7bd03d3b90d7dcbb463d61", - "compassOrientation": "2a0623d35eaac6f98c310079a41d74d71d975f3e28d6d8de654963e089312d92", + "lightDemo": "775ecaa079f4bd1dd7130ff0f962de512dec16dace84b5e91bb36b39619d426a", + "recovery": "eda5a41b5f22562e8628f879dbe04f9e3d7e929d54157840398fe49b2dca0a56", + "compassOrientation": "6841fe2586d72f8a940f646880e1ffcf29b504ffe15c2e747a0a1e12d7ea52f4", "usbStorage": "cd4a76e556b38614ce3194c15fb40de92c012ab78f128d9624d4c3043047c089", - "recorderFault": "9dec4d072234546324d9894eca09c85259c42bca150208ddf97f540789b78762" + "recorderFault": "32219f426fded5eafe2a0a5199c593e94d9e858f5bb973190bcd71ce76e5ca2c" } diff --git a/ground_station/simulator/web/browser-tests.js b/ground_station/simulator/web/browser-tests.js index 5b9ef3f6..f3528f94 100644 --- a/ground_station/simulator/web/browser-tests.js +++ b/ground_station/simulator/web/browser-tests.js @@ -52,7 +52,7 @@ async function run() { const [moduleImport, manifest, goldens] = await Promise.all([ import('./gs-sim.js?v=browser-tests-1'), loadFixtureManifest(), - fetch('./browser-golden.json').then(response => response.json()) + fetch('./browser-golden.json', {cache: 'no-store'}).then(response => response.json()) ]); const wasm = await moduleImport.default({ locateFile: path => `${path}?v=browser-tests-1` }); @@ -62,6 +62,7 @@ async function run() { 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 () => { @@ -102,6 +103,7 @@ async function run() { 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 () => { @@ -112,6 +114,7 @@ async function run() { 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 () => { @@ -160,6 +163,7 @@ async function run() { 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('all declarative fixtures', async () => { @@ -186,6 +190,7 @@ async function run() { 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 () => { @@ -195,6 +200,299 @@ async function run() { } }); + 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: true, 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: three phases and confirmation before every manual check', async () => { + openSelfTest(); + captureSelfTest('Self-test entry'); + tap(wasm, 'ok'); + advanceSelfTest(800); + captureSelfTest('Phase 1 - Ground Station'); + advanceSelfTest(3200); + let state = snapshot(wasm); + assert(!state.actions.some(action => action.type === 'self_test_radio'), 'Phase 1 does not change radio settings'); + confirmSelfTestStep(2, 'Phase 2 - ready for telemetry'); + advanceSelfTest(8000); + captureSelfTest('Phase 2 - telemetry running'); + advanceSelfTest(64000); + state = snapshot(wasm); + assert(state.selfTestChecks.slice(0, 11).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(3, '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(4, '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', 'center', 'ok', 'back']) tap(wasm, button); + confirmSelfTestStep(5, '3.3 - ready for display'); + advanceSelfTest(2100); + captureSelfTest('Display - black pattern'); + advanceSelfTest(2000); + captureSelfTest('Display - white pattern'); + advanceSelfTest(2000); + captureSelfTest('Display - checkerboard'); + advanceSelfTest(2000); + captureSelfTest('Display - inverse checkerboard'); + advanceSelfTest(2000); + captureSelfTest('Display confirmation'); + wasm.ccall('gs_press', null, ['string'], ['ok']); + advanceSelfTest(15000); + assert(snapshot(wasm).selfTestWaiting, 'holding display confirmation cannot start the LED check'); + wasm.ccall('gs_release', null, ['string'], ['ok']); + confirmSelfTestStep(6, '3.4 - ready for receiver LEDs'); + advanceSelfTest(12100); + captureSelfTest('Receiver LED confirmation'); + tap(wasm, 'ok'); + captureSelfTest('Restoring settings'); + advanceSelfTest(2600); + assert(snapshot(wasm).selfTestPhase === 8, 'normal receiver settings restored before battery test'); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":false}']); + confirmSelfTestStep(8, '3.5 - ready for battery', 9); + advanceSelfTest(2000); + captureSelfTest('Battery operation'); + advanceSelfTest(3200); + assert(snapshot(wasm).selfTestPhase === 10, 'five battery seconds precede reconnect'); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":true}']); + confirmSelfTestStep(10, '3.6 - ready for USB reconnect'); + advanceSelfTest(1000); + assert(snapshot(wasm).selfTestPhase === 10, 'connecting before Start cannot pass reconnect'); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":false}']); + advanceSelfTest(20); + wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":true}']); + 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 < 11; ++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(4000); + tap(wasm, 'ok'); advanceSelfTest(140000); + const state = snapshot(wasm); + assert(state.selfTestChecks[0] === 'FAIL' && state.selfTestChecks[1] === 'PASS', 'independent firmware replies'); + assert(state.selfTestChecks[5] === 'FAIL' && state.selfTestChecks[7] === 'FAIL', 'both dual and single expose missing receiver'); + assert(state.selfTestChecks[9] === 'PASS', 'receiver 2 still tested independently'); + assert(state.selfTestPhase === 3 && 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(121000); + let state = snapshot(wasm); + assert(state.selfTestChecks[2] === 'FAIL', 'GNSS timeout fails acquisition'); + assert(state.selfTestChecks[10] === 'FAIL', 'storage failure is visible'); + assert(state.selfTestPhase === 2 && state.selfTestWaiting, 'telemetry waits for its own confirmation'); + tap(wasm, 'ok'); advanceSelfTest(72000); + state = snapshot(wasm); + assert(state.selfTestPhase === 3 && state.selfTestWaiting, 'manual checks wait after independent telemetry checks'); + assert(state.selfTestChecks[2] === 'FAIL' && state.selfTestChecks[10] === 'FAIL', 'later checks preserve Phase 1 failures'); + }); + + await test('factory self-test: cancellation cannot pass and restores configuration', async () => { + openSelfTest(); tap(wasm, 'ok'); advanceSelfTest(4000); + tap(wasm, 'ok'); advanceSelfTest(4000); + wasm.ccall('gs_hold', null, ['string', 'number'], ['back', 2100]); + advanceSelfTest(2600); + const state = snapshot(wasm); + assert(state.selfTestPhase === 11 && state.selfTestResult === 'FAIL', `cancellation phase=${state.selfTestPhase} result=${state.selfTestResult} checks=${state.selfTestChecks}`); + assert(state.selfTestChecks[11] === '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 === 'bootloader' && snapshot(wasm).actions.some(action => action.type === 'bootloader_requested'), 'Update Firmware 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'); + }); + const failed = results.filter(result => !result.passed); const report = { passed: failed.length === 0, results, framebufferSha256: actualHashes }; globalThis.browserTestReport = report; diff --git a/ground_station/simulator/web/fixtures.json b/ground_station/simulator/web/fixtures.json index fde8d681..fa0cc19a 100644 --- a/ground_station/simulator/web/fixtures.json +++ b/ground_station/simulator/web/fixtures.json @@ -110,11 +110,11 @@ { "id": "usb-storage", "label": "USB Drive active", - "description": "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.", + "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": "down"}, {"tap": "down"}, {"tap": "down"}, {"tap": "ok"} + {"tap": "right"}, {"tap": "right"}, {"tap": "down"}, {"tap": "down"}, {"tap": "ok"} ] }, { diff --git a/ground_station/simulator/window_hmi_renderer.cpp b/ground_station/simulator/window_hmi_renderer.cpp index 9495cdd4..ca6f2a6f 100644 --- a/ground_station/simulator/window_hmi_renderer.cpp +++ b/ground_station/simulator/window_hmi_renderer.cpp @@ -71,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) { @@ -88,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") { @@ -225,6 +231,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 4f18bcc6..18f89760 100644 --- a/ground_station/src/hmi/hmi.cpp +++ b/ground_station/src/hmi/hmi.cpp @@ -84,6 +84,10 @@ void Hmi::fsm() { usbStorage(); break; + case SELF_TEST: + selfTestStep(); + break; + default: break; } @@ -742,6 +746,7 @@ void Hmi::sensors() { /* SETTINGS */ void Hmi::initSettings() { + versionsSelected = false; settingSubMenu = 0; settingIndex = -1; window.initSettings(settingSubMenu); @@ -848,7 +853,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'; @@ -867,7 +873,11 @@ void Hmi::settings() { window.Bootloader(); Utils::startBootloader(); return; + case BUTTON_ACTION_SELF_TEST: + initSelfTest(); + return; case BUTTON_ACTION_NONE: + case BUTTON_ACTION_VERSION: break; } } @@ -912,6 +922,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() { @@ -1005,6 +1042,7 @@ void Hmi::usbStorage() { } void Hmi::updateAutomaticUsbStorage(const RecorderStatus &recorderStatus) { + if (state == SELF_TEST) return; const bool connected = Utils::isConnected(); if (!connected) { usbPreviouslyConnected = false; @@ -1083,7 +1121,7 @@ void Hmi::update(void *pvParameter) { // ref->window.updateBar(link1.data.ts()); } - if (millis() - barUpdate >= 1000) { + if (ref->state != SELF_TEST && millis() - barUpdate >= 1000) { 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 38b5da72..00b041c4 100644 --- a/ground_station/src/hmi/hmi.hpp +++ b/ground_station/src/hmi/hmi.hpp @@ -26,6 +26,11 @@ class Hmi { 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; @@ -42,6 +47,7 @@ class Hmi { SENSORS = 5, SETTINGS = 6, USB_STORAGE = 7, + SELF_TEST = 8, }; enum TestingState { @@ -103,10 +109,27 @@ class Hmi { void sensors(); void initSettings(); void settings(); + void updateSettingVersions(); + bool versionsSelected{false}; + char displayedVersions[2][17]{}; void initUsbStorage(); void usbStorage(); 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; 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..c8f780ac --- /dev/null +++ b/ground_station/src/hmi/hmi_self_test.cpp @@ -0,0 +1,170 @@ +/// 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(); + input.usbConnected = Utils::isConnected(); + 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()); + 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..aaeee1f2 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", + "Press A to restart the GS for a USB update", + "Connect to a computer before continuing", + BUTTON, + {.buttonAction = BUTTON_ACTION_START_BOOTLOADER}, + nullptr}, + }, +}; diff --git a/ground_station/src/hmi/settings.hpp b/ground_station/src/hmi/settings.hpp index bcdf1668..4e9c51e8 100644 --- a/ground_station/src/hmi/settings.hpp +++ b/ground_station/src/hmi/settings.hpp @@ -21,6 +21,8 @@ enum settings_button_action_e { BUTTON_ACTION_NONE = 0, BUTTON_ACTION_USB_STORAGE, BUTTON_ACTION_START_BOOTLOADER, + 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 90cd42e7..ca5ed5c5 100644 --- a/ground_station/src/hmi/window.cpp +++ b/ground_station/src/hmi/window.cpp @@ -254,6 +254,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); @@ -270,7 +274,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; @@ -291,7 +295,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); @@ -307,7 +311,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); @@ -335,7 +339,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); @@ -360,6 +364,7 @@ void Window::updateBar(float batteryVoltage, bool usb, bool logging, bool locati } barBlinkStatus = !barBlinkStatus; + barNeedsRedraw = false; surface.present(); } @@ -1709,8 +1714,13 @@ void Window::addSettingEntry(uint32_t settingIndex, const device_settings_t *set static_cast(y + 22), color); } } else if (setting->type == STRING) { - display.setFont(&FreeMonoBold12pt7b); - drawCentreString(static_cast(setting->dataPtr), 285, y); + if (setting->dataPtr == systemConfig.config.linkPhrase2 && systemConfig.config.receiverMode == SINGLE) { + display.setFont(&FreeSans9pt7b); + drawCentreString("Dual mode only", 285, y); + } else { + display.setFont(&FreeMonoBold12pt7b); + drawCentreString(static_cast(setting->dataPtr), 285, y); + } display.setFont(&FreeSans12pt7b); } else if (setting->type == NUMBER) { char buffer[8]; @@ -1748,6 +1758,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); @@ -1755,6 +1772,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(); } @@ -1779,6 +1800,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); @@ -1788,6 +1814,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 c0af1b52..1b4d463b 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" @@ -88,7 +89,9 @@ class Window { 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); @@ -147,6 +150,7 @@ 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}; 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..43e2fa4d --- /dev/null +++ b/ground_station/src/hmi/window_self_test.cpp @@ -0,0 +1,222 @@ +/// 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(84, "1. Ground Station: GNSS, sensors, storage"); + text(110, "2. Telemetry: two test Vegas"); + text(136, "3. Manual: confirm before each check"); + text(175, "Results are shown on this screen."); + text(199, "Normal settings and calibration are retained."); + 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 seven buttons."); + text(117, "The Start press does not count."); + break; + case SelfTest::Phase::Display: + text(90, "Four patterns will fill the display."); + text(117, "Watch for missing pixels, lines or noise."); + text(154, "Afterward: A = good, B = fault."); + break; + case SelfTest::Phase::Leds: + text(90, "Keep both test Vegas powered on."); + text(117, "Watch receiver 1, then receiver 2 blink."); + text(154, "Afterward: A = good, B = fault."); + break; + case SelfTest::Phase::Unplug: + text(90, "After Start, unplug USB."); + text(117, "Keep the power switch on."); + text(154, "The GS must run on battery for 5 seconds."); + break; + case SelfTest::Phase::Reconnect: + text(90, "After Start, reconnect USB to the PC."); + text(117, "If already connected, unplug it first."); + break; + default: + break; + } + text(211, "Waiting for your confirmation."); + } 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}; + 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 ? 4 : 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"}; + for (size_t i = 0; i < 7; ++i) { + std::snprintf(line, sizeof(line), "[%c] %s", test.completedButtons & (1U << i) ? '+' : ' ', names[i]); + text(static_cast(76 + i * 18), line); + } + 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) >= 10000U) { + text(190, "Hold B for 2 seconds to cancel."); + leftHint = "Display fault (B)"; + rightHint = "Looks good (A)"; + } + } else if (test.phase == SelfTest::Phase::Leds) { + text(95, "Each receiver is enabled in turn."); + text(120, "Its LED should blink as packets arrive."); + if (test.phaseElapsedMs(now) >= 12000U) { + text(190, "Hold B for 2 seconds to cancel."); + leftHint = "LED fault (B)"; + rightHint = "Both worked (A)"; + } + } else if (test.phase == SelfTest::Phase::Battery) { + std::snprintf(line, sizeof(line), "Battery: %.2f V", static_cast(test.batteryMinimum)); + display.setFont(&FreeSansBold12pt7b); + drawCentreString(line, 200, 120); + display.setFont(&FreeSans9pt7b); + } else if (test.phase == SelfTest::Phase::Unplug) { + text(100, "Battery operation is checked next."); + text(130, "Skipping leaves this check not tested."); + rightHint = "Skip (A)"; + } else if (test.phase == SelfTest::Phase::Reconnect) { + text(100, "Waiting for USB to reconnect."); + rightHint = "Skip (A)"; + } + } + text(232, leftHint); + rightText(232, rightHint); + display.setTextWrap(true); + surface.present(); +} 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..f519e6f1 100644 --- a/ground_station/src/navigation.cpp +++ b/ground_station/src/navigation.cpp @@ -9,6 +9,32 @@ 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::begin() { initialized = true; @@ -16,7 +42,8 @@ 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!"); } @@ -161,18 +188,53 @@ 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; + 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.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]); diff --git a/ground_station/src/navigation.hpp b/ground_station/src/navigation.hpp index 8ff5fd22..6ac7122a 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,8 @@ class EarthPoint3D { class Navigation { public: bool begin(); + void enableSelfTestSampling(bool enabled) { selfTestSampling = enabled; } + SelfTestSensorObservation selfTestSensors() const; void setPointA(EarthPoint3D point) { pointA = point; } void setPointA(float lat, float lon, float height = 0) { pointA = EarthPoint3D(lat, lon, height); } @@ -142,6 +146,10 @@ class Navigation { private: bool initialized = false; + std::atomic selfTestSampling{false}; + bool imuDetected{false}; + mutable portMUX_TYPE sensorMux = portMUX_INITIALIZER_UNLOCKED; + SelfTestSensorObservation sensorObservation{}; 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..ece619f0 --- /dev/null +++ b/ground_station/src/self_test.cpp @@ -0,0 +1,443 @@ +/// 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::Hardware, 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::Leds || next == Phase::Unplug || + next == Phase::Reconnect; +} + +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 - startedAt >= 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::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 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 >= measuredMs * SelfTestProfile::kMinimumPacketsPerSecond / 1000 && + stats.maxGapMs <= SelfTestProfile::kMaximumGapMs && stats.infoSamples > 0 && + stats.lqSum / stats.infoSamples >= SelfTestProfile::kMinimumMeanLq; + } + if (radioStage >= 2) { + combinedMaxGap = std::max(combinedMaxGap, now - lastCombinedAt); + passed &= combinedPackets >= measuredMs * SelfTestProfile::kMinimumPacketsPerSecond / 1000 && + combinedMaxGap <= SelfTestProfile::kMaximumGapMs; + } + setResult(kRadioChecks[radioStage], passed ? Result::Pass : Result::Fail, + passed ? "Packet rate, gaps and quality within limits" : "Low packet rate, quality or reception gap"); + 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; + } else if (phase == Phase::Leds) { + radioRequest = RadioConfiguration::Receiver1Only; + radioChanged = true; + } else if (phase == Phase::Reconnect) { + usbDisconnected = !input.usbConnected; + } + } + return; + } + if (phase == Phase::Hardware || phase == Phase::Motion) observeSensors(now, input.sensors); + switch (phase) { + case Phase::Hardware: + observeGnss(now, input); + 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) + 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; + completedButtons |= pressedButtons & static_cast(~input.buttonsHeld); + if (completedButtons == 0x7f) { + setResult(Check::Buttons, Result::Pass, "All seven 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 >= 10000U && (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::Leds, now); + } + break; + case Phase::Leds: + if (!ledSecond && now - phaseStartedAt >= 6000U) { + ledSecond = true; + radioRequest = RadioConfiguration::Receiver2Only; + } + if (now - phaseStartedAt >= 12000U && (pressed & (kA | kB)) != 0) { + const bool passed = (pressed & kA) != 0; + setResult(Check::Leds, passed ? Result::Pass : Result::Fail, + passed ? "Operator confirmed each receiver LED" : "Operator reported LED fault"); + radioRequest = RadioConfiguration::Restore; + afterRestore = Phase::Unplug; + enter(Phase::Restoring, 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::Unplug: + if (!input.usbConnected) { + batteryMinimum = input.batteryVoltage; + enter(Phase::Battery, now); + } else if ((pressed & kA) != 0) { + setResult(Check::Battery, Result::Incomplete, "Battery operation skipped"); + setResult(Check::Usb, Result::Incomplete, "USB reconnect skipped"); + finish(now); + } + break; + case Phase::Battery: + batteryMinimum = std::min(batteryMinimum, input.batteryVoltage); + if (input.usbConnected) + enter(Phase::Unplug, now); + else if (now - phaseStartedAt >= 5000U) { + const bool passed = plausibleBattery(batteryMinimum); + setResult(Check::Battery, passed ? Result::Pass : Result::Fail, + passed ? "Five seconds on battery" : "Battery voltage outside 3.0-4.35 V"); + enter(Phase::Reconnect, now); + } + break; + case Phase::Reconnect: + usbDisconnected |= !input.usbConnected; + if (usbDisconnected && input.usbConnected) { + setResult(Check::Usb, Result::Pass, "USB enumerated after battery operation"); + finish(now); + } else if ((pressed & kA) != 0) { + setResult(Check::Usb, Result::Incomplete, "USB reconnect 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 < 2000U || now - phaseStartedAt >= 10000U) + return 0; + return static_cast(1U + (now - phaseStartedAt - 2000U) / 2000U); +} +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[] = {"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", + "Receiver LEDs", + "Battery operation", + "USB reconnect", + "Settings restored"}; + return names[static_cast(item)]; +} +const char* SelfTest::title() const { + static constexpr const char* names[] = {"Factory self-test", "1. Ground Station", "2. Telemetry", + "3.1 Sensor movement", "3.2 Buttons", "3.3 Display", + "3.4 Receiver LEDs", "Restoring settings", "3.5 Battery", + "3.5 Battery", "3.6 USB reconnect", "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 "Rest on the bench with access to the sky."; + case Phase::Hardware: + return "Keep still. Checks run automatically."; + case Phase::Telemetry: + return "Testing both telemetry receivers."; + 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::Leds: + return ledSecond ? "Receiver 2 LED should blink." : "Receiver 1 LED should blink."; + case Phase::Restoring: + return "Restoring normal receiver settings."; + case Phase::Unplug: + return "Disconnect USB; keep the power switch on."; + case Phase::Battery: + return "Keep USB disconnected for 5 seconds."; + case Phase::Reconnect: + return usbDisconnected ? "Connect USB to the computer." : "Unplug USB, then reconnect it."; + 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..380c28e1 --- /dev/null +++ b/ground_station/src/self_test.hpp @@ -0,0 +1,197 @@ +/// 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}; + 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 magnetic{}; // Uncalibrated ADC counts, for both variants. +}; + +struct SelfTestInput { + std::array links{}; + SelfTestSensorObservation sensors{}; + bool linksReady{false}; + bool usbConnected{false}; + 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 = 5; + static constexpr uint32_t kMaximumGapMs = 1500; + static constexpr uint32_t kMinimumMeanLq = 50; +}; + +class SelfTest { + public: + enum class Result : uint8_t { Pending, Pass, Fail, Incomplete }; + enum class Check : uint8_t { + Firmware1, + Firmware2, + Gnss, + Stationary, + Magnetic, + Dual, + Swapped, + Single, + Receiver1Only, + Receiver2Only, + Storage, + Motion, + Buttons, + Display, + Leds, + Battery, + Usb, + Restoration, + Count + }; + enum class Phase : uint8_t { + Ready, + Hardware, + Telemetry, + Motion, + Buttons, + Display, + Leds, + Restoring, + Unplug, + Battery, + Reconnect, + 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(); + 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}; + float batteryMinimum{0}; + bool cancelled{false}; + + 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 finish(uint32_t now); + RadioConfiguration radioRequest{RadioConfiguration::None}; + bool radioReset{false}; + bool versionRequest{false}; + 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 ledSecond{false}; + bool usbDisconnected{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/telemetry/parser.cpp b/ground_station/src/telemetry/parser.cpp index ea9e94d7..b7d0ded6 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,93 @@ 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]; + 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..2cc9b01e 100644 --- a/ground_station/src/telemetry/telemetry.cpp +++ b/ground_station/src/telemetry/telemetry.cpp @@ -12,11 +12,21 @@ constexpr uint8_t TASK_TELE_FREQ = 100; void Telemetry::begin() { 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 +58,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; } @@ -116,13 +129,38 @@ void Telemetry::triggerEvent(uint8_t event) { 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 (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) { diff --git a/ground_station/src/telemetry/telemetry.hpp b/ground_station/src/telemetry/telemetry.hpp index 5b476636..6af6819f 100644 --- a/ground_station/src/telemetry/telemetry.hpp +++ b/ground_station/src/telemetry/telemetry.hpp @@ -4,6 +4,7 @@ #pragma once +#include #include "config.hpp" #include "parser.hpp" #include "telemetryData.hpp" @@ -28,6 +29,12 @@ class Telemetry { void exitTesting(); void enterTesting(); void triggerEvent(uint8_t event); + 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() { if (linkInitialized) { @@ -52,6 +59,18 @@ 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}; 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/utils.cpp b/ground_station/src/utils.cpp index 2397fcfe..a396d82d 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" @@ -58,8 +60,59 @@ 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 +276,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; } } @@ -491,7 +548,10 @@ 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); xSemaphoreGive(storageAccessMutex); return read ? static_cast(bufsize) : -1; } @@ -503,7 +563,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 +576,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/tests/self_test.cpp b/ground_station/tests/self_test.cpp new file mode 100644 index 00000000..d840bdb1 --- /dev/null +++ b/ground_station/tests/self_test.cpp @@ -0,0 +1,226 @@ +// Contract tests for the production state machine, including manual start gates. +#include "self_test.hpp" +#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}; + Radio configuration{Radio::Restore}; + std::array requests{}; + + Rig() { + input.linksReady = input.usbConnected = 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; + if (packets && sinceStart % 100 == 0) { + for (auto& link : input.links) { + ++link.radio.packets; + ++link.radio.infoSamples; + link.radio.lqSum += lq; + 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); + 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(3000); + 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.gyro = {}; + input.sensors.gyro[axis] = 30; + input.sensors.magnetic = {sign * 1000, sign * 800, sign * 600}; + tick(); + } + } +}; + +int main() { + { + Rig rig; + rig.hardware(); + 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 = 1; bit < 7; ++bit) rig.tap(1U << bit); + rig.gate(Phase::Display); + for (uint8_t pattern = 1; pattern <= 4; ++pattern) { + rig.advance(2000); + assert(rig.test.displayPattern(rig.now) == pattern); + } + rig.advance(2100); + rig.press(A); // Keep the approval button held through the next prompt. + assert(rig.test.phase == Phase::Leds && rig.test.awaitingConfirmation); + const auto requests = rig.requests; + rig.advance(15000); + assert(rig.test.awaitingConfirmation && rig.requests == requests); + rig.release(A); + rig.gate(Phase::Leds); + assert(rig.configuration == Radio::Receiver1Only); + rig.advance(6000); + assert(rig.configuration == Radio::Receiver2Only); + rig.advance(6100); + rig.tap(A); + rig.advance(2600); + assert(rig.test.phase == Phase::Unplug && rig.test.awaitingConfirmation); + rig.input.usbConnected = false; + rig.advance(10000); + assert(rig.test.phase == Phase::Unplug && rig.test.check(Check::Battery).result == Result::Pending); + rig.input.usbConnected = true; + rig.gate(Phase::Unplug); + rig.input.usbConnected = false; + rig.advance(5100); + assert(rig.test.phase == Phase::Reconnect && rig.test.awaitingConfirmation); + rig.input.usbConnected = true; // An early reconnection cannot complete the gated test. + rig.gate(Phase::Reconnect); + rig.advance(1000); + assert(!rig.test.finished()); + rig.input.usbConnected = false; rig.tick(); + rig.input.usbConnected = true; 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(121000); + 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.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); + } + std::cout << "Three-phase self-test and all manual confirmation gates passed\n"; +} From ab44d8ead0052cc68794777afa092ad7d6043574 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Fri, 4 Sep 2026 22:43:28 +0200 Subject: [PATCH 06/10] feat(gs): add telemetry firmware updates --- ground_station/RADIO-UPDATE.md | 40 ++ ground_station/gs-wsl-check.sh | 13 + ground_station/simulator/gs_sim.py | 87 +++- ground_station/simulator/hmi_controller.cpp | 167 ++++++-- ground_station/simulator/hmi_controller.hpp | 29 +- .../simulator/scenarios/bootloader.json | 2 + .../simulator/scenarios/radio-update.json | 44 ++ ground_station/simulator/wasm_api.cpp | 4 + ground_station/simulator/web/app.js | 2 +- ground_station/simulator/web/browser-tests.js | 5 +- .../simulator/window_hmi_renderer.cpp | 81 ++-- ground_station/src/hmi/hmi.cpp | 161 ++++++- ground_station/src/hmi/hmi.hpp | 14 + ground_station/src/hmi/settings.cpp | 6 +- ground_station/src/hmi/settings.hpp | 2 +- ground_station/src/hmi/window.cpp | 124 ++++++ ground_station/src/hmi/window.hpp | 6 + ground_station/src/logging/recorder.cpp | 33 +- ground_station/src/logging/recorder.hpp | 14 +- ground_station/src/systemParser.cpp | 6 + ground_station/src/telemetry/telemetry.cpp | 149 ++++++- ground_station/src/telemetry/telemetry.hpp | 30 +- .../src/update/radio_firmware_updater.cpp | 321 ++++++++++++++ .../src/update/radio_firmware_updater.hpp | 69 +++ ground_station/src/update/rom_bootloader.cpp | 405 ++++++++++++++++++ ground_station/src/update/rom_bootloader.hpp | 104 +++++ ground_station/src/utils.cpp | 26 +- ground_station/src/utils.hpp | 2 + ground_station/tests/rom_bootloader_test.cpp | 202 +++++++++ telemetry/platformio.ini | 2 +- telemetry/src/Common.cpp | 1 + telemetry/src/Common.hpp | 1 + telemetry/src/Main.cpp | 87 +++- telemetry/src/SerialComm/Parser.cpp | 9 +- telemetry/src/Telemetry_reg.h | 5 + 35 files changed, 2136 insertions(+), 117 deletions(-) create mode 100644 ground_station/RADIO-UPDATE.md create mode 100644 ground_station/simulator/scenarios/radio-update.json create mode 100644 ground_station/src/update/radio_firmware_updater.cpp create mode 100644 ground_station/src/update/radio_firmware_updater.hpp create mode 100644 ground_station/src/update/rom_bootloader.cpp create mode 100644 ground_station/src/update/rom_bootloader.hpp create mode 100644 ground_station/tests/rom_bootloader_test.cpp diff --git a/ground_station/RADIO-UPDATE.md b/ground_station/RADIO-UPDATE.md new file mode 100644 index 00000000..6e2f2af1 --- /dev/null +++ b/ground_station/RADIO-UPDATE.md @@ -0,0 +1,40 @@ +# 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. + +Receiver applications predating the guarded `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/gs-wsl-check.sh b/ground_station/gs-wsl-check.sh index da7cbfc1..51afc2b2 100644 --- a/ground_station/gs-wsl-check.sh +++ b/ground_station/gs-wsl-check.sh @@ -215,7 +215,20 @@ 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" +} + timed dependencies prepare_dependencies +timed rom-bootloader-test test_rom_bootloader timed build build_project timed lint lint_project diff --git a/ground_station/simulator/gs_sim.py b/ground_station/simulator/gs_sim.py index 17903502..139fab6f 100644 --- a/ground_station/simulator/gs_sim.py +++ b/ground_station/simulator/gs_sim.py @@ -305,6 +305,9 @@ def __init__(self, initial: dict[str, Any] | None = None, ready: bool = True, ba 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" @@ -509,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) @@ -584,10 +602,17 @@ def tick(self, explicit_pressed: set[str]) -> None: self.settings_step(explicit_pressed) elif self.screen == "recovery": self.recovery_step(explicit_pressed) - elif self.screen in ("bootloader", "self_test") and "back" in 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 @@ -976,7 +1001,7 @@ 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: @@ -1004,8 +1029,8 @@ def settings_step(self, pressed: set[str]) -> None: self.screen = "usb_storage" return elif self.settings_page == 2 and self.settings_selection == 3 and "ok" in pressed: - self.screen = "bootloader" - self.emit("bootloader_requested") + 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 @@ -1021,7 +1046,6 @@ def settings_step(self, pressed: set[str]) -> None: 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: @@ -1035,6 +1059,51 @@ 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": @@ -1202,6 +1271,12 @@ 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, @@ -1465,7 +1540,7 @@ def deterministic_test(root: Path) -> int: "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: diff --git a/ground_station/simulator/hmi_controller.cpp b/ground_station/simulator/hmi_controller.cpp index 21a5b99a..c8c84b30 100644 --- a/ground_station/simulator/hmi_controller.cpp +++ b/ground_station/simulator/hmi_controller.cpp @@ -56,6 +56,9 @@ void HmiController::start() { usbPreviouslyConnected_ = false; automaticUsbSharePending_ = false; previousRecorderState_ = logs_.recorderStatus().state; + firmwareUpdateSelection_ = 0; + radioUpdateState_ = "browse"; + radioUpdateStartedMs_ = 0; input_ = {}; previousInput_ = {}; heldSince_.fill(0); @@ -135,9 +138,14 @@ void HmiController::step(const HmiInput& input, uint64_t nowMs) { 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)) { @@ -167,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: { @@ -217,7 +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; + 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) { @@ -236,8 +247,10 @@ void HmiController::enter(Screen screen) { 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) { @@ -247,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)); } } @@ -464,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; } @@ -486,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 { @@ -531,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; @@ -543,8 +560,8 @@ 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); } @@ -603,7 +620,6 @@ void HmiController::sensorsStep(uint64_t nowMs) { } void HmiController::settingsStep(uint64_t nowMs) { - const int16_t counts[3] = {4, 4, 4}; if (keyboardActive_) { const bool moveRight = pressed(HmiButton::Right) || repeated(HmiButton::Right, nowMs); const bool moveLeft = pressed(HmiButton::Left) || repeated(HmiButton::Left, nowMs); @@ -664,7 +680,7 @@ 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) { @@ -695,6 +711,11 @@ void HmiController::settingsStep(uint64_t nowMs) { if (increment) config_.config().startupAnimation = true; if (decrement) config_.config().startupAnimation = false; } + 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(); @@ -720,12 +741,8 @@ void HmiController::settingsStep(uint64_t nowMs) { enter(Screen::SelfTest); return; } - if (pressed(HmiButton::Ok) && action == BUTTON_ACTION_START_BOOTLOADER) { - enter(Screen::Bootloader); - emit("bootloader_requested"); - } } - if (pressed(HmiButton::Down) && settingsSelection_ < counts[settingsPage_] - 1) { + if (pressed(HmiButton::Down) && settingsSelection_ < settingsTableValueCount[settingsPage_] - 1) { ++settingsSelection_; } if (pressed(HmiButton::Up) && settingsSelection_ > -1) { @@ -747,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}); } @@ -770,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; @@ -782,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", "self_test"}; + 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_)]; } @@ -798,7 +872,8 @@ 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_)]; } @@ -833,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_; @@ -840,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(); @@ -856,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 a21d7c9e..1457e8fe 100644 --- a/ground_station/simulator/hmi_controller.hpp +++ b/ground_station/simulator/hmi_controller.hpp @@ -236,6 +236,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; @@ -277,12 +283,27 @@ class HmiController { void clearActions() { actions_.clear(); } private: - enum class Screen : uint8_t { Logo, Menu, Live, Recovery, Testing, Data, Sensors, Settings, Bootloader, UsbStorage, SelfTest }; + 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; @@ -300,6 +321,8 @@ class HmiController { 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 = {}); @@ -363,4 +386,8 @@ class HmiController { 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 index c6ab4ce3..0207939e 100644 --- a/ground_station/simulator/scenarios/bootloader.json +++ b/ground_station/simulator/scenarios/bootloader.json @@ -12,6 +12,8 @@ {"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/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/wasm_api.cpp b/ground_station/simulator/wasm_api.cpp index ba8413b2..ec2fed2b 100644 --- a/ground_station/simulator/wasm_api.cpp +++ b/ground_station/simulator/wasm_api.cpp @@ -516,6 +516,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) + diff --git a/ground_station/simulator/web/app.js b/ground_station/simulator/web/app.js index 071db772..a8037d98 100644 --- a/ground_station/simulator/web/app.js +++ b/ground_station/simulator/web/app.js @@ -26,7 +26,7 @@ let fixtureManifest = null; let demoPlayback = null; let lastRealtimeMs = performance.now(); let realtimeRemainderMs = 0; -const simulatorBuildTag = 'compass-view-20260823-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 }; diff --git a/ground_station/simulator/web/browser-tests.js b/ground_station/simulator/web/browser-tests.js index f3528f94..001f7211 100644 --- a/ground_station/simulator/web/browser-tests.js +++ b/ground_station/simulator/web/browser-tests.js @@ -426,7 +426,10 @@ async function run() { tap(wasm, 'down'); captureSelfTest('Settings - System update'); tap(wasm, 'ok'); - assert(snapshot(wasm).activeScreen === 'bootloader' && snapshot(wasm).actions.some(action => action.type === 'bootloader_requested'), 'Update Firmware invokes the existing bootloader'); + 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 () => { diff --git a/ground_station/simulator/window_hmi_renderer.cpp b/ground_station/simulator/window_hmi_renderer.cpp index ca6f2a6f..a3a8f5d0 100644 --- a/ground_station/simulator/window_hmi_renderer.cpp +++ b/ground_station/simulator/window_hmi_renderer.cpp @@ -51,11 +51,11 @@ void WindowHmiRenderer::syncLink(const LinkSnapshot& source, size_t index) { } 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) { @@ -110,10 +110,9 @@ void WindowHmiRenderer::render(const HmiSnapshot& state) { } } 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); @@ -127,9 +126,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); } @@ -144,11 +142,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); @@ -156,24 +156,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()) { @@ -189,12 +187,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") { @@ -203,6 +203,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(); diff --git a/ground_station/src/hmi/hmi.cpp b/ground_station/src/hmi/hmi.cpp index 18f89760..1b903a65 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; @@ -88,6 +94,14 @@ void Hmi::fsm() { selfTestStep(); break; + case FIRMWARE_UPDATE: + firmwareUpdate(); + break; + + case RADIO_UPDATE: + radioUpdate(); + break; + default: break; } @@ -869,16 +883,16 @@ 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_NONE: case BUTTON_ACTION_VERSION: break; + case BUTTON_ACTION_NONE: + break; } } break; @@ -1041,8 +1055,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) return; + if (state == SELF_TEST || state == RADIO_UPDATE) { + return; + } const bool connected = Utils::isConnected(); if (!connected) { usbPreviouslyConnected = false; diff --git a/ground_station/src/hmi/hmi.hpp b/ground_station/src/hmi/hmi.hpp index 00b041c4..d4ab0738 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 @@ -48,6 +49,8 @@ class Hmi { SETTINGS = 6, USB_STORAGE = 7, SELF_TEST = 8, + FIRMWARE_UPDATE = 9, + RADIO_UPDATE = 10, }; enum TestingState { @@ -79,6 +82,7 @@ class Hmi { uint32_t lastSensorRefresh = 0; Recorder recorder; + RadioUpdate::RadioFirmwareUpdater* radioUpdater{nullptr}; uint16_t oldTimeStamp = 0; @@ -114,6 +118,11 @@ class Hmi { 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(); @@ -178,4 +187,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/settings.cpp b/ground_station/src/hmi/settings.cpp index aaeee1f2..751a6c98 100644 --- a/ground_station/src/hmi/settings.cpp +++ b/ground_station/src/hmi/settings.cpp @@ -74,10 +74,10 @@ const device_settings_t settingsTable[][4] = { {.buttonAction = BUTTON_ACTION_SELF_TEST}, nullptr}, {"Update Firmware", - "Press A to restart the GS for a USB update", - "Connect to a computer before continuing", + "Update the Ground Station or its radios", + "Press A to choose an update target", BUTTON, - {.buttonAction = BUTTON_ACTION_START_BOOTLOADER}, + {.buttonAction = BUTTON_ACTION_UPDATE_FIRMWARE}, nullptr}, }, }; diff --git a/ground_station/src/hmi/settings.hpp b/ground_station/src/hmi/settings.hpp index 4e9c51e8..c5a6a068 100644 --- a/ground_station/src/hmi/settings.hpp +++ b/ground_station/src/hmi/settings.hpp @@ -20,7 +20,7 @@ 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, }; diff --git a/ground_station/src/hmi/window.cpp b/ground_station/src/hmi/window.cpp index ca5ed5c5..62cd5620 100644 --- a/ground_station/src/hmi/window.cpp +++ b/ground_station/src/hmi/window.cpp @@ -15,6 +15,7 @@ #include #include #include +#include uint16_t GetNegativeColor(uint16_t color) { if (color == BLACK) { @@ -1310,6 +1311,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)) { diff --git a/ground_station/src/hmi/window.hpp b/ground_station/src/hmi/window.hpp index 1b4d463b..f605228a 100644 --- a/ground_station/src/hmi/window.hpp +++ b/ground_station/src/hmi/window.hpp @@ -78,6 +78,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(); 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/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/telemetry.cpp b/ground_station/src/telemetry/telemetry.cpp index 2cc9b01e..3dc8f124 100644 --- a/ground_station/src/telemetry/telemetry.cpp +++ b/ground_station/src/telemetry/telemetry.cpp @@ -10,6 +10,10 @@ 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)); @@ -94,6 +98,9 @@ void Telemetry::initLink() { } void Telemetry::exitTesting() { + if (!lockNormalWriter()) { + return; + } testingMsg.header = 0x72; testingMsg.passcode = testingCrc; testingMsg.enable_pyros = 0; @@ -103,9 +110,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; @@ -114,9 +126,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; @@ -125,6 +141,7 @@ void Telemetry::triggerEvent(uint8_t event) { sendTXPayload(reinterpret_cast(&testingMsg), 15); triggerAction = true; triggerActionStart = xTaskGetTickCount(); + xSemaphoreGive(uartMutex); } void Telemetry::update(void* pvParameter) { @@ -135,6 +152,29 @@ void Telemetry::update(void* pvParameter) { 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; } @@ -167,6 +207,7 @@ void Telemetry::update(void* pvParameter) { ref->requestExitTesting = false; vTaskDelay(1000); ref->setMode(UNIDIRECTIONAL); + ref->testingActive = false; } if (ref->triggerAction && (ref->triggerActionStart + 1000) < xTaskGetTickCount()) { @@ -179,14 +220,120 @@ 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) { + 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(); + 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 6af6819f..e54c16a0 100644 --- a/ground_station/src/telemetry/telemetry.hpp +++ b/ground_station/src/telemetry/telemetry.hpp @@ -5,6 +5,7 @@ #pragma once #include + #include "config.hpp" #include "parser.hpp" #include "telemetryData.hpp" @@ -36,19 +37,17 @@ class Telemetry { SelfTestLinkObservation diagnostics() const { return parser.diagnostics(); } void resetReceptionStats(uint32_t now) { parser.resetReceptionStats(now); } - void disable() { - if (linkInitialized) { - sendDisable(); - linkInitialized = false; - } - } + void disable(); + void enable(); - void enable() { - if (!linkInitialized) { - sendEnable(); - linkInitialized = true; - } - } + // 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); + HardwareSerial& updateSerial() { return serial; } + void configureUpdateUart(bool rom); + bool isQuarantined() const { return quarantined; } // NOLINTBEGIN(cppcoreguidelines-non-private-member-variables-in-classes) TelemetryData data{}; @@ -71,6 +70,13 @@ class Telemetry { 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/update/radio_firmware_updater.cpp b/ground_station/src/update/radio_firmware_updater.cpp new file mode 100644 index 00000000..d55ed667 --- /dev/null +++ b/ground_station/src/update/radio_firmware_updater.cpp @@ -0,0 +1,321 @@ +/// 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); + } + } + 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..bfa2768c --- /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, 8, 'C', 'A', 'T', 'S', 'B', 'L', 1, 0xA5, 0}; + request[10] = crc8(request, 10); + 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 a396d82d..8f78dee5 100644 --- a/ground_station/src/utils.cpp +++ b/ground_station/src/utils.cpp @@ -29,6 +29,7 @@ #include #include +#include #include constexpr uint8_t BOOT_BUTTON = 0; @@ -56,6 +57,7 @@ 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}; USBMSC usb_msc; Adafruit_FlashTransport_ESP32 flashTransport; Adafruit_SPIFlash flash(&flashTransport); @@ -339,7 +341,7 @@ bool Utils::requestMassStorage() { return false; } const bool available = usbConnectionState == UsbConnectionState::Active && - usbStorageState == UsbStorageState::FirmwareOwned && filesystemMounted; + usbStorageState == UsbStorageState::FirmwareOwned && filesystemMounted && !radioUpdateStorage; if (available) { usbStorageState = UsbStorageState::Preparing; } @@ -367,9 +369,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. diff --git a/ground_station/src/utils.hpp b/ground_station/src/utils.hpp index 40d4eea8..86f221b1 100644 --- a/ground_station/src/utils.hpp +++ b/ground_station/src/utils.hpp @@ -40,6 +40,8 @@ class Utils { 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..1cf1d351 --- /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 == 11 && data[0] == 0x80) { + 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/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..5d5800c2 100644 --- a/telemetry/src/SerialComm/Parser.cpp +++ b/telemetry/src/SerialComm/Parser.cpp @@ -7,6 +7,8 @@ #include "Common.hpp" #include "Parser.hpp" +#include + void Parser::parse() { cmd_table[opCodeIndex].cmd(&buffer[2], dataIndex); @@ -141,7 +143,12 @@ void Parser::cmdVersionNum([[maybe_unused]] uint8_t *args, [[maybe_unused]] uint } void Parser::cmdBootloader(uint8_t *args, uint32_t length) { - // UNUSED + static constexpr uint8_t kBootloaderRequest[BOOTLOADER_REQUEST_LENGTH] = { + 'C', 'A', 'T', 'S', 'B', 'L', BOOTLOADER_PROTOCOL_VERSION, BOOTLOADER_REQUEST_GUARD}; + + if ((length == BOOTLOADER_REQUEST_LENGTH) && (memcmp(args, kBootloaderRequest, BOOTLOADER_REQUEST_LENGTH) == 0)) { + 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..08e5a188 100644 --- a/telemetry/src/Telemetry_reg.h +++ b/telemetry/src/Telemetry_reg.h @@ -57,3 +57,8 @@ typedef struct modulation_settings_s { #define CMD_VERSION_INFO 0x60 #define CMD_BOOTLOADER 0x80 + +#define BOOTLOADER_REQUEST_LENGTH 8U +#define BOOTLOADER_ACK_LENGTH 2U +#define BOOTLOADER_PROTOCOL_VERSION 1U +#define BOOTLOADER_REQUEST_GUARD 0xA5U From 8b8ab995b827ef8d14741c0c6b6facc76d826423 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Sat, 5 Sep 2026 18:04:18 +0200 Subject: [PATCH 07/10] refactor(telemetry): simplify bootloader entry command Accept bootloader entry only for an empty payload and remove the unused request signature and constants. --- telemetry/src/SerialComm/Parser.cpp | 13 +++++-------- telemetry/src/Telemetry_reg.h | 2 -- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/telemetry/src/SerialComm/Parser.cpp b/telemetry/src/SerialComm/Parser.cpp index 5d5800c2..6daafc4a 100644 --- a/telemetry/src/SerialComm/Parser.cpp +++ b/telemetry/src/SerialComm/Parser.cpp @@ -7,8 +7,6 @@ #include "Common.hpp" #include "Parser.hpp" -#include - void Parser::parse() { cmd_table[opCodeIndex].cmd(&buffer[2], dataIndex); @@ -142,13 +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) { - static constexpr uint8_t kBootloaderRequest[BOOTLOADER_REQUEST_LENGTH] = { - 'C', 'A', 'T', 'S', 'B', 'L', BOOTLOADER_PROTOCOL_VERSION, BOOTLOADER_REQUEST_GUARD}; - - if ((length == BOOTLOADER_REQUEST_LENGTH) && (memcmp(args, kBootloaderRequest, BOOTLOADER_REQUEST_LENGTH) == 0)) { - bootloader_requested = true; +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 08e5a188..762bdaa8 100644 --- a/telemetry/src/Telemetry_reg.h +++ b/telemetry/src/Telemetry_reg.h @@ -58,7 +58,5 @@ typedef struct modulation_settings_s { #define CMD_BOOTLOADER 0x80 -#define BOOTLOADER_REQUEST_LENGTH 8U #define BOOTLOADER_ACK_LENGTH 2U #define BOOTLOADER_PROTOCOL_VERSION 1U -#define BOOTLOADER_REQUEST_GUARD 0xA5U From a28f60ed6ed5387766dc533e33d9920df6968aff Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Sat, 5 Sep 2026 18:07:48 +0200 Subject: [PATCH 08/10] build(gs): keep WROVER TinyUF2 0.35.0 artifacts Add the official combined-ota factory image, retain the matching updater UF2, and remove the WROOM variant. Document the verified release checksums and factory versus updater usage. --- ground_station/bootloader/README.md | 61 ++++++++++-------- ...ed-ota-espressif_saola_1_wrover-0.35.0.bin | Bin 0 -> 3116960 bytes ...tinyuf2-espressif_saola_1_wroom-0.35.0.uf2 | Bin 598528 -> 0 bytes 3 files changed, 34 insertions(+), 27 deletions(-) create mode 100644 ground_station/bootloader/combined-ota-espressif_saola_1_wrover-0.35.0.bin delete mode 100644 ground_station/bootloader/update-tinyuf2-espressif_saola_1_wroom-0.35.0.uf2 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 0000000000000000000000000000000000000000..b1f1cb5d24488a632ef672eb7fed3c312e1c9889 GIT binary patch literal 3116960 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 z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1pXg8cOG9ySuc+Nw9rzP%8o@q4@#jRl(c2Ds5H&e z2C|W)v@8BQt_q0A;=L}Y7hJixfr5%(aY0l>+^!3F0k@0S z@8_9wPI3~G7X9AeKfl*Iuh-;r&NDO5%)IBBXP%jP<`5u2fB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 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 K6M0vN{{I7g9u4mR literal 0 HcmV?d00001 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 1735d4f98d8f31ee0f5cfbd2eb44a0f29bde04b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 598528 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�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

6pCi2tQ-0!!;} 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 z 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
H;jn%0}r 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

96zKd3;cDdP=) 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 VkQJ0=wq;~Ef(uJs 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

*eIEHzj zJewSXG%Y-ivpiedcv$A!5ChD)uBrBz5Sx4_!iThH9~a`czRSbZv@yj14{h+)R=Jj$ zXXB72v*o~hl8n67!<)uwipOb{X%65-nTDd+O1~jraHv=N5-+A5_rX+EtJxAp^*vO9 z>Q~bROu@EMigP5&7}>HpY1~btYW^=#@TWw@-!8xBlt@C8v;rShGC@o{-l;*S4CTWy z6Itm23qT1>Gu@jYKjUOr_~8g=@4D`~ykBnaUJfUmWVNS^6zs2~is3Kjj&@Ms+E0TN z+^Mnxt{kF7ow5|r9{jL6ILJ4w4zJe9;$Gn2w(xxtcBHuC*s-S1x#DpH;$?!7SsTKP z`zwYiokU>s{)=N3mR?JY#0y5-FGF6#@$J~_9Sn9)DteX|KRqVIsdX&2QHB5a75pht z@&E5|148ztlIw25r1YZAo^?kH-zV~EwhM657KJsk=n|d7*zy>5=r0GizIapBKaV-f zWl8JS*ld3*kGn5^z1?doul^e}DiPcZQN>n-f`)xJoQl;*X= |3w}5V!BLmCw-? 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 From 2f1f7f0a38125951a68a129b7540ac50a764b8a6 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Sat, 5 Sep 2026 18:11:29 +0200 Subject: [PATCH 09/10] feat(gs): refine self-test and live guidance for 1.3.0 Bump the Ground Station to 1.3.0. Add persistent gyro calibration, stricter reception checks, automatic battery checks, verified USB host reads, longer display presets, and checks for the six physical buttons. Correct compass alignment and signed per-link GPS guidance, keep live buttons responsive, render degree symbols without leftover pixels, and show Link Phrase 2 in Single mode. Refresh verified receiver versions and align bootloader entry with the telemetry zero-payload command. Update simulator coverage and tooling. Validation: Ground Station pre-commit checks and 22 browser checks passed; physical hardware qualification remains pending. --- ground_station/RADIO-UPDATE.md | 5 +- ground_station/SELF-TEST.md | 51 +++- ground_station/gs-sim.ps1 | 2 +- ground_station/gs-wsl-check.sh | 7 + ground_station/platformio.ini | 2 +- .../compat/telemetry/telemetryData.hpp | 5 +- ground_station/simulator/gs-sim.ps1 | 4 +- ground_station/simulator/hmi_controller.hpp | 6 + .../scenarios/sensors-orientation.json | 2 +- .../simulator/self_test_fixture.cpp | 12 +- ground_station/simulator/wasm_api.cpp | 16 + .../simulator/web/browser-golden.json | 15 +- ground_station/simulator/web/browser-tests.js | 273 ++++++++++++++---- .../simulator/window_hmi_renderer.cpp | 3 +- ground_station/src/hmi/hmi.cpp | 63 ++-- ground_station/src/hmi/hmi.hpp | 12 +- ground_station/src/hmi/hmi_self_test.cpp | 12 +- ground_station/src/hmi/window.cpp | 245 ++++++---------- ground_station/src/hmi/window.hpp | 6 +- ground_station/src/hmi/window_self_test.cpp | 74 ++--- ground_station/src/navigation.cpp | 46 ++- ground_station/src/navigation.hpp | 2 + ground_station/src/self_test.cpp | 219 +++++++++----- ground_station/src/self_test.hpp | 49 +++- ground_station/src/telemetry/parser.cpp | 1 + ground_station/src/telemetry/telemetry.cpp | 13 +- ground_station/src/telemetry/telemetry.hpp | 2 +- .../src/update/radio_firmware_updater.cpp | 3 +- ground_station/src/update/rom_bootloader.cpp | 4 +- ground_station/src/utils.cpp | 6 + ground_station/src/utils.hpp | 2 + ground_station/tests/rom_bootloader_test.cpp | 2 +- ground_station/tests/self_test.cpp | 220 ++++++++++++-- 33 files changed, 965 insertions(+), 419 deletions(-) diff --git a/ground_station/RADIO-UPDATE.md b/ground_station/RADIO-UPDATE.md index 6e2f2af1..7583347b 100644 --- a/ground_station/RADIO-UPDATE.md +++ b/ground_station/RADIO-UPDATE.md @@ -35,6 +35,9 @@ new ROM session, but an interruption during final vector activation can require 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. -Receiver applications predating the guarded `CMD_BOOTLOADER` implementation need a one-time ST-Link installation of a +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 index 3b941efa..06768286 100644 --- a/ground_station/SELF-TEST.md +++ b/ground_station/SELF-TEST.md @@ -1,9 +1,26 @@ # Ground Station self-test -Open **Settings > System > Self-Test**, rest the Ground Station on the bench with -access to the sky, and press **A: Start**. Results stay on the screen; no reports +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: @@ -11,6 +28,8 @@ 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. @@ -24,9 +43,14 @@ Power two test Vegas with phrases **cats_test_1** and **cats_test_2**, then pres 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 initial limits are at least 5 packets/s, mean link quality at least 50%, and -no gap longer than 1.5 seconds. These provisional limits need checking against +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. @@ -39,15 +63,16 @@ one check opens the next confirmation screen. Holding A cannot start the next ch | 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 seven buttons. The Start press does not count. | -| 3.3 Display | Inspect black, white and two checkerboard patterns. Then A = good, B = fault. | -| 3.4 Receiver LEDs | Keep both test Vegas powered. Watch receiver 1, then receiver 2 blink. Then A = good, B = fault. | -| 3.5 Battery | Unplug USB with the power switch on. The GS checks five seconds of battery operation at 3.0-4.35 V. | -| 3.6 USB reconnect | Reconnect USB to the computer. A connection made before Start does not complete this check. | - -Receiver settings are restored again after the LED check. USB storage sharing -resumes when the run ends and USB is connected. The power checks do not measure -charge current or battery capacity. +| 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; 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 51afc2b2..8a571383 100644 --- a/ground_station/gs-wsl-check.sh +++ b/ground_station/gs-wsl-check.sh @@ -227,8 +227,15 @@ test_rom_bootloader() { "$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/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/gs-sim.ps1 b/ground_station/simulator/gs-sim.ps1 index a4f405d6..2da70ce9 100644 --- a/ground_station/simulator/gs-sim.ps1 +++ b/ground_station/simulator/gs-sim.ps1 @@ -246,7 +246,9 @@ function Invoke-BrowserRegressionTest { $result = [regex]::Match($dom, '

]*>(.*?)
', 'Singleline').Groups[1].Value throw "Browser/WASM regression test failed: $result" } - Write-Host 'browser/WASM: 9 regression checks passed' + $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 diff --git a/ground_station/simulator/hmi_controller.hpp b/ground_station/simulator/hmi_controller.hpp index 1457e8fe..172296a1 100644 --- a/ground_station/simulator/hmi_controller.hpp +++ b/ground_station/simulator/hmi_controller.hpp @@ -89,6 +89,11 @@ struct DeviceStatusSnapshot { 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 { @@ -211,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 { diff --git a/ground_station/simulator/scenarios/sensors-orientation.json b/ground_station/simulator/scenarios/sensors-orientation.json index cce20158..d0da2c7e 100644 --- a/ground_station/simulator/scenarios/sensors-orientation.json +++ b/ground_station/simulator/scenarios/sensors-orientation.json @@ -2,7 +2,7 @@ "initial": { "ready": true, "navigation": { - "northRad": 1.5707963, + "northRad": -1.5707963, "pitchRad": -0.2094395, "rollRad": 0.1396263, "mx": 940.0, diff --git a/ground_station/simulator/self_test_fixture.cpp b/ground_station/simulator/self_test_fixture.cpp index a001d438..258a814b 100644 --- a/ground_station/simulator/self_test_fixture.cpp +++ b/ground_station/simulator/self_test_fixture.cpp @@ -31,6 +31,9 @@ void HmiController::selfTestStep(uint64_t 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) { @@ -73,6 +76,7 @@ void HmiController::selfTestStep(uint64_t nowMs) { 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; @@ -96,13 +100,19 @@ void HmiController::selfTestStep(uint64_t nowMs) { sensor.magnetometer = device.selfTestSensorFailure ? 0 : 0x0d; sensor.readError = device.selfTestSensorFailure; sensor.acceleration = {nav.ax, nav.ay, nav.az}; - sensor.gyro = {nav.gx, nav.gy, nav.gz}; + 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()) { diff --git a/ground_station/simulator/wasm_api.cpp b/ground_station/simulator/wasm_api.cpp index ec2fed2b..4a9526f9 100644 --- a/ground_station/simulator/wasm_api.cpp +++ b/ground_station/simulator/wasm_api.cpp @@ -344,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; }; @@ -466,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) { @@ -486,6 +494,9 @@ void rebuildSnapshot() { ",\"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) { @@ -574,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; @@ -585,6 +597,7 @@ 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; @@ -685,6 +698,9 @@ void gs_set_device_status_json(const char* json) { 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; diff --git a/ground_station/simulator/web/browser-golden.json b/ground_station/simulator/web/browser-golden.json index 3f46af96..5e96451f 100644 --- a/ground_station/simulator/web/browser-golden.json +++ b/ground_station/simulator/web/browser-golden.json @@ -3,7 +3,18 @@ "startupRocket": "c8db9d72adcd669e7f60cb33e213e1f05c9f4eecead98bf8d2510d254163ae64", "lightDemo": "775ecaa079f4bd1dd7130ff0f962de512dec16dace84b5e91bb36b39619d426a", "recovery": "eda5a41b5f22562e8628f879dbe04f9e3d7e929d54157840398fe49b2dca0a56", - "compassOrientation": "6841fe2586d72f8a940f646880e1ffcf29b504ffe15c2e747a0a1e12d7ea52f4", + "compassOrientation": "6d5a84bc0813d49a491c11b1dbd3924046aa5ac43bcf8767dc4738e15eb1954a", + "phoneHeading0": "41742a8683e13fcebfa75265c01050a76aacaa7dcd4bc0e148ac991c22128d2e", + "phoneHeading45": "f4bb2bcc468dc9af42b6d6e278b1d3a663e59216908030dc96d06d9eff68cb53", + "phoneHeading90": "2bff3a8f661784e963e42f45e65057acbcac36f5a4eb8514edb6ab8dacebe697", + "phoneHeading135": "2eedbd095f7aa8153030e42f8e35ad66a12339dd646079d2d2e25d95a34a20f2", + "phoneHeading180": "1cc123e8ef83d9586b0d754c06934d0956552f880c46660bb5ca86252208716d", + "phoneHeading225": "7ae8ea2756a5eebea4d6b0b4944ded1748377844a198ceb3bf41fa9d7ea427f9", + "phoneHeading270": "f8496afd46f01c1ae8a7adb2b2f41d8041ef70af98acace3f33b90f63ae85f5e", + "phoneHeading315": "98f68820f68cf7049458c78f7a68dd5e46bbd6c4ace40efc98a67061eca69e4b", "usbStorage": "cd4a76e556b38614ce3194c15fb40de92c012ab78f128d9624d4c3043047c089", - "recorderFault": "32219f426fded5eafe2a0a5199c593e94d9e858f5bb973190bcd71ce76e5ca2c" + "recorderFault": "32219f426fded5eafe2a0a5199c593e94d9e858f5bb973190bcd71ce76e5ca2c", + "liveAheadRight": "6cd2afcd9a1e1a24441dd99a8e49acc954ff8d626f859f831462b01ebf813cfc", + "liveLeftAhead": "166c4d1acf0932c9bdd7b2f71aa0922cdc1da2daa6ccd0eae633e2c72349b0fb", + "liveBehindLeft": "1e768ec62806a82f9ac29065753054d017014ad95337a811bd09b92e6e9c3b0f" } diff --git a/ground_station/simulator/web/browser-tests.js b/ground_station/simulator/web/browser-tests.js index 001f7211..b5c6a6a3 100644 --- a/ground_station/simulator/web/browser-tests.js +++ b/ground_station/simulator/web/browser-tests.js @@ -148,7 +148,7 @@ async function run() { 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, + northRad: -Math.PI / 2, pitchRad: -Math.PI / 15, rollRad: Math.PI / 22.5, mx: 940, @@ -166,6 +166,71 @@ async function run() { 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); @@ -203,7 +268,7 @@ async function run() { 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: true, batteryVoltage: 3.8, ...faults})]); + 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'); } @@ -261,23 +326,66 @@ async function run() { assert(state.selfTestPhase === activePhase && !state.selfTestWaiting, `${label} starts only on A`); } - await test('factory self-test: three phases and confirmation before every manual check', async () => { + 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(3200); + 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(2, 'Phase 2 - ready for telemetry'); + confirmSelfTestStep(3, 'Phase 2 - ready for telemetry'); advanceSelfTest(8000); captureSelfTest('Phase 2 - telemetry running'); advanceSelfTest(64000); state = snapshot(wasm); - assert(state.selfTestChecks.slice(0, 11).every(value => value === 'PASS'), `automatic results: ${state.selfTestChecks}`); + 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(3, '3.1 - ready for sensor movement'); + 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]) { @@ -287,54 +395,45 @@ async function run() { wasm.ccall('gs_set_sensor_json', null, ['string'], [JSON.stringify(sample)]); } } - confirmSelfTestStep(4, '3.2 - ready for buttons'); + 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', 'center', 'ok', 'back']) tap(wasm, button); - confirmSelfTestStep(5, '3.3 - ready for display'); + 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(2000); + advanceSelfTest(4000); captureSelfTest('Display - white pattern'); - advanceSelfTest(2000); + advanceSelfTest(4000); captureSelfTest('Display - checkerboard'); - advanceSelfTest(2000); + advanceSelfTest(4000); captureSelfTest('Display - inverse checkerboard'); - advanceSelfTest(2000); + advanceSelfTest(4000); captureSelfTest('Display confirmation'); wasm.ccall('gs_press', null, ['string'], ['ok']); advanceSelfTest(15000); - assert(snapshot(wasm).selfTestWaiting, 'holding display confirmation cannot start the LED check'); + assert(snapshot(wasm).selfTestWaiting, 'holding display confirmation cannot start the USB check'); wasm.ccall('gs_release', null, ['string'], ['ok']); - confirmSelfTestStep(6, '3.4 - ready for receiver LEDs'); - advanceSelfTest(12100); - captureSelfTest('Receiver LED confirmation'); - tap(wasm, 'ok'); - captureSelfTest('Restoring settings'); - advanceSelfTest(2600); - assert(snapshot(wasm).selfTestPhase === 8, 'normal receiver settings restored before battery test'); - wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":false}']); - confirmSelfTestStep(8, '3.5 - ready for battery', 9); - advanceSelfTest(2000); - captureSelfTest('Battery operation'); - advanceSelfTest(3200); - assert(snapshot(wasm).selfTestPhase === 10, 'five battery seconds precede reconnect'); - wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":true}']); - confirmSelfTestStep(10, '3.6 - ready for USB reconnect'); + 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 === 10, 'connecting before Start cannot pass reconnect'); - wasm.ccall('gs_set_device_status_json', null, ['string'], ['{"usb":false}']); - advanceSelfTest(20); + 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 < 11; ++index) tap(wasm, 'down'); + 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'); @@ -346,36 +445,36 @@ async function run() { await test('factory self-test: missing receiver cannot be hidden by the other', async () => { openSelfTest({selfTestMissingReceiver: 1}); - tap(wasm, 'ok'); advanceSelfTest(4000); + tap(wasm, 'ok'); advanceSelfTest(12000); tap(wasm, 'ok'); advanceSelfTest(140000); const state = snapshot(wasm); - assert(state.selfTestChecks[0] === 'FAIL' && state.selfTestChecks[1] === 'PASS', 'independent firmware replies'); - assert(state.selfTestChecks[5] === 'FAIL' && state.selfTestChecks[7] === 'FAIL', 'both dual and single expose missing receiver'); - assert(state.selfTestChecks[9] === 'PASS', 'receiver 2 still tested independently'); - assert(state.selfTestPhase === 3 && state.selfTestWaiting, 'failures still reach the first manual confirmation'); + 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(121000); + tap(wasm, 'ok'); advanceSelfTest(127000); let state = snapshot(wasm); - assert(state.selfTestChecks[2] === 'FAIL', 'GNSS timeout fails acquisition'); - assert(state.selfTestChecks[10] === 'FAIL', 'storage failure is visible'); - assert(state.selfTestPhase === 2 && state.selfTestWaiting, 'telemetry waits for its own confirmation'); + 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 === 3 && state.selfTestWaiting, 'manual checks wait after independent telemetry checks'); - assert(state.selfTestChecks[2] === 'FAIL' && state.selfTestChecks[10] === 'FAIL', 'later checks preserve Phase 1 failures'); + 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(4000); - tap(wasm, 'ok'); advanceSelfTest(4000); + 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 === 11 && state.selfTestResult === 'FAIL', `cancellation phase=${state.selfTestPhase} result=${state.selfTestResult} checks=${state.selfTestChecks}`); - assert(state.selfTestChecks[11] === 'NOT TESTED', 'unfinished manual checks remain not tested'); + 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'); @@ -496,6 +595,82 @@ async function run() { 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; diff --git a/ground_station/simulator/window_hmi_renderer.cpp b/ground_station/simulator/window_hmi_renderer.cpp index a3a8f5d0..a1c5709e 100644 --- a/ground_station/simulator/window_hmi_renderer.cpp +++ b/ground_station/simulator/window_hmi_renderer.cpp @@ -46,7 +46,7 @@ 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); } @@ -108,6 +108,7 @@ 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( diff --git a/ground_station/src/hmi/hmi.cpp b/ground_station/src/hmi/hmi.cpp index 1b903a65..8e6a3bac 100644 --- a/ground_station/src/hmi/hmi.cpp +++ b/ground_station/src/hmi/hmi.cpp @@ -153,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; @@ -169,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 */ @@ -1272,7 +1295,7 @@ void Hmi::update(void *pvParameter) { // ref->window.updateBar(link1.data.ts()); } - if (ref->state != SELF_TEST && 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 d4ab0738..e305e6b3 100644 --- a/ground_station/src/hmi/hmi.hpp +++ b/ground_station/src/hmi/hmi.hpp @@ -19,11 +19,11 @@ 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(); @@ -36,8 +36,12 @@ class Hmi { 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, @@ -80,6 +84,7 @@ class Hmi { CalibrationState calibrationState = IDLE; SensorView sensorView = SensorView::Readings; uint32_t lastSensorRefresh = 0; + uint32_t lastLiveRefresh = 0; Recorder recorder; RadioUpdate::RadioFirmwareUpdater* radioUpdater{nullptr}; @@ -97,6 +102,7 @@ class Hmi { void menu(); void initLive(); void live(); + bool liveButtonPending() const; void initRecovery(); void recovery(); void updateRecoveryLocations(); diff --git a/ground_station/src/hmi/hmi_self_test.cpp b/ground_station/src/hmi/hmi_self_test.cpp index c8f780ac..d41fa80a 100644 --- a/ground_station/src/hmi/hmi_self_test.cpp +++ b/ground_station/src/hmi/hmi_self_test.cpp @@ -15,7 +15,12 @@ SelfTestInput Hmi::selfTestInput() { input.links = {link1.diagnostics(), link2.diagnostics()}; input.linksReady = selfTestControlsOk && link1.selfTestReady() && link2.selfTestReady(); input.sensors = navigation.selfTestSensors(); - input.usbConnected = Utils::isConnected(); + // 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}; @@ -148,6 +153,11 @@ void Hmi::selfTestStep() { 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()) { diff --git a/ground_station/src/hmi/window.cpp b/ground_station/src/hmi/window.cpp index 62cd5620..de405f36 100644 --- a/ground_station/src/hmi/window.cpp +++ b/ground_station/src/hmi/window.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include uint16_t GetNegativeColor(uint16_t color) { @@ -49,8 +50,7 @@ void Window::begin() { oldMenuHighlight = 0; oldTestingIndex = 0; 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; @@ -441,6 +441,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); @@ -497,106 +499,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], 1); - 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') { + // Match the GPS degree symbol and keep every pixel inside the cleared row. + display.drawCircle(static_cast(display.getCursorX() + 3), y + 6, 2, 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], 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 - drawCoordinate(data1->lat(), 'N', 'S', static_cast(xOffset1 + first_row_offset), 120, WHITE); - drawCoordinate(data1->lon(), 'E', 'W', static_cast(xOffset1 + first_row_offset), 145, WHITE); - drawCoordinate(data2->lat(), 'N', 'S', static_cast(xOffset2 + first_row_offset), 120, WHITE); - drawCoordinate(data2->lon(), 'E', 'W', static_cast(xOffset2 + first_row_offset), 145, WHITE); - - // 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) { @@ -625,7 +607,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); @@ -635,7 +617,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); } @@ -649,7 +632,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); @@ -657,7 +640,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"}; @@ -665,7 +649,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); @@ -708,39 +692,6 @@ void Window::updateLiveData(TelemetryData *data, Navigation *navigation, int16_t if (livestate == LiveState::kShowGnss) { 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); - } 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], 1); - display.print(" deg"); - display.setTextColor(color); - display.setCursor(static_cast(xOffset + first_row_offset), 145); - display.print(bearing, 1); - display.print(" deg"); - old_bearing[index] = bearing; } display.setCursor(static_cast(xOffset + first_row_offset), 170); @@ -1538,14 +1489,15 @@ void Window::updateSensorOrientation(Navigation *navigation) { constexpr int16_t centerX = 105; constexpr int16_t centerY = 132; constexpr int16_t radius = 60; - float heading = navigation->getNorth(); - while (heading < 0.0F) { - heading += 2.0F * PI_F; + float northAngle = navigation->getNorth(); + while (northAngle < 0.0F) { + northAngle += 2.0F * PI_F; } - while (heading >= 2.0F * PI_F) { - heading -= 2.0F * PI_F; + while (northAngle >= 2.0F * PI_F) { + northAngle -= 2.0F * PI_F; } - const float headingDegrees = heading * 180.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; @@ -1570,8 +1522,8 @@ void Window::updateSensorOrientation(Navigation *navigation) { drawCentreString("W", 31, 139); drawCentreString("E", 179, 139); - const float sine = sinf(heading); - const float cosine = cosf(heading); + 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); @@ -1838,13 +1790,8 @@ void Window::addSettingEntry(uint32_t settingIndex, const device_settings_t *set static_cast(y + 22), color); } } else if (setting->type == STRING) { - if (setting->dataPtr == systemConfig.config.linkPhrase2 && systemConfig.config.receiverMode == SINGLE) { - display.setFont(&FreeSans9pt7b); - drawCentreString("Dual mode only", 285, y); - } else { - display.setFont(&FreeMonoBold12pt7b); - drawCentreString(static_cast(setting->dataPtr), 285, y); - } + display.setFont(&FreeMonoBold12pt7b); + drawCentreString(static_cast(setting->dataPtr), 285, y); display.setFont(&FreeSans12pt7b); } else if (setting->type == NUMBER) { char buffer[8]; diff --git a/ground_station/src/hmi/window.hpp b/ground_station/src/hmi/window.hpp index f605228a..0b9ba3e3 100644 --- a/ground_station/src/hmi/window.hpp +++ b/ground_station/src/hmi/window.hpp @@ -57,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); @@ -117,7 +118,7 @@ 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); @@ -160,8 +161,7 @@ class Window { 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}; diff --git a/ground_station/src/hmi/window_self_test.cpp b/ground_station/src/hmi/window_self_test.cpp index 43e2fa4d..1f87f435 100644 --- a/ground_station/src/hmi/window_self_test.cpp +++ b/ground_station/src/hmi/window_self_test.cpp @@ -71,11 +71,12 @@ void Window::drawSelfTest(const SelfTest& test, uint32_t now, int16_t selectedRe char line[80]{}; if (!test.finished()) text(51, test.instruction()); if (!test.started()) { - text(84, "1. Ground Station: GNSS, sensors, storage"); - text(110, "2. Telemetry: two test Vegas"); - text(136, "3. Manual: confirm before each check"); - text(175, "Results are shown on this screen."); - text(199, "Normal settings and calibration are retained."); + 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()) { @@ -118,42 +119,41 @@ void Window::drawSelfTest(const SelfTest& test, uint32_t now, int16_t selectedRe text(117, "Tilt and rotate around all three axes."); break; case SelfTest::Phase::Buttons: - text(90, "Press and release all seven 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, "Watch for missing pixels, lines or noise."); + text(117, "Each pattern stays for four seconds."); text(154, "Afterward: A = good, B = fault."); break; - case SelfTest::Phase::Leds: - text(90, "Keep both test Vegas powered on."); - text(117, "Watch receiver 1, then receiver 2 blink."); - text(154, "Afterward: A = good, B = fault."); - break; - case SelfTest::Phase::Unplug: - text(90, "After Start, unplug USB."); - text(117, "Keep the power switch on."); - text(154, "The GS must run on battery for 5 seconds."); - break; - case SelfTest::Phase::Reconnect: - text(90, "After Start, reconnect USB to the PC."); - text(117, "If already connected, unplug it first."); + 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::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 ? 4 : 7; + 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])); @@ -180,38 +180,26 @@ void Window::drawSelfTest(const SelfTest& test, uint32_t now, int16_t selectedRe 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(static_cast(76 + i * 18), line); + 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) >= 10000U) { + 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::Leds) { - text(95, "Each receiver is enabled in turn."); - text(120, "Its LED should blink as packets arrive."); - if (test.phaseElapsedMs(now) >= 12000U) { - text(190, "Hold B for 2 seconds to cancel."); - leftHint = "LED fault (B)"; - rightHint = "Both worked (A)"; - } - } else if (test.phase == SelfTest::Phase::Battery) { - std::snprintf(line, sizeof(line), "Battery: %.2f V", static_cast(test.batteryMinimum)); - display.setFont(&FreeSansBold12pt7b); - drawCentreString(line, 200, 120); - display.setFont(&FreeSans9pt7b); - } else if (test.phase == SelfTest::Phase::Unplug) { - text(100, "Battery operation is checked next."); - text(130, "Skipping leaves this check not tested."); - rightHint = "Skip (A)"; - } else if (test.phase == SelfTest::Phase::Reconnect) { - text(100, "Waiting for USB to reconnect."); + } 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)"; } } diff --git a/ground_station/src/navigation.cpp b/ground_station/src/navigation.cpp index f519e6f1..baf00cf8 100644 --- a/ground_station/src/navigation.cpp +++ b/ground_station/src/navigation.cpp @@ -3,6 +3,8 @@ /// SPDX-License-Identifier: GPL-3.0-or-later #include "navigation.hpp" + +#include #include "config.hpp" #include "console.hpp" #include "utils.hpp" @@ -35,6 +37,26 @@ SelfTestSensorObservation Navigation::selfTestSensors() const { 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; @@ -47,6 +69,18 @@ bool Navigation::begin() { 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; @@ -223,12 +257,20 @@ void Navigation::navigationTask(void *pvParameter) { 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); @@ -236,7 +278,9 @@ void Navigation::navigationTask(void *pvParameter) { 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 6ac7122a..5ebfed97 100644 --- a/ground_station/src/navigation.hpp +++ b/ground_station/src/navigation.hpp @@ -54,6 +54,7 @@ class Navigation { 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); } @@ -150,6 +151,7 @@ class Navigation { 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 index ece619f0..55a9e4a4 100644 --- a/ground_station/src/self_test.cpp +++ b/ground_station/src/self_test.cpp @@ -21,7 +21,7 @@ void SelfTest::start(uint32_t now, const SelfTestInput& input) { initialFixCount = input.links[1].fixCount; initialUtcSeconds = input.links[1].utcSeconds; sensorSequence = input.sensors.sequence; - enter(Phase::Hardware, now); + enter(Phase::GyroCalibration, now); } void SelfTest::setResult(Check item, Result result, const char* reason) { @@ -41,8 +41,7 @@ void SelfTest::enter(Phase next, uint32_t now) { phaseStartedAt = now; blockedButtons = 0x7f; awaitingConfirmation = next == Phase::Telemetry || next == Phase::Motion || next == Phase::Buttons || - next == Phase::Display || next == Phase::Leds || next == Phase::Unplug || - next == Phase::Reconnect; + next == Phase::Display || next == Phase::Usb; } void SelfTest::observeVersions(uint32_t now, const SelfTestInput& input) { @@ -71,7 +70,7 @@ void SelfTest::observeGnss(uint32_t now, const SelfTestInput& input) { 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 - startedAt >= SelfTestProfile::kGnssTimeoutMs) { + } else if (now - phaseStartedAt >= SelfTestProfile::kGnssTimeoutMs) { setResult(Check::Gnss, Result::Fail, "No fresh fix: retest with open sky"); } } @@ -84,6 +83,83 @@ void SelfTest::resetStationary() { 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; @@ -166,23 +242,24 @@ void SelfTest::updateRadio(uint32_t now, const SelfTestInput& input) { 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 >= measuredMs * SelfTestProfile::kMinimumPacketsPerSecond / 1000 && - stats.maxGapMs <= SelfTestProfile::kMaximumGapMs && stats.infoSamples > 0 && - stats.lqSum / stats.infoSamples >= SelfTestProfile::kMinimumMeanLq; + 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 >= measuredMs * SelfTestProfile::kMinimumPacketsPerSecond / 1000 && - combinedMaxGap <= SelfTestProfile::kMaximumGapMs; + passed &= combinedPackets >= minimumPackets && combinedMaxGap <= SelfTestProfile::kMaximumGapMs; } - setResult(kRadioChecks[radioStage], passed ? Result::Pass : Result::Fail, - passed ? "Packet rate, gaps and quality within limits" : "Low packet rate, quality or reception gap"); + 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); } @@ -222,19 +299,29 @@ void SelfTest::update(uint32_t now, const SelfTestInput& input) { lastVersionRequest = radioStartedAt = now; radioRequest = RadioConfiguration::Dual; radioChanged = true; - } else if (phase == Phase::Leds) { - radioRequest = RadioConfiguration::Receiver1Only; - radioChanged = true; - } else if (phase == Phase::Reconnect) { - usbDisconnected = !input.usbConnected; } } 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"); @@ -242,7 +329,7 @@ void SelfTest::update(uint32_t now, const SelfTestInput& input) { 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::Magnetic).result != Result::Pending && check(Check::Battery).result != Result::Pending) enter(Phase::Telemetry, now); break; case Phase::Telemetry: @@ -260,10 +347,10 @@ void SelfTest::update(uint32_t now, const SelfTestInput& input) { } break; case Phase::Buttons: - pressedButtons |= pressed; + pressedButtons |= pressed & kRequiredButtons; completedButtons |= pressedButtons & static_cast(~input.buttonsHeld); - if (completedButtons == 0x7f) { - setResult(Check::Buttons, Result::Pass, "All seven buttons pressed and released"); + 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"); @@ -271,25 +358,11 @@ void SelfTest::update(uint32_t now, const SelfTestInput& input) { } break; case Phase::Display: - if (now - phaseStartedAt >= 10000U && (pressed & (kA | kB)) != 0) { + 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::Leds, now); - } - break; - case Phase::Leds: - if (!ledSecond && now - phaseStartedAt >= 6000U) { - ledSecond = true; - radioRequest = RadioConfiguration::Receiver2Only; - } - if (now - phaseStartedAt >= 12000U && (pressed & (kA | kB)) != 0) { - const bool passed = (pressed & kA) != 0; - setResult(Check::Leds, passed ? Result::Pass : Result::Fail, - passed ? "Operator confirmed each receiver LED" : "Operator reported LED fault"); - radioRequest = RadioConfiguration::Restore; - afterRestore = Phase::Unplug; - enter(Phase::Restoring, now); + enter(Phase::Usb, now); } break; case Phase::Restoring: @@ -305,34 +378,23 @@ void SelfTest::update(uint32_t now, const SelfTestInput& input) { finish(now); } break; - case Phase::Unplug: - if (!input.usbConnected) { - batteryMinimum = input.batteryVoltage; - enter(Phase::Battery, now); - } else if ((pressed & kA) != 0) { - setResult(Check::Battery, Result::Incomplete, "Battery operation skipped"); - setResult(Check::Usb, Result::Incomplete, "USB reconnect skipped"); - finish(now); - } - break; - case Phase::Battery: - batteryMinimum = std::min(batteryMinimum, input.batteryVoltage); - if (input.usbConnected) - enter(Phase::Unplug, now); - else if (now - phaseStartedAt >= 5000U) { - const bool passed = plausibleBattery(batteryMinimum); - setResult(Check::Battery, passed ? Result::Pass : Result::Fail, - passed ? "Five seconds on battery" : "Battery voltage outside 3.0-4.35 V"); - enter(Phase::Reconnect, now); + case Phase::Usb: + if (input.usbConnected && !usbStorageRequested) { + usbReadBaseline = input.usbReadCount; + usbStorageRequest = usbStorageRequested = true; } - break; - case Phase::Reconnect: - usbDisconnected |= !input.usbConnected; - if (usbDisconnected && input.usbConnected) { - setResult(Check::Usb, Result::Pass, "USB enumerated after battery operation"); + 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 reconnect skipped"); + setResult(Check::Usb, Result::Incomplete, "USB drive check skipped"); finish(now); } break; @@ -375,16 +437,19 @@ bool SelfTest::takeVersionRequest() { return value; } uint8_t SelfTest::displayPattern(uint32_t now) const { - if (awaitingConfirmation || phase != Phase::Display || now - phaseStartedAt < 2000U || now - phaseStartedAt >= 10000U) + if (awaitingConfirmation || phase != Phase::Display || now - phaseStartedAt < SelfTestProfile::kDisplayLeadInMs || + now - phaseStartedAt >= SelfTestProfile::kDisplayEndMs) return 0; - return static_cast(1U + (now - phaseStartedAt - 2000U) / 2000U); + 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[] = {"Telemetry 1 firmware", + static constexpr const char* names[] = {"Gyroscope calibration", + "Telemetry 1 firmware", "Telemetry 2 firmware", "Onboard GNSS", "Stationary IMU", @@ -398,44 +463,38 @@ const char* SelfTest::checkName(Check item) { "Sensor movement", "Buttons", "Display", - "Receiver LEDs", "Battery operation", - "USB reconnect", + "USB drive", "Settings restored"}; return names[static_cast(item)]; } const char* SelfTest::title() const { - static constexpr const char* names[] = {"Factory self-test", "1. Ground Station", "2. Telemetry", - "3.1 Sensor movement", "3.2 Buttons", "3.3 Display", - "3.4 Receiver LEDs", "Restoring settings", "3.5 Battery", - "3.5 Battery", "3.6 USB reconnect", "Self-test results"}; + 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 "Rest on the bench with access to the sky."; + 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 "Testing both telemetry receivers."; + 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::Leds: - return ledSecond ? "Receiver 2 LED should blink." : "Receiver 1 LED should blink."; case Phase::Restoring: return "Restoring normal receiver settings."; - case Phase::Unplug: - return "Disconnect USB; keep the power switch on."; - case Phase::Battery: - return "Keep USB disconnected for 5 seconds."; - case Phase::Reconnect: - return usbDisconnected ? "Connect USB to the computer." : "Unplug USB, then reconnect it."; + 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."; } diff --git a/ground_station/src/self_test.hpp b/ground_station/src/self_test.hpp index 380c28e1..ce05d0b7 100644 --- a/ground_station/src/self_test.hpp +++ b/ground_station/src/self_test.hpp @@ -12,6 +12,7 @@ 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}; }; @@ -41,6 +42,7 @@ struct SelfTestSensorObservation { 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. }; @@ -49,6 +51,9 @@ struct SelfTestInput { 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 @@ -63,15 +68,23 @@ struct SelfTestProfile { static constexpr uint32_t kRadioWindowMs = 15000; static constexpr uint32_t kFailoverWindowMs = 5000; static constexpr uint32_t kGnssTimeoutMs = 120000; - static constexpr uint32_t kMinimumPacketsPerSecond = 5; - static constexpr uint32_t kMaximumGapMs = 1500; - static constexpr uint32_t kMinimumMeanLq = 50; + 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, @@ -86,7 +99,6 @@ class SelfTest { Motion, Buttons, Display, - Leds, Battery, Usb, Restoration, @@ -94,16 +106,14 @@ class SelfTest { }; enum class Phase : uint8_t { Ready, + GyroCalibration, Hardware, Telemetry, Motion, Buttons, Display, - Leds, Restoring, - Unplug, - Battery, - Reconnect, + Usb, Finished }; enum class RadioConfiguration : int8_t { @@ -135,6 +145,9 @@ class SelfTest { [[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); @@ -149,8 +162,8 @@ class SelfTest { uint8_t magneticAxes{0}; int16_t satellites{-1}; uint32_t sensorErrors{0}; - float batteryMinimum{0}; bool cancelled{false}; + uint32_t gyroCalibrationSamples{0}; private: void enter(Phase next, uint32_t now); @@ -160,10 +173,24 @@ class SelfTest { 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}; @@ -183,8 +210,8 @@ class SelfTest { uint8_t blockedButtons{0x7f}; uint8_t radioAttempts{0}; bool radioChanged{false}; - bool ledSecond{false}; - bool usbDisconnected{false}; + bool batteryVoltageValid{true}; + bool batteryUsbSeen{false}; Phase afterRestore{Phase::Motion}; float accelSum{0}; float accelSquareSum{0}; diff --git a/ground_station/src/telemetry/parser.cpp b/ground_station/src/telemetry/parser.cpp index b7d0ded6..e2d44071 100644 --- a/ground_station/src/telemetry/parser.cpp +++ b/ground_station/src/telemetry/parser.cpp @@ -110,6 +110,7 @@ void Parser::cmdInfo(uint8_t *args, uint32_t length) { auto &stats = observation.radio; ++stats.infoSamples; stats.lqSum += args[0]; + stats.minimumSnr = std::min(stats.minimumSnr, static_cast(args[2])); portEXIT_CRITICAL(&diagnosticsMux); } diff --git a/ground_station/src/telemetry/telemetry.cpp b/ground_station/src/telemetry/telemetry.cpp index 3dc8f124..ba919ae6 100644 --- a/ground_station/src/telemetry/telemetry.cpp +++ b/ground_station/src/telemetry/telemetry.cpp @@ -7,6 +7,8 @@ #include "console.hpp" #include "crc.hpp" +#include + constexpr uint8_t TASK_TELE_FREQ = 100; void Telemetry::begin() { @@ -317,7 +319,7 @@ void Telemetry::configureUpdateUart(bool rom) { serial.begin(115200, rom ? SERIAL_8E1 : SERIAL_8N1, rxPin, txPin); } -void Telemetry::finishUpdate(bool healthy) { +void Telemetry::finishUpdate(bool healthy, const char* verifiedVersion) { if (xSemaphoreTake(uartMutex, pdMS_TO_TICKS(1500)) != pdTRUE) { quarantined = true; updateRequested = false; @@ -326,6 +328,15 @@ void Telemetry::finishUpdate(bool healthy) { // 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; diff --git a/ground_station/src/telemetry/telemetry.hpp b/ground_station/src/telemetry/telemetry.hpp index e54c16a0..eb270f87 100644 --- a/ground_station/src/telemetry/telemetry.hpp +++ b/ground_station/src/telemetry/telemetry.hpp @@ -44,7 +44,7 @@ class Telemetry { // finishUpdate(). No task suspension while a parser/setting write is active. bool safeForUpdate(); bool beginUpdate(); - void finishUpdate(bool healthy); + void finishUpdate(bool healthy, const char* verifiedVersion = nullptr); HardwareSerial& updateSerial() { return serial; } void configureUpdateUart(bool rom); bool isQuarantined() const { return quarantined; } diff --git a/ground_station/src/update/radio_firmware_updater.cpp b/ground_station/src/update/radio_firmware_updater.cpp index d55ed667..89a96cca 100644 --- a/ground_station/src/update/radio_firmware_updater.cpp +++ b/ground_station/src/update/radio_firmware_updater.cpp @@ -282,7 +282,8 @@ void RadioFirmwareUpdater::updateBoth() { 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); + links[index]->finishUpdate(!results[index].entryRequested || results[index].success, + results[index].success ? results[index].version : nullptr); } } release(); diff --git a/ground_station/src/update/rom_bootloader.cpp b/ground_station/src/update/rom_bootloader.cpp index bfa2768c..b1b6f615 100644 --- a/ground_station/src/update/rom_bootloader.cpp +++ b/ground_station/src/update/rom_bootloader.cpp @@ -171,8 +171,8 @@ bool RomBootloader::enter(LinkResult& result) { } port.wait(100); port.drain(); - uint8_t request[] = {kBootCommand, 8, 'C', 'A', 'T', 'S', 'B', 'L', 1, 0xA5, 0}; - request[10] = crc8(request, 10); + 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; diff --git a/ground_station/src/utils.cpp b/ground_station/src/utils.cpp index 8f78dee5..3f69f762 100644 --- a/ground_station/src/utils.cpp +++ b/ground_station/src/utils.cpp @@ -58,6 +58,7 @@ 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); @@ -336,6 +337,10 @@ 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; @@ -574,6 +579,7 @@ static int32_t coreMscRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t // 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; } diff --git a/ground_station/src/utils.hpp b/ground_station/src/utils.hpp index 86f221b1..8acdd25e 100644 --- a/ground_station/src/utils.hpp +++ b/ground_station/src/utils.hpp @@ -35,6 +35,8 @@ 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); diff --git a/ground_station/tests/rom_bootloader_test.cpp b/ground_station/tests/rom_bootloader_test.cpp index 1cf1d351..9988047a 100644 --- a/ground_station/tests/rom_bootloader_test.cpp +++ b/ground_station/tests/rom_bootloader_test.cpp @@ -34,7 +34,7 @@ class FakePort final : public Port { public: bool write(const uint8_t* data, size_t size) override { if (!rom) { - if (size == 11 && data[0] == 0x80) { + 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}; diff --git a/ground_station/tests/self_test.cpp b/ground_station/tests/self_test.cpp index d840bdb1..ebd43729 100644 --- a/ground_station/tests/self_test.cpp +++ b/ground_station/tests/self_test.cpp @@ -1,8 +1,10 @@ // 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; @@ -20,11 +22,15 @@ struct Rig { 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 = input.usbConnected = true; + input.linksReady = true; input.batteryVoltage = 3.8F; input.sensors.imuDetected = true; input.sensors.magnetometer = 0x2c; @@ -41,11 +47,13 @@ struct Rig { 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; } @@ -61,6 +69,12 @@ struct Rig { ++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)]; } @@ -74,7 +88,7 @@ struct Rig { void release(uint8_t button) { input.buttonsHeld &= ~button; tick(); } void tap(uint8_t button) { press(button); release(button); } void hardware() { - start(); advance(3000); + start(); advance(12000); assert(test.phase == Phase::Telemetry && test.awaitingConfirmation); assert(requests[0] == 0 && input.links[0].versionReplies == 0); } @@ -98,8 +112,8 @@ struct Rig { 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.gyro = {}; - input.sensors.gyro[axis] = 30; + input.sensors.rawGyro = {}; + input.sensors.rawGyro[axis] = 30; input.sensors.magnetic = {sign * 1000, sign * 800, sign * 600}; tick(); } @@ -110,6 +124,7 @@ 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) @@ -124,41 +139,31 @@ int main() { assert(rig.test.completedButtons == 0); rig.release(1U); assert(rig.test.completedButtons == 1); - for (unsigned bit = 1; bit < 7; ++bit) rig.tap(1U << bit); + 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(2000); + rig.advance(pattern == 1 ? 2000 : 4000); assert(rig.test.displayPattern(rig.now) == pattern); } - rig.advance(2100); + rig.advance(4100); rig.press(A); // Keep the approval button held through the next prompt. - assert(rig.test.phase == Phase::Leds && rig.test.awaitingConfirmation); + 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); - rig.gate(Phase::Leds); - assert(rig.configuration == Radio::Receiver1Only); - rig.advance(6000); - assert(rig.configuration == Radio::Receiver2Only); - rig.advance(6100); - rig.tap(A); - rig.advance(2600); - assert(rig.test.phase == Phase::Unplug && rig.test.awaitingConfirmation); - rig.input.usbConnected = false; - rig.advance(10000); - assert(rig.test.phase == Phase::Unplug && rig.test.check(Check::Battery).result == Result::Pending); - rig.input.usbConnected = true; - rig.gate(Phase::Unplug); - rig.input.usbConnected = false; - rig.advance(5100); - assert(rig.test.phase == Phase::Reconnect && rig.test.awaitingConfirmation); - rig.input.usbConnected = true; // An early reconnection cannot complete the gated test. - rig.gate(Phase::Reconnect); + assert(rig.test.phase == Phase::Usb && rig.test.awaitingConfirmation); + rig.gate(Phase::Usb); rig.advance(1000); - assert(!rig.test.finished()); - rig.input.usbConnected = false; rig.tick(); + 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); } @@ -168,7 +173,7 @@ int main() { 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(121000); + 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); @@ -217,10 +222,165 @@ int main() { } { Rig rig; - rig.start(); rig.input.sensors.readError = true; rig.advance(200); + 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"; } From 1ddfdf095d2c65396f7e6b0f52613071dd61a593 Mon Sep 17 00:00:00 2001 From: Nemanja Stojoski Date: Sat, 5 Sep 2026 18:24:04 +0200 Subject: [PATCH 10/10] fix(gs): adjust live coordinate and bearing symbols Remove degree symbols from live GNSS coordinates and enlarge the bearing degree symbol while keeping it within the cleared row. Update the visually verified framebuffer references. Validation: Ground Station pre-commit checks, 22 browser checks, and 3600 dual-link angle redraw checks passed. --- ground_station/simulator/web/browser-golden.json | 8 ++++---- ground_station/src/hmi/window.cpp | 8 +++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/ground_station/simulator/web/browser-golden.json b/ground_station/simulator/web/browser-golden.json index 5e96451f..cef00389 100644 --- a/ground_station/simulator/web/browser-golden.json +++ b/ground_station/simulator/web/browser-golden.json @@ -1,7 +1,7 @@ { "menu": "2102f0f91cd0c03176ee70f679063cdc9fb95593e64c401b88149c4316c5c117", "startupRocket": "c8db9d72adcd669e7f60cb33e213e1f05c9f4eecead98bf8d2510d254163ae64", - "lightDemo": "775ecaa079f4bd1dd7130ff0f962de512dec16dace84b5e91bb36b39619d426a", + "lightDemo": "4f619bbbf53bda15a3e7d7a7f3e152808b01f6f65aa24f8624f4a12f8e5228af", "recovery": "eda5a41b5f22562e8628f879dbe04f9e3d7e929d54157840398fe49b2dca0a56", "compassOrientation": "6d5a84bc0813d49a491c11b1dbd3924046aa5ac43bcf8767dc4738e15eb1954a", "phoneHeading0": "41742a8683e13fcebfa75265c01050a76aacaa7dcd4bc0e148ac991c22128d2e", @@ -14,7 +14,7 @@ "phoneHeading315": "98f68820f68cf7049458c78f7a68dd5e46bbd6c4ace40efc98a67061eca69e4b", "usbStorage": "cd4a76e556b38614ce3194c15fb40de92c012ab78f128d9624d4c3043047c089", "recorderFault": "32219f426fded5eafe2a0a5199c593e94d9e858f5bb973190bcd71ce76e5ca2c", - "liveAheadRight": "6cd2afcd9a1e1a24441dd99a8e49acc954ff8d626f859f831462b01ebf813cfc", - "liveLeftAhead": "166c4d1acf0932c9bdd7b2f71aa0922cdc1da2daa6ccd0eae633e2c72349b0fb", - "liveBehindLeft": "1e768ec62806a82f9ac29065753054d017014ad95337a811bd09b92e6e9c3b0f" + "liveAheadRight": "1cfb144c6e9a8c51eac0a67ef329e83f1f2bc56b7c79e9a117f3793d3962bf65", + "liveLeftAhead": "0b4cce4e33c9e93fe44c5e36e8ac0a178e1f9b16ab7704775116aba5480ace08", + "liveBehindLeft": "7c1cba8d8e141bcacb76a3bc538ed3f973d7464cc29ecff42ca3c23ddc780198" } diff --git a/ground_station/src/hmi/window.cpp b/ground_station/src/hmi/window.cpp index de405f36..5d4eb5fe 100644 --- a/ground_station/src/hmi/window.cpp +++ b/ground_station/src/hmi/window.cpp @@ -30,9 +30,7 @@ void Window::drawCoordinate(float value, char positiveHemisphere, char negativeH display.setTextColor(color); display.setCursor(x, baseline); display.print(std::fabs(value), 4); - const int16_t degreeX = static_cast(display.getCursorX() + 3); - display.drawCircle(degreeX, static_cast(baseline - 15), 2, color); - display.setCursor(static_cast(degreeX + 7), baseline); + display.print(' '); display.print(value < 0.0F ? negativeHemisphere : positiveHemisphere); } @@ -571,8 +569,8 @@ bool Window::updateLiveNavigation(const TelemetryData &data1, const TelemetryDat display.setCursor(x, y + 21); display.print(values[row]); if (row == 1 && values[row][0] != '\0') { - // Match the GPS degree symbol and keep every pixel inside the cleared row. - display.drawCircle(static_cast(display.getCursorX() + 3), y + 6, 2, BLACK); + // 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;