diff --git a/include/droid_persistence.h b/include/droid_persistence.h index 8c9bd33..344afe9 100644 --- a/include/droid_persistence.h +++ b/include/droid_persistence.h @@ -14,3 +14,12 @@ DroidStore load(); void save(const DroidStore &store); } // namespace DroidPersistence + +// Note on the *currently selected* PAN ID (as opposed to the list above): +// there's deliberately no persistence for it here. Once XbeeControl:: +// setPanId() commits a PAN ID via "WR", the XBee module remembers it in +// its own flash across power cycles on its own — it never needs to be +// re-applied at boot, and never changes except when a Switch Droid action +// explicitly calls setPanId() again. SnipsController.ino derives the +// display name for it at boot by querying the module's current PAN ID +// (XbeeControl::queryPanId()) and matching it against the list above. diff --git a/include/menu.h b/include/menu.h index c18f947..fff4e48 100644 --- a/include/menu.h +++ b/include/menu.h @@ -111,10 +111,14 @@ 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). + // The currently-active droid's name, for the complications system's + // "Droid Name" source — "(none)" until either a switch succeeds or + // setCurrentDroidName() seeds it. SnipsController.ino calls the setter + // once at boot, having derived the name by querying the XBee module's + // current PAN ID (which it remembers on its own — see + // XbeeControl::queryPanId()) and matching it against the droid list. const char *currentDroidName() const { return currentDroidName_; } + void setCurrentDroidName(const char *name); // Display Config edits an externally-owned ComplicationRegistry rather // than duplicating its slot-assignment state here — set once at boot. diff --git a/include/xbee_control.h b/include/xbee_control.h index 847fb58..503c1a0 100644 --- a/include/xbee_control.h +++ b/include/xbee_control.h @@ -26,6 +26,15 @@ class XbeeControl : public XbeeTransport { // query failure, leaving outHex untouched. bool querySerialLow(char *outHex, size_t outHexCapacity); + // Queries the module's current PAN ID ("ID"). Once setPanId() commits + // one via "WR", the module remembers it in its own flash across power + // cycles on its own — this exists so SnipsController.ino can derive + // which saved droid (by name) that PAN ID corresponds to at boot, + // rather than to re-apply anything. Writes up to 16 hex chars + a null + // terminator into outHex (needs a 17-byte buffer). Returns false on + // query failure, leaving outHex untouched. + bool queryPanId(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 diff --git a/src/SnipsController.ino b/src/SnipsController.ino index e202e75..dc3832c 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -178,6 +178,25 @@ void setup() { xbeeControl.begin(); menuController.setXbeeTransport(&xbeeControl); + // The XBee module remembers its own PAN ID across power cycles once + // XbeeControl::setPanId() commits one via "WR" — it never needs to be + // re-applied here. This just derives which saved droid (by name) that + // PAN ID corresponds to, for the "Droid Name" complication; no match + // (e.g. first boot, or after a Factory Reset clears the list) leaves + // the "(none)" default in place. + char currentPanId[17]; + if (xbeeControl.queryPanId(currentPanId, sizeof(currentPanId))) { + const DroidStore &droidStore = menuController.droidStore(); + for (size_t i = 0; i < droidStore.count(); ++i) { + if (std::strcmp(droidStore.at(i).panId, currentPanId) == 0) { + menuController.setCurrentDroidName(droidStore.at(i).name); + break; + } + } + } else { + Serial.println("XBee PAN ID query failed at boot."); + } + // The module's SL is fixed hardware, so querying it once at boot (for // the Device Info screen) is enough — no need to re-query per menu // visit. deviceSerialLowBuf must outlive setup() since MenuController diff --git a/src/menu.cpp b/src/menu.cpp index 64cf0b2..2e28dc2 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -3,6 +3,14 @@ #include #include +namespace { +// A 64-bit all-zero PAN ID — Digi's convention for "unconfigured," used +// by Factory Reset to make sure the radio doesn't quietly stay +// associated with whatever droid it was last on. See the +// kFactoryResetConfirm case in MenuController::onEnter(). +constexpr const char *kClearedPanId = "0000000000000000"; +} // namespace + const char *mainMenuItemLabel(MainMenuItem item) { switch (item) { case MainMenuItem::kSwitchDroid: return "Switch Droid"; @@ -20,6 +28,11 @@ MainMenuItem MenuController::selectedMainMenuItem() const { return static_cast(mainMenuIndex_); } +void MenuController::setCurrentDroidName(const char *name) { + std::strncpy(currentDroidName_, name, sizeof(currentDroidName_) - 1); + currentDroidName_[sizeof(currentDroidName_) - 1] = '\0'; +} + int MenuController::wrapIndex(int index, int count) { if (count <= 0) return 0; return (index % count + count) % count; @@ -198,9 +211,7 @@ void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { 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'; + setCurrentDroidName(target.name); } screen_ = MenuScreen::kSwitchDroidResult; } @@ -292,6 +303,19 @@ void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { factoryResetConfirmed_ = true; droidStore_ = DroidStore(); droidStoreChanged_ = true; + // A factory reset should genuinely disconnect from whatever droid + // this controller was last on — leaving it silently still joined + // post-reset would be confusing. Clears the PAN ID to a neutral, + // unconfigured value and forces an immediate leave — deliberately + // no rejoinNetwork() call after, unlike a normal Switch Droid: it + // should sit disconnected until the user explicitly picks a new + // droid, not auto-associate with whatever it can find. Everything + // else about the radio (SL, etc.) is untouched. + if (xbeeTransport_ != nullptr) { + xbeeTransport_->leaveNetwork(); + xbeeTransport_->setPanId(kClearedPanId); + } + setCurrentDroidName("(none)"); screen_ = MenuScreen::kMainMenu; break; diff --git a/src/xbee_control.cpp b/src/xbee_control.cpp index 906ae03..c837925 100644 --- a/src/xbee_control.cpp +++ b/src/xbee_control.cpp @@ -45,8 +45,15 @@ bool XbeeControl::setPanId(const char *panId) { if (!hexStringToBytes(panId, panIdBytes, sizeof(panIdBytes))) { return false; } - return spi_.sendAtCommand("ID", panIdBytes, sizeof(panIdBytes), nullptr, 0, - nullptr); + if (!spi_.sendAtCommand("ID", panIdBytes, sizeof(panIdBytes), nullptr, 0, + nullptr)) { + return false; + } + // "WR": commit to the module's own flash. Once written, the module + // remembers this PAN ID across power cycles on its own — nothing needs + // to re-apply it at boot, and it never changes again until this method + // is called again for a different droid. + return spi_.sendAtCommand("WR", nullptr, 0, nullptr, 0, nullptr); } bool XbeeControl::rejoinNetwork() { @@ -71,6 +78,21 @@ bool XbeeControl::querySerialLow(char *outHex, size_t outHexCapacity) { return true; } +bool XbeeControl::queryPanId(char *outHex, size_t outHexCapacity) { + if (outHexCapacity < 17) { + return false; // 16 hex chars + null + } + uint8_t value[8]; + uint8_t valueLength = 0; + if (!spi_.sendAtCommand("ID", nullptr, 0, value, sizeof(value), + &valueLength) || + valueLength != sizeof(value)) { + return false; + } + bytesToHexString(value, sizeof(value), outHex); + return true; +} + bool XbeeControl::queryLocalRssiDbm(int *outDbm) { uint8_t value[1]; uint8_t valueLength = 0; diff --git a/test/test_menu/test_menu.cpp b/test/test_menu/test_menu.cpp index 754cc6a..4d5b586 100644 --- a/test/test_menu/test_menu.cpp +++ b/test/test_menu/test_menu.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "menu.h" @@ -31,9 +32,22 @@ DroidStore twoDroidStore() { class FakeTransport : public XbeeTransport { public: - bool leaveNetwork() override { return true; } - bool setPanId(const char * /*panId*/) override { return true; } - bool rejoinNetwork() override { return true; } + bool leaveCalled = false; + bool rejoinCalled = false; + std::string lastPanId; + + bool leaveNetwork() override { + leaveCalled = true; + return true; + } + bool setPanId(const char *panId) override { + lastPanId = panId; + return true; + } + bool rejoinNetwork() override { + rejoinCalled = true; + return true; + } }; } // namespace @@ -156,11 +170,11 @@ void test_enter_factory_reset_confirm_then_back_cancels() { } void test_factory_reset_confirmed_via_enter() { - MenuController menu; + MenuController menu; // no transport set — exercises the null guard menu.setDroidStore(twoDroidStore()); selectMainMenuItem(&menu, MainMenuItem::kFactoryReset); menu.onEnter(0, 0, 0); // -> confirm screen - menu.onEnter(0, 0, 0); // confirms + menu.onEnter(0, 0, 0); // confirms — should not crash without a transport TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); TEST_ASSERT_TRUE(menu.consumeFactoryResetConfirmed()); @@ -170,6 +184,24 @@ void test_factory_reset_confirmed_via_enter() { // Factory reset also clears the droid list, and reports that change. TEST_ASSERT_EQUAL_INT(0, menu.droidStore().count()); TEST_ASSERT_TRUE(menu.consumeDroidStoreChanged()); + + // And resets the current-droid display. + TEST_ASSERT_EQUAL_STRING("(none)", menu.currentDroidName()); +} + +void test_factory_reset_disconnects_the_radio() { + MenuController menu; + FakeTransport transport; + menu.setXbeeTransport(&transport); + selectMainMenuItem(&menu, MainMenuItem::kFactoryReset); + menu.onEnter(0, 0, 0); // -> confirm screen + menu.onEnter(0, 0, 0); // confirms + + TEST_ASSERT_TRUE(transport.leaveCalled); + TEST_ASSERT_EQUAL_STRING("0000000000000000", transport.lastPanId.c_str()); + // Deliberately does not rejoin — should sit disconnected until the + // user explicitly picks a new droid via Switch Droid. + TEST_ASSERT_FALSE(transport.rejoinCalled); } // ---- trigger calibration --------------------------------------------------- @@ -524,6 +556,14 @@ void test_current_droid_name_unchanged_on_failed_switch() { TEST_ASSERT_EQUAL_STRING("(none)", menu.currentDroidName()); } +void test_set_current_droid_name_seeds_it_directly() { + MenuController menu; // simulates SnipsController.ino restoring a + // persisted selection at boot, before any switch + menu.setCurrentDroidName("BB-8"); + TEST_ASSERT_EQUAL_STRING("BB-8", menu.currentDroidName()); +} + + // ---- labels ------------------------------------------------------------------ void test_main_menu_item_labels() { @@ -774,6 +814,7 @@ int main(int argc, char **argv) { RUN_TEST(test_device_info_enter_or_back_returns_to_main_menu); RUN_TEST(test_enter_factory_reset_confirm_then_back_cancels); RUN_TEST(test_factory_reset_confirmed_via_enter); + RUN_TEST(test_factory_reset_disconnects_the_radio); RUN_TEST(test_trigger_calibration_full_flow); RUN_TEST(test_back_during_trigger_calibration_cancels_and_resets); RUN_TEST(test_stick_calibration_full_flow); @@ -797,6 +838,7 @@ int main(int argc, char **argv) { 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_set_current_droid_name_seeds_it_directly); RUN_TEST(test_main_menu_item_labels); RUN_TEST(test_render_inactive_leaves_screen_blank); RUN_TEST(test_render_main_menu_marks_selected_item);