Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down
32 changes: 32 additions & 0 deletions include/droid_switcher.h
Original file line number Diff line number Diff line change
@@ -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);
};
9 changes: 8 additions & 1 deletion include/menu.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_; }
Expand Down Expand Up @@ -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.
Expand Down
52 changes: 21 additions & 31 deletions include/xbee_control.h
Original file line number Diff line number Diff line change
@@ -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 <cstddef>

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_;
};
58 changes: 58 additions & 0 deletions include/xbee_frame.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#pragma once

#include <cstddef>
#include <cstdint>

// 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
37 changes: 37 additions & 0 deletions include/xbee_spi.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#pragma once

#include <cstdint>

// 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);
};
8 changes: 4 additions & 4 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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> -<oled.cpp> -<rgb_led.cpp> -<calibration_store.cpp> -<droid_persistence.cpp>
build_src_filter = +<*> -<*.ino> -<oled.cpp> -<rgb_led.cpp> -<calibration_store.cpp> -<droid_persistence.cpp> -<xbee_spi.cpp> -<xbee_control.cpp>
test_framework = unity
build_flags = --coverage
extra_scripts = pre:scripts/native_coverage_linkflags.py
19 changes: 16 additions & 3 deletions src/SnipsController.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/droid_switcher.cpp
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion src/menu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading