diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7907ffb..c8a244c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Coverage report run: | - output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --exclude 'src/oled\.cpp' --exclude 'src/rgb_led\.cpp' --exclude 'src/calibration_store\.cpp' --exclude 'src/droid_persistence\.cpp' --exclude 'src/xbee_spi\.cpp' --exclude 'src/xbee_control\.cpp' --print-summary --fail-under-line 90) + output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --exclude 'src/oled\.cpp' --exclude 'src/rgb_led\.cpp' --exclude 'src/calibration_store\.cpp' --exclude 'src/droid_persistence\.cpp' --exclude 'src/xbee_spi\.cpp' --exclude 'src/xbee_control\.cpp' --exclude 'src/complication_persistence\.cpp' --print-summary --fail-under-line 90) status=$? { echo '### Coverage (src/, excluding SnipsController.ino and hardware adapters)' diff --git a/include/complication_persistence.h b/include/complication_persistence.h new file mode 100644 index 0000000..76146b8 --- /dev/null +++ b/include/complication_persistence.h @@ -0,0 +1,17 @@ +#pragma once + +#include "complications.h" + +// Thin adapter persisting a ComplicationRegistry's slot assignments to +// NVS (ESP32 Preferences). No complication logic lives here — see +// complications.h. Excluded from native build/coverage (see +// platformio.ini's [env:native] build_src_filter). +namespace ComplicationPersistence { + +// Applies any previously-saved slot assignments onto `registry` (leaving +// its defaults in place for any slot that's never been saved). +void load(ComplicationRegistry *registry); + +void save(const ComplicationRegistry ®istry); + +} // namespace ComplicationPersistence diff --git a/include/complications.h b/include/complications.h new file mode 100644 index 0000000..b3f8bf7 --- /dev/null +++ b/include/complications.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +#include "packet.h" +#include "screen.h" + +// Maps OLED display slots to data sources for the "normal operating" +// screen (shown whenever the on-device menu is closed) — smartwatch-style +// complications, user-assignable via the Display Config menu screen. +// Pure logic: knows nothing about how the underlying data is obtained +// (XBee queries, ADC reads, etc.) — SnipsController.ino feeds current +// values in every tick via setData(). +enum class ComplicationSource { + kBattery, + kLeftSlot, + kRightSlot, + kSignal, + kDroidName, + kHandedness, + kCount, +}; + +const char *complicationSourceLabel(ComplicationSource source); + +struct ComplicationData { + int batteryPercent = 0; + char leftLabel[DownlinkPacket::kFieldLength + 1] = {}; + char leftValue[DownlinkPacket::kFieldLength + 1] = {}; + char rightLabel[DownlinkPacket::kFieldLength + 1] = {}; + char rightValue[DownlinkPacket::kFieldLength + 1] = {}; + int signalDbm = 0; + bool signalKnown = false; // distinguishes "0dBm" from "never queried" + char droidName[17] = {}; + DownlinkPacket::Handedness handedness = + DownlinkPacket::Handedness::kUnknown; +}; + +class ComplicationRegistry { + public: + static constexpr int kSlotCount = 4; + + ComplicationSource slotSource(int slotIndex) const; + + // Both are bounds-checked no-ops on an out-of-range slotIndex. + void setSlotSource(int slotIndex, ComplicationSource source); + void cycleSlotSource(int slotIndex); + + void setData(const ComplicationData &data) { data_ = data; } + + // Renders every slot into consecutive ScreenBuffer lines, one per slot. + void render(ScreenBuffer *screen) const; + + private: + ComplicationSource slots_[kSlotCount] = { + ComplicationSource::kBattery, + ComplicationSource::kDroidName, + ComplicationSource::kLeftSlot, + ComplicationSource::kRightSlot, + }; + ComplicationData data_; +}; diff --git a/include/menu.h b/include/menu.h index 6d92d5c..c18f947 100644 --- a/include/menu.h +++ b/include/menu.h @@ -1,6 +1,7 @@ #pragma once #include "calibration.h" +#include "complications.h" #include "droid_store.h" #include "screen.h" #include "text_entry.h" @@ -11,9 +12,6 @@ // the nav calls below (and detects the open combo via updateOpenCombo()), // and renderMenuScreen() turns the current state into a ScreenBuffer for // the (already-existing, hardware-touching) OledDisplay to draw. -// -// Display Config (PR 9) isn't part of this menu tree yet — it needs the -// packet protocol's label/value data, which doesn't exist yet. enum class MenuScreen { kInactive, kMainMenu, @@ -25,6 +23,7 @@ enum class MenuScreen { kManageDroidsDeleteConfirm, kCalibrateStick, kCalibrateTrigger, + kDisplayConfig, kDeviceInfo, kFactoryResetConfirm, }; @@ -34,6 +33,7 @@ enum class MainMenuItem { kManageDroids, kCalibrateStick, kCalibrateTrigger, + kDisplayConfig, kDeviceInfo, kFactoryReset, kCount, @@ -111,6 +111,21 @@ class MenuController { const TextEntryWidget &nameEntry() const { return nameEntry_; } const TextEntryWidget &panIdEntry() const { return panIdEntry_; } + // The most recently successfully-switched-to droid's name, for the + // complications system's "Droid Name" source — "(none)" until a switch + // has actually succeeded this session (not persisted; resets on boot). + const char *currentDroidName() const { return currentDroidName_; } + + // Display Config edits an externally-owned ComplicationRegistry rather + // than duplicating its slot-assignment state here — set once at boot. + // May be left null (the menu screen then just does nothing on Enter). + void setComplications(ComplicationRegistry *registry) { + complications_ = registry; + } + const ComplicationRegistry *complications() const { return complications_; } + int selectedDisplayConfigSlot() const { return displayConfigSlotIndex_; } + bool consumeComplicationsChanged(); + private: static constexpr unsigned long kOpenComboHoldMs = 1000; @@ -149,6 +164,11 @@ class MenuController { DroidSwitchResult lastSwitchResult_ = DroidSwitchResult::kSuccess; XbeeTransport *xbeeTransport_ = nullptr; const char *deviceSerialLow_ = "(unknown)"; + char currentDroidName_[DroidEntry::kMaxNameLength + 1] = "(none)"; + + ComplicationRegistry *complications_ = nullptr; + int displayConfigSlotIndex_ = 0; + bool complicationsChanged_ = false; }; // Decides what text should be on screen for the menu's current state. diff --git a/include/packet.h b/include/packet.h index ca36f8d..c17d7dc 100644 --- a/include/packet.h +++ b/include/packet.h @@ -20,12 +20,18 @@ // gesture classification — see the rewrite plan's Context section) plus // calibrated analog and battery/charge status. struct UplinkPacket { + static constexpr uint8_t kFlagShuttingDown = 0x01; + uint16_t buttonMask = 0; // bit i = Buttons::Index i is pressed uint8_t triggerPercent = 0; // 0-100 int8_t stickXPercent = 0; // -100..100 int8_t stickYPercent = 0; // -100..100 uint8_t batteryPercent = 0; // 0-100 ChargeState chargeState = ChargeState::kDone; + // Bitfield, currently just kFlagShuttingDown — set on the final few + // uplinks before power cuts so Amidala can mark this controller + // disconnected immediately instead of waiting out a timeout. + uint8_t flags = 0; }; // Amidala -> controller. Handedness is sent once at connect and is static @@ -46,7 +52,7 @@ struct DownlinkPacket { namespace Packet { -constexpr size_t kUplinkEncodedSize = 7; +constexpr size_t kUplinkEncodedSize = 8; constexpr size_t kDownlinkEncodedSize = 1 + 4 * DownlinkPacket::kFieldLength; // 33 diff --git a/include/xbee_control.h b/include/xbee_control.h index a8fb808..847fb58 100644 --- a/include/xbee_control.h +++ b/include/xbee_control.h @@ -26,6 +26,13 @@ class XbeeControl : public XbeeTransport { // query failure, leaving outHex untouched. bool querySerialLow(char *outHex, size_t outHexCapacity); + // Queries the local module's own last-hop received signal strength + // ("DB" AT command — a single byte, the RSSI magnitude in dBm, e.g. a + // response of 0x2A means -42dBm). Purely local: no round trip to + // Amidala needed, unlike everything else this controller displays. + // Returns false on query failure, leaving outDbm untouched. + bool queryLocalRssiDbm(int *outDbm); + // Passthroughs to the owned XbeeSpi — see xbee_spi.h. XbeeControl is // kept as the single facade over the one physical XBee connection // rather than SnipsController.ino owning a second XbeeSpi instance. diff --git a/platformio.ini b/platformio.ini index c6d09b6..a3449ac 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,14 +38,14 @@ lib_deps = ; adapter files (thin wrappers over I2C/SPI/ADC/RMT/NVS), added here ; alongside as new subsystems land — see CLAUDE.md's testing-approach note. ; Currently: oled.cpp (Adafruit_SSD1306/Wire), rgb_led.cpp -; (Adafruit_NeoPixel/RMT), calibration_store.cpp and droid_persistence.cpp -; (both Preferences/NVS), xbee_spi.cpp (SPI) and xbee_control.cpp (calls -; through to XbeeSpi, hardware-dependent now that it's a real transport -; rather than a stub). +; (Adafruit_NeoPixel/RMT), calibration_store.cpp, droid_persistence.cpp, +; and complication_persistence.cpp (all Preferences/NVS), xbee_spi.cpp +; (SPI) and xbee_control.cpp (calls through to XbeeSpi, hardware-dependent +; now that it's a real transport rather than a stub). [env:native] platform = native test_build_src = yes -build_src_filter = +<*> -<*.ino> - - - - - - +build_src_filter = +<*> -<*.ino> - - - - - - - test_framework = unity build_flags = --coverage extra_scripts = pre:scripts/native_coverage_linkflags.py diff --git a/src/SnipsController.ino b/src/SnipsController.ino index 04c4e51..e202e75 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -1,9 +1,12 @@ #include +#include #include "battery.h" #include "buttons.h" #include "calibration.h" #include "calibration_store.h" +#include "complication_persistence.h" +#include "complications.h" #include "droid_persistence.h" #include "menu.h" #include "oled.h" @@ -30,12 +33,16 @@ BatteryMonitor batteryMonitor; CalibrationData calibrationData; MenuController menuController; XbeeControl xbeeControl; +ComplicationRegistry complications; +ComplicationData complicationData; char deviceSerialLowBuf[9] = {}; // must outlive setup() — see its use below bool lastReportedPressed[Buttons::kCount] = {}; unsigned long lastTelemetryLogMs = 0; constexpr unsigned long kTelemetryLogIntervalMs = 1000; unsigned long lastUplinkSendMs = 0; constexpr unsigned long kUplinkSendIntervalMs = 50; +unsigned long lastDownlinkMs = 0; // 0 = never received one +constexpr unsigned long kConnectionTimeoutMs = 5000; MenuScreen previousMenuScreen = MenuScreen::kInactive; MainMenuItem previousMainMenuItem = MainMenuItem::kSwitchDroid; @@ -46,6 +53,54 @@ void showBootScreen() { oledDisplay.render(bootScreen); } +// The "normal operating" screen, shown whenever the on-device menu is +// closed — smartwatch-style complications, user-assignable via Display +// Config. +void showOperatingScreen() { + ScreenBuffer screen; + complications.render(&screen); + oledDisplay.render(screen); +} + +void copyField(char *dest, size_t destCapacity, const char *src) { + std::strncpy(dest, src, destCapacity - 1); + dest[destCapacity - 1] = '\0'; +} + +// Notifies Amidala this controller is powering off intentionally (so it +// doesn't wait out a stale-connection timeout), shows a brief message, +// then cuts power. Capped total duration (a few hundred ms) so a +// non-responsive radio can't hang the shutdown indefinitely. +void performGracefulShutdown() { + Serial.println("Shutting down..."); + + ScreenBuffer shutdownScreen; + shutdownScreen.setLine(0, "Powering Off..."); + oledDisplay.render(shutdownScreen); + + UplinkPacket shutdownPacket; + shutdownPacket.flags = UplinkPacket::kFlagShuttingDown; + uint8_t buf[Packet::kUplinkEncodedSize]; + const size_t length = + Packet::encodeUplink(shutdownPacket, buf, sizeof(buf)); + + constexpr int kShutdownRetries = 3; + constexpr unsigned long kShutdownRetryDelayMs = 100; + for (int i = 0; i < kShutdownRetries; ++i) { + if (length > 0) { + xbeeControl.sendPacket(buf, static_cast(length)); + } + delay(kShutdownRetryDelayMs); + } + + digitalWrite(PinAssignment::kPowerLatchHold, LOW); + // The rail should collapse almost immediately; spin here rather than + // falling back into loop() in an undefined half-shutdown state in case + // it doesn't. + while (true) { + } +} + const char *buttonName(size_t index) { switch (index) { case Buttons::kMacro1: return "Macro1"; @@ -108,15 +163,18 @@ void setup() { pinMode(PinAssignment::kChargeStat2, INPUT); // Restores any previously-run trigger/stick calibration; defaults to an - // uncalibrated full ADC range if none has been saved yet. The guided - // calibration flows that produce new values live in calibration.h and - // get wired to the on-device menu in a later PR. + // uncalibrated full ADC range if none has been saved yet. calibrationData = CalibrationStore::load(); // Restores any previously-saved droid list; defaults to empty if none // has been saved yet. menuController.setDroidStore(DroidPersistence::load()); + // Restores any previously-saved Display Config slot assignments; + // defaults are left in place for anything never saved. + ComplicationPersistence::load(&complications); + menuController.setComplications(&complications); + xbeeControl.begin(); menuController.setXbeeTransport(&xbeeControl); @@ -131,9 +189,8 @@ void setup() { Serial.println("XBee SL query failed at boot."); } - // Real "normal operating" screen content (complications) lands in a - // later PR. For now this just proves the display works end to end, and - // is what's restored whenever the on-device menu closes. + // Brief boot confirmation — loop() takes over with the real complications + // screen once real sensor data starts flowing. if (oledDisplay.begin()) { showBootScreen(); } else { @@ -142,9 +199,8 @@ void setup() { // Bring-up check per PCB/README.md's recommended order: cycle through // every status color once to prove RMT output on the real LED. Real - // state (connected/charging/error) gets driven by later PRs once there's - // an XBee link and charge-status reading to base it on; for now this - // just settles on "disconnected," which is accurate today. + // state takes over once loop() starts running (see the once-a-second + // block below). rgbLed.begin(); const SystemState bringUpSequence[] = { SystemState::kBooting, SystemState::kConnected, @@ -165,11 +221,7 @@ void loop() { digitalRead(PinAssignment::kPowerButtonSense) == HIGH; if (powerOffDetector.update(powerButtonHeld, now)) { - // The real graceful-shutdown sequence (notify Amidala, OLED message, - // then drive the latch pin low) lands in PR 10. For now, just prove - // the hold is detected. - Serial.println( - "Power button held 3s - shutdown sequence would run here (PR 10)."); + performGracefulShutdown(); // never returns } // Read every tick (not just on the telemetry throttle below) — the menu @@ -197,8 +249,9 @@ void loop() { // On-device menu: Left Up+Down held together opens it; once open, Left // Up/Down scroll, Stick Click confirms, Bumper backs out. These four // buttons are "stolen" for navigation only while the menu is active — - // Amidala never sees them any differently either way, since the packet - // protocol (PR 8) doesn't exist yet. + // their uplink bits get suppressed below so Amidala doesn't see spurious + // presses from menu use (e.g. nudging whatever the Left slot is assigned + // to while the user is just scrolling a menu). menuController.updateOpenCombo(buttonPanel.isPressed(Buttons::kLeftUp), buttonPanel.isPressed(Buttons::kLeftDown), now); @@ -260,10 +313,15 @@ void loop() { renderMenuScreen(menuController, &menuScreen); oledDisplay.render(menuScreen); } else { - showBootScreen(); + showOperatingScreen(); } } + if (menuController.consumeComplicationsChanged()) { + ComplicationPersistence::save(complications); + Serial.println("Display config saved."); + } + const int rawVsys = analogRead(PinAssignment::kVsysSense); const bool stat1High = digitalRead(PinAssignment::kChargeStat1) == HIGH; const bool stat2High = digitalRead(PinAssignment::kChargeStat2) == HIGH; @@ -286,9 +344,16 @@ void loop() { if (now - lastUplinkSendMs >= kUplinkSendIntervalMs) { lastUplinkSendMs = now; + const bool menuActive = + menuController.currentScreen() != MenuScreen::kInactive; + UplinkPacket uplink; for (size_t i = 0; i < Buttons::kCount; ++i) { - if (buttonPanel.isPressed(i)) { + const bool isMenuNavButton = i == Buttons::kLeftUp || + i == Buttons::kLeftDown || + i == Buttons::kStickClick || + i == Buttons::kBumper; + if (buttonPanel.isPressed(i) && !(menuActive && isMenuNavButton)) { uplink.buttonMask |= static_cast(1u << i); } } @@ -306,33 +371,69 @@ void loop() { } } - // Downlink: non-blocking poll every tick. Nothing consumes handedness - // or the Left/Right label+value yet — that's the complications system, - // PR 10 — so for now this just proves the round trip works. + // Downlink: non-blocking poll every tick. Handedness and the Left/Right + // label+value feed the complications system directly — a received + // field is sticky (kept displayed) until a newer downlink updates it. uint8_t downlinkBuf[Packet::kDownlinkEncodedSize]; uint16_t downlinkLength = 0; if (xbeeControl.pollForPacket(downlinkBuf, sizeof(downlinkBuf), &downlinkLength)) { DownlinkPacket downlink; if (Packet::decodeDownlink(downlinkBuf, downlinkLength, &downlink)) { - Serial.print("Downlink: hand="); - Serial.print(static_cast(downlink.handedness)); - Serial.print(" L="); - Serial.print(downlink.leftLabel); - Serial.print(":"); - Serial.print(downlink.leftValue); - Serial.print(" R="); - Serial.print(downlink.rightLabel); - Serial.print(":"); - Serial.println(downlink.rightValue); + lastDownlinkMs = now; + complicationData.handedness = downlink.handedness; + copyField(complicationData.leftLabel, sizeof(complicationData.leftLabel), + downlink.leftLabel); + copyField(complicationData.leftValue, sizeof(complicationData.leftValue), + downlink.leftValue); + copyField(complicationData.rightLabel, + sizeof(complicationData.rightLabel), downlink.rightLabel); + copyField(complicationData.rightValue, + sizeof(complicationData.rightValue), downlink.rightValue); + Serial.println("Downlink packet received."); } } - // Human-readable Serial telemetry — not what Amidala sees, just a - // slower-cadence bring-up check that the values above look right. + // Feed the complications system every tick — cheap, and keeps the + // operating screen's next scheduled redraw (below) always showing + // current data. + complicationData.batteryPercent = batteryPercent; + copyField(complicationData.droidName, sizeof(complicationData.droidName), + menuController.currentDroidName()); + complications.setData(complicationData); + + // Once a second: query local signal strength (blocking, up to ~200ms — + // too slow to do every tick), update the status LED, refresh the + // operating screen so it doesn't just sit stale between menu-state + // changes, and log human-readable telemetry (not what Amidala sees, + // just a bring-up check that the values above look right). if (now - lastTelemetryLogMs >= kTelemetryLogIntervalMs) { lastTelemetryLogMs = now; + int rssiDbm = 0; + if (xbeeControl.queryLocalRssiDbm(&rssiDbm)) { + complicationData.signalDbm = rssiDbm; + complicationData.signalKnown = true; + complications.setData(complicationData); + } + + SystemState ledState; + if (chargeState == ChargeState::kLatchedFault) { + ledState = SystemState::kError; + } else if (chargeState == ChargeState::kCharging) { + ledState = SystemState::kCharging; + } else if (lastDownlinkMs != 0 && + now - lastDownlinkMs < kConnectionTimeoutMs) { + ledState = SystemState::kConnected; + } else { + ledState = SystemState::kDisconnected; + } + rgbLed.show(statusLedController.colorFor(ledState)); + + if (menuController.currentScreen() == MenuScreen::kInactive) { + showOperatingScreen(); + } + Serial.print("Battery "); Serial.print(batteryPercent); Serial.print("% ("); diff --git a/src/complication_persistence.cpp b/src/complication_persistence.cpp new file mode 100644 index 0000000..fa4620e --- /dev/null +++ b/src/complication_persistence.cpp @@ -0,0 +1,39 @@ +#include "complication_persistence.h" + +#include + +#include + +namespace { +constexpr const char *kNamespace = "snips_disp"; +} // namespace + +void ComplicationPersistence::load(ComplicationRegistry *registry) { + Preferences prefs; + if (!prefs.begin(kNamespace, /*readOnly=*/true)) { + return; + } + + char key[8]; + for (int i = 0; i < ComplicationRegistry::kSlotCount; ++i) { + std::snprintf(key, sizeof(key), "slot%d", i); + const int defaultValue = static_cast(registry->slotSource(i)); + const int stored = prefs.getInt(key, defaultValue); + registry->setSlotSource(i, static_cast(stored)); + } + prefs.end(); +} + +void ComplicationPersistence::save(const ComplicationRegistry ®istry) { + Preferences prefs; + if (!prefs.begin(kNamespace, /*readOnly=*/false)) { + return; + } + + char key[8]; + for (int i = 0; i < ComplicationRegistry::kSlotCount; ++i) { + std::snprintf(key, sizeof(key), "slot%d", i); + prefs.putInt(key, static_cast(registry.slotSource(i))); + } + prefs.end(); +} diff --git a/src/complications.cpp b/src/complications.cpp new file mode 100644 index 0000000..becc608 --- /dev/null +++ b/src/complications.cpp @@ -0,0 +1,89 @@ +#include "complications.h" + +#include + +const char *complicationSourceLabel(ComplicationSource source) { + switch (source) { + case ComplicationSource::kBattery: return "Battery"; + case ComplicationSource::kLeftSlot: return "Left Slot"; + case ComplicationSource::kRightSlot: return "Right Slot"; + case ComplicationSource::kSignal: return "Signal"; + case ComplicationSource::kDroidName: return "Droid Name"; + case ComplicationSource::kHandedness: return "Handedness"; + default: return "Unknown"; + } +} + +ComplicationSource ComplicationRegistry::slotSource(int slotIndex) const { + return (slotIndex >= 0 && slotIndex < kSlotCount) + ? slots_[slotIndex] + : ComplicationSource::kBattery; +} + +void ComplicationRegistry::setSlotSource(int slotIndex, + ComplicationSource source) { + if (slotIndex < 0 || slotIndex >= kSlotCount) return; + slots_[slotIndex] = source; +} + +void ComplicationRegistry::cycleSlotSource(int slotIndex) { + if (slotIndex < 0 || slotIndex >= kSlotCount) return; + const int next = (static_cast(slots_[slotIndex]) + 1) % + static_cast(ComplicationSource::kCount); + slots_[slotIndex] = static_cast(next); +} + +void ComplicationRegistry::render(ScreenBuffer *screen) const { + screen->clear(); + char line[ScreenBuffer::kMaxLineLength + 1]; + + for (int i = 0; i < kSlotCount; ++i) { + switch (slots_[i]) { + case ComplicationSource::kBattery: + std::snprintf(line, sizeof(line), "Battery: %d%%", + data_.batteryPercent); + break; + case ComplicationSource::kLeftSlot: + std::snprintf(line, sizeof(line), "%s: %s", + data_.leftLabel[0] != '\0' ? data_.leftLabel : "Left", + data_.leftValue); + break; + case ComplicationSource::kRightSlot: + std::snprintf(line, sizeof(line), "%s: %s", + data_.rightLabel[0] != '\0' ? data_.rightLabel + : "Right", + data_.rightValue); + break; + case ComplicationSource::kSignal: + if (data_.signalKnown) { + std::snprintf(line, sizeof(line), "Signal: %ddBm", + data_.signalDbm); + } else { + std::snprintf(line, sizeof(line), "Signal: --"); + } + break; + case ComplicationSource::kDroidName: + std::snprintf(line, sizeof(line), "%s", + data_.droidName[0] != '\0' ? data_.droidName + : "(no droid)"); + break; + case ComplicationSource::kHandedness: + switch (data_.handedness) { + case DownlinkPacket::Handedness::kLeft: + std::snprintf(line, sizeof(line), "Hand: Left"); + break; + case DownlinkPacket::Handedness::kRight: + std::snprintf(line, sizeof(line), "Hand: Right"); + break; + default: + std::snprintf(line, sizeof(line), "Hand: ?"); + break; + } + break; + default: + line[0] = '\0'; + break; + } + screen->setLine(i, line); + } +} diff --git a/src/menu.cpp b/src/menu.cpp index 8d45e4e..64cf0b2 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -1,6 +1,7 @@ #include "menu.h" #include +#include const char *mainMenuItemLabel(MainMenuItem item) { switch (item) { @@ -8,6 +9,7 @@ const char *mainMenuItemLabel(MainMenuItem item) { case MainMenuItem::kManageDroids: return "Manage Droids"; case MainMenuItem::kCalibrateStick: return "Calibrate Stick"; case MainMenuItem::kCalibrateTrigger: return "Calibrate Trigger"; + case MainMenuItem::kDisplayConfig: return "Display Config"; case MainMenuItem::kDeviceInfo: return "Device Info"; case MainMenuItem::kFactoryReset: return "Factory Reset"; default: return "Unknown"; @@ -67,6 +69,10 @@ void MenuController::onUp() { case MenuScreen::kManageDroidsEnterPanId: panIdEntry_.scrollPrev(); break; + case MenuScreen::kDisplayConfig: + displayConfigSlotIndex_ = + wrapIndex(displayConfigSlotIndex_ - 1, ComplicationRegistry::kSlotCount); + break; default: break; } @@ -94,6 +100,10 @@ void MenuController::onDown() { case MenuScreen::kManageDroidsEnterPanId: panIdEntry_.scrollNext(); break; + case MenuScreen::kDisplayConfig: + displayConfigSlotIndex_ = + wrapIndex(displayConfigSlotIndex_ + 1, ComplicationRegistry::kSlotCount); + break; default: break; } @@ -134,6 +144,7 @@ void MenuController::onBack() { triggerFlow_ = TriggerCalibrationFlow(); screen_ = MenuScreen::kMainMenu; break; + case MenuScreen::kDisplayConfig: case MenuScreen::kDeviceInfo: case MenuScreen::kFactoryResetConfirm: screen_ = MenuScreen::kMainMenu; @@ -161,6 +172,10 @@ void MenuController::enterMainMenuItem(MainMenuItem item) { triggerFlow_ = TriggerCalibrationFlow(); screen_ = MenuScreen::kCalibrateTrigger; break; + case MainMenuItem::kDisplayConfig: + displayConfigSlotIndex_ = 0; + screen_ = MenuScreen::kDisplayConfig; + break; case MainMenuItem::kDeviceInfo: screen_ = MenuScreen::kDeviceInfo; break; @@ -180,8 +195,13 @@ void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { case MenuScreen::kSwitchDroidList: if (droidStore_.count() > 0) { - lastSwitchResult_ = DroidSwitcher::switchTo( - droidStore_.at(droidListIndex_).panId, xbeeTransport_); + const DroidEntry &target = droidStore_.at(droidListIndex_); + lastSwitchResult_ = DroidSwitcher::switchTo(target.panId, xbeeTransport_); + if (lastSwitchResult_ == DroidSwitchResult::kSuccess) { + std::strncpy(currentDroidName_, target.name, + sizeof(currentDroidName_) - 1); + currentDroidName_[sizeof(currentDroidName_) - 1] = '\0'; + } screen_ = MenuScreen::kSwitchDroidResult; } break; @@ -257,6 +277,13 @@ void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { } break; + case MenuScreen::kDisplayConfig: + if (complications_ != nullptr) { + complications_->cycleSlotSource(displayConfigSlotIndex_); + complicationsChanged_ = true; + } + break; + case MenuScreen::kDeviceInfo: screen_ = MenuScreen::kMainMenu; break; @@ -315,6 +342,12 @@ bool MenuController::consumeDroidStoreChanged() { return true; } +bool MenuController::consumeComplicationsChanged() { + if (!complicationsChanged_) return false; + complicationsChanged_ = false; + return true; +} + namespace { void renderTextEntryLine(const TextEntryWidget &widget, ScreenBuffer *screen, @@ -335,7 +368,7 @@ const char *switchResultText(DroidSwitchResult result) { case DroidSwitchResult::kLeaveFailed: return "Leave failed"; case DroidSwitchResult::kSetPanFailed: return "Set PAN failed"; case DroidSwitchResult::kRejoinFailed: return "Rejoin failed"; - case DroidSwitchResult::kNoTransport: return "No XBee link (PR 8)"; + case DroidSwitchResult::kNoTransport: return "No XBee link"; default: return "Unknown"; } } @@ -466,6 +499,22 @@ void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { } break; + case MenuScreen::kDisplayConfig: { + screen->setLine(0, "Display Config"); + char line[ScreenBuffer::kMaxLineLength + 1]; + for (int i = 0; i < ComplicationRegistry::kSlotCount; ++i) { + const char *sourceLabel = + menu.complications() != nullptr + ? complicationSourceLabel(menu.complications()->slotSource(i)) + : "(none)"; + std::snprintf(line, sizeof(line), "%s%d: %s", + i == menu.selectedDisplayConfigSlot() ? "> " : " ", + i + 1, sourceLabel); + screen->setLine(1 + i, line); + } + break; + } + case MenuScreen::kDeviceInfo: screen->setLine(0, "Device Info"); screen->setLine(1, "XBee SL:"); diff --git a/src/packet.cpp b/src/packet.cpp index b064c16..a168b6f 100644 --- a/src/packet.cpp +++ b/src/packet.cpp @@ -38,6 +38,7 @@ size_t Packet::encodeUplink(const UplinkPacket &packet, uint8_t *outBuf, outBuf[4] = static_cast(packet.stickYPercent); outBuf[5] = packet.batteryPercent; outBuf[6] = static_cast(packet.chargeState); + outBuf[7] = packet.flags; return kUplinkEncodedSize; } @@ -52,6 +53,7 @@ bool Packet::decodeUplink(const uint8_t *buf, size_t length, out->stickYPercent = static_cast(buf[4]); out->batteryPercent = buf[5]; out->chargeState = static_cast(buf[6]); + out->flags = buf[7]; return true; } diff --git a/src/xbee_control.cpp b/src/xbee_control.cpp index 1e2bd17..906ae03 100644 --- a/src/xbee_control.cpp +++ b/src/xbee_control.cpp @@ -70,3 +70,15 @@ bool XbeeControl::querySerialLow(char *outHex, size_t outHexCapacity) { bytesToHexString(value, sizeof(value), outHex); return true; } + +bool XbeeControl::queryLocalRssiDbm(int *outDbm) { + uint8_t value[1]; + uint8_t valueLength = 0; + if (!spi_.sendAtCommand("DB", nullptr, 0, value, sizeof(value), + &valueLength) || + valueLength != sizeof(value)) { + return false; + } + *outDbm = -static_cast(value[0]); + return true; +} diff --git a/test/test_complications/test_complications.cpp b/test/test_complications/test_complications.cpp new file mode 100644 index 0000000..8dd0f98 --- /dev/null +++ b/test/test_complications/test_complications.cpp @@ -0,0 +1,185 @@ +#include +#include + +#include "complications.h" + +void setUp(void) {} +void tearDown(void) {} + +// ---- labels ---------------------------------------------------------- + +void test_source_labels() { + TEST_ASSERT_EQUAL_STRING("Battery", + complicationSourceLabel(ComplicationSource::kBattery)); + TEST_ASSERT_EQUAL_STRING( + "Left Slot", complicationSourceLabel(ComplicationSource::kLeftSlot)); + TEST_ASSERT_EQUAL_STRING( + "Right Slot", complicationSourceLabel(ComplicationSource::kRightSlot)); + TEST_ASSERT_EQUAL_STRING("Signal", + complicationSourceLabel(ComplicationSource::kSignal)); + TEST_ASSERT_EQUAL_STRING( + "Droid Name", complicationSourceLabel(ComplicationSource::kDroidName)); + TEST_ASSERT_EQUAL_STRING( + "Handedness", complicationSourceLabel(ComplicationSource::kHandedness)); + TEST_ASSERT_EQUAL_STRING("Unknown", + complicationSourceLabel(ComplicationSource::kCount)); +} + +// ---- slot assignment --------------------------------------------------- + +void test_default_slot_assignments() { + ComplicationRegistry registry; + TEST_ASSERT_TRUE(ComplicationSource::kBattery == registry.slotSource(0)); + TEST_ASSERT_TRUE(ComplicationSource::kDroidName == registry.slotSource(1)); + TEST_ASSERT_TRUE(ComplicationSource::kLeftSlot == registry.slotSource(2)); + TEST_ASSERT_TRUE(ComplicationSource::kRightSlot == registry.slotSource(3)); +} + +void test_set_slot_source() { + ComplicationRegistry registry; + registry.setSlotSource(0, ComplicationSource::kSignal); + TEST_ASSERT_TRUE(ComplicationSource::kSignal == registry.slotSource(0)); +} + +void test_set_slot_source_out_of_range_is_noop() { + ComplicationRegistry registry; + registry.setSlotSource(99, ComplicationSource::kSignal); + registry.setSlotSource(-1, ComplicationSource::kSignal); + // Nothing crashed, and in-range slots are untouched. + TEST_ASSERT_TRUE(ComplicationSource::kBattery == registry.slotSource(0)); +} + +void test_slot_source_out_of_range_returns_default() { + ComplicationRegistry registry; + TEST_ASSERT_TRUE(ComplicationSource::kBattery == registry.slotSource(99)); +} + +void test_cycle_slot_source_advances_and_wraps() { + ComplicationRegistry registry; + registry.setSlotSource(0, ComplicationSource::kBattery); + registry.cycleSlotSource(0); + TEST_ASSERT_TRUE(ComplicationSource::kLeftSlot == registry.slotSource(0)); + + // Cycle all the way around back to kBattery. + for (int i = 0; i < static_cast(ComplicationSource::kCount) - 1; + ++i) { + registry.cycleSlotSource(0); + } + TEST_ASSERT_TRUE(ComplicationSource::kBattery == registry.slotSource(0)); +} + +void test_cycle_slot_source_out_of_range_is_noop() { + ComplicationRegistry registry; + registry.cycleSlotSource(99); // should not crash +} + +// ---- rendering ---------------------------------------------------------- + +void test_render_battery() { + ComplicationRegistry registry; + ComplicationData data; + data.batteryPercent = 73; + registry.setData(data); + registry.setSlotSource(0, ComplicationSource::kBattery); + + ScreenBuffer screen; + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Battery: 73%", screen.line(0)); +} + +void test_render_left_and_right_slot_with_labels() { + ComplicationRegistry registry; + ComplicationData data; + std::strncpy(data.leftLabel, "Volume", sizeof(data.leftLabel)); + std::strncpy(data.leftValue, "42%", sizeof(data.leftValue)); + std::strncpy(data.rightLabel, "Throttle", sizeof(data.rightLabel)); + std::strncpy(data.rightValue, "88%", sizeof(data.rightValue)); + registry.setData(data); + registry.setSlotSource(0, ComplicationSource::kLeftSlot); + registry.setSlotSource(1, ComplicationSource::kRightSlot); + + ScreenBuffer screen; + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Volume: 42%", screen.line(0)); + TEST_ASSERT_EQUAL_STRING("Throttle: 88%", screen.line(1)); +} + +void test_render_left_slot_falls_back_when_label_empty() { + ComplicationRegistry registry; + registry.setSlotSource(0, ComplicationSource::kLeftSlot); + + ScreenBuffer screen; + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Left: ", screen.line(0)); +} + +void test_render_signal_known_and_unknown() { + ComplicationRegistry registry; + registry.setSlotSource(0, ComplicationSource::kSignal); + + ScreenBuffer screen; + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Signal: --", screen.line(0)); + + ComplicationData data; + data.signalKnown = true; + data.signalDbm = -42; + registry.setData(data); + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Signal: -42dBm", screen.line(0)); +} + +void test_render_droid_name_empty_and_set() { + ComplicationRegistry registry; + registry.setSlotSource(0, ComplicationSource::kDroidName); + + ScreenBuffer screen; + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("(no droid)", screen.line(0)); + + ComplicationData data; + std::strncpy(data.droidName, "R2-D2", sizeof(data.droidName)); + registry.setData(data); + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("R2-D2", screen.line(0)); +} + +void test_render_handedness_all_variants() { + ComplicationRegistry registry; + registry.setSlotSource(0, ComplicationSource::kHandedness); + ScreenBuffer screen; + + ComplicationData data; + data.handedness = DownlinkPacket::Handedness::kLeft; + registry.setData(data); + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Hand: Left", screen.line(0)); + + data.handedness = DownlinkPacket::Handedness::kRight; + registry.setData(data); + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Hand: Right", screen.line(0)); + + data.handedness = DownlinkPacket::Handedness::kUnknown; + registry.setData(data); + registry.render(&screen); + TEST_ASSERT_EQUAL_STRING("Hand: ?", screen.line(0)); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_source_labels); + RUN_TEST(test_default_slot_assignments); + RUN_TEST(test_set_slot_source); + RUN_TEST(test_set_slot_source_out_of_range_is_noop); + RUN_TEST(test_slot_source_out_of_range_returns_default); + RUN_TEST(test_cycle_slot_source_advances_and_wraps); + RUN_TEST(test_cycle_slot_source_out_of_range_is_noop); + RUN_TEST(test_render_battery); + RUN_TEST(test_render_left_and_right_slot_with_labels); + RUN_TEST(test_render_left_slot_falls_back_when_label_empty); + RUN_TEST(test_render_signal_known_and_unknown); + RUN_TEST(test_render_droid_name_empty_and_set); + RUN_TEST(test_render_handedness_all_variants); + return UNITY_END(); +} diff --git a/test/test_menu/test_menu.cpp b/test/test_menu/test_menu.cpp index 23ae9a3..754cc6a 100644 --- a/test/test_menu/test_menu.cpp +++ b/test/test_menu/test_menu.cpp @@ -94,6 +94,9 @@ void test_down_cycles_through_every_main_menu_item_in_order() { TEST_ASSERT_TRUE(MainMenuItem::kCalibrateTrigger == menu.selectedMainMenuItem()); menu.onDown(); + TEST_ASSERT_TRUE(MainMenuItem::kDisplayConfig == + menu.selectedMainMenuItem()); + menu.onDown(); TEST_ASSERT_TRUE(MainMenuItem::kDeviceInfo == menu.selectedMainMenuItem()); menu.onDown(); TEST_ASSERT_TRUE(MainMenuItem::kFactoryReset == @@ -443,6 +446,84 @@ void test_manage_droids_delete_confirm_back_cancels() { TEST_ASSERT_FALSE(menu.consumeDroidStoreChanged()); } +// ---- display config ----------------------------------------------------------- + +void test_display_config_cycles_selected_slot_source() { + MenuController menu; + ComplicationRegistry registry; + menu.setComplications(®istry); + selectMainMenuItem(&menu, MainMenuItem::kDisplayConfig); + menu.onEnter(0, 0, 0); // -> kDisplayConfig + TEST_ASSERT_TRUE(MenuScreen::kDisplayConfig == menu.currentScreen()); + TEST_ASSERT_EQUAL_INT(0, menu.selectedDisplayConfigSlot()); + + const ComplicationSource before = registry.slotSource(0); + menu.onEnter(0, 0, 0); + TEST_ASSERT_FALSE(before == registry.slotSource(0)); + TEST_ASSERT_TRUE(menu.consumeComplicationsChanged()); + TEST_ASSERT_FALSE(menu.consumeComplicationsChanged()); +} + +void test_display_config_up_down_selects_slot_not_source() { + MenuController menu; + ComplicationRegistry registry; + menu.setComplications(®istry); + selectMainMenuItem(&menu, MainMenuItem::kDisplayConfig); + menu.onEnter(0, 0, 0); // -> kDisplayConfig + + menu.onDown(); + TEST_ASSERT_EQUAL_INT(1, menu.selectedDisplayConfigSlot()); + menu.onUp(); + menu.onUp(); // wraps to the last slot + TEST_ASSERT_EQUAL_INT(ComplicationRegistry::kSlotCount - 1, + menu.selectedDisplayConfigSlot()); +} + +void test_display_config_enter_without_registry_is_noop() { + MenuController menu; // no setComplications() call + selectMainMenuItem(&menu, MainMenuItem::kDisplayConfig); + menu.onEnter(0, 0, 0); // -> kDisplayConfig + menu.onEnter(0, 0, 0); // should not crash + TEST_ASSERT_FALSE(menu.consumeComplicationsChanged()); +} + +void test_display_config_back_returns_to_main_menu() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kDisplayConfig); + menu.onEnter(0, 0, 0); // -> kDisplayConfig + menu.onBack(); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); +} + +// ---- current droid name -------------------------------------------------------- + +void test_current_droid_name_defaults_to_none() { + MenuController menu; + TEST_ASSERT_EQUAL_STRING("(none)", menu.currentDroidName()); +} + +void test_current_droid_name_set_on_successful_switch() { + MenuController menu; + FakeTransport transport; + menu.setXbeeTransport(&transport); + menu.setDroidStore(twoDroidStore()); + selectMainMenuItem(&menu, MainMenuItem::kSwitchDroid); + menu.onEnter(0, 0, 0); // -> kSwitchDroidList + menu.onEnter(0, 0, 0); // select R2-D2, switch succeeds + + TEST_ASSERT_EQUAL_STRING("R2-D2", menu.currentDroidName()); +} + +void test_current_droid_name_unchanged_on_failed_switch() { + MenuController menu; // no transport set -> switch fails + menu.setDroidStore(twoDroidStore()); + selectMainMenuItem(&menu, MainMenuItem::kSwitchDroid); + menu.onEnter(0, 0, 0); + menu.onEnter(0, 0, 0); + + TEST_ASSERT_EQUAL_STRING("(none)", menu.currentDroidName()); +} + // ---- labels ------------------------------------------------------------------ void test_main_menu_item_labels() { @@ -454,6 +535,8 @@ void test_main_menu_item_labels() { mainMenuItemLabel(MainMenuItem::kCalibrateStick)); TEST_ASSERT_EQUAL_STRING("Calibrate Trigger", mainMenuItemLabel(MainMenuItem::kCalibrateTrigger)); + TEST_ASSERT_EQUAL_STRING("Display Config", + mainMenuItemLabel(MainMenuItem::kDisplayConfig)); TEST_ASSERT_EQUAL_STRING("Device Info", mainMenuItemLabel(MainMenuItem::kDeviceInfo)); TEST_ASSERT_EQUAL_STRING("Factory Reset", @@ -548,6 +631,29 @@ void test_render_factory_reset_confirm() { TEST_ASSERT_EQUAL_STRING("Factory Reset?", screen.line(0)); } +void test_render_display_config_shows_slots_and_selection() { + MenuController menu; + ComplicationRegistry registry; + menu.setComplications(®istry); + ScreenBuffer screen; + selectMainMenuItem(&menu, MainMenuItem::kDisplayConfig); + menu.onEnter(0, 0, 0); // -> kDisplayConfig + menu.onDown(); // select slot 1 + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Display Config", screen.line(0)); + TEST_ASSERT_EQUAL_STRING(" 1: Battery", screen.line(1)); + TEST_ASSERT_EQUAL_STRING("> 2: Droid Name", screen.line(2)); +} + +void test_render_display_config_without_registry_shows_placeholder() { + MenuController menu; // no setComplications() call + ScreenBuffer screen; + selectMainMenuItem(&menu, MainMenuItem::kDisplayConfig); + menu.onEnter(0, 0, 0); // -> kDisplayConfig + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("> 1: (none)", screen.line(1)); +} + void test_render_switch_droid_list_empty() { MenuController menu; ScreenBuffer screen; @@ -577,7 +683,7 @@ void test_render_switch_droid_result() { menu.onEnter(0, 0, 0); // -> kSwitchDroidList menu.onEnter(0, 0, 0); // select R2-D2, attempt switch renderMenuScreen(menu, &screen); - TEST_ASSERT_EQUAL_STRING("No XBee link (PR 8)", screen.line(1)); + TEST_ASSERT_EQUAL_STRING("No XBee link", screen.line(1)); } void test_render_manage_droids_list_shows_add_new() { @@ -684,6 +790,13 @@ int main(int argc, char **argv) { RUN_TEST(test_manage_droids_pan_id_backspace_and_cancel); RUN_TEST(test_manage_droids_delete_flow_removes_entry); RUN_TEST(test_manage_droids_delete_confirm_back_cancels); + RUN_TEST(test_display_config_cycles_selected_slot_source); + RUN_TEST(test_display_config_up_down_selects_slot_not_source); + RUN_TEST(test_display_config_enter_without_registry_is_noop); + RUN_TEST(test_display_config_back_returns_to_main_menu); + RUN_TEST(test_current_droid_name_defaults_to_none); + RUN_TEST(test_current_droid_name_set_on_successful_switch); + RUN_TEST(test_current_droid_name_unchanged_on_failed_switch); RUN_TEST(test_main_menu_item_labels); RUN_TEST(test_render_inactive_leaves_screen_blank); RUN_TEST(test_render_main_menu_marks_selected_item); @@ -693,6 +806,8 @@ int main(int argc, char **argv) { RUN_TEST(test_render_device_info); RUN_TEST(test_device_serial_low_defaults_then_reflects_what_was_set); RUN_TEST(test_render_factory_reset_confirm); + RUN_TEST(test_render_display_config_shows_slots_and_selection); + RUN_TEST(test_render_display_config_without_registry_shows_placeholder); RUN_TEST(test_render_switch_droid_list_empty); RUN_TEST(test_render_switch_droid_list_marks_selected); RUN_TEST(test_render_switch_droid_result); diff --git a/test/test_packet/test_packet.cpp b/test/test_packet/test_packet.cpp index 472d7e5..ef2c15f 100644 --- a/test/test_packet/test_packet.cpp +++ b/test/test_packet/test_packet.cpp @@ -16,6 +16,7 @@ void test_uplink_round_trip() { packet.stickYPercent = 100; packet.batteryPercent = 88; packet.chargeState = ChargeState::kCharging; + packet.flags = UplinkPacket::kFlagShuttingDown; uint8_t buf[Packet::kUplinkEncodedSize]; TEST_ASSERT_EQUAL_UINT(Packet::kUplinkEncodedSize, @@ -29,6 +30,17 @@ void test_uplink_round_trip() { TEST_ASSERT_EQUAL_INT8(100, decoded.stickYPercent); TEST_ASSERT_EQUAL_UINT8(88, decoded.batteryPercent); TEST_ASSERT_TRUE(ChargeState::kCharging == decoded.chargeState); + TEST_ASSERT_EQUAL_UINT8(UplinkPacket::kFlagShuttingDown, decoded.flags); +} + +void test_uplink_flags_default_to_zero() { + UplinkPacket packet; + uint8_t buf[Packet::kUplinkEncodedSize]; + Packet::encodeUplink(packet, buf, sizeof(buf)); + + UplinkPacket decoded; + Packet::decodeUplink(buf, sizeof(buf), &decoded); + TEST_ASSERT_EQUAL_UINT8(0, decoded.flags); } void test_uplink_encode_fails_when_buffer_too_small() { @@ -109,6 +121,7 @@ void test_downlink_decode_fails_when_length_too_short() { int main(int argc, char **argv) { UNITY_BEGIN(); RUN_TEST(test_uplink_round_trip); + RUN_TEST(test_uplink_flags_default_to_zero); RUN_TEST(test_uplink_encode_fails_when_buffer_too_small); RUN_TEST(test_uplink_decode_fails_when_length_too_short); RUN_TEST(test_downlink_round_trip);