diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5862eb4..7907ffb 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' --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' --print-summary --fail-under-line 90) status=$? { echo '### Coverage (src/, excluding SnipsController.ino and hardware adapters)' diff --git a/include/droid_switcher.h b/include/droid_switcher.h new file mode 100644 index 0000000..78a38a3 --- /dev/null +++ b/include/droid_switcher.h @@ -0,0 +1,32 @@ +#pragma once + +// Abstraction over "the ability to control network membership on the +// XBee module" — leave the current network, set a new PAN ID, and +// rejoin. XbeeControl (xbee_control.h) is the real, hardware-dependent +// implementation; tests use a fake. +class XbeeTransport { + public: + virtual ~XbeeTransport() = default; + virtual bool leaveNetwork() = 0; + virtual bool setPanId(const char *panId) = 0; + virtual bool rejoinNetwork() = 0; +}; + +enum class DroidSwitchResult { + kSuccess, + kLeaveFailed, + kSetPanFailed, + kRejoinFailed, + kNoTransport, +}; + +// Pure orchestration of the leave/set-PAN/rejoin sequence — testable +// against any XbeeTransport, fake or real. Kept in its own file (separate +// from XbeeControl) so it stays compiled and covered natively even though +// XbeeControl itself is hardware-dependent and excluded from that build. +class DroidSwitcher { + public: + // transport may be null (returns kNoTransport without touching it). + static DroidSwitchResult switchTo(const char *panId, + XbeeTransport *transport); +}; diff --git a/include/menu.h b/include/menu.h index 7f482fc..6d92d5c 100644 --- a/include/menu.h +++ b/include/menu.h @@ -4,7 +4,7 @@ #include "droid_store.h" #include "screen.h" #include "text_entry.h" -#include "xbee_control.h" +#include "droid_switcher.h" // Pure on-device menu state machine. Knows nothing about real buttons or // the display — SnipsController.ino translates physical button edges into @@ -100,6 +100,12 @@ class MenuController { xbeeTransport_ = transport; } + // The XBee's own SL (queried once at boot, since it's a fixed hardware + // address) for the Device Info screen — plain string storage, no + // hardware access here. + void setDeviceSerialLow(const char *hex) { deviceSerialLow_ = hex; } + const char *deviceSerialLow() const { return deviceSerialLow_; } + int selectedDroidListIndex() const { return droidListIndex_; } DroidSwitchResult lastSwitchResult() const { return lastSwitchResult_; } const TextEntryWidget &nameEntry() const { return nameEntry_; } @@ -142,6 +148,7 @@ class MenuController { TextEntryWidget panIdEntry_{TextEntryWidget::CharSet::kHex}; DroidSwitchResult lastSwitchResult_ = DroidSwitchResult::kSuccess; XbeeTransport *xbeeTransport_ = nullptr; + const char *deviceSerialLow_ = "(unknown)"; }; // Decides what text should be on screen for the menu's current state. diff --git a/include/xbee_control.h b/include/xbee_control.h index 31324dd..3b6c3fa 100644 --- a/include/xbee_control.h +++ b/include/xbee_control.h @@ -1,41 +1,31 @@ #pragma once -// Abstraction over "the ability to control network membership on the -// XBee module" — leave the current network, set a new PAN ID, and -// rejoin. A real implementation using XBee SPI/AT commands lands in -// PR 8; for now XbeeControl below is an honest stub reporting failure, -// so the menu's Switch Droid flow is fully wired end to end but not yet -// functional over real radio. -class XbeeTransport { - public: - virtual ~XbeeTransport() = default; - virtual bool leaveNetwork() = 0; - virtual bool setPanId(const char *panId) = 0; - virtual bool rejoinNetwork() = 0; -}; +#include -enum class DroidSwitchResult { - kSuccess, - kLeaveFailed, - kSetPanFailed, - kRejoinFailed, - kNoTransport, -}; +#include "droid_switcher.h" +#include "xbee_spi.h" -// Pure orchestration of the leave/set-PAN/rejoin sequence — testable -// against any XbeeTransport, fake or real. -class DroidSwitcher { - public: - // transport may be null (returns kNoTransport without touching it). - static DroidSwitchResult switchTo(const char *panId, - XbeeTransport *transport); -}; - -// Stub XbeeTransport — PR 8 replaces the method bodies with the real -// XBee SPI/API-mode implementation. +// Real XbeeTransport (see droid_switcher.h), over the SPI transport in +// xbee_spi.h. Exact AT command sequencing (NR for leave, ID for the new +// PAN, AC to apply and trigger rejoin) is this class's best-effort +// reading of Digi's XBee3 manual, not yet validated against real +// hardware — confirm during this PR's bring-up and adjust here if the +// sequence needs correcting. class XbeeControl : public XbeeTransport { public: + void begin() { spi_.begin(); } + bool leaveNetwork() override; bool setPanId(const char *panId) override; bool rejoinNetwork() override; + + // Queries the module's own 64-bit address (low 32 bits, "SL") for + // display in the Device Info menu screen — this is what a user reads + // off-screen to enter into Amidala. Writes up to 8 hex chars + a null + // terminator into outHex (needs a 9-byte buffer). Returns false on + // query failure, leaving outHex untouched. + bool querySerialLow(char *outHex, size_t outHexCapacity); + + private: + XbeeSpi spi_; }; diff --git a/include/xbee_frame.h b/include/xbee_frame.h new file mode 100644 index 0000000..1857124 --- /dev/null +++ b/include/xbee_frame.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +// Pure logic for XBee API frame framing (AP=1, unescaped) — checksums and +// the two frame types this firmware needs: local AT Command (0x08) request +// / response (0x88). Deliberately free of any Arduino/SPI dependency, like +// thePunderWoman/Amidala's xbee_frame_checksum.h, so it's unit-testable +// natively. See xbee_spi.h for the SPI-driven transport that uses this. +// +// All functions here operate on a frame's data — the frame-type byte +// through the end, NOT including the leading 0x7E/length header or the +// trailing checksum byte. +namespace XbeeFrame { + +// 0xFF minus the low 8 bits of the sum of all bytes in the frame. +uint8_t computeChecksum(const uint8_t *frameData, uint16_t length); + +// True if `checksum` matches what computeChecksum() gives for this frame. +bool checksumValid(const uint8_t *frameData, uint16_t length, + uint8_t checksum); + +constexpr uint8_t kFrameTypeAtCommand = 0x08; +constexpr uint8_t kFrameTypeAtCommandResponse = 0x88; + +// Builds a local AT Command frame's data (type 0x08) into `outFrameData`. +// `atCmd` is the two-character command name (e.g. "ID", not +// null-terminated — only the first 2 bytes are read). `value` may be +// null/zero-length for a query. Returns the number of bytes written, or 0 +// if outCapacity is too small. +uint16_t buildAtCommandFrame(uint8_t *outFrameData, uint16_t outCapacity, + uint8_t frameId, const char *atCmd, + const uint8_t *value, uint8_t valueLength); + +enum class AtCommandStatus : uint8_t { + kOk = 0, + kError = 1, + kInvalidCommand = 2, + kInvalidParameter = 3, +}; + +struct AtCommandResponse { + uint8_t frameId; + char atCmd[2]; + AtCommandStatus status; + const uint8_t *value; // points into the buffer passed to parse(); may be + // null if valueLength == 0 + uint8_t valueLength; +}; + +// Parses an AT Command Response frame's data (type 0x88). `frameData` must +// stay valid as long as `out->value` is used. Returns false if `frameData` +// isn't a recognized/well-formed AT Command Response. +bool parseAtCommandResponse(const uint8_t *frameData, uint16_t length, + AtCommandResponse *out); + +} // namespace XbeeFrame diff --git a/include/xbee_spi.h b/include/xbee_spi.h new file mode 100644 index 0000000..5187bfa --- /dev/null +++ b/include/xbee_spi.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +// Thin hardware adapter for the XBee3's SPI interface (AP=1/unescaped API +// mode). Frame envelope/checksum logic lives in xbee_frame.h (pure, +// tested); this file only does real SPI transfers, CS toggling, and ATTN +// polling — mirrors thePunderWoman/Amidala's proven xbee_spi.cpp read +// path (same module family, same SPI settings), extended with a write +// path and a blocking AT-command round trip. Excluded from native +// build/coverage (see platformio.ini's [env:native] build_src_filter). +class XbeeSpi { + public: + void begin(); + + // True if the module has at least one frame queued (ATTN asserted). + bool frameAvailable(); + + // Reads one queued frame's data (excludes the 0x7E/length header and + // trailing checksum) into buf. Returns the length on success, -1 if no + // start delimiter was found at all (ATTN may be stuck low with nothing + // actually queued — caller should stop draining), or 0 if a delimiter + // was found but the frame was unusable (bad length or checksum — the + // SPI stream is still in sync, so the caller should keep going). + int32_t readFrame(uint8_t *buf, uint16_t maxLen); + + // Writes one complete frame (0x7E + length + frameData + checksum). + void writeFrame(const uint8_t *frameData, uint16_t length); + + // Sends a local AT command and blocks (up to timeoutMs) reading frames + // until the matching (by frameId) AT Command Response arrives. Returns + // false on send failure, an unmatched/malformed response, or timeout. + bool sendAtCommand(const char *atCmd, const uint8_t *value, + uint8_t valueLength, uint8_t *outValue, + uint8_t outValueCapacity, uint8_t *outValueLength, + unsigned long timeoutMs = 200); +}; diff --git a/platformio.ini b/platformio.ini index f3355c4..c6d09b6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -39,13 +39,13 @@ lib_deps = ; 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_control.cpp is NOT excluded yet — it's -; still an honest stub with no real hardware calls, pending PR 8's SPI -; transport; it'll join this list once it actually touches hardware. +; (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). [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 d538440..8f1ae79 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -28,7 +28,8 @@ StatusLedController statusLedController; BatteryMonitor batteryMonitor; CalibrationData calibrationData; MenuController menuController; -XbeeControl xbeeControl; // stub pending PR 8's real XBee SPI transport +XbeeControl xbeeControl; +char deviceSerialLowBuf[9] = {}; // must outlive setup() — see its use below bool lastReportedPressed[Buttons::kCount] = {}; unsigned long lastTelemetryLogMs = 0; constexpr unsigned long kTelemetryLogIntervalMs = 1000; @@ -111,11 +112,23 @@ void setup() { calibrationData = CalibrationStore::load(); // Restores any previously-saved droid list; defaults to empty if none - // has been saved yet. Switching still won't actually work over radio - // until PR 8's real XBee SPI transport replaces the XbeeControl stub. + // has been saved yet. menuController.setDroidStore(DroidPersistence::load()); + + xbeeControl.begin(); menuController.setXbeeTransport(&xbeeControl); + // 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 + // only stores the pointer it's given, not a copy. + if (xbeeControl.querySerialLow(deviceSerialLowBuf, + sizeof(deviceSerialLowBuf))) { + menuController.setDeviceSerialLow(deviceSerialLowBuf); + } else { + 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. diff --git a/src/droid_switcher.cpp b/src/droid_switcher.cpp new file mode 100644 index 0000000..9635581 --- /dev/null +++ b/src/droid_switcher.cpp @@ -0,0 +1,18 @@ +#include "droid_switcher.h" + +DroidSwitchResult DroidSwitcher::switchTo(const char *panId, + XbeeTransport *transport) { + if (transport == nullptr) { + return DroidSwitchResult::kNoTransport; + } + if (!transport->leaveNetwork()) { + return DroidSwitchResult::kLeaveFailed; + } + if (!transport->setPanId(panId)) { + return DroidSwitchResult::kSetPanFailed; + } + if (!transport->rejoinNetwork()) { + return DroidSwitchResult::kRejoinFailed; + } + return DroidSwitchResult::kSuccess; +} diff --git a/src/menu.cpp b/src/menu.cpp index 57aebbe..8d45e4e 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -469,7 +469,7 @@ void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { case MenuScreen::kDeviceInfo: screen->setLine(0, "Device Info"); screen->setLine(1, "XBee SL:"); - screen->setLine(2, "(needs PR 8)"); + screen->setLine(2, menu.deviceSerialLow()); break; case MenuScreen::kFactoryResetConfirm: diff --git a/src/xbee_control.cpp b/src/xbee_control.cpp index 1deba6e..1e2bd17 100644 --- a/src/xbee_control.cpp +++ b/src/xbee_control.cpp @@ -1,35 +1,72 @@ #include "xbee_control.h" -DroidSwitchResult DroidSwitcher::switchTo(const char *panId, - XbeeTransport *transport) { - if (transport == nullptr) { - return DroidSwitchResult::kNoTransport; - } - if (!transport->leaveNetwork()) { - return DroidSwitchResult::kLeaveFailed; - } - if (!transport->setPanId(panId)) { - return DroidSwitchResult::kSetPanFailed; +namespace { + +int hexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; +} + +// Parses exactly byteCount*2 hex characters from hex into outBytes. +// Returns false (leaving outBytes untouched) on any non-hex character or +// a string shorter than expected. +bool hexStringToBytes(const char *hex, uint8_t *outBytes, size_t byteCount) { + for (size_t i = 0; i < byteCount; i++) { + const int hi = hexNibble(hex[i * 2]); + const int lo = hi >= 0 ? hexNibble(hex[i * 2 + 1]) : -1; + if (hi < 0 || lo < 0) return false; + outBytes[i] = static_cast((hi << 4) | lo); } - if (!transport->rejoinNetwork()) { - return DroidSwitchResult::kRejoinFailed; + return true; +} + +void bytesToHexString(const uint8_t *bytes, size_t byteCount, char *outHex) { + constexpr char kDigits[] = "0123456789ABCDEF"; + for (size_t i = 0; i < byteCount; i++) { + outHex[i * 2] = kDigits[bytes[i] >> 4]; + outHex[i * 2 + 1] = kDigits[bytes[i] & 0x0F]; } - return DroidSwitchResult::kSuccess; + outHex[byteCount * 2] = '\0'; } +} // namespace + bool XbeeControl::leaveNetwork() { - // TODO(PR 8): issue the real XBee API-mode leave-network command over - // SPI once the transport exists. - return false; + // "NR0": local network reset — forces the module to leave its current + // network and search again per its (about-to-change) ID setting. + uint8_t value[] = {0}; + return spi_.sendAtCommand("NR", value, sizeof(value), nullptr, 0, nullptr); } bool XbeeControl::setPanId(const char *panId) { - (void)panId; - // TODO(PR 8): issue the real "ID" AT command over SPI. - return false; + uint8_t panIdBytes[8]; + if (!hexStringToBytes(panId, panIdBytes, sizeof(panIdBytes))) { + return false; + } + return spi_.sendAtCommand("ID", panIdBytes, sizeof(panIdBytes), nullptr, 0, + nullptr); } bool XbeeControl::rejoinNetwork() { - // TODO(PR 8): trigger rejoin/associate over SPI. - return false; + // "AC": apply pending parameter changes now, triggering the module to + // act on the new ID and (re)associate rather than waiting for its next + // natural re-read of that setting. + return spi_.sendAtCommand("AC", nullptr, 0, nullptr, 0, nullptr); +} + +bool XbeeControl::querySerialLow(char *outHex, size_t outHexCapacity) { + if (outHexCapacity < 9) { + return false; // 8 hex chars + null + } + uint8_t value[4]; + uint8_t valueLength = 0; + if (!spi_.sendAtCommand("SL", nullptr, 0, value, sizeof(value), + &valueLength) || + valueLength != sizeof(value)) { + return false; + } + bytesToHexString(value, sizeof(value), outHex); + return true; } diff --git a/src/xbee_frame.cpp b/src/xbee_frame.cpp new file mode 100644 index 0000000..b4a84e1 --- /dev/null +++ b/src/xbee_frame.cpp @@ -0,0 +1,49 @@ +#include "xbee_frame.h" + +uint8_t XbeeFrame::computeChecksum(const uint8_t *frameData, uint16_t length) { + uint8_t sum = 0; + for (uint16_t i = 0; i < length; i++) { + sum = static_cast(sum + frameData[i]); + } + return static_cast(0xFF - sum); +} + +bool XbeeFrame::checksumValid(const uint8_t *frameData, uint16_t length, + uint8_t checksum) { + return computeChecksum(frameData, length) == checksum; +} + +uint16_t XbeeFrame::buildAtCommandFrame(uint8_t *outFrameData, + uint16_t outCapacity, + uint8_t frameId, const char *atCmd, + const uint8_t *value, + uint8_t valueLength) { + const uint16_t total = 4 + valueLength; + if (total > outCapacity) { + return 0; + } + outFrameData[0] = kFrameTypeAtCommand; + outFrameData[1] = frameId; + outFrameData[2] = static_cast(atCmd[0]); + outFrameData[3] = static_cast(atCmd[1]); + for (uint8_t i = 0; i < valueLength; i++) { + outFrameData[4 + i] = value[i]; + } + return total; +} + +bool XbeeFrame::parseAtCommandResponse(const uint8_t *frameData, + uint16_t length, + AtCommandResponse *out) { + constexpr uint16_t kMinLength = 5; // type + frameId + cmd(2) + status + if (length < kMinLength || frameData[0] != kFrameTypeAtCommandResponse) { + return false; + } + out->frameId = frameData[1]; + out->atCmd[0] = static_cast(frameData[2]); + out->atCmd[1] = static_cast(frameData[3]); + out->status = static_cast(frameData[4]); + out->valueLength = static_cast(length - kMinLength); + out->value = out->valueLength > 0 ? frameData + kMinLength : nullptr; + return true; +} diff --git a/src/xbee_spi.cpp b/src/xbee_spi.cpp new file mode 100644 index 0000000..cc38947 --- /dev/null +++ b/src/xbee_spi.cpp @@ -0,0 +1,142 @@ +#include "xbee_spi.h" + +#include +#include + +#include "pin_assignment.h" +#include "xbee_frame.h" + +namespace { + +// Matches thePunderWoman/Amidala's proven-working XBee3 SPI config (same +// module family) — see its xbee_spi.cpp. +const SPISettings kXbeeSpiSettings(3000000, MSBFIRST, SPI_MODE0); + +uint8_t xbeeTransfer() { return SPI.transfer(0xFF); } +void xbeeDrain(uint16_t n) { + while (n--) SPI.transfer(0xFF); +} + +} // namespace + +void XbeeSpi::begin() { + pinMode(PinAssignment::kXbeeSpiCs, OUTPUT); + digitalWrite(PinAssignment::kXbeeSpiCs, HIGH); + pinMode(PinAssignment::kXbeeSpiAttn, INPUT); + SPI.begin(PinAssignment::kXbeeSpiSck, PinAssignment::kXbeeSpiMiso, + PinAssignment::kXbeeSpiMosi, PinAssignment::kXbeeSpiCs); +} + +bool XbeeSpi::frameAvailable() { + return digitalRead(PinAssignment::kXbeeSpiAttn) == LOW; +} + +int32_t XbeeSpi::readFrame(uint8_t *buf, uint16_t maxLen) { + SPI.beginTransaction(kXbeeSpiSettings); + digitalWrite(PinAssignment::kXbeeSpiCs, LOW); + + // Skip idle 0xFF bytes (the module pads before the start delimiter). + uint8_t b = 0xFF; + for (int i = 0; i < 32 && b != 0x7E; i++) { + b = xbeeTransfer(); + } + if (b != 0x7E) { + digitalWrite(PinAssignment::kXbeeSpiCs, HIGH); + SPI.endTransaction(); + return -1; + } + + const uint16_t length = + (static_cast(xbeeTransfer()) << 8) | xbeeTransfer(); + if (length == 0 || length > maxLen) { + xbeeDrain(length + 1); // drain data + checksum so the stream stays in sync + digitalWrite(PinAssignment::kXbeeSpiCs, HIGH); + SPI.endTransaction(); + return 0; + } + + for (uint16_t i = 0; i < length; i++) { + buf[i] = xbeeTransfer(); + } + const uint8_t checksum = xbeeTransfer(); + + digitalWrite(PinAssignment::kXbeeSpiCs, HIGH); + SPI.endTransaction(); + + if (!XbeeFrame::checksumValid(buf, length, checksum)) { + return 0; + } + return static_cast(length); +} + +void XbeeSpi::writeFrame(const uint8_t *frameData, uint16_t length) { + const uint8_t checksum = XbeeFrame::computeChecksum(frameData, length); + + SPI.beginTransaction(kXbeeSpiSettings); + digitalWrite(PinAssignment::kXbeeSpiCs, LOW); + + SPI.transfer(0x7E); + SPI.transfer(static_cast(length >> 8)); + SPI.transfer(static_cast(length & 0xFF)); + for (uint16_t i = 0; i < length; i++) { + SPI.transfer(frameData[i]); + } + SPI.transfer(checksum); + + digitalWrite(PinAssignment::kXbeeSpiCs, HIGH); + SPI.endTransaction(); +} + +bool XbeeSpi::sendAtCommand(const char *atCmd, const uint8_t *value, + uint8_t valueLength, uint8_t *outValue, + uint8_t outValueCapacity, + uint8_t *outValueLength, + unsigned long timeoutMs) { + constexpr uint8_t kFrameId = 0x01; + uint8_t requestFrame[64]; + const uint16_t requestLength = XbeeFrame::buildAtCommandFrame( + requestFrame, sizeof(requestFrame), kFrameId, atCmd, value, + valueLength); + if (requestLength == 0) { + return false; + } + writeFrame(requestFrame, requestLength); + + const unsigned long start = millis(); + uint8_t responseFrame[64]; + while (millis() - start < timeoutMs) { + if (!frameAvailable()) { + yield(); // let the ESP32's background tasks/watchdog run + continue; + } + + const int32_t length = readFrame(responseFrame, sizeof(responseFrame)); + if (length <= 0) { + continue; // no delimiter yet, or a bad frame — keep waiting + } + + XbeeFrame::AtCommandResponse response; + if (!XbeeFrame::parseAtCommandResponse( + responseFrame, static_cast(length), &response)) { + continue; // some other frame type was queued — not our response + } + if (response.frameId != kFrameId) { + continue; + } + if (response.status != XbeeFrame::AtCommandStatus::kOk) { + return false; + } + + const uint8_t copyLength = response.valueLength < outValueCapacity + ? response.valueLength + : outValueCapacity; + for (uint8_t i = 0; i < copyLength; i++) { + outValue[i] = response.value[i]; + } + if (outValueLength != nullptr) { + *outValueLength = copyLength; + } + return true; + } + return false; // timed out +} diff --git a/test/test_xbee_control/test_xbee_control.cpp b/test/test_droid_switcher/test_droid_switcher.cpp similarity index 87% rename from test/test_xbee_control/test_xbee_control.cpp rename to test/test_droid_switcher/test_droid_switcher.cpp index 83d1eac..cedf757 100644 --- a/test/test_xbee_control/test_xbee_control.cpp +++ b/test/test_droid_switcher/test_droid_switcher.cpp @@ -1,6 +1,6 @@ #include -#include "xbee_control.h" +#include "droid_switcher.h" void setUp(void) {} void tearDown(void) {} @@ -78,14 +78,10 @@ void test_switch_reports_rejoin_failure() { DroidSwitcher::switchTo("1111111111111111", &transport)); } -// ---- XbeeControl — honest stub pending PR 8's real SPI transport -------- - -void test_xbee_control_stub_reports_failure() { - XbeeControl control; - TEST_ASSERT_FALSE(control.leaveNetwork()); - TEST_ASSERT_FALSE(control.setPanId("1111111111111111")); - TEST_ASSERT_FALSE(control.rejoinNetwork()); -} +// XbeeControl itself (the real XbeeTransport, using XbeeSpi) is +// hardware-dependent now and excluded from native builds — see +// platformio.ini. Nothing here instantiates it directly; DroidSwitcher is +// tested purely against FakeTransport above. int main(int argc, char **argv) { UNITY_BEGIN(); @@ -94,6 +90,5 @@ int main(int argc, char **argv) { RUN_TEST(test_switch_stops_after_leave_failure); RUN_TEST(test_switch_stops_after_set_pan_failure); RUN_TEST(test_switch_reports_rejoin_failure); - RUN_TEST(test_xbee_control_stub_reports_failure); return UNITY_END(); } diff --git a/test/test_menu/test_menu.cpp b/test/test_menu/test_menu.cpp index 36a1604..23ae9a3 100644 --- a/test/test_menu/test_menu.cpp +++ b/test/test_menu/test_menu.cpp @@ -521,6 +521,22 @@ void test_render_device_info() { menu.onEnter(0, 0, 0); renderMenuScreen(menu, &screen); TEST_ASSERT_EQUAL_STRING("Device Info", screen.line(0)); + // Default before SnipsController.ino ever calls setDeviceSerialLow(). + TEST_ASSERT_EQUAL_STRING("(unknown)", screen.line(2)); +} + +void test_device_serial_low_defaults_then_reflects_what_was_set() { + MenuController menu; + ScreenBuffer screen; + TEST_ASSERT_EQUAL_STRING("(unknown)", menu.deviceSerialLow()); + + menu.setDeviceSerialLow("41A7B3C2"); + TEST_ASSERT_EQUAL_STRING("41A7B3C2", menu.deviceSerialLow()); + + selectMainMenuItem(&menu, MainMenuItem::kDeviceInfo); + menu.onEnter(0, 0, 0); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("41A7B3C2", screen.line(2)); } void test_render_factory_reset_confirm() { @@ -675,6 +691,7 @@ int main(int argc, char **argv) { RUN_TEST(test_render_stick_calibration_awaiting_center_step_text); RUN_TEST(test_render_stick_calibration_rolling_step_text); 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_switch_droid_list_empty); RUN_TEST(test_render_switch_droid_list_marks_selected); diff --git a/test/test_xbee_frame/test_xbee_frame.cpp b/test/test_xbee_frame/test_xbee_frame.cpp new file mode 100644 index 0000000..a5f317b --- /dev/null +++ b/test/test_xbee_frame/test_xbee_frame.cpp @@ -0,0 +1,118 @@ +#include + +#include "xbee_frame.h" + +void setUp(void) {} +void tearDown(void) {} + +// ---- checksum --------------------------------------------------------- + +void test_checksum_valid_round_trip() { + const uint8_t frame[] = {0x08, 0x01, 'I', 'D'}; + const uint8_t checksum = XbeeFrame::computeChecksum(frame, sizeof(frame)); + TEST_ASSERT_TRUE( + XbeeFrame::checksumValid(frame, sizeof(frame), checksum)); +} + +void test_checksum_invalid_when_wrong() { + const uint8_t frame[] = {0x08, 0x01, 'I', 'D'}; + const uint8_t checksum = XbeeFrame::computeChecksum(frame, sizeof(frame)); + TEST_ASSERT_FALSE( + XbeeFrame::checksumValid(frame, sizeof(frame), checksum + 1)); +} + +// ---- buildAtCommandFrame ------------------------------------------------ + +void test_build_at_command_frame_query_no_value() { + uint8_t buf[16]; + const uint16_t length = + XbeeFrame::buildAtCommandFrame(buf, sizeof(buf), 0x01, "SL", nullptr, 0); + TEST_ASSERT_EQUAL_UINT16(4, length); + TEST_ASSERT_EQUAL_UINT8(XbeeFrame::kFrameTypeAtCommand, buf[0]); + TEST_ASSERT_EQUAL_UINT8(0x01, buf[1]); + TEST_ASSERT_EQUAL_UINT8('S', buf[2]); + TEST_ASSERT_EQUAL_UINT8('L', buf[3]); +} + +void test_build_at_command_frame_with_value() { + uint8_t buf[16]; + const uint8_t value[] = {0xAA, 0xBB, 0xCC}; + const uint16_t length = XbeeFrame::buildAtCommandFrame( + buf, sizeof(buf), 0x02, "ID", value, sizeof(value)); + TEST_ASSERT_EQUAL_UINT16(7, length); + TEST_ASSERT_EQUAL_UINT8('I', buf[2]); + TEST_ASSERT_EQUAL_UINT8('D', buf[3]); + TEST_ASSERT_EQUAL_UINT8(0xAA, buf[4]); + TEST_ASSERT_EQUAL_UINT8(0xBB, buf[5]); + TEST_ASSERT_EQUAL_UINT8(0xCC, buf[6]); +} + +void test_build_at_command_frame_returns_zero_when_buffer_too_small() { + uint8_t buf[3]; // needs at least 4 for a valueless command + const uint16_t length = + XbeeFrame::buildAtCommandFrame(buf, sizeof(buf), 0x01, "SL", nullptr, 0); + TEST_ASSERT_EQUAL_UINT16(0, length); +} + +// ---- parseAtCommandResponse ---------------------------------------------- + +void test_parse_at_command_response_ok_with_value() { + const uint8_t frame[] = {0x88, 0x01, 'S', 'L', 0x00, + 0x41, 0xA7, 0xB3, 0xC2}; + XbeeFrame::AtCommandResponse response{}; + TEST_ASSERT_TRUE( + XbeeFrame::parseAtCommandResponse(frame, sizeof(frame), &response)); + TEST_ASSERT_EQUAL_UINT8(0x01, response.frameId); + TEST_ASSERT_EQUAL_INT('S', response.atCmd[0]); + TEST_ASSERT_EQUAL_INT('L', response.atCmd[1]); + TEST_ASSERT_TRUE(XbeeFrame::AtCommandStatus::kOk == response.status); + TEST_ASSERT_EQUAL_UINT8(4, response.valueLength); + TEST_ASSERT_EQUAL_UINT8(0x41, response.value[0]); + TEST_ASSERT_EQUAL_UINT8(0xC2, response.value[3]); +} + +void test_parse_at_command_response_without_value() { + const uint8_t frame[] = {0x88, 0x02, 'N', 'R', 0x00}; + XbeeFrame::AtCommandResponse response{}; + TEST_ASSERT_TRUE( + XbeeFrame::parseAtCommandResponse(frame, sizeof(frame), &response)); + TEST_ASSERT_EQUAL_UINT8(0, response.valueLength); + TEST_ASSERT_TRUE(response.value == nullptr); +} + +void test_parse_at_command_response_reports_error_status() { + const uint8_t frame[] = {0x88, 0x01, 'I', 'D', 0x01}; + XbeeFrame::AtCommandResponse response{}; + TEST_ASSERT_TRUE( + XbeeFrame::parseAtCommandResponse(frame, sizeof(frame), &response)); + TEST_ASSERT_TRUE(XbeeFrame::AtCommandStatus::kError == response.status); +} + +void test_parse_at_command_response_rejects_too_short_frame() { + const uint8_t frame[] = {0x88, 0x01, 'I', 'D'}; // missing status byte + XbeeFrame::AtCommandResponse response{}; + TEST_ASSERT_FALSE( + XbeeFrame::parseAtCommandResponse(frame, sizeof(frame), &response)); +} + +void test_parse_at_command_response_rejects_wrong_frame_type() { + const uint8_t frame[] = {0x90, 0x01, 'I', 'D', 0x00}; + XbeeFrame::AtCommandResponse response{}; + TEST_ASSERT_FALSE( + XbeeFrame::parseAtCommandResponse(frame, sizeof(frame), &response)); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_checksum_valid_round_trip); + RUN_TEST(test_checksum_invalid_when_wrong); + RUN_TEST(test_build_at_command_frame_query_no_value); + RUN_TEST(test_build_at_command_frame_with_value); + RUN_TEST(test_build_at_command_frame_returns_zero_when_buffer_too_small); + RUN_TEST(test_parse_at_command_response_ok_with_value); + RUN_TEST(test_parse_at_command_response_without_value); + RUN_TEST(test_parse_at_command_response_reports_error_status); + RUN_TEST(test_parse_at_command_response_rejects_too_short_frame); + RUN_TEST(test_parse_at_command_response_rejects_wrong_frame_type); + return UNITY_END(); +}