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' --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' --print-summary --fail-under-line 90)
status=$?
{
echo '### Coverage (src/, excluding SnipsController.ino and hardware adapters)'
Expand Down
16 changes: 16 additions & 0 deletions include/droid_persistence.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#pragma once

#include "droid_store.h"

// Thin adapter persisting a DroidStore to NVS (ESP32 Preferences). No
// droid-list logic lives here — see droid_store.h. Excluded from native
// build/coverage (see platformio.ini's [env:native] build_src_filter).
namespace DroidPersistence {

// Returns the stored droid list, or an empty DroidStore if none has been
// saved yet.
DroidStore load();

void save(const DroidStore &store);

} // namespace DroidPersistence
35 changes: 35 additions & 0 deletions include/droid_store.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#pragma once

#include <cstddef>

// Pure in-memory droid list (name + PAN ID pairs), the data a user builds
// up via the Manage Droids menu screen. Knows nothing about persistence —
// see droid_persistence.h for the thin NVS adapter that saves/restores it.
struct DroidEntry {
static constexpr size_t kMaxNameLength = 16;
static constexpr size_t kMaxPanIdLength = 16; // 64-bit PAN ID, hex

char name[kMaxNameLength + 1] = {};
char panId[kMaxPanIdLength + 1] = {};
};

class DroidStore {
public:
static constexpr size_t kMaxDroids = 8;

size_t count() const { return count_; }

// Bounds-checked; returns a reference to a shared empty entry for an
// out-of-range index rather than a null/dangling reference.
const DroidEntry &at(size_t index) const;

// Returns false (no-op) if already at kMaxDroids capacity.
bool add(const char *name, const char *panId);

// Returns false (no-op) if index is out of range.
bool remove(size_t index);

private:
DroidEntry entries_[kMaxDroids];
size_t count_ = 0;
};
57 changes: 48 additions & 9 deletions include/menu.h
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
#pragma once

#include "calibration.h"
#include "droid_store.h"
#include "screen.h"
#include "text_entry.h"
#include "xbee_control.h"

// Pure on-device menu state machine. Knows nothing about real buttons or
// the display — SnipsController.ino translates physical button edges into
// the four 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.
// 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.
//
// Only screens with everything they need already built land here: Manage/
// Switch Droid (PR 7) and Display Config (PR 9) aren't part of this menu
// tree yet.
// 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,
kSwitchDroidList,
kSwitchDroidResult,
kManageDroidsList,
kManageDroidsEnterName,
kManageDroidsEnterPanId,
kManageDroidsDeleteConfirm,
kCalibrateStick,
kCalibrateTrigger,
kDeviceInfo,
kFactoryResetConfirm,
};

enum class MainMenuItem {
kCalibrateStick = 0,
kSwitchDroid = 0,
kManageDroids,
kCalibrateStick,
kCalibrateTrigger,
kDeviceInfo,
kFactoryReset,
Expand All @@ -44,7 +53,9 @@ class MenuController {
unsigned long nowMs);

// Discrete nav events — call at most once per tick, only on a fresh
// button-press edge (not while held).
// button-press edge (not while held). Meaning depends on the current
// screen: list navigation on list screens, character scroll on text
// entry screens.
void onUp();
void onDown();
void onBack();
Expand Down Expand Up @@ -75,11 +86,31 @@ class MenuController {
int *outMaxY);
bool consumeFactoryResetConfirmed();

// Droid management — the store is owned here so rendering/navigation
// can see it directly; SnipsController.ino restores it from
// DroidPersistence once at boot and re-persists it whenever
// consumeDroidStoreChanged() reports a change.
void setDroidStore(const DroidStore &store) { droidStore_ = store; }
const DroidStore &droidStore() const { return droidStore_; }
bool consumeDroidStoreChanged();

// Switch Droid needs a way to actually talk to the radio — set once at
// boot. May be left null (switching then always reports kNoTransport).
void setXbeeTransport(XbeeTransport *transport) {
xbeeTransport_ = transport;
}

int selectedDroidListIndex() const { return droidListIndex_; }
DroidSwitchResult lastSwitchResult() const { return lastSwitchResult_; }
const TextEntryWidget &nameEntry() const { return nameEntry_; }
const TextEntryWidget &panIdEntry() const { return panIdEntry_; }

private:
static constexpr unsigned long kOpenComboHoldMs = 1000;

void open();
void enterMainMenuItem(MainMenuItem item);
static int wrapIndex(int index, int count);

MenuScreen screen_ = MenuScreen::kInactive;
int mainMenuIndex_ = 0;
Expand All @@ -103,6 +134,14 @@ class MenuController {
int pendingStickMaxY_ = 0;

bool factoryResetConfirmed_ = false;

DroidStore droidStore_;
int droidListIndex_ = 0;
bool droidStoreChanged_ = false;
TextEntryWidget nameEntry_{TextEntryWidget::CharSet::kAlphanumeric};
TextEntryWidget panIdEntry_{TextEntryWidget::CharSet::kHex};
DroidSwitchResult lastSwitchResult_ = DroidSwitchResult::kSuccess;
XbeeTransport *xbeeTransport_ = nullptr;
};

// Decides what text should be on screen for the menu's current state.
Expand Down
5 changes: 5 additions & 0 deletions include/text_entry.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ class TextEntryWidget {

explicit TextEntryWidget(CharSet charSet, size_t maxLength = 20);

// Reinitializes in place (empty buffer, not done, highlight reset) —
// lets a long-lived owner (e.g. a menu screen) reuse one instance
// across separate entry sessions instead of needing to reconstruct it.
void reset(CharSet charSet, size_t maxLength = 20);

// Cycles the current slot's highlight forward/backward through the
// charset, wrapping at both ends (including the trailing "done"
// position). No-op once done().
Expand Down
41 changes: 41 additions & 0 deletions include/xbee_control.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#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;
};

enum class DroidSwitchResult {
kSuccess,
kLeaveFailed,
kSetPanFailed,
kRejoinFailed,
kNoTransport,
};

// 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.
class XbeeControl : public XbeeTransport {
public:
bool leaveNetwork() override;
bool setPanId(const char *panId) override;
bool rejoinNetwork() override;
};
7 changes: 5 additions & 2 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +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 (Preferences/NVS).
; (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.
[env:native]
platform = native
test_build_src = yes
build_src_filter = +<*> -<*.ino> -<oled.cpp> -<rgb_led.cpp> -<calibration_store.cpp>
build_src_filter = +<*> -<*.ino> -<oled.cpp> -<rgb_led.cpp> -<calibration_store.cpp> -<droid_persistence.cpp>
test_framework = unity
build_flags = --coverage
extra_scripts = pre:scripts/native_coverage_linkflags.py
19 changes: 17 additions & 2 deletions src/SnipsController.ino
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
#include "buttons.h"
#include "calibration.h"
#include "calibration_store.h"
#include "droid_persistence.h"
#include "menu.h"
#include "oled.h"
#include "pin_assignment.h"
#include "power_latch.h"
#include "rgb_led.h"
#include "screen.h"
#include "status_led.h"
#include "xbee_control.h"

// Pure entry point — wiring only. All real logic lives in dedicated
// subsystem files under src/ + include/; this file just owns real
Expand All @@ -26,11 +28,12 @@ StatusLedController statusLedController;
BatteryMonitor batteryMonitor;
CalibrationData calibrationData;
MenuController menuController;
XbeeControl xbeeControl; // stub pending PR 8's real XBee SPI transport
bool lastReportedPressed[Buttons::kCount] = {};
unsigned long lastTelemetryLogMs = 0;
constexpr unsigned long kTelemetryLogIntervalMs = 1000;
MenuScreen previousMenuScreen = MenuScreen::kInactive;
MainMenuItem previousMainMenuItem = MainMenuItem::kCalibrateStick;
MainMenuItem previousMainMenuItem = MainMenuItem::kSwitchDroid;

void showBootScreen() {
ScreenBuffer bootScreen;
Expand Down Expand Up @@ -107,6 +110,12 @@ void setup() {
// get wired to the on-device menu in a later PR.
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.
menuController.setDroidStore(DroidPersistence::load());
menuController.setXbeeTransport(&xbeeControl);

// 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 Expand Up @@ -210,12 +219,18 @@ void loop() {
}

if (menuController.consumeFactoryResetConfirmed()) {
// Droid list wipe joins this once DroidStore exists (PR 7).
calibrationData = CalibrationData();
CalibrationStore::save(calibrationData);
Serial.println("Factory reset: calibration cleared.");
}

// MenuController clears its in-memory droid list as part of factory
// reset too, and reports that here like any other droid-list change.
if (menuController.consumeDroidStoreChanged()) {
DroidPersistence::save(menuController.droidStore());
Serial.println("Droid list saved.");
}

// Only touch the display when something actually changed — a full
// redraw every tick would be needless I2C traffic for static text.
const bool menuStateChanged =
Expand Down
57 changes: 57 additions & 0 deletions src/droid_persistence.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#include "droid_persistence.h"

#include <cstdio>

#include <Preferences.h>

namespace {
constexpr const char *kNamespace = "snips_droids";
} // namespace

DroidStore DroidPersistence::load() {
DroidStore store;

Preferences prefs;
if (!prefs.begin(kNamespace, /*readOnly=*/true)) {
return store;
}

const int count = prefs.getInt("count", 0);
char nameKey[8];
char panKey[8];
char name[DroidEntry::kMaxNameLength + 1];
char panId[DroidEntry::kMaxPanIdLength + 1];

for (int i = 0; i < count && i < static_cast<int>(DroidStore::kMaxDroids);
++i) {
std::snprintf(nameKey, sizeof(nameKey), "name%d", i);
std::snprintf(panKey, sizeof(panKey), "pan%d", i);
prefs.getString(nameKey, name, sizeof(name));
prefs.getString(panKey, panId, sizeof(panId));
store.add(name, panId);
}

prefs.end();
return store;
}

void DroidPersistence::save(const DroidStore &store) {
Preferences prefs;
if (!prefs.begin(kNamespace, /*readOnly=*/false)) {
return;
}

prefs.clear(); // drop any entries from a previously-longer list
prefs.putInt("count", static_cast<int>(store.count()));

char nameKey[8];
char panKey[8];
for (size_t i = 0; i < store.count(); ++i) {
std::snprintf(nameKey, sizeof(nameKey), "name%zu", i);
std::snprintf(panKey, sizeof(panKey), "pan%zu", i);
prefs.putString(nameKey, store.at(i).name);
prefs.putString(panKey, store.at(i).panId);
}

prefs.end();
}
35 changes: 35 additions & 0 deletions src/droid_store.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#include "droid_store.h"

#include <cstring>

namespace {
const DroidEntry kEmptyEntry;
} // namespace

const DroidEntry &DroidStore::at(size_t index) const {
return index < count_ ? entries_[index] : kEmptyEntry;
}

bool DroidStore::add(const char *name, const char *panId) {
if (count_ >= kMaxDroids) {
return false;
}
DroidEntry &entry = entries_[count_];
std::strncpy(entry.name, name, DroidEntry::kMaxNameLength);
entry.name[DroidEntry::kMaxNameLength] = '\0';
std::strncpy(entry.panId, panId, DroidEntry::kMaxPanIdLength);
entry.panId[DroidEntry::kMaxPanIdLength] = '\0';
++count_;
return true;
}

bool DroidStore::remove(size_t index) {
if (index >= count_) {
return false;
}
for (size_t i = index; i + 1 < count_; ++i) {
entries_[i] = entries_[i + 1];
}
--count_;
return true;
}
Loading
Loading