mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-21 05:45:27 -04:00
Merge branch 'develop' into crowpanel-p4
This commit is contained in:
54 files changed
+2514
-175
No files matched your search
+20
-13
@@ -4,7 +4,7 @@ cli:
|
||||
plugins:
|
||||
sources:
|
||||
- id: trunk
|
||||
ref: v1.10.2
|
||||
ref: v1.11.0
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
# Custom file set + formatter that rewrites Unicode em-dash (U+2014, UTF-8
|
||||
@@ -116,28 +116,28 @@ lint:
|
||||
- node-id-format@SYSTEM
|
||||
- unity-exit@SYSTEM
|
||||
- unset-sentinel-millis@SYSTEM
|
||||
- checkov@3.3.8
|
||||
- renovate@44.2.3
|
||||
- prettier@3.9.6
|
||||
- trufflehog@3.96.0
|
||||
- checkov@3.3.19
|
||||
- renovate@44.103.2
|
||||
- prettier@3.9.8
|
||||
- trufflehog@3.97.5
|
||||
- yamllint@1.38.0
|
||||
- bandit@1.9.4
|
||||
- trivy@0.72.0
|
||||
- trivy@0.74.0
|
||||
- taplo@0.10.0
|
||||
- ruff@0.16.0
|
||||
- isort@8.0.1
|
||||
- ruff@0.16.8
|
||||
- isort@9.0.1
|
||||
- markdownlint@0.49.1
|
||||
- oxipng@10.1.1
|
||||
- svgo@4.0.2
|
||||
- oxipng@10.2.1
|
||||
- svgo@4.1.0
|
||||
- actionlint@1.7.12
|
||||
- flake8@7.3.0
|
||||
- hadolint@2.14.0
|
||||
- shfmt@3.6.0
|
||||
- hadolint@2.15.1
|
||||
- shfmt@3.14.1
|
||||
- shellcheck@0.11.0
|
||||
- black@26.5.1
|
||||
- git-diff-check
|
||||
- gitleaks@8.30.1
|
||||
- clang-format@16.0.3
|
||||
- clang-format@20.1.0
|
||||
ignore:
|
||||
- linters: [ALL]
|
||||
paths:
|
||||
@@ -183,10 +183,17 @@ lint:
|
||||
# numbers and request ids (0x0A0A0A0A, 0x0B0B0B0B, 0xABCD1234); its key
|
||||
# material is generated at runtime by crypto->generateKeyPair, so there is
|
||||
# no literal key in the file to leak.
|
||||
#
|
||||
# test_nodedb_blocked trips it on an identifier rather than on hex: one of
|
||||
# its removeNodeByNum cases has a name exactly 35 characters long after the
|
||||
# test_ prefix, which is the Lob key length, so the detector matches the
|
||||
# bare word. Nothing in that file is a credential. Do not quote the name in
|
||||
# full anywhere under lint - a comment containing it trips the same rule.
|
||||
- linters: [trufflehog]
|
||||
paths:
|
||||
- test/test_ack_proof/test_main.cpp
|
||||
- test/test_airtime/test_main.cpp
|
||||
- test/test_nodedb_blocked/test_main.cpp
|
||||
- test/test_throttle/test_main.cpp
|
||||
- test/test_uptime_clock/test_main.cpp
|
||||
# The nightly index templates are halves of one page, not whole documents:
|
||||
|
||||
+11
-3
@@ -211,10 +211,18 @@ Input:
|
||||
# JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick
|
||||
### Map evdev button codes (hex or decimal) to actions. Omit to use built-in
|
||||
### defaults (select=0x121, cancel=0x122). Actions: select, cancel, back, up,
|
||||
### down, left, right, user.
|
||||
### down, left, right, user. Give an action a list of codes to bind several
|
||||
### physical buttons to it -- e.g. the shoulder buttons alongside the D-pad.
|
||||
### Games can tell which physical button was pressed even when several share an
|
||||
### action: in Snake the shoulders turn relative to the snake's heading while
|
||||
### the D-pad steers absolutely, and Start pauses in play while selecting
|
||||
### everywhere else.
|
||||
### The codes below are an example only -- confirm yours with `evtest`.
|
||||
# JoystickButtons:
|
||||
# select: 0x122
|
||||
# cancel: 0x121
|
||||
# select: [0x121, 0x123, 0x129] # A, Y and Start
|
||||
# cancel: [0x122, 0x120] # B and X
|
||||
# left: [0x124] # L shoulder, alongside the D-pad
|
||||
# right: [0x125] # R shoulder
|
||||
|
||||
### Standard User Button Config
|
||||
# UserButton: 6
|
||||
|
||||
@@ -376,6 +376,18 @@ assert "pin value that resolves to -1" 1 pin-unreadable.yaml check \
|
||||
"Lora.CS is set, but its value could not be read as a pin number" \
|
||||
"Result: 1 error, 0 warnings"
|
||||
|
||||
echo
|
||||
echo "joystick buttons:"
|
||||
# Keyed by action rather than by button so that one action can list several codes.
|
||||
# The clean case is the regression guard on the list form staying accepted.
|
||||
assert "several buttons bound to one action" 0 joystick-buttons.yaml check \
|
||||
"Result: 0 errors, 0 warnings"
|
||||
assert "joystick mappings that do nothing" 0 joystick-buttons-bad.yaml check \
|
||||
"'fire' is not a recognised action" \
|
||||
"'BTN_SOUTH' is not an evdev button code" \
|
||||
"button 0x121 is mapped to both 'select' and 'cancel'" \
|
||||
"Result: 0 errors, 3 warnings"
|
||||
|
||||
echo
|
||||
echo "CH341 USB-SPI adapters:"
|
||||
# The Lora pins of a ch341 device are indexes on the adapter, driven by the usermode
|
||||
|
||||
+2
-1
@@ -265,7 +265,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define BBQ10_KB_ADDR 0x1F
|
||||
#define MPR121_KB_ADDR 0x5A
|
||||
#define TCA8418_KB_ADDR 0x34
|
||||
#define TSTC8_KB_ADDR 0x6C // STC8H companion-MCU keypad on the ThinkNode-M9
|
||||
#define TSTC8_KB_V1_ADDR 0x6C // STC8H companion-MCU keypad on the ThinkNode-M9 V1
|
||||
#define TSTC8_KB_V2_ADDR 0x6D // STC8H companion-MCU keypad on the ThinkNode-M9 V2
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SENSOR
|
||||
|
||||
@@ -455,7 +455,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
type = BBQ10KB;
|
||||
logFoundDevice("BB Q10", (uint8_t)addr.address);
|
||||
break;
|
||||
SCAN_SIMPLE_CASE(TSTC8_KB_ADDR, STC8HKB, "STC8H KB", (uint8_t)addr.address);
|
||||
SCAN_SIMPLE_CASE(TSTC8_KB_V1_ADDR, STC8HKB, "STC8H KB", (uint8_t)addr.address);
|
||||
SCAN_SIMPLE_CASE(TSTC8_KB_V2_ADDR, STC8HKB, "STC8H KB", (uint8_t)addr.address);
|
||||
SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address);
|
||||
#ifdef HAS_NCP5623
|
||||
SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address);
|
||||
|
||||
+43
-12
@@ -2275,18 +2275,8 @@ int Screen::handleInputEvent(const InputEvent *event)
|
||||
// so long as a mesh module isn't using these events for some other purpose
|
||||
if (showingNormalScreen) {
|
||||
|
||||
// Ask any MeshModules if they're handling keyboard input right now
|
||||
bool inputIntercepted = false;
|
||||
for (MeshModule *module : moduleFrames) {
|
||||
if (module && module->interceptingKeyboardInput())
|
||||
inputIntercepted = true;
|
||||
}
|
||||
#if BASEUI_HAS_GAMES
|
||||
// The games frame isn't a moduleFrame, so check it explicitly: while a game is running it
|
||||
// owns the D-pad (turns/pause) and we must not switch frames or open menus underneath it.
|
||||
if (gamesModule && gamesModule->interceptingKeyboardInput())
|
||||
inputIntercepted = true;
|
||||
#endif
|
||||
// Ask any MeshModules (and the games frame) if they're handling keyboard input right now
|
||||
const bool inputIntercepted = anyModuleInterceptingInput();
|
||||
|
||||
// If no modules are using the input, move between frames
|
||||
if (!inputIntercepted) {
|
||||
@@ -2449,6 +2439,47 @@ bool Screen::isGamesFrameShown()
|
||||
return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games;
|
||||
}
|
||||
|
||||
void Screen::showHomeFrame()
|
||||
{
|
||||
if (!ui)
|
||||
return;
|
||||
// Home is optional -- setFrames() only adds it when !hiddenFrames.home, leaving the position
|
||||
// 255. Bouncing to nothing would strand the caller on the frame it wanted to leave, so fall
|
||||
// back to the messages frame, which setFrames() always adds.
|
||||
const uint8_t target =
|
||||
(framesetInfo.positions.home != 255) ? framesetInfo.positions.home : framesetInfo.positions.textMessage;
|
||||
if (target != 255)
|
||||
ui->switchToFrame(target);
|
||||
}
|
||||
|
||||
bool Screen::anyModuleInterceptingInput()
|
||||
{
|
||||
for (MeshModule *module : moduleFrames) {
|
||||
if (module && module->interceptingKeyboardInput())
|
||||
return true;
|
||||
}
|
||||
#if BASEUI_HAS_GAMES
|
||||
// The games frame isn't a moduleFrame, so check it explicitly: while a game is running it owns
|
||||
// the D-pad (turns/pause) and we must not switch frames or open menus underneath it.
|
||||
if (gamesModule && gamesModule->interceptingKeyboardInput())
|
||||
return true;
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Screen::isInteractionBusy()
|
||||
{
|
||||
// Something is holding the D-pad -- the user is mid-interaction. A modal module owns the whole
|
||||
// screen; an intercepting one owns the keys on its own frame.
|
||||
if (hasModalModule() || anyModuleInterceptingInput())
|
||||
return true;
|
||||
// An interactive overlay (picker / text entry) is open. Showing a transient banner REPLACES the
|
||||
// active overlay, so this would silently discard whatever the user was entering. A plain
|
||||
// text_banner is itself transient, so superseding one of those is fine.
|
||||
const notificationTypeEnum nt = NotificationRenderer::current_notification_type;
|
||||
return nt != notificationTypeEnum::none && nt != notificationTypeEnum::text_banner;
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
|
||||
#else
|
||||
|
||||
+16
-1
@@ -260,7 +260,7 @@ class Screen : public concurrency::OSThread
|
||||
|
||||
std::vector<const uint8_t *> indicatorIcons; // Per-frame custom icon pointers
|
||||
#if defined(OLED_COMPACT_UI)
|
||||
std::vector<const char *> frameTitles; // Per-frame short labels, parallel to indicatorIcons
|
||||
std::vector<const char *> frameTitles; // Per-frame short labels, parallel to indicatorIcons
|
||||
#endif
|
||||
Screen(const Screen &) = delete;
|
||||
Screen &operator=(const Screen &) = delete;
|
||||
@@ -294,6 +294,17 @@ class Screen : public concurrency::OSThread
|
||||
// ignore D-pad input when the player has navigated to a different frame.
|
||||
bool isGamesFrameShown();
|
||||
|
||||
// Jump straight to the home (device-focused) frame. Used to bounce back to a clearly "this is a
|
||||
// Meshtastic node" screen after a game is left idle. Home is optional, so when it is hidden this
|
||||
// falls back to the messages frame rather than staying put.
|
||||
void showHomeFrame();
|
||||
|
||||
// True when the user is in the middle of something that must not be interrupted: a module (or
|
||||
// game) is holding the D-pad, or an interactive overlay (picker / text entry) is open. Callers
|
||||
// that would pop a transient banner should check this first -- a banner both covers the screen
|
||||
// and REPLACES any interactive overlay, discarding a half-finished entry.
|
||||
bool isInteractionBusy();
|
||||
|
||||
bool isScreenOn() { return screenOn; }
|
||||
|
||||
// Stores the last 4 of our hardware ID, to make finding the device for pairing easier
|
||||
@@ -850,6 +861,10 @@ class Screen : public concurrency::OSThread
|
||||
#endif
|
||||
|
||||
/// UI helper for rendering to frames and switching between them
|
||||
// True if any module frame -- or the games frame, which is not a moduleFrame -- is currently
|
||||
// holding the D-pad. Shared by the input router and isInteractionBusy().
|
||||
bool anyModuleInterceptingInput();
|
||||
|
||||
OLEDDisplayUi *ui;
|
||||
};
|
||||
|
||||
|
||||
@@ -603,7 +603,17 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
|
||||
display->setColor(BLACK);
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
|
||||
// The full-width bar reads as a clean footer where there is room below the body, but the band is
|
||||
// the bottom (connection_icon_height + 2) rows and the body grid does not shrink with the panel:
|
||||
// textSixthLine is 58 whatever the height, so on a 64-row display the bar lands exactly on it and
|
||||
// erases the last thing the frame drew (the sixth body line, the LoRa ChUtil bar, the clock).
|
||||
// Only the icon's own rect is colour-tinted, so the wide fill buys the tint nothing - fall back to
|
||||
// the icon-width fill, as the monochrome path already does, whenever it would overlap the body.
|
||||
const int bodyBottom = getTextPositions(display)[6] + FONT_HEIGHT_SMALL;
|
||||
if (footerY >= bodyBottom)
|
||||
display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
|
||||
else
|
||||
display->fillRect(0, footerY, connection_icon_width + 1, footerH);
|
||||
#else
|
||||
display->fillRect(0, footerY, connection_icon_width + 1, footerH);
|
||||
#endif
|
||||
|
||||
@@ -121,6 +121,18 @@ const StoredMessage *getNewestMessageForActiveThread()
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Freetext compose is offered whenever the device can enter text at all: a physical
|
||||
// keyboard, an on-screen keyboard driven by rotary/trackball/joystick, or a touchscreen
|
||||
// virtual keyboard.
|
||||
bool freetextAvailable()
|
||||
{
|
||||
#if defined(USE_VIRTUAL_KEYBOARD)
|
||||
return true;
|
||||
#else
|
||||
return kb_found || osk_found;
|
||||
#endif
|
||||
}
|
||||
|
||||
void launchReplyForMessage(const StoredMessage &message, bool freetext)
|
||||
{
|
||||
if (message.type == MessageType::BROADCAST || message.dest == NODENUM_BROADCAST) {
|
||||
@@ -156,12 +168,7 @@ uint8_t test_count = 0;
|
||||
void menuHandler::loraMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {
|
||||
"Back",
|
||||
"Device Role",
|
||||
"Radio Preset",
|
||||
"Frequency Slot",
|
||||
"LoRa Region",
|
||||
"Transmit Enabled",
|
||||
"Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region", "Transmit Enabled",
|
||||
#if HAS_LORA_FEM
|
||||
"FEM LNA",
|
||||
#endif
|
||||
@@ -918,8 +925,8 @@ void menuHandler::replyMenu()
|
||||
optionsArray[options] = "With Preset";
|
||||
optionsEnumArray[options++] = ReplyPreset;
|
||||
|
||||
// Freetext reply (only when keyboard exists)
|
||||
if (kb_found) {
|
||||
// Freetext reply (only when the device can enter text)
|
||||
if (freetextAvailable()) {
|
||||
optionsArray[options] = "With Freetext";
|
||||
optionsEnumArray[options++] = ReplyFreetext;
|
||||
}
|
||||
@@ -1285,7 +1292,7 @@ void menuHandler::textMessageBaseMenu()
|
||||
int options = 1;
|
||||
optionsArray[options] = "New Preset Msg";
|
||||
optionsEnumArray[options++] = Preset;
|
||||
if (kb_found) {
|
||||
if (freetextAvailable()) {
|
||||
optionsArray[options] = "New Freetext Msg";
|
||||
optionsEnumArray[options++] = Freetext;
|
||||
}
|
||||
@@ -1408,7 +1415,7 @@ void menuHandler::favoriteBaseMenu()
|
||||
}
|
||||
optionsEnumArray[options++] = Preset;
|
||||
|
||||
if (kb_found) {
|
||||
if (freetextAvailable()) {
|
||||
optionsArray[options] = "New Freetext Msg";
|
||||
optionsEnumArray[options++] = Freetext;
|
||||
}
|
||||
|
||||
@@ -1215,9 +1215,14 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
screen->setOn(true);
|
||||
}
|
||||
|
||||
if (!suppressBanner && !menuShowing && !screen->hasModalModule()) {
|
||||
// Don't let the banner interrupt whatever the user is in the middle of -- it would cover an
|
||||
// active module/game, and worse, a transient banner replaces any interactive overlay, so it
|
||||
// would discard a half-entered picker / text entry (e.g. high-score initials). The message
|
||||
// is still stored, its thread still selected below, and the unread indicator set, so nothing
|
||||
// is lost -- the user just sees it once they're done. (isInteractionBusy() subsumes the
|
||||
// modal-module check this guard used to make.)
|
||||
if (!screen->isInteractionBusy() && !menuShowing && !suppressBanner)
|
||||
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Always focus into the correct conversation thread when a message with real text arrives
|
||||
|
||||
@@ -50,6 +50,48 @@ enum input_broker_event {
|
||||
#define INPUT_BROKER_MSG_TAB 0x09
|
||||
#define INPUT_BROKER_MSG_EMOTE_LIST 0x8F
|
||||
|
||||
// Which physical joystick/gamepad button produced an event, carried in InputEvent::kbchar
|
||||
// beside the action it is mapped to. Several buttons may share one action, so this is what
|
||||
// lets a game tell SELECT-pressed-on-Y from SELECT-pressed-on-B and use more inputs than the
|
||||
// handful of actions the broker defines.
|
||||
//
|
||||
// The value is the button's evdev code offset into a reserved kbchar range. 0x120..0x13f spans
|
||||
// both the classic joystick codes (BTN_TRIGGER..BTN_DEAD) and the modern gamepad ones
|
||||
// (BTN_SOUTH..BTN_THUMBR). The range deliberately misses printable ASCII (0x20-0x7e, which
|
||||
// CannedMessages appends to a message) and every INPUT_BROKER_MSG_ value above: SystemCommands
|
||||
// switches on kbchar without looking at inputEvent, so a collision there would toggle Bluetooth
|
||||
// or reboot the node rather than move a paddle.
|
||||
#define INPUT_BROKER_MSG_JOY_BUTTON_FIRST 0xC0
|
||||
#define INPUT_BROKER_MSG_JOY_BUTTON_LAST 0xDF
|
||||
#define INPUT_BROKER_JOY_CODE_FIRST 0x120
|
||||
#define INPUT_BROKER_JOY_CODE_LAST 0x13F
|
||||
|
||||
// evdev button code -> the kbchar that reports it, or 0 for a code outside the encodable range
|
||||
// (0 is also "no button", which is what every non-joystick source leaves in kbchar).
|
||||
constexpr unsigned char joyButtonToKbchar(int code)
|
||||
{
|
||||
return (code >= INPUT_BROKER_JOY_CODE_FIRST && code <= INPUT_BROKER_JOY_CODE_LAST)
|
||||
? (unsigned char)(INPUT_BROKER_MSG_JOY_BUTTON_FIRST + (code - INPUT_BROKER_JOY_CODE_FIRST))
|
||||
: 0;
|
||||
}
|
||||
|
||||
// True if this kbchar names a gamepad button, i.e. the event came from a real button press
|
||||
// rather than from a D-pad axis (which has no button and leaves kbchar 0) or a keyboard key.
|
||||
// Lets a consumer treat "LEFT from a shoulder button" differently from "LEFT from the D-pad"
|
||||
// without hardcoding one pad's button codes.
|
||||
constexpr bool isJoyButton(unsigned char kbchar)
|
||||
{
|
||||
return kbchar >= INPUT_BROKER_MSG_JOY_BUTTON_FIRST && kbchar <= INPUT_BROKER_MSG_JOY_BUTTON_LAST;
|
||||
}
|
||||
|
||||
// The Start button. Map it to "select" like any other button: it selects normally, and a consumer
|
||||
// that wants Start specifically (GamesModule pauses on it) picks it out of kbchar. 0x129
|
||||
// (BTN_BASE4) is Start on the classic 10-button pads, 0x13b (BTN_START) on modern gamepads.
|
||||
constexpr bool isJoyStartButton(unsigned char kbchar)
|
||||
{
|
||||
return kbchar == joyButtonToKbchar(0x129) || kbchar == joyButtonToKbchar(0x13b);
|
||||
}
|
||||
|
||||
typedef struct _InputEvent {
|
||||
const char *source;
|
||||
input_broker_event inputEvent;
|
||||
|
||||
@@ -79,12 +79,15 @@ static int axisZone(int value)
|
||||
return 0;
|
||||
}
|
||||
|
||||
void LinuxJoystick::emitEvent(input_broker_event event)
|
||||
// kbchar carries which physical button produced the event (0 for the D-pad, which is an axis
|
||||
// rather than a button). Several buttons can be mapped to one action, so this is how a consumer
|
||||
// tells them apart -- see joyButtonToKbchar() in InputBroker.h.
|
||||
void LinuxJoystick::emitEvent(input_broker_event event, unsigned char kbchar)
|
||||
{
|
||||
InputEvent e = {};
|
||||
e.inputEvent = event;
|
||||
e.source = this->_originName;
|
||||
e.kbchar = 0;
|
||||
e.kbchar = kbchar;
|
||||
// LOG_DEBUG("joystick: %s event %d", this->_originName, event);
|
||||
this->notifyObservers(&e);
|
||||
}
|
||||
@@ -158,7 +161,7 @@ int32_t LinuxJoystick::runOnce()
|
||||
// Buttons fire once per press (no auto-repeat).
|
||||
auto mapped = buttonMap.find(code);
|
||||
if (mapped != buttonMap.end())
|
||||
emitEvent(mapped->second);
|
||||
emitEvent(mapped->second, joyButtonToKbchar(code));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,15 @@ class LinuxJoystick : public Observable<const InputEvent *>, public concurrency:
|
||||
int heldXZone() const { return heldX; }
|
||||
int heldYZone() const { return heldY; }
|
||||
|
||||
// The name this driver stamps into InputEvent::source. Lets a consumer tell an event that came
|
||||
// from this gamepad from one that came from a keyboard or touchscreen carrying the same action.
|
||||
const char *originName() const { return _originName; }
|
||||
|
||||
protected:
|
||||
virtual int32_t runOnce() override;
|
||||
|
||||
private:
|
||||
void emitEvent(input_broker_event event);
|
||||
void emitEvent(input_broker_event event, unsigned char kbchar = 0);
|
||||
|
||||
const char *_originName;
|
||||
bool firstTime = true;
|
||||
|
||||
@@ -53,7 +53,7 @@ class STC8HKeyboard
|
||||
private:
|
||||
void writeRegister(uint8_t reg, uint8_t val);
|
||||
|
||||
uint8_t _I2C_addr = TSTC8_KB_ADDR;
|
||||
uint8_t _I2C_addr = TSTC8_KB_V1_ADDR;
|
||||
|
||||
TwoWire *_pWire = &Wire;
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ int32_t KbI2cBase::runOnce()
|
||||
// than the local Wire1 (e.g. SenseCAP Indicator)
|
||||
i2cBus = ScanI2CTwoWire::fetchI2CBus(cardkb_found);
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
if (cardkb_found.address == TSTC8_KB_ADDR) {
|
||||
Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire1);
|
||||
if (cardkb_found.address == TSTC8_KB_V1_ADDR) {
|
||||
Stc8HKeyBoard.begin(TSTC8_KB_V1_ADDR, &Wire1);
|
||||
}
|
||||
#endif
|
||||
if (cardkb_found.address == BBQ10_KB_ADDR) {
|
||||
@@ -91,8 +91,8 @@ int32_t KbI2cBase::runOnce()
|
||||
LOG_DEBUG("Use I2C Bus 0 (the first one)");
|
||||
i2cBus = &Wire;
|
||||
#if defined(ELECROW_ThinkNode_M9)
|
||||
if (cardkb_found.address == TSTC8_KB_ADDR) {
|
||||
Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire);
|
||||
if (cardkb_found.address == TSTC8_KB_V1_ADDR || cardkb_found.address == TSTC8_KB_V2_ADDR) {
|
||||
Stc8HKeyBoard.begin(cardkb_found.address, &Wire);
|
||||
}
|
||||
#endif
|
||||
if (cardkb_found.address == BBQ10_KB_ADDR) {
|
||||
|
||||
+8
-1
@@ -1081,6 +1081,13 @@ void setup()
|
||||
#ifndef HAS_PHYSICAL_KEYBOARD
|
||||
osk_found = true;
|
||||
#endif
|
||||
#endif
|
||||
#if ARCH_PORTDUINO && defined(__linux__)
|
||||
// Same idea for a gamepad: it can drive the on-screen keyboard but cannot type, so without a
|
||||
// configured keyboard device it is the only way to compose freetext on this host.
|
||||
if (portduino_config.joystickDevice != "" && portduino_config.keyboardDevice == "") {
|
||||
osk_found = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Now that the mesh service is created, create any modules
|
||||
@@ -1187,7 +1194,7 @@ void setup()
|
||||
|
||||
#ifndef ARCH_PORTDUINO
|
||||
|
||||
// Initialize Wifi
|
||||
// Initialize Wifi
|
||||
#if HAS_WIFI
|
||||
initWifi();
|
||||
#endif
|
||||
|
||||
@@ -100,7 +100,7 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp)
|
||||
// ignore our request for its NodeInfo
|
||||
} else if (mp->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
!nodeInfoLiteHasUser(nodeDB->getMeshNode(mp->from)) && nodeInfoModule && !isPreferredRebroadcaster &&
|
||||
!nodeDB->isFull()) {
|
||||
nodeDB->isHalfEmpty()) {
|
||||
if (airTime->isTxAllowedChannelUtil(true)) {
|
||||
const int8_t hopsUsed = getHopsAway(*mp, config.lora.hop_limit);
|
||||
if (hopsUsed > (int32_t)(config.lora.hop_limit + 2)) {
|
||||
|
||||
@@ -34,10 +34,21 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
|
||||
if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed ||
|
||||
!IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY) ||
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY) ||
|
||||
(p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum())))
|
||||
return false;
|
||||
|
||||
// LOCAL_ONLY and KNOWN_ONLY gate on identity, and the only opaque frame carrying its parties in
|
||||
// the header is a PKI-shaped unicast: relay one just when a party is known, which is the rule
|
||||
// RoutingModule applied before opaque frames stopped reaching modules.
|
||||
if (IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY) &&
|
||||
!(p->channel == 0 && !isBroadcast(p->to) &&
|
||||
(nodeInfoLiteHasUser(nodeDB->getMeshNode(p->from)) || nodeInfoLiteHasUser(nodeDB->getMeshNode(p->to)))))
|
||||
return false;
|
||||
|
||||
// Dedup opaque relays. Opaque frames deliberately never enter PacketHistory (so unauthenticated
|
||||
// traffic can't influence routing/ACK/next-hop) - but with NO dedup at all, a dense mesh re-relays
|
||||
// every copy of every frame, multiplying at each hop into an unbounded broadcast storm ("let hop
|
||||
|
||||
@@ -4225,6 +4225,14 @@ bool NodeDB::isFull()
|
||||
return (numMeshNodes >= MAX_NUM_NODES) || (memGet.getFreeHeap() < MINIMUM_SAFE_FREE_HEAP);
|
||||
}
|
||||
|
||||
bool NodeDB::isHalfEmpty() const
|
||||
{
|
||||
// MAX_NUM_NODES is a runtime call on portduino, so read it once. Strictly more than half the
|
||||
// slots must be free, and low heap disqualifies the store just as it does in isFull().
|
||||
const size_t cap = (size_t)MAX_NUM_NODES;
|
||||
return ((size_t)numMeshNodes * 2 < cap) && (memGet.getFreeHeap() >= MINIMUM_SAFE_FREE_HEAP);
|
||||
}
|
||||
|
||||
uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
|
||||
{
|
||||
for (int i = 0; i < numMeshNodes; i++)
|
||||
|
||||
@@ -604,6 +604,9 @@ class NodeDB
|
||||
// returns true if the maximum number of nodes is reached or we are running low on memory
|
||||
bool isFull();
|
||||
|
||||
// returns true only if more than half the node slots are still empty (and memory is not tight)
|
||||
bool isHalfEmpty() const;
|
||||
|
||||
void clearLocalPosition();
|
||||
|
||||
void setLocalPosition(meshtastic_Position position, bool timeOnly = false)
|
||||
|
||||
+40
-1
@@ -1206,6 +1206,36 @@ bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, b
|
||||
// to handle the case where the remote node has our key, but we don't have theirs.
|
||||
!(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* PKC fallback for an ack that has no channel in common with the sender.
|
||||
*
|
||||
* PKI needs only the two keys, so a DM can reach us over a channel we do not carry. Its ack is a
|
||||
* ROUTING packet, which wouldEncryptWithPKC() excludes, so it would be channel-encoded, fail at
|
||||
* setActiveByIndex() with NO_CHANNEL, and never be sent - leaving the sender to retransmit to
|
||||
* exhaustion for a message that was in fact delivered.
|
||||
*
|
||||
* This is the one place an ack is deliberately made opaque to relays. Normally that costs next-hop
|
||||
* learning and intermediate retransmission cancel, which is why ROUTING is PKC-excluded in general;
|
||||
* here there is no readable alternative to lose, because without this the ack does not exist.
|
||||
*
|
||||
* Scoped as tightly as that argument reaches: a unicast ROUTING packet we originate, carrying a
|
||||
* request_id, to a destination whose key we hold, under the same ham/sim/private-key preconditions
|
||||
* PKC always has - and only when the channel index does not resolve. It tests channels.getHash()
|
||||
* rather than setActiveByIndex() so the predicate has no side effect; generateHash already returns
|
||||
* -1 for an invalid key, so the two agree on which indexes are unusable. The range check has to come
|
||||
* first and stay first: getHash() is a bare hashes[i] with no bounds test of its own.
|
||||
*/
|
||||
static bool ackNeedsPkcFallback(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey)
|
||||
{
|
||||
return isFromUs(p) &&
|
||||
#if ARCH_PORTDUINO
|
||||
!portduino_config.force_simradio &&
|
||||
#endif
|
||||
!owner.is_licensed && config.security.private_key.size == 32 && haveDestKey && !isBroadcast(p->to) &&
|
||||
p->decoded.portnum == meshtastic_PortNum_ROUTING_APP && p->decoded.request_id != 0 &&
|
||||
(chIndex >= MAX_NUM_CHANNELS || channels.getHash(chIndex) < 0);
|
||||
}
|
||||
#endif
|
||||
|
||||
/** Return 0 for success or a Routing_Error code for failure
|
||||
@@ -1299,9 +1329,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
crypto->getPendingPublicKey(p->to, destKey)) {
|
||||
haveDestKey = true;
|
||||
}
|
||||
const bool ackFallback = ackNeedsPkcFallback(p, chIndex, haveDestKey);
|
||||
if (ackFallback)
|
||||
LOG_INFO("No usable channel %d for ack of 0x%08x, send it over PKC", chIndex, p->decoded.request_id);
|
||||
|
||||
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
|
||||
// is not in the local nodedb
|
||||
if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) {
|
||||
//
|
||||
// ackFallback is tested first so an out-of-range chIndex short-circuits: wouldEncryptWithPKC
|
||||
// reaches channels.getName(chIndex) before its portnum exclusion, and getByIndex() logs
|
||||
// "Invalid channel index" on the way past. Without the short-circuit this path would print
|
||||
// an error and then go on to encode the packet successfully.
|
||||
if (ackFallback || wouldEncryptWithPKC(p, chIndex, haveDestKey)) {
|
||||
LOG_DEBUG("Use PKI");
|
||||
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
||||
return meshtastic_Routing_Error_TOO_LARGE;
|
||||
|
||||
@@ -115,6 +115,61 @@ void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChan
|
||||
LOG_TRACE("[CannedMessage] LaunchWithDestination dest=0x%08x ch=%d", dest, channel);
|
||||
}
|
||||
|
||||
// Compose through the on-screen keyboard used by devices without a physical one
|
||||
// (rotary encoder, trackball, joystick). Returns false when no such keyboard exists,
|
||||
// so callers can fall back to the plain freetext screen.
|
||||
bool CannedMessageModule::showOnScreenKeyboard()
|
||||
{
|
||||
if (!osk_found || !screen)
|
||||
return false;
|
||||
|
||||
char headerBuffer[64];
|
||||
if (this->dest == NODENUM_BROADCAST) {
|
||||
snprintf(headerBuffer, sizeof(headerBuffer), "To: #%s", channels.getName(this->channel));
|
||||
} else {
|
||||
snprintf(headerBuffer, sizeof(headerBuffer), "To: @%s", getNodeName(this->dest));
|
||||
}
|
||||
screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) {
|
||||
if (!text.empty()) {
|
||||
this->freetext = text.c_str();
|
||||
this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT;
|
||||
updateState(CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE);
|
||||
currentMessageIndex = -1;
|
||||
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->notifyObservers(&e);
|
||||
screen->forceDisplay();
|
||||
|
||||
setIntervalFromNow(500);
|
||||
return;
|
||||
} else {
|
||||
// Don't delete virtual keyboard immediately - it might still be executing
|
||||
// Instead, just clear the callback and reset banner to stop input processing
|
||||
graphics::NotificationRenderer::textInputCallback = nullptr;
|
||||
graphics::NotificationRenderer::resetBanner();
|
||||
|
||||
// Return to inactive state
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
|
||||
// Force display update to show normal screen
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->notifyObservers(&e);
|
||||
screen->forceDisplay();
|
||||
|
||||
// Schedule cleanup for next loop iteration to ensure safe deletion
|
||||
setIntervalFromNow(50);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t newChannel)
|
||||
{
|
||||
// Do NOT override explicit broadcast replies
|
||||
@@ -130,6 +185,19 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t
|
||||
lastChannel = channel;
|
||||
lastDestSet = true;
|
||||
|
||||
#if !defined(USE_VIRTUAL_KEYBOARD)
|
||||
// The freetext screen needs real key input, so devices without a physical keyboard
|
||||
// compose on the on-screen keyboard instead. Open it from runOnce() rather than here:
|
||||
// menus call us from a banner callback, and the banner is torn down as soon as that
|
||||
// callback returns, which would take the keyboard down with it.
|
||||
if (!kb_found && osk_found && screen) {
|
||||
pendingOskLaunch = true;
|
||||
setIntervalFromNow(0);
|
||||
LOG_TRACE("[CannedMessage] LaunchFreetextWithDestination (OSK) dest=0x%08x ch=%d", dest, channel);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true);
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
@@ -717,65 +785,18 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo
|
||||
}
|
||||
|
||||
// [Free Text] triggers the free text input (virtual keyboard)
|
||||
#if defined(USE_VIRTUAL_KEYBOARD)
|
||||
if (strcmp(current, "[-- Free Text --]") == 0) {
|
||||
#if defined(USE_VIRTUAL_KEYBOARD)
|
||||
updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true);
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
notifyObservers(&e);
|
||||
return true;
|
||||
}
|
||||
#else
|
||||
if (strcmp(current, "[-- Free Text --]") == 0) {
|
||||
if (osk_found && screen) {
|
||||
char headerBuffer[64];
|
||||
if (this->dest == NODENUM_BROADCAST) {
|
||||
snprintf(headerBuffer, sizeof(headerBuffer), "To: #%s", channels.getName(this->channel));
|
||||
} else {
|
||||
snprintf(headerBuffer, sizeof(headerBuffer), "To: @%s", getNodeName(this->dest));
|
||||
}
|
||||
screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) {
|
||||
if (!text.empty()) {
|
||||
this->freetext = text.c_str();
|
||||
this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT;
|
||||
updateState(CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE);
|
||||
currentMessageIndex = -1;
|
||||
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->notifyObservers(&e);
|
||||
screen->forceDisplay();
|
||||
|
||||
setIntervalFromNow(500);
|
||||
return;
|
||||
} else {
|
||||
// Don't delete virtual keyboard immediately - it might still be executing
|
||||
// Instead, just clear the callback and reset banner to stop input processing
|
||||
graphics::NotificationRenderer::textInputCallback = nullptr;
|
||||
graphics::NotificationRenderer::resetBanner();
|
||||
|
||||
// Return to inactive state
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
|
||||
// Force display update to show normal screen
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->notifyObservers(&e);
|
||||
screen->forceDisplay();
|
||||
|
||||
// Schedule cleanup for next loop iteration to ensure safe deletion
|
||||
setIntervalFromNow(50);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (showOnScreenKeyboard())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Normal canned message selection
|
||||
if (runState == CANNED_MESSAGE_RUN_STATE_INACTIVE || runState == CANNED_MESSAGE_RUN_STATE_DISABLED) {
|
||||
@@ -1144,6 +1165,14 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
|
||||
|
||||
int32_t CannedMessageModule::runOnce()
|
||||
{
|
||||
// A menu asked to compose freetext on the on-screen keyboard; the menu banner is gone
|
||||
// by now, so it is safe to bring the keyboard up.
|
||||
if (this->pendingOskLaunch) {
|
||||
this->pendingOskLaunch = false;
|
||||
if (showOnScreenKeyboard())
|
||||
return INT32_MAX; // the text input callback drives everything from here
|
||||
}
|
||||
|
||||
if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && needsUpdate) {
|
||||
updateDestinationSelectionList();
|
||||
needsUpdate = false;
|
||||
|
||||
@@ -190,6 +190,11 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
int handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
|
||||
bool handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
|
||||
bool handleFreeTextInput(const InputEvent *event);
|
||||
// Opens the on-screen keyboard prompt for the current destination.
|
||||
// Returns false when this device has no on-screen keyboard.
|
||||
bool showOnScreenKeyboard();
|
||||
// Set when a menu requested on-screen-keyboard compose; serviced by runOnce().
|
||||
bool pendingOskLaunch = false;
|
||||
|
||||
#if defined(USE_VIRTUAL_KEYBOARD)
|
||||
Letter keyboard[2][4][10] = {{{{"Q", 20, 0, 0, 0, 0},
|
||||
|
||||
@@ -101,9 +101,11 @@ bool NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha
|
||||
if (prevPacketId) // if we wrap around to zero, we'll simply fail to cancel in that rare case (no big deal)
|
||||
service->cancelSending(prevPacketId);
|
||||
shorterTimeout = _shorterTimeout;
|
||||
deferHistoryStamp = true;
|
||||
DEBUG_HEAP_BEFORE;
|
||||
meshtastic_MeshPacket *p = allocReply();
|
||||
DEBUG_HEAP_AFTER("NodeInfoModule::sendOurNodeInfo", p);
|
||||
deferHistoryStamp = false;
|
||||
|
||||
if (p) { // Check whether we didn't ignore it
|
||||
p->to = dest;
|
||||
@@ -123,8 +125,20 @@ bool NodeInfoModule::sendOurNodeInfo(NodeNum dest, bool wantReplies, uint8_t cha
|
||||
|
||||
prevPacketId = p->id;
|
||||
|
||||
service->sendToMesh(p);
|
||||
const ErrorCode res = service->sendToMesh(p);
|
||||
shorterTimeout = false;
|
||||
// A rejected send never reached the air, so it neither defers the routine broadcast nor
|
||||
// consumes a pending channel change. sendToMesh() has already released the packet.
|
||||
if (res != ERRNO_OK && res != ERRNO_SHOULD_RELEASE) {
|
||||
LOG_WARN("NodeInfo send rejected (err=%d)", res);
|
||||
return false;
|
||||
}
|
||||
if (transmitHistory)
|
||||
transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP);
|
||||
// Our NodeInfo just went on the air, so the routine broadcast is due a full interval from now
|
||||
// rather than from the last tick - an ad-hoc send otherwise leaves the periodic copy right behind it.
|
||||
setIntervalFromNow(
|
||||
Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -157,8 +171,17 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply()
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Use graduated scaling based on active mesh size (10 minute base, scales with congestion coefficient)
|
||||
uint32_t timeoutMs = Default::getConfiguredOrDefaultMsScaled(0, 10 * 60, nodeStatus->getNumOnline());
|
||||
// Use graduated scaling based on active mesh size (30 minute base, scales with congestion coefficient)
|
||||
uint32_t timeoutMs = Default::getConfiguredOrDefaultMsScaled(0, 30 * 60, nodeStatus->getNumOnline());
|
||||
// A licensed station's call-sign announcement is a regulatory interval, not a preference: ham mode
|
||||
// sets node_info_broadcast_secs to 600 s, which a set-config would otherwise clamp to an hour.
|
||||
// Never hold such a station past its own interval, whatever the floor and the scaling say.
|
||||
if (owner.is_licensed) {
|
||||
const uint32_t hamMs =
|
||||
Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs);
|
||||
if (hamMs < timeoutMs)
|
||||
timeoutMs = hamMs;
|
||||
}
|
||||
uint32_t lastNodeInfo = transmitHistory ? transmitHistory->getLastSentToMeshMillis(meshtastic_PortNum_NODEINFO_APP) : 0;
|
||||
if (!shorterTimeout && lastNodeInfo && Throttle::isWithinTimespanMs(lastNodeInfo, timeoutMs)) {
|
||||
LOG_DEBUG("Skip send NodeInfo since we sent it <%us ago", timeoutMs / 1000);
|
||||
@@ -180,7 +203,10 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply()
|
||||
strcpy(u.id, nodeDB->getNodeId().c_str());
|
||||
|
||||
LOG_INFO("Send owner %s/%s/%s", u.id, u.long_name, u.short_name);
|
||||
if (transmitHistory)
|
||||
// The framework sends its own reply, so stamp here for that path. sendOurNodeInfo() stamps
|
||||
// after the router accepts the packet instead - a send that never went out must not throttle
|
||||
// the next one, and the floor it would sit out is 30 minutes.
|
||||
if (transmitHistory && !deferHistoryStamp)
|
||||
transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP);
|
||||
return allocDataProtobuf(u);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,18 @@ class NodeInfoModule : public ProtobufModule<meshtastic_User>, private concurren
|
||||
*/
|
||||
void triggerImmediateNodeInfoCheck();
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
/// Test-only reads of the routine-broadcast countdown a send re-arms. concurrency::OSThread is a
|
||||
/// private base, so only this class can reach it - a test shim cannot.
|
||||
unsigned long broadcastCountdownMsForTests() const { return interval; }
|
||||
void armBroadcastCountdownForTests(unsigned long ms) { setIntervalFromNow(ms); }
|
||||
/// The deadline the scheduler actually reads. interval alone cannot tell a deadline moved to
|
||||
/// now from one recomputed off a stale last_run, which is the regression worth catching.
|
||||
unsigned long broadcastDeadlineMsForTests() const { return _cached_next_run; }
|
||||
/// Pretend the periodic thread last ran ageMs ago, so those two deadlines differ by ageMs.
|
||||
void ageLastRunForTests(unsigned long ageMs) { runned(millis() - ageMs); }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/** Called to handle a particular incoming message
|
||||
|
||||
@@ -49,6 +61,8 @@ class NodeInfoModule : public ProtobufModule<meshtastic_User>, private concurren
|
||||
|
||||
private:
|
||||
bool shorterTimeout = false;
|
||||
/// Set across sendOurNodeInfo()'s own allocReply(), so the transmit stamp waits for an accepted send.
|
||||
bool deferHistoryStamp = false;
|
||||
bool suppressReplyForCurrentRequest = false;
|
||||
/// Sender -> uptime seconds (Time::getUptimeSecs()) at our last reply. Seconds, not millis:
|
||||
/// the suppression window is hours wide. See handleReceivedProtobuf().
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "Breakout.h"
|
||||
#include <cstring> // strcmp, for the joystick-source test in handleInput()
|
||||
|
||||
// ===========================================================================
|
||||
// Pure BreakoutGame logic (no display/FS dependencies; always compiled)
|
||||
@@ -26,11 +27,18 @@ void BreakoutGame::serveBall()
|
||||
{
|
||||
// Centre the paddle and launch the ball upward from just above it, at a slight angle whose
|
||||
// side is chosen randomly so successive serves are not identical.
|
||||
paddleLeft = (BOARD_W - PADDLE_W) / 2;
|
||||
|
||||
ballPxX = static_cast<int32_t>(BOARD_W / 2) * SUBPX;
|
||||
ballPxY = static_cast<int32_t>(PADDLE_Y - 2) * SUBPX;
|
||||
ballVx = (nextRandom() & 1u) ? 28 : -28;
|
||||
ballVy = -BALL_VY;
|
||||
ballDocked = true; // wait for the player to fire before the ball moves
|
||||
}
|
||||
|
||||
void BreakoutGame::launchBall()
|
||||
{
|
||||
if (alive)
|
||||
ballDocked = false;
|
||||
}
|
||||
|
||||
void BreakoutGame::nextLevel()
|
||||
@@ -48,6 +56,9 @@ void BreakoutGame::reset(uint32_t seed)
|
||||
levelNum = 1;
|
||||
alive = true;
|
||||
ballTick = false;
|
||||
// A new game starts centred. serveBall() deliberately does NOT re-centre, so the paddle keeps
|
||||
// the player's position between lives.
|
||||
paddleLeft = (BOARD_W - PADDLE_W) / 2;
|
||||
buildBricks();
|
||||
serveBall();
|
||||
}
|
||||
@@ -78,6 +89,14 @@ bool BreakoutGame::step()
|
||||
if (!alive)
|
||||
return false;
|
||||
|
||||
// Waiting to serve: the ball rides the centre of the paddle (so it still aims by sliding) and
|
||||
// no physics, collisions or life losses happen until the player fires it.
|
||||
if (ballDocked) {
|
||||
ballPxX = static_cast<int32_t>(paddleLeft + PADDLE_W / 2) * SUBPX;
|
||||
ballPxY = static_cast<int32_t>(PADDLE_Y - 2) * SUBPX;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The ball advances on every other step() so the caller can tick (and poll the paddle) at twice
|
||||
// the ball's rate -- this keeps the ball speed constant while paddle control refreshes faster.
|
||||
ballTick = !ballTick;
|
||||
@@ -224,20 +243,41 @@ bool Breakout::tick()
|
||||
return game.step();
|
||||
}
|
||||
|
||||
void Breakout::handleInput(input_broker_event ev)
|
||||
void Breakout::handleInput(const InputEvent *event)
|
||||
{
|
||||
#if ARCH_PORTDUINO && defined(__linux__)
|
||||
// When a joystick is present the paddle is polled continuously in tick(); ignore the discrete
|
||||
// (and slow) repeat events so we don't double-move.
|
||||
if (aLinuxJoystick)
|
||||
return;
|
||||
#endif
|
||||
const input_broker_event ev = event->inputEvent;
|
||||
switch (ev) {
|
||||
case INPUT_BROKER_LEFT:
|
||||
game.moveLeft();
|
||||
break;
|
||||
case INPUT_BROKER_RIGHT:
|
||||
game.moveRight();
|
||||
#if ARCH_PORTDUINO && defined(__linux__)
|
||||
// While the stick is held, tick() polls heldXZone() and moves the paddle itself, so the
|
||||
// joystick's own discrete (and slow) repeat events would double-move. Suppress exactly
|
||||
// those and nothing else.
|
||||
//
|
||||
// All three parts are load-bearing:
|
||||
// source -- the event actually came from this gamepad. Without it, LEFT/RIGHT from the
|
||||
// keyboard or touchscreen is swallowed too; aLinuxJoystick is constructed on
|
||||
// every Linux host whether or not a gamepad is configured, so a pointer check
|
||||
// alone is true even with nothing attached.
|
||||
// kbchar -- no button produced it, so it is the D-pad axis. A shoulder button mapped to
|
||||
// left/right is a discrete press the axis poll knows nothing about, and must
|
||||
// still nudge the paddle.
|
||||
// heldX -- the axis is what is driving right now, so tick() already has it covered.
|
||||
if (aLinuxJoystick && event->kbchar == 0 && event->source && aLinuxJoystick->originName() &&
|
||||
strcmp(event->source, aLinuxJoystick->originName()) == 0 && aLinuxJoystick->heldXZone() != 0)
|
||||
break;
|
||||
#endif
|
||||
if (ev == INPUT_BROKER_LEFT)
|
||||
game.moveLeft();
|
||||
else
|
||||
game.moveRight();
|
||||
break;
|
||||
// Serve: B (CANCEL/BACK, forwarded here via wantsBackButton) or A (SELECT) fires a docked ball.
|
||||
case INPUT_BROKER_CANCEL:
|
||||
case INPUT_BROKER_BACK:
|
||||
case INPUT_BROKER_SELECT:
|
||||
case INPUT_BROKER_SELECT_LONG:
|
||||
game.launchBall();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -298,6 +338,12 @@ void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
// Ball.
|
||||
display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2);
|
||||
|
||||
// Waiting to serve: prompt the player (the ball rides the paddle until then).
|
||||
if (game.isBallDocked()) {
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(x + display->getWidth() / 2, y + BreakoutGame::PADDLE_Y - FONT_HEIGHT_SMALL - 4, "B TO SERVE");
|
||||
}
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
// Colour the wall by row, plus a blue paddle and white ball. One region per brick row (the row's
|
||||
// lit bricks take the colour; cleared cells and gaps stay background). Paddle then ball register
|
||||
|
||||
@@ -52,6 +52,11 @@ class BreakoutGame
|
||||
void moveLeft();
|
||||
void moveRight();
|
||||
|
||||
/** After every serve the ball rides the paddle until the player fires it. launchBall() releases
|
||||
* it; while docked the ball tracks the paddle and no collisions or life losses occur. */
|
||||
void launchBall();
|
||||
bool isBallDocked() const { return ballDocked; }
|
||||
|
||||
bool isPlaying() const { return alive; }
|
||||
uint32_t score() const { return points; }
|
||||
uint8_t lives() const { return livesLeft; }
|
||||
@@ -87,7 +92,8 @@ class BreakoutGame
|
||||
uint8_t levelNum = 1;
|
||||
uint32_t rng = 1; // xorshift32 state (never 0)
|
||||
bool alive = false;
|
||||
bool ballTick = false; // ball advances on every other step() (see step())
|
||||
bool ballTick = false; // ball advances on every other step() (see step())
|
||||
bool ballDocked = false; // ball is waiting on the paddle to be launched
|
||||
};
|
||||
|
||||
#include "configuration.h"
|
||||
@@ -115,7 +121,10 @@ class Breakout : public Game
|
||||
uint32_t score() const override { return game.score(); }
|
||||
int32_t tickIntervalMs() const override;
|
||||
|
||||
void handleInput(input_broker_event ev) override;
|
||||
void handleInput(const InputEvent *event) override;
|
||||
// Claim BACK/CANCEL (the gamepad's B) while the ball waits on the paddle, so it serves instead
|
||||
// of pausing. Released as soon as the ball is in play, so BACK pauses normally again.
|
||||
bool wantsBackButton() const override { return game.isBallDocked(); }
|
||||
|
||||
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
|
||||
@@ -181,9 +181,10 @@ ChirpyRunner::ChirpyRunner()
|
||||
scores_.load();
|
||||
}
|
||||
|
||||
void ChirpyRunner::handleInput(input_broker_event ev)
|
||||
void ChirpyRunner::handleInput(const InputEvent *event)
|
||||
{
|
||||
// SELECT is the jump (as requested); UP is accepted as a convenient alternate.
|
||||
const input_broker_event ev = event->inputEvent;
|
||||
if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_SELECT_LONG || ev == INPUT_BROKER_UP)
|
||||
game.jump();
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ class ChirpyRunner : public Game
|
||||
uint32_t score() const override { return game.score(); }
|
||||
int32_t tickIntervalMs() const override { return 33; } // ~30 fps; difficulty ramps via scroll speed
|
||||
|
||||
void handleInput(input_broker_event ev) override;
|
||||
void handleInput(const InputEvent *event) override;
|
||||
|
||||
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
|
||||
@@ -35,7 +35,21 @@ class Game
|
||||
virtual int32_t tickIntervalMs() const = 0; // per-game speed curve
|
||||
|
||||
// --- Input while PLAYING (the host handles the BACK-to-pause and menu keys) ---
|
||||
virtual void handleInput(input_broker_event ev) = 0;
|
||||
// The whole event, not just the action, because an action alone cannot answer everything a
|
||||
// game needs to know:
|
||||
// - kbchar is a keystroke from a keyboard, or the physical gamepad button behind the action
|
||||
// (INPUT_BROKER_MSG_JOY_BUTTON_FIRST..LAST, see InputBroker.h), or 0 when the source has
|
||||
// nothing to add. Several buttons can share one action, so this is what lets a game act on
|
||||
// the button rather than the action, and use more inputs than SELECT and CANCEL.
|
||||
// - source names the driver that produced it. Two sources can send the same action with the
|
||||
// same (zero) kbchar, so this is the only thing that tells them apart -- Breakout needs it
|
||||
// to ignore the joystick's own D-pad repeats without also ignoring the keyboard.
|
||||
// A game that only cares about the action just reads event->inputEvent.
|
||||
virtual void handleInput(const InputEvent *event) = 0;
|
||||
|
||||
// While true, the host forwards BACK/CANCEL to handleInput() instead of using it to pause. Lets
|
||||
// a game briefly claim that button (e.g. Breakout's "press B to serve" before the ball launches).
|
||||
virtual bool wantsBackButton() const { return false; }
|
||||
|
||||
// --- Rendering (the host draws the shared PAUSED / GAME OVER / HIGH SCORES chrome) ---
|
||||
virtual void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) = 0; // title/art + hi + hint
|
||||
|
||||
@@ -56,10 +56,19 @@ void GamesModule::startPlaying()
|
||||
active->start(static_cast<uint32_t>(random()) ^ millis());
|
||||
uiState = GAMES_PLAYING;
|
||||
lastAwakeKickMs = millis();
|
||||
noteActivity();
|
||||
kickTick();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void GamesModule::goHome()
|
||||
{
|
||||
// Left idle too long: drop any game and return to the clearly-Meshtastic home frame.
|
||||
exitToIdle();
|
||||
if (screen)
|
||||
screen->showHomeFrame();
|
||||
}
|
||||
|
||||
void GamesModule::enterGameOver()
|
||||
{
|
||||
lastScore = active ? active->score() : 0;
|
||||
@@ -113,7 +122,7 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score)
|
||||
// One shared message for every game, with the game's name spliced in. ASCII only -- avoids tofu
|
||||
// if a receiving node's font lacks a glyph.
|
||||
p->decoded.payload.size = snprintf(reinterpret_cast<char *>(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes),
|
||||
GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast<unsigned long>(score));
|
||||
GAMES_HIGH_SCORE_STRING, active->name(), static_cast<unsigned long>(score), initials);
|
||||
service->sendToMesh(p);
|
||||
LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast<unsigned long>(score));
|
||||
}
|
||||
@@ -151,6 +160,27 @@ void GamesModule::kickTick()
|
||||
|
||||
int32_t GamesModule::runOnce()
|
||||
{
|
||||
const uint32_t now = millis();
|
||||
|
||||
// Whether the games UI is actually in front of the player: a game is active (which forces the
|
||||
// games frame), or the attract screen is the current frame. When it is, an idle stretch bounces
|
||||
// back to the home frame so a walked-away device reads as a Meshtastic node.
|
||||
const bool gamesVisible = (uiState != GAMES_IDLE) || (screen && screen->isGamesFrameShown());
|
||||
if (gamesVisible) {
|
||||
if (screen && screen->isOverlayBannerShowing()) {
|
||||
// A picker or banner is up (e.g. high-score initials entry, which our handleInputEvent
|
||||
// never sees). The user is busy with it -- don't time out and yank them away.
|
||||
lastActivityMs = now;
|
||||
} else if (now - lastActivityMs >= INACTIVITY_TIMEOUT_MS) {
|
||||
goHome();
|
||||
return disable();
|
||||
}
|
||||
} else {
|
||||
// Not in front of the player (attract screen is just one of the rotating frames, and the
|
||||
// player is elsewhere): keep the timer fresh so a later visit starts a full 15 s.
|
||||
lastActivityMs = now;
|
||||
}
|
||||
|
||||
if (uiState == GAMES_PLAYING && active) {
|
||||
if (!active->tick()) {
|
||||
enterGameOver();
|
||||
@@ -158,7 +188,6 @@ int32_t GamesModule::runOnce()
|
||||
}
|
||||
|
||||
// Keep the display awake through long runs that generate no key presses.
|
||||
const uint32_t now = millis();
|
||||
if (now - lastAwakeKickMs > 1500) {
|
||||
powerFSM.trigger(EVENT_PRESS);
|
||||
lastAwakeKickMs = now;
|
||||
@@ -168,13 +197,18 @@ int32_t GamesModule::runOnce()
|
||||
return active->tickIntervalMs();
|
||||
}
|
||||
|
||||
// Idle: service any game that broadcasts periodically; sleep until the soonest one is due.
|
||||
// Idle-ish (attract / paused / game-over / high scores): service any periodic mesh broadcast,
|
||||
// and while the games UI is visible keep a slow poll running so the inactivity timeout fires.
|
||||
int32_t next = -1;
|
||||
for (Game *g : games) {
|
||||
const int32_t due = g->meshTick(*this);
|
||||
if (due >= 0 && (next < 0 || due < next))
|
||||
next = due;
|
||||
}
|
||||
if (gamesVisible) {
|
||||
const int32_t poll = 1000;
|
||||
return (next >= 0 && next < poll) ? next : poll;
|
||||
}
|
||||
return next < 0 ? disable() : next;
|
||||
}
|
||||
|
||||
@@ -191,9 +225,16 @@ int GamesModule::handleInputEvent(const InputEvent *event)
|
||||
if (screen->isOverlayBannerShowing())
|
||||
return 0; // a menu banner is up; don't steal its input
|
||||
|
||||
noteActivity(); // any input on the games frame resets the return-to-home timer
|
||||
|
||||
const input_broker_event ev = event->inputEvent;
|
||||
const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK);
|
||||
|
||||
// Start is mapped to select like any other button, so it launches games and works the menus.
|
||||
// Inside a running game it means pause/resume instead, which we can tell only because kbchar
|
||||
// names the physical button behind the action.
|
||||
const bool isPauseButton = (ev == INPUT_BROKER_SELECT && isJoyStartButton(event->kbchar));
|
||||
|
||||
switch (uiState) {
|
||||
case GAMES_IDLE:
|
||||
// Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it;
|
||||
@@ -214,12 +255,22 @@ int GamesModule::handleInputEvent(const InputEvent *event)
|
||||
return 0;
|
||||
|
||||
case GAMES_PLAYING:
|
||||
if (isBack) {
|
||||
// Start pauses, and is never forwarded to the game: unlike BACK it has no second meaning
|
||||
// in play, so a game cannot claim it the way Breakout claims BACK to serve.
|
||||
if (isPauseButton) {
|
||||
uiState = GAMES_PAUSED;
|
||||
disable();
|
||||
requestRedraw();
|
||||
return 1;
|
||||
}
|
||||
// BACK pauses, unless the active game has temporarily claimed that button (see
|
||||
// Game::wantsBackButton) -- then it is forwarded like any other key.
|
||||
if (isBack && !(active && active->wantsBackButton())) {
|
||||
uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit
|
||||
disable();
|
||||
requestRedraw();
|
||||
} else if (active) {
|
||||
active->handleInput(ev);
|
||||
active->handleInput(event);
|
||||
if (!active->isPlaying()) {
|
||||
enterGameOver();
|
||||
return 1;
|
||||
@@ -232,7 +283,7 @@ int GamesModule::handleInputEvent(const InputEvent *event)
|
||||
if (isBack) {
|
||||
exitToIdle(); // quit from pause
|
||||
} else if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_UP || ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_LEFT ||
|
||||
ev == INPUT_BROKER_RIGHT) {
|
||||
ev == INPUT_BROKER_RIGHT) { // Start arrives as SELECT, so it resumes too
|
||||
uiState = GAMES_PLAYING;
|
||||
kickTick();
|
||||
requestRedraw();
|
||||
@@ -317,6 +368,18 @@ void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/
|
||||
{
|
||||
display->setColor(WHITE);
|
||||
|
||||
// drawFrame runs only while the games frame is the current frame. Several idle-ish states
|
||||
// (attract / paused / game-over / high-scores) otherwise leave the tick thread asleep, so use
|
||||
// this render as the trigger to (re)start the slow inactivity poll and reset the timer -- so
|
||||
// arriving on such a screen gets a fresh 15 s before we bounce back home, and walking away from
|
||||
// it eventually does. (While a game is PLAYING the thread is already ticking, so !enabled is
|
||||
// false here and the play-time timer is left to run.)
|
||||
if (!enabled) {
|
||||
noteActivity();
|
||||
enabled = true;
|
||||
setIntervalFromNow(1000);
|
||||
}
|
||||
|
||||
switch (uiState) {
|
||||
case GAMES_IDLE:
|
||||
if (!games.empty())
|
||||
|
||||
@@ -67,12 +67,18 @@ class GamesModule : public SinglePortModule, public Observable<const UIFrameEven
|
||||
CallbackObserver<GamesModule, const InputEvent *> inputObserver =
|
||||
CallbackObserver<GamesModule, const InputEvent *>(this, &GamesModule::handleInputEvent);
|
||||
|
||||
// After this long with no input while the games frame is up, bounce back to the home frame so a
|
||||
// walked-away device clearly reads as a Meshtastic node rather than sitting on a game screen.
|
||||
static constexpr uint32_t INACTIVITY_TIMEOUT_MS = 15000;
|
||||
|
||||
// === State transitions ===
|
||||
void startPlaying();
|
||||
void enterGameOver();
|
||||
void exitToIdle();
|
||||
void requestRedraw();
|
||||
void kickTick();
|
||||
void noteActivity() { lastActivityMs = millis(); } // reset the inactivity timer
|
||||
void goHome(); // exit any game and switch to the home frame
|
||||
|
||||
// === Shared game-over / high-score flow ===
|
||||
void promptForInitials();
|
||||
@@ -94,6 +100,7 @@ class GamesModule : public SinglePortModule, public Observable<const UIFrameEven
|
||||
int lastRank = -1; // rank achieved last game (-1 == didn't place)
|
||||
bool lastWasNewTop = false; // last game set a new all-time #1
|
||||
uint32_t lastAwakeKickMs = 0; // throttles the power-FSM wake nudge during long runs
|
||||
uint32_t lastActivityMs = 0; // millis() of the last input / becoming-visible (inactivity timer)
|
||||
};
|
||||
|
||||
extern GamesModule *gamesModule;
|
||||
|
||||
@@ -45,6 +45,15 @@ bool SnakeGame::setDirection(Direction d)
|
||||
return true;
|
||||
}
|
||||
|
||||
void SnakeGame::turn(bool clockwise)
|
||||
{
|
||||
// Indexed by Direction (UP, DOWN, LEFT, RIGHT). Screen space has y growing downward, so the
|
||||
// clockwise cycle the player sees is UP -> RIGHT -> DOWN -> LEFT.
|
||||
static constexpr Direction CW[4] = {DIR_RIGHT, DIR_LEFT, DIR_UP, DIR_DOWN};
|
||||
static constexpr Direction CCW[4] = {DIR_LEFT, DIR_RIGHT, DIR_DOWN, DIR_UP};
|
||||
setDirection(clockwise ? CW[dir] : CCW[dir]);
|
||||
}
|
||||
|
||||
uint32_t SnakeGame::nextRandom()
|
||||
{
|
||||
uint32_t x = rng;
|
||||
@@ -172,8 +181,20 @@ int32_t Snake::tickIntervalMs() const
|
||||
return iv < 70 ? 70 : iv;
|
||||
}
|
||||
|
||||
void Snake::handleInput(input_broker_event ev)
|
||||
void Snake::handleInput(const InputEvent *event)
|
||||
{
|
||||
const input_broker_event ev = event->inputEvent;
|
||||
const unsigned char kbchar = event->kbchar;
|
||||
|
||||
// Shoulder-button steering: a gamepad button mapped to left/right turns the snake relative to
|
||||
// where it is already heading (L counter-clockwise, R clockwise) rather than setting an
|
||||
// absolute heading. The D-pad and keyboard keep steering absolutely -- the D-pad is an axis,
|
||||
// so it arrives with no button in kbchar, which is exactly what tells the two apart.
|
||||
if (isJoyButton(kbchar) && (ev == INPUT_BROKER_LEFT || ev == INPUT_BROKER_RIGHT)) {
|
||||
game.turn(ev == INPUT_BROKER_RIGHT);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev) {
|
||||
case INPUT_BROKER_UP:
|
||||
game.setDirection(SnakeGame::DIR_UP);
|
||||
|
||||
@@ -47,6 +47,18 @@ class SnakeGame
|
||||
*/
|
||||
bool setDirection(Direction d);
|
||||
|
||||
/**
|
||||
* Steer relative to the current heading: a quarter turn clockwise, or counter-clockwise.
|
||||
* Clockwise is what the player sees on screen (UP -> RIGHT -> DOWN -> LEFT), remembering that
|
||||
* y grows downward. A quarter turn from the committed heading can never be a reversal, so
|
||||
* unlike setDirection() this always takes.
|
||||
*
|
||||
* Turning from the committed direction rather than the pending one is deliberate, and matches
|
||||
* setDirection()'s reasoning: two presses inside one tick then settle on a single quarter
|
||||
* turn instead of chaining into the 180 that would run the head into the neck.
|
||||
*/
|
||||
void turn(bool clockwise);
|
||||
|
||||
/**
|
||||
* Advance the simulation by one tick. Returns true if the snake is still alive afterwards,
|
||||
* false if this move ended the game (wall hit, self-collision, or board filled == win).
|
||||
@@ -128,7 +140,7 @@ class Snake : public Game
|
||||
uint32_t score() const override { return game.score(); }
|
||||
int32_t tickIntervalMs() const override;
|
||||
|
||||
void handleInput(input_broker_event ev) override;
|
||||
void handleInput(const InputEvent *event) override;
|
||||
|
||||
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
|
||||
@@ -285,9 +285,9 @@ int32_t Tetris::tickIntervalMs() const
|
||||
return iv < 50 ? 50 : iv;
|
||||
}
|
||||
|
||||
void Tetris::handleInput(input_broker_event ev)
|
||||
void Tetris::handleInput(const InputEvent *event)
|
||||
{
|
||||
switch (ev) {
|
||||
switch (event->inputEvent) {
|
||||
case INPUT_BROKER_UP:
|
||||
game.rotate();
|
||||
break;
|
||||
|
||||
@@ -131,7 +131,7 @@ class Tetris : public Game
|
||||
uint32_t score() const override { return game.score(); }
|
||||
int32_t tickIntervalMs() const override;
|
||||
|
||||
void handleInput(input_broker_event ev) override;
|
||||
void handleInput(const InputEvent *event) override;
|
||||
|
||||
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
|
||||
|
||||
@@ -37,6 +37,9 @@ constexpr int MAX_NODES_SANITY_CEILING = 16000;
|
||||
|
||||
const std::set<std::string> kLoraPinKeys = {"CS", "IRQ", "Busy", "Reset", "TXen", "RXen", "SX126X_ANT_SW", "GPIO_DETECT_PA"};
|
||||
|
||||
// Action names LinuxJoystick understands; anything else leaves the button unmapped.
|
||||
const std::set<std::string> kJoystickActions = {"select", "cancel", "back", "up", "down", "left", "right", "user", "userpress"};
|
||||
|
||||
const std::map<std::string, std::set<std::string>> &schema()
|
||||
{
|
||||
static const std::map<std::string, std::set<std::string>> s = {
|
||||
@@ -366,6 +369,61 @@ void checkPinNode(const std::string &file, const std::string &path, const YAML::
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by action, not by button, so one action can list several codes and have every one of
|
||||
// those buttons drive it. The value is a single evdev code or a list of them.
|
||||
void checkJoystickButtons(const std::string &file, const YAML::Node &node, std::vector<Finding> &findings)
|
||||
{
|
||||
if (!node.IsMap()) {
|
||||
findings.push_back(
|
||||
{kError, file, lineOf(node), "Input.JoystickButtons must be a mapping of action name to evdev button code"});
|
||||
return;
|
||||
}
|
||||
|
||||
std::map<int, std::string> owner; // code -> the action that claimed it first
|
||||
for (const auto &entry : node) {
|
||||
std::string action = entry.first.as<std::string>("");
|
||||
for (auto &c : action)
|
||||
c = tolower(c);
|
||||
if (!kJoystickActions.count(action)) {
|
||||
findings.push_back({kWarn, file, lineOf(entry.first),
|
||||
"Input.JoystickButtons: '" + action +
|
||||
"' is not a recognised action, so those buttons do nothing. Valid actions are select, "
|
||||
"cancel, back, up, down, left, right and user"});
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<YAML::Node> codeNodes;
|
||||
if (entry.second.IsSequence())
|
||||
for (const auto &codeNode : entry.second)
|
||||
codeNodes.push_back(codeNode);
|
||||
else
|
||||
codeNodes.push_back(entry.second);
|
||||
|
||||
for (const auto &codeNode : codeNodes) {
|
||||
const std::string raw = codeNode.as<std::string>("");
|
||||
int code = 0;
|
||||
try {
|
||||
code = std::stoi(raw, nullptr, 0);
|
||||
} catch (const std::exception &) {
|
||||
code = 0;
|
||||
}
|
||||
if (code == 0) {
|
||||
findings.push_back({kWarn, file, lineOf(codeNode),
|
||||
"Input.JoystickButtons." + action + ": '" + raw +
|
||||
"' is not an evdev button code (hex like 0x121, or decimal), so it is unmapped"});
|
||||
continue;
|
||||
}
|
||||
// One button cannot do two things: the later action silently replaces the earlier one.
|
||||
const auto claimed = owner.find(code);
|
||||
if (claimed != owner.end() && claimed->second != action)
|
||||
findings.push_back({kWarn, file, lineOf(codeNode),
|
||||
"Input.JoystickButtons: button " + raw + " is mapped to both '" + claimed->second +
|
||||
"' and '" + action + "'. Only '" + action + "' takes effect"});
|
||||
owner[code] = action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void checkRfSwitchTable(const std::string &file, const YAML::Node &table, std::vector<Finding> &findings)
|
||||
{
|
||||
if (!table.IsMap()) {
|
||||
@@ -837,7 +895,7 @@ void checkSection(const std::string &file, const std::string §ion, const YAM
|
||||
for (const auto &pin : value)
|
||||
checkPinNode(file, section + "." + key, pin, findings);
|
||||
} else if (key == "JoystickButtons") {
|
||||
// Free-form: any action name mapped to an evdev code.
|
||||
checkJoystickButtons(file, value, findings);
|
||||
} else if ((section == "Lora" && kLoraPinKeys.count(key)) ||
|
||||
(section == "Display" &&
|
||||
(key == "DC" || key == "CS" || key == "Backlight" || key == "BacklightPWMChannel" || key == "Reset")) ||
|
||||
|
||||
@@ -1297,21 +1297,33 @@ bool loadConfig(const char *configPath)
|
||||
portduino_config.pointerDevice = (yamlConfig["Input"]["PointerDevice"]).as<std::string>("");
|
||||
portduino_config.joystickDevice = (yamlConfig["Input"]["JoystickDevice"]).as<std::string>("");
|
||||
if (yamlConfig["Input"]["JoystickButtons"]) {
|
||||
// action name -> evdev button code (hex like 0x122 or decimal); stored inverted
|
||||
// as code -> lowercase action name for the driver to look up per keypress.
|
||||
// action name -> evdev button code (hex like 0x122 or decimal), or a list of codes
|
||||
// so several physical buttons drive the same action. Stored inverted as
|
||||
// code -> lowercase action name for the driver to look up per keypress.
|
||||
for (const auto &button : yamlConfig["Input"]["JoystickButtons"]) {
|
||||
std::string action = button.first.as<std::string>("");
|
||||
for (auto &c : action)
|
||||
c = tolower(c);
|
||||
int code = 0;
|
||||
try {
|
||||
// base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry.
|
||||
code = std::stoi(button.second.as<std::string>(""), nullptr, 0);
|
||||
} catch (const std::exception &) {
|
||||
code = 0;
|
||||
if (action == "")
|
||||
continue;
|
||||
// A bare scalar is just a one-entry list.
|
||||
std::vector<YAML::Node> codeNodes;
|
||||
if (button.second.IsSequence())
|
||||
for (const auto &codeNode : button.second)
|
||||
codeNodes.push_back(codeNode);
|
||||
else
|
||||
codeNodes.push_back(button.second);
|
||||
for (const auto &codeNode : codeNodes) {
|
||||
int code = 0;
|
||||
try {
|
||||
// base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry.
|
||||
code = std::stoi(codeNode.as<std::string>(""), nullptr, 0);
|
||||
} catch (const std::exception &) {
|
||||
code = 0;
|
||||
}
|
||||
if (code != 0)
|
||||
portduino_config.joystickButtons[code] = action;
|
||||
}
|
||||
if (code != 0 && action != "")
|
||||
portduino_config.joystickButtons[code] = action;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -545,9 +545,23 @@ extern struct portduino_config_struct {
|
||||
if (joystickDevice != "")
|
||||
out << YAML::Key << "JoystickDevice" << YAML::Value << joystickDevice;
|
||||
if (!joystickButtons.empty()) {
|
||||
out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap;
|
||||
// Stored as code -> action; invert so each action lists every code bound to it.
|
||||
// Several buttons may share one action, so a multi-code action emits a list.
|
||||
std::map<std::string, std::vector<int>> codesByAction;
|
||||
for (const auto &button : joystickButtons)
|
||||
out << YAML::Key << button.second << YAML::Value << button.first;
|
||||
codesByAction[button.second].push_back(button.first);
|
||||
out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap;
|
||||
for (const auto &action : codesByAction) {
|
||||
out << YAML::Key << action.first << YAML::Value;
|
||||
if (action.second.size() == 1) {
|
||||
out << action.second.front();
|
||||
} else {
|
||||
out << YAML::Flow << YAML::BeginSeq;
|
||||
for (const int code : action.second)
|
||||
out << code;
|
||||
out << YAML::EndSeq;
|
||||
}
|
||||
}
|
||||
out << YAML::EndMap;
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -161,6 +161,16 @@ if those yield nothing meshtasticd exits with "Blank MAC Address not allowed!".
|
||||
| `mac-malformed.yaml` | `AA:BB:CC` is under 12 hex digits, so it is silently dropped. |
|
||||
| `mac-source-missing.yaml` | Names an interface with no `/sys/class/net/<n>/address`. Warning, not an error: it is machine-dependent and may be checked on another host. |
|
||||
|
||||
## Joystick buttons
|
||||
|
||||
`Input.JoystickButtons` is keyed by action, not by button, so an action can name a
|
||||
single evdev code or a list of them and every one of those buttons drives it.
|
||||
|
||||
| File | Expected |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `joystick-buttons.yaml` | **Clean, and a regression guard** - four actions, two of them with several codes. |
|
||||
| `joystick-buttons-bad.yaml` | Three silent no-ops: an action name nothing reads, an evdev name where a code belongs, one code claimed twice. |
|
||||
|
||||
## CH341 USB-SPI adapters
|
||||
|
||||
`spidev: ch341` is a different hardware model, not a variant of the same one. The Lora
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# FAULT: three ways a joystick mapping silently does nothing. 'fire' is not an action
|
||||
# LinuxJoystick knows, so those buttons are dropped; 'BTN_SOUTH' is a name rather than the
|
||||
# numeric evdev code the loader parses; and 0x121 appears under two actions, where the
|
||||
# later one wins and the earlier binding is lost.
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 21
|
||||
IRQ: 16
|
||||
Busy: 20
|
||||
Reset: 18
|
||||
Input:
|
||||
JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick
|
||||
JoystickButtons:
|
||||
select: [0x121, BTN_SOUTH]
|
||||
fire: 0x123
|
||||
cancel: [0x122, 0x121]
|
||||
@@ -0,0 +1,17 @@
|
||||
# CLEAN. Several physical buttons bound to one action: an action's value may be a single
|
||||
# evdev code or a list of them, so A and Y both select, B and X both cancel, and the
|
||||
# shoulder buttons work as left/right alongside the D-pad. Nothing here is a fault -- the
|
||||
# case exists so the checker cannot start rejecting the list form.
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 21
|
||||
IRQ: 16
|
||||
Busy: 20
|
||||
Reset: 18
|
||||
Input:
|
||||
JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick
|
||||
JoystickButtons:
|
||||
select: [0x121, 0x123]
|
||||
cancel: [0x122, 0x120]
|
||||
left: 0x124
|
||||
right: 0x125
|
||||
@@ -67,9 +67,11 @@ test_nodedb_legacy_migration writes=config.proto,module.proto,device.proto,chann
|
||||
test_nodedb_lora_slot writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty; the tests themselves only mutate config.lora in RAM and restore it in tearDown
|
||||
test_nodedb_save_retry writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (persists the default set on an empty prefs dir), then drives saveToDisk(SEGMENT_CONFIG) through the retry gate and rewrites config.proto in place to pin the no-format contract
|
||||
test_nodedb_v25_roundtrip writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat v25 persistence round-trips: every test saves nodes.proto and cold-boots a NodeDB whose constructor persists the default segments; warm.dat on the node-DB save cadence
|
||||
test_nodeinfo_send_window writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,transmit_history.dat constructs a NodeDB for getNodeId()/updateFrom() on our own sends; every window probe stamps TransmitHistory, which persists the NodeInfo send time
|
||||
test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=300 needs a NodeDB holding both peers' keys for the PKI encode/decode paths
|
||||
test_phone_api_config_dump writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto per-test NodeDB fixture backing full PhoneAPI want_config dumps; the constructor persists a default config/channel/node set in a fresh sandbox
|
||||
test_pki_admin_fallback writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto needs a NodeDB holding admin keys for the fallback paths
|
||||
test_rebroadcast_mode writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat the ingress harness builds a NodeDB holding the relay peers' keys and our own identity, whose constructor persists a default set when the prefs directory is empty
|
||||
test_reliable_ack_matrix writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (whose constructor persists a default set when the prefs directory is empty) for the sender-key lookups in the ACK/NAK matrix
|
||||
test_stream_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto drives real PhoneAPI handshakes, which read and persist config and the node DB
|
||||
test_traceroute_nexthop writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto traceroute route selection reads the node DB
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 48.
|
@@ -0,0 +1,672 @@
|
||||
#pragma once
|
||||
// Ingress harness shared by the suites that push a packet through Router::perhapsHandleReceived()
|
||||
// and observe what comes out the other side: a mock NodeDB with controllable keys and bits, a
|
||||
// counting radio / routing module / module / MQTT, and builders for decoded, channel-encrypted and
|
||||
// genuinely PKI-encrypted frames. One TU per suite includes this; the statics are per suite.
|
||||
//
|
||||
// Lifecycle: pipelineHarnessCreate() once from setup() before UNITY_BEGIN(); pipelineHarnessSetUp()
|
||||
// at the top of setUp(); pipelineHarnessTearDown() at the top of tearDown(); pipelineHarnessDestroy()
|
||||
// after UNITY_END(). Suites layer their own defaults (e.g. a signature policy) on top.
|
||||
#include "MeshTypes.h" // include BEFORE TestUtil.h
|
||||
#include "NodeStatus.h"
|
||||
#include "TestUtil.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "airtime.h"
|
||||
#include "mesh/Channels.h"
|
||||
#include "mesh/CryptoEngine.h"
|
||||
#include "mesh/MeshRadio.h"
|
||||
#include "mesh/MeshService.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/ReliableRouter.h"
|
||||
#include "mesh/Router.h"
|
||||
#include "mesh/SinglePortModule.h"
|
||||
#include "modules/RoutingModule.h"
|
||||
#include "mqtt/MQTT.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <pb_decode.h>
|
||||
#include <pb_encode.h>
|
||||
#include <unity.h>
|
||||
#include <vector>
|
||||
|
||||
// Test fixture identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
|
||||
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
|
||||
|
||||
// A "small" broadcast payload whose signed encoding easily fits a LoRa frame, and an "oversized"
|
||||
// one whose signed encoding does not, yet still encodes within a LoRa frame unsigned.
|
||||
static constexpr size_t SMALL_PAYLOAD = 16;
|
||||
static constexpr size_t OVERSIZED_PAYLOAD = 180;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockNodeDB - inject nodes with controlled public keys / signer bits.
|
||||
// Mirrors the pattern in test/test_hop_scaling. meshNodes/numMeshNodes are public on NodeDB.
|
||||
// ---------------------------------------------------------------------------
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void installDefaultsPreservingIdentity() { installDefaultConfig(true); }
|
||||
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
// Add a bare node and return a stable handle (fetch via getMeshNode so the pointer stays valid
|
||||
// even if the vector reallocates after later adds).
|
||||
void addNode(NodeNum num)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
void setPublicKey(NodeNum num, const uint8_t *pubKey)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
n->public_key.size = 32;
|
||||
memcpy(n->public_key.bytes, pubKey, 32);
|
||||
}
|
||||
|
||||
void setSignerBit(NodeNum num, bool value)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value);
|
||||
}
|
||||
|
||||
void markHasUser(NodeNum num)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||||
}
|
||||
|
||||
/// A node with a User whose licensed flag is `licensed`; getLicenseStatus() then says so.
|
||||
void markLicenseStatus(NodeNum num, bool licensed)
|
||||
{
|
||||
markHasUser(num);
|
||||
nodeInfoLiteSetBit(getMeshNode(num), NODEINFO_BITFIELD_IS_LICENSED_MASK, licensed);
|
||||
}
|
||||
|
||||
void setLongName(NodeNum num, const char *name)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
strncpy(n->long_name, name, sizeof(n->long_name) - 1);
|
||||
n->long_name[sizeof(n->long_name) - 1] = '\0';
|
||||
}
|
||||
|
||||
const char *longName(NodeNum num)
|
||||
{
|
||||
meshtastic_NodeInfoLite *n = getMeshNode(num);
|
||||
TEST_ASSERT_NOT_NULL(n);
|
||||
return n->long_name;
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
|
||||
/// Stands in for the radio: records what was sent instead of transmitting it, and models the TX
|
||||
/// queue the router consults. `failSend` simulates a full queue.
|
||||
class AuthPipelineRadio : public RadioInterface
|
||||
{
|
||||
public:
|
||||
ErrorCode send(meshtastic_MeshPacket *p) override
|
||||
{
|
||||
sendCalls++;
|
||||
sent.push_back(*p);
|
||||
packetPool.release(p);
|
||||
return failSend ? ERRNO_DISABLED : ERRNO_OK;
|
||||
}
|
||||
bool cancelSending(NodeNum from, PacketId id) override
|
||||
{
|
||||
cancelCalls++;
|
||||
releaseFromTxQueue(from, id);
|
||||
return true;
|
||||
}
|
||||
bool findInTxQueue(NodeNum from, PacketId id) override
|
||||
{
|
||||
findCalls++;
|
||||
return txQueueHolds(from, id);
|
||||
}
|
||||
bool removePendingTXPacket(NodeNum from, PacketId id, uint32_t) override
|
||||
{
|
||||
removeCalls++;
|
||||
releaseFromTxQueue(from, id);
|
||||
return true;
|
||||
}
|
||||
uint32_t getPacketTime(uint32_t, bool = false) override { return 7; }
|
||||
|
||||
// The TX queue the router asks about. A test loads it to say "this one has not gone out yet";
|
||||
// cancelSending() and removePendingTXPacket() take entries back out, as the real queue does.
|
||||
void holdInTxQueue(NodeNum from, PacketId id)
|
||||
{
|
||||
if (!txQueueHolds(from, id))
|
||||
txQueue.push_back({from, id});
|
||||
}
|
||||
void releaseFromTxQueue(NodeNum from, PacketId id)
|
||||
{
|
||||
for (size_t i = 0; i < txQueue.size(); i++) {
|
||||
if (txQueue[i].from == from && txQueue[i].id == id) {
|
||||
txQueue.erase(txQueue.begin() + i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool txQueueHolds(NodeNum from, PacketId id) const
|
||||
{
|
||||
for (const auto &e : txQueue)
|
||||
if (e.from == from && e.id == id)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
size_t txQueueSize() const { return txQueue.size(); }
|
||||
|
||||
// What actually went out, in order, so a test can assert on the frame and not just the count.
|
||||
const std::vector<meshtastic_MeshPacket> &sentPackets() const { return sent; }
|
||||
const meshtastic_MeshPacket *lastSent() const { return sent.empty() ? nullptr : &sent.back(); }
|
||||
/// How many of the sends carried this (from,id) - one relay of a frame heard twice, say.
|
||||
uint32_t sentCountFor(NodeNum from, PacketId id) const
|
||||
{
|
||||
uint32_t n = 0;
|
||||
for (const auto &p : sent)
|
||||
if (getFrom(&p) == from && p.id == id)
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
void reset()
|
||||
{
|
||||
sendCalls = cancelCalls = findCalls = removeCalls = 0;
|
||||
failSend = false;
|
||||
sent.clear();
|
||||
txQueue.clear();
|
||||
}
|
||||
|
||||
bool failSend = false;
|
||||
uint32_t sendCalls = 0;
|
||||
uint32_t cancelCalls = 0;
|
||||
uint32_t findCalls = 0;
|
||||
uint32_t removeCalls = 0;
|
||||
|
||||
private:
|
||||
struct TxQueueEntry {
|
||||
NodeNum from;
|
||||
PacketId id;
|
||||
};
|
||||
std::vector<TxQueueEntry> txQueue;
|
||||
std::vector<meshtastic_MeshPacket> sent;
|
||||
};
|
||||
|
||||
/// The production router with its protected state (history, pending retransmissions, upgrades) exposed.
|
||||
class AuthPipelineRouter : public ReliableRouter
|
||||
{
|
||||
public:
|
||||
bool filter(meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); }
|
||||
bool historyContains(const meshtastic_MeshPacket *p) { return wasSeenRecently(p, false); }
|
||||
void remember(const meshtastic_MeshPacket *p) { wasSeenRecently(p, true); }
|
||||
void forgetRelayer(uint8_t relay, PacketId id, NodeNum from) { removeRelayer(relay, id, from); }
|
||||
bool handleUpgrade(meshtastic_MeshPacket *p) { return perhapsHandleUpgradedPacket(p); }
|
||||
void addPending(const meshtastic_MeshPacket &p, uint32_t nextTx)
|
||||
{
|
||||
auto *copy = packetPool.allocCopy(p);
|
||||
TEST_ASSERT_NOT_NULL(copy);
|
||||
const GlobalPacketId key(copy);
|
||||
pending.emplace(key, PendingPacket(copy, NUM_INTERMEDIATE_RETX));
|
||||
pending.at(key).nextTxMsec = nextTx;
|
||||
}
|
||||
uint32_t pendingNextTx(NodeNum from, PacketId id)
|
||||
{
|
||||
PendingPacket *entry = findPendingPacket(from, id);
|
||||
return entry ? entry->nextTxMsec : 0;
|
||||
}
|
||||
uint8_t pendingTotalAttempts(NodeNum from, PacketId id)
|
||||
{
|
||||
PendingPacket *entry = findPendingPacket(from, id);
|
||||
return entry ? entry->initialNumRetransmissions + 1 : 0;
|
||||
}
|
||||
size_t pendingCount() const { return pending.size(); }
|
||||
/// Forget every opaque (from,id): the router outlives a test, so the dedup ring must not.
|
||||
void clearOpaqueSeen()
|
||||
{
|
||||
for (auto &slot : opaqueSeen) {
|
||||
slot.sender = 0;
|
||||
slot.id = 0;
|
||||
}
|
||||
opaqueSeenNext = 0;
|
||||
}
|
||||
void clearPending()
|
||||
{
|
||||
for (auto &entry : pending)
|
||||
packetPool.release(entry.second.packet);
|
||||
pending.clear();
|
||||
}
|
||||
};
|
||||
|
||||
/// Counts every ACK/NAK the router asks for instead of transmitting it. The opaque path sends none.
|
||||
class AuthPipelineRoutingModule : public RoutingModule
|
||||
{
|
||||
public:
|
||||
void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false,
|
||||
const meshtastic_MeshPacket * = nullptr) override
|
||||
{
|
||||
ackCalls++;
|
||||
}
|
||||
void reset() { ackCalls = 0; }
|
||||
|
||||
uint32_t ackCalls = 0;
|
||||
};
|
||||
|
||||
/// A POSITION_APP module that counts how often ingress reaches module dispatch.
|
||||
class AuthPipelineModule : public SinglePortModule
|
||||
{
|
||||
public:
|
||||
AuthPipelineModule() : SinglePortModule("authPipeline", meshtastic_PortNum_POSITION_APP) {}
|
||||
ProcessMessage handleReceived(const meshtastic_MeshPacket &) override
|
||||
{
|
||||
calls++;
|
||||
return ProcessMessage::CONTINUE;
|
||||
}
|
||||
uint32_t calls = 0;
|
||||
};
|
||||
|
||||
/// Exposes the uplink queue so a test can count what would have been published.
|
||||
class AuthPipelineMqtt : public MQTT
|
||||
{
|
||||
public:
|
||||
int queueSize() { return mqttQueue.numUsed(); }
|
||||
/// Fill the offline queue with decoded-traffic stand-ins, oldest first, so an eviction is visible.
|
||||
void fillQueue()
|
||||
{
|
||||
for (int i = 0; mqttQueue.numFree() > 0; i++) {
|
||||
auto *entry = new QueueEntry;
|
||||
entry->topic = "sentinel/" + std::to_string(i);
|
||||
mqttQueue.enqueue(entry, 0);
|
||||
}
|
||||
}
|
||||
std::string oldestTopic()
|
||||
{
|
||||
QueueEntry *entry = mqttQueue.dequeuePtr(0);
|
||||
if (!entry)
|
||||
return "";
|
||||
std::string topic = entry->topic;
|
||||
delete entry;
|
||||
return topic;
|
||||
}
|
||||
void clearQueue()
|
||||
{
|
||||
while (QueueEntry *entry = mqttQueue.dequeuePtr(0))
|
||||
delete entry;
|
||||
}
|
||||
};
|
||||
|
||||
static AuthPipelineRouter *pipelineRouter = nullptr;
|
||||
static AuthPipelineRadio *pipelineRadio = nullptr;
|
||||
static AuthPipelineRoutingModule *pipelineRouting = nullptr;
|
||||
static AuthPipelineModule *pipelineModule = nullptr;
|
||||
static AuthPipelineMqtt *pipelineMqtt = nullptr;
|
||||
static MeshService *pipelineService = nullptr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a decoded packet with a deterministic payload of the requested size.
|
||||
static meshtastic_MeshPacket makeDecoded(NodeNum from, NodeNum to, meshtastic_PortNum port, size_t payloadLen)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.from = from;
|
||||
p.to = to;
|
||||
p.id = 0x12345678;
|
||||
p.channel = 0; // primary channel index (perhapsEncode rewrites this to the channel hash)
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.decoded.portnum = port;
|
||||
p.decoded.payload.size = payloadLen;
|
||||
for (size_t i = 0; i < payloadLen; i++)
|
||||
p.decoded.payload.bytes[i] = (uint8_t)(i & 0xff);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Sign a decoded packet with the CryptoEngine's current key - used to simulate a *remote* signer,
|
||||
// because perhapsEncode only auto-signs packets that originate from us.
|
||||
static meshtastic_MeshPacket channelEncode(meshtastic_MeshPacket p)
|
||||
{
|
||||
uint8_t encoded[MAX_LORA_PAYLOAD_LEN + 1] = {};
|
||||
const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_Data_msg, &p.decoded);
|
||||
TEST_ASSERT_GREATER_THAN(0, encodedSize);
|
||||
const int16_t hash = channels.setActiveByIndex(p.channel);
|
||||
TEST_ASSERT_GREATER_OR_EQUAL(0, hash);
|
||||
crypto->encryptPacket(p.from, p.id, encodedSize, encoded);
|
||||
memcpy(p.encrypted.bytes, encoded, encodedSize);
|
||||
p.encrypted.size = encodedSize;
|
||||
p.channel = hash;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Set the receive-side signature policy for the next ingress.
|
||||
static void setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy)
|
||||
{
|
||||
config.security.packet_signature_policy = policy;
|
||||
}
|
||||
|
||||
/// Push a copy of `p` through Router::perhapsHandleReceived() as radio ingress.
|
||||
static void runPipelineIngress(const meshtastic_MeshPacket &p)
|
||||
{
|
||||
meshtastic_MeshPacket *copy = packetPool.allocCopy(p);
|
||||
TEST_ASSERT_NOT_NULL(copy);
|
||||
pipelineRouter->enqueueReceivedMessage(copy);
|
||||
pipelineRouter->runOnce();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKI fixtures: two remote identities and frames encrypted between them
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A remote node the tests can encrypt from and to.
|
||||
struct RelayIdentity {
|
||||
NodeNum num;
|
||||
uint8_t pub[32];
|
||||
uint8_t priv[32];
|
||||
};
|
||||
|
||||
// CryptoEngine::setDHPrivateKey takes a mutable pointer; the identities above are const.
|
||||
static void useDHKey(const uint8_t *priv)
|
||||
{
|
||||
uint8_t k[32];
|
||||
memcpy(k, priv, sizeof(k));
|
||||
crypto->setDHPrivateKey(k);
|
||||
}
|
||||
|
||||
/// A remote node with a fresh Curve25519 keypair.
|
||||
static RelayIdentity makeIdentity(NodeNum num)
|
||||
{
|
||||
RelayIdentity id;
|
||||
id.num = num;
|
||||
crypto->generateKeyPair(id.pub, id.priv);
|
||||
return id;
|
||||
}
|
||||
|
||||
static constexpr NodeNum ADMIN_NODE = 0x0C0C0C0C; // the operator's node, sending remote admin
|
||||
static constexpr NodeNum TARGET_NODE = 0x0D0D0D0D; // the node being administered
|
||||
|
||||
/// A genuine PKI-encrypted packet on `port` from one remote identity to another, as it would be
|
||||
/// heard off the air by a third node (us). The engine is left holding whatever DH key it held on
|
||||
/// entry, so a caller may install its own key before or after building the frame.
|
||||
static meshtastic_MeshPacket makePkiUnicastBetween(const RelayIdentity &from, const RelayIdentity &to, meshtastic_PortNum port,
|
||||
PacketId id, bool wantAck = false)
|
||||
{
|
||||
meshtastic_Data d = meshtastic_Data_init_zero;
|
||||
d.portnum = port;
|
||||
d.payload.size = SMALL_PAYLOAD;
|
||||
for (size_t i = 0; i < SMALL_PAYLOAD; i++)
|
||||
d.payload.bytes[i] = (uint8_t)(0xA0 + i);
|
||||
uint8_t plain[MAX_LORA_PAYLOAD_LEN + 1];
|
||||
const size_t plainSize = pb_encode_to_bytes(plain, sizeof(plain), &meshtastic_Data_msg, &d);
|
||||
TEST_ASSERT_GREATER_THAN(0, plainSize);
|
||||
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.from = from.num;
|
||||
p.to = to.num;
|
||||
p.id = id;
|
||||
p.channel = 0; // PKI packets carry channel hash 0 on the wire
|
||||
p.hop_limit = 2;
|
||||
p.hop_start = 3;
|
||||
p.want_ack = wantAck;
|
||||
p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
|
||||
|
||||
meshtastic_NodeInfoLite_public_key_t toKey = {32, {0}};
|
||||
memcpy(toKey.bytes, to.pub, 32);
|
||||
// Borrow the sender's key for the encrypt only. private_key is public under PIO_UNIT_TESTING.
|
||||
uint8_t savedPriv[32];
|
||||
memcpy(savedPriv, crypto->private_key, sizeof(savedPriv));
|
||||
useDHKey(from.priv);
|
||||
TEST_ASSERT_TRUE(crypto->encryptCurve25519(p.to, p.from, toKey, p.id, plainSize, plain, p.encrypted.bytes));
|
||||
p.encrypted.size = plainSize + MESHTASTIC_PKC_OVERHEAD;
|
||||
crypto->setDHPrivateKey(savedPriv);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Every rebroadcast_mode, for cases that table the full matrix.
|
||||
static const meshtastic_Config_DeviceConfig_RebroadcastMode ALL_MODES[] = {
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_ALL,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_NONE,
|
||||
};
|
||||
|
||||
/// Mode name for assertion messages.
|
||||
static const char *modeName(meshtastic_Config_DeviceConfig_RebroadcastMode m)
|
||||
{
|
||||
switch (m) {
|
||||
case meshtastic_Config_DeviceConfig_RebroadcastMode_ALL:
|
||||
return "ALL";
|
||||
case meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING:
|
||||
return "ALL_SKIP_DECODING";
|
||||
case meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY:
|
||||
return "CORE_PORTNUMS_ONLY";
|
||||
case meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY:
|
||||
return "KNOWN_ONLY";
|
||||
case meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY:
|
||||
return "LOCAL_ONLY";
|
||||
default:
|
||||
return "NONE";
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty the phone queue, returning how many frames it held.
|
||||
static int drainPhoneQueue()
|
||||
{
|
||||
int seen = 0;
|
||||
while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) {
|
||||
packetPool.release(queued);
|
||||
seen++;
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/// Drain the phone queue, asserting exactly `n` frames arrived and every one is still ciphertext.
|
||||
static void expectEncryptedPhoneDeliveries(int n)
|
||||
{
|
||||
int seen = 0;
|
||||
while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) {
|
||||
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, queued->which_payload_variant);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, queued->channel, "an unreadable frame carries no channel information to the phone");
|
||||
packetPool.release(queued);
|
||||
seen++;
|
||||
}
|
||||
TEST_ASSERT_EQUAL_MESSAGE(n, seen, "encrypted frames delivered to the phone");
|
||||
}
|
||||
|
||||
/// Feed `p` through ingress under `mode` and assert it was, or was not, handed to the radio - and
|
||||
/// that nothing else happened to it either way.
|
||||
static void assertOpaqueRelay(const meshtastic_MeshPacket &p, meshtastic_Config_DeviceConfig_RebroadcastMode mode,
|
||||
bool expectRelay, const char *why)
|
||||
{
|
||||
pipelineRadio->reset();
|
||||
pipelineRouting->reset();
|
||||
pipelineModule->calls = 0;
|
||||
config.device.rebroadcast_mode = mode;
|
||||
meshtastic_MeshPacket copy = p;
|
||||
copy.id += (uint32_t)mode; // a fresh id per mode so the opaque dedup does not decide the outcome
|
||||
runPipelineIngress(copy);
|
||||
char msg[200];
|
||||
snprintf(msg, sizeof(msg), "%s: %s", modeName(mode), why);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(expectRelay ? 1 : 0, pipelineRadio->sendCalls, msg);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRouting->ackCalls, "an opaque packet not for us must never be ACKed");
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, pipelineModule->calls, "an opaque packet must not reach modules");
|
||||
TEST_ASSERT_NULL_MESSAGE(pipelineService->getForPhone(), "an opaque unicast for someone else is not the phone's business");
|
||||
TEST_ASSERT_FALSE_MESSAGE(pipelineRouter->historyContains(©), "an opaque packet must not enter PacketHistory");
|
||||
}
|
||||
|
||||
/// A broadcast on a channel hash no local channel produces: opaque to us.
|
||||
static meshtastic_MeshPacket makeUnknownChannelBroadcast(PacketId id)
|
||||
{
|
||||
meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero;
|
||||
foreign.from = ADMIN_NODE;
|
||||
foreign.to = NODENUM_BROADCAST;
|
||||
foreign.id = id;
|
||||
foreign.channel = 0xFE;
|
||||
foreign.hop_limit = 1;
|
||||
foreign.hop_start = 2;
|
||||
foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
|
||||
foreign.encrypted.size = 16;
|
||||
memset(foreign.encrypted.bytes, 0x5A, foreign.encrypted.size);
|
||||
return foreign;
|
||||
}
|
||||
|
||||
/// Give the local node a keypair in NodeDB so PKI frames addressed to it are decrypt candidates.
|
||||
static RelayIdentity installOurIdentity()
|
||||
{
|
||||
RelayIdentity us = makeIdentity(LOCAL_NODE);
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->setPublicKey(LOCAL_NODE, us.pub);
|
||||
// NodeDB always holds a User for our own node, and the rebroadcast_mode predicates read that bit.
|
||||
mockNodeDB->markHasUser(LOCAL_NODE);
|
||||
return us;
|
||||
}
|
||||
|
||||
/// Put this node into licensed mode the way the device does: `owner` and our own NodeDB record agree,
|
||||
/// so getLicenseStatus(us) says Licensed rather than NotLicensed.
|
||||
static void markOurselvesLicensed()
|
||||
{
|
||||
owner.is_licensed = true;
|
||||
if (!mockNodeDB->getMeshNode(LOCAL_NODE))
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->markLicenseStatus(LOCAL_NODE, true);
|
||||
}
|
||||
|
||||
/// A decodable, unsigned channel broadcast from `from` to `to`, as a plain-text relay would see it.
|
||||
static meshtastic_MeshPacket makeChannelBroadcastFrom(NodeNum from, NodeNum to, PacketId id)
|
||||
{
|
||||
meshtastic_MeshPacket p = makeDecoded(from, to, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
|
||||
p.id = id;
|
||||
p.hop_limit = 1;
|
||||
p.hop_start = 2;
|
||||
p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
|
||||
return channelEncode(p);
|
||||
}
|
||||
|
||||
/// The second copy of `p` a neighbour would put back on the air: same (from,id), one hop spent.
|
||||
static meshtastic_MeshPacket makeRelayedCopy(const meshtastic_MeshPacket &p)
|
||||
{
|
||||
meshtastic_MeshPacket copy = p;
|
||||
if (copy.hop_limit > 0)
|
||||
copy.hop_limit--;
|
||||
copy.relay_node = 0x42;
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static AirTime *harnessSavedAirTime = nullptr;
|
||||
static meshtastic::NodeStatus *harnessSavedNodeStatus = nullptr;
|
||||
|
||||
/// Build the router / radio / module / service / MQTT stack once per process.
|
||||
static void pipelineHarnessCreate()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
harnessSavedAirTime = airTime;
|
||||
harnessSavedNodeStatus = nodeStatus;
|
||||
airTime = new AirTime();
|
||||
nodeStatus = new meshtastic::NodeStatus();
|
||||
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
initRegion();
|
||||
pipelineRouter = new AuthPipelineRouter();
|
||||
auto pipelineRadioOwner = std::make_unique<AuthPipelineRadio>();
|
||||
pipelineRadio = pipelineRadioOwner.get();
|
||||
pipelineRouter->addInterface(std::move(pipelineRadioOwner));
|
||||
router = pipelineRouter;
|
||||
routingModule = pipelineRouting = new AuthPipelineRoutingModule();
|
||||
pipelineModule = new AuthPipelineModule();
|
||||
service = pipelineService = new MeshService();
|
||||
mqtt = pipelineMqtt = new AuthPipelineMqtt();
|
||||
}
|
||||
|
||||
/// Free the AirTime and NodeStatus the harness installed, then put back the originals.
|
||||
static void pipelineHarnessDestroy()
|
||||
{
|
||||
delete airTime;
|
||||
delete nodeStatus;
|
||||
airTime = harnessSavedAirTime;
|
||||
nodeStatus = harnessSavedNodeStatus;
|
||||
}
|
||||
|
||||
/// Fresh NodeDB, zeroed config / owner (=> rebroadcast ALL, no private key), default channels,
|
||||
/// every counter reset. The signature policy is left at zero (COMPATIBLE); suites set their own.
|
||||
static void pipelineHarnessSetUp()
|
||||
{
|
||||
service = pipelineService;
|
||||
|
||||
// Construct the mock FIRST: the NodeDB constructor can reload persisted state from the
|
||||
// host filesystem (portduino VFS) and repopulate the globals - a saved private key
|
||||
// re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs.
|
||||
mockNodeDB = new MockNodeDB();
|
||||
mockNodeDB->clearTestNodes();
|
||||
#if WARM_NODE_COUNT > 0
|
||||
mockNodeDB->warmStore.clear();
|
||||
#endif
|
||||
nodeDB = mockNodeDB;
|
||||
|
||||
// Clean global config/owner AFTER the ctor; zeroed config => rebroadcast ALL (no KNOWN_ONLY
|
||||
// drop) and security.private_key.size == 0 (PKI encrypt path skipped => simple channel crypto).
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
|
||||
owner = meshtastic_User_init_zero;
|
||||
myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs()
|
||||
|
||||
// Working primary channel with the default PSK so encrypt/decrypt round-trips.
|
||||
channels.initDefaults();
|
||||
channels.onConfigChanged();
|
||||
|
||||
// The router, radio, routing module and crypto engine are built once per process, so every
|
||||
// piece of state they carry has to be cleared here or it decides the next test's outcome.
|
||||
pipelineRouter->clearPending();
|
||||
pipelineRouter->clearOpaqueSeen();
|
||||
pipelineRouter->rxDupe = 0;
|
||||
pipelineRouter->txRelayCanceled = 0;
|
||||
pipelineRadio->reset();
|
||||
pipelineRouting->reset();
|
||||
pipelineModule->calls = 0;
|
||||
pipelineMqtt->clearQueue();
|
||||
drainPhoneQueue();
|
||||
while (meshtastic_QueueStatus *queued = pipelineService->getQueueStatusForPhone())
|
||||
pipelineService->releaseQueueStatusToPool(queued);
|
||||
resetRoutingAuthEvaluationCount(); // also invalidates the single-slot auth cache
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// No DH key and no in-flight handshake key: a test that wants either installs its own.
|
||||
uint8_t noKey[32] = {0};
|
||||
crypto->setDHPrivateKey(noKey);
|
||||
crypto->clearPendingPublicKey();
|
||||
resetAdminKeyFallbackBudget(); // a suite that drains the bucket must not starve the next one
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Drop the NodeDB and put the clock and region back. Runs here, not at the end of a test body:
|
||||
/// an assertion aborts the body, and these would otherwise leak into every later case.
|
||||
static void pipelineHarnessTearDown()
|
||||
{
|
||||
delete mockNodeDB;
|
||||
mockNodeDB = nullptr;
|
||||
nodeDB = nullptr;
|
||||
Time::useRealClock();
|
||||
Time::resetMonotonicForTests();
|
||||
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
||||
initRegion();
|
||||
}
|
||||
@@ -2,11 +2,21 @@
|
||||
#include "modules/games/Breakout.h"
|
||||
#include <unity.h>
|
||||
|
||||
// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on
|
||||
// a straight-up serve, and the ball staying within the board. No device globals or display stack.
|
||||
// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, the ball waiting
|
||||
// on the paddle until launched, brick-clearing on a straight-up serve, and the ball staying within
|
||||
// the board. No device globals or display stack.
|
||||
|
||||
static const uint32_t kSeed = 0xC0FFEEu;
|
||||
|
||||
// The ball docks on the paddle after every serve (including after losing a life), so a test that
|
||||
// wants continuous play has to fire it whenever it is waiting.
|
||||
static void stepLaunched(BreakoutGame &game)
|
||||
{
|
||||
if (game.isBallDocked())
|
||||
game.launchBall();
|
||||
game.step();
|
||||
}
|
||||
|
||||
void test_reset_initialState()
|
||||
{
|
||||
BreakoutGame game;
|
||||
@@ -21,6 +31,37 @@ void test_reset_initialState()
|
||||
TEST_ASSERT_EQUAL_INT16((BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W) / 2, game.paddleX());
|
||||
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
|
||||
TEST_ASSERT_TRUE(game.ballY() >= 0 && game.ballY() < BreakoutGame::BOARD_H);
|
||||
// The ball waits on the paddle until the player serves it.
|
||||
TEST_ASSERT_TRUE(game.isBallDocked());
|
||||
}
|
||||
|
||||
void test_ball_waitsOnPaddleUntilLaunched()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
const int16_t restY = game.ballY();
|
||||
|
||||
// Stepping without serving must not move the ball vertically, lose a life, or end the run.
|
||||
for (int i = 0; i < 50; i++)
|
||||
game.step();
|
||||
TEST_ASSERT_TRUE(game.isBallDocked());
|
||||
TEST_ASSERT_EQUAL_INT16(restY, game.ballY());
|
||||
TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives());
|
||||
TEST_ASSERT_TRUE(game.isPlaying());
|
||||
|
||||
// A docked ball tracks the paddle, so it can still be aimed before serving.
|
||||
const int16_t beforeX = game.ballX();
|
||||
for (int i = 0; i < 5; i++)
|
||||
game.moveLeft();
|
||||
game.step();
|
||||
TEST_ASSERT_TRUE(game.ballX() < beforeX);
|
||||
|
||||
// Once launched it is in play and starts climbing toward the bricks.
|
||||
game.launchBall();
|
||||
TEST_ASSERT_FALSE(game.isBallDocked());
|
||||
for (int i = 0; i < 4; i++)
|
||||
game.step();
|
||||
TEST_ASSERT_TRUE(game.ballY() < restY);
|
||||
}
|
||||
|
||||
void test_paddle_clampsToEdges()
|
||||
@@ -39,8 +80,9 @@ void test_serve_clearsABrickAndScores()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
// The ball serves upward from just above the paddle straight into the brick field; within a
|
||||
// few dozen steps it must clear at least one brick and score.
|
||||
// Once served, the ball travels upward from just above the paddle straight into the brick field;
|
||||
// within a few dozen steps it must clear at least one brick and score.
|
||||
game.launchBall();
|
||||
for (int i = 0;
|
||||
i < 60 && game.bricksRemaining() == static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++)
|
||||
game.step();
|
||||
@@ -59,7 +101,7 @@ void test_ball_staysInBounds()
|
||||
game.moveLeft();
|
||||
else
|
||||
game.moveRight();
|
||||
game.step();
|
||||
stepLaunched(game);
|
||||
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
|
||||
TEST_ASSERT_TRUE(game.ballY() >= 0);
|
||||
}
|
||||
@@ -69,10 +111,15 @@ void test_deadGame_stepIsNoOp()
|
||||
{
|
||||
BreakoutGame game;
|
||||
game.reset(kSeed);
|
||||
// Park the paddle in a corner and never move it; the ball is eventually lost every life.
|
||||
game.moveLeft();
|
||||
// Serve each ball, then steer the paddle AWAY from it so every ball is missed and all lives
|
||||
// drain. (The ball re-docks after each loss, so it has to be re-served. Note it now serves from
|
||||
// the paddle's centre, so simply parking the paddle would let it rally instead of dying.)
|
||||
for (int i = 0; i < 20000 && game.isPlaying(); i++) {
|
||||
for (int j = 0; j < 40; j++) // hold the paddle pinned left
|
||||
if (game.isBallDocked())
|
||||
game.launchBall();
|
||||
else if (game.ballX() < game.paddleX())
|
||||
game.moveRight();
|
||||
else
|
||||
game.moveLeft();
|
||||
game.step();
|
||||
}
|
||||
@@ -92,6 +139,7 @@ void setup()
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_reset_initialState);
|
||||
RUN_TEST(test_ball_waitsOnPaddleUntilLaunched);
|
||||
RUN_TEST(test_paddle_clampsToEdges);
|
||||
RUN_TEST(test_serve_clearsABrickAndScores);
|
||||
RUN_TEST(test_ball_staysInBounds);
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
#include "mesh/NodeDB.h"
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#endif
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
// Subclass shim: exposes the private maintenance paths (via the friend
|
||||
@@ -66,12 +70,17 @@ class NodeDBTestShim : public NodeDB
|
||||
|
||||
// Index 0 is our own node; the eviction/migration scans treat it as self.
|
||||
void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu, false, false, /*withUser=*/true, /*withKey=*/false); }
|
||||
|
||||
// isHalfEmpty() and isFull() read numMeshNodes against MAX_NUM_NODES and nothing else, so the
|
||||
// occupancy tests set the count directly rather than allocating rows at every cap under test.
|
||||
void setOccupancy(int n) { numMeshNodes = (pb_size_t)n; }
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
NodeDBTestShim *db = nullptr;
|
||||
int savedMaxNodes = 0;
|
||||
|
||||
bool warmHasKey(NodeNum n)
|
||||
{
|
||||
@@ -84,8 +93,18 @@ bool warmHasKey(NodeNum n)
|
||||
void setUp(void)
|
||||
{
|
||||
db->clearHot();
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
savedMaxNodes = portduino_config.MaxNodes;
|
||||
#endif
|
||||
}
|
||||
void tearDown(void)
|
||||
{
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
// The occupancy sweeps below move the cap. Restore it here rather than at the end of each
|
||||
// test, so an assertion that fires mid-sweep cannot leak a 2-node cap into the next test.
|
||||
portduino_config.MaxNodes = savedMaxNodes;
|
||||
#endif
|
||||
}
|
||||
void tearDown(void) {}
|
||||
|
||||
// Migration: a database from a larger-cap build trims to MAX_NUM_NODES; the
|
||||
// oldest non-protected nodes are demoted into the warm tier (keys preserved),
|
||||
@@ -318,6 +337,101 @@ static void test_removeNodeByNum_presentNodeOnFullDb(void)
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // survivors kept
|
||||
}
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
// NodeDB::isHalfEmpty() and the band it opens against isFull(). The ad-hoc greeting in
|
||||
// MeshService::handleFromRadio() reads it before sending an unsolicited NodeInfo to a node it holds
|
||||
// no user record for: greeting now stops at the half-way mark while admission continues to the cap,
|
||||
// so there is a deliberate occupancy band in which the store still takes new nodes but no longer
|
||||
// introduces itself to them. Before this the gate was !isFull(), and a node kept greeting up to the
|
||||
// last free slot - the regime where the store is already churning and the entry a greeting buys is
|
||||
// least likely to survive.
|
||||
//
|
||||
// Sweeping the cap matters because it is not a constant: on portduino MAX_NUM_NODES resolves to
|
||||
// General.MaxNodes on every read, and a predicate that captured it once - a static, a value copied
|
||||
// in the constructor - would greet at the wrong occupancy on every deployment that sets one.
|
||||
//
|
||||
// Not covered: the MINIMUM_SAFE_FREE_HEAP term both predicates carry. memGet.getFreeHeap() returns
|
||||
// UINT32_MAX on portduino, so that branch is unreachable natively and is not faked.
|
||||
|
||||
// The caps a real deployment has - STM32WL's 10, the nRF52840/ESP32 120, portduino/ESP32-S3 200 and
|
||||
// 250 - plus odd caps, and 2 where an off-by-one stops being one slot and becomes the upper half.
|
||||
static constexpr int kCaps[] = {2, 3, 10, 11, 120, 121, 200, 250};
|
||||
|
||||
static const char *occ(int cap, int n)
|
||||
{
|
||||
static char buf[64];
|
||||
snprintf(buf, sizeof(buf), "cap=%d occupancy=%d", cap, n);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Strictly more than half the slots must be free: exactly half full is not half empty, and at an
|
||||
// odd cap the unsplittable slot counts as empty (2n < cap). Relaxing this to >= hands greeting one
|
||||
// more slot at every cap.
|
||||
static void test_halfEmpty_boundaryIsExclusiveAtEveryCap(void)
|
||||
{
|
||||
for (int cap : kCaps) {
|
||||
portduino_config.MaxNodes = cap;
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(cap, (int)MAX_NUM_NODES, "MAX_NUM_NODES must track General.MaxNodes at runtime");
|
||||
|
||||
const int halfWay = cap / 2;
|
||||
|
||||
db->setOccupancy(0);
|
||||
TEST_ASSERT_TRUE_MESSAGE(db->isHalfEmpty(), occ(cap, 0)); // a fresh node must greet
|
||||
|
||||
db->setOccupancy(halfWay);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(2 * halfWay < cap, db->isHalfEmpty(), occ(cap, halfWay));
|
||||
|
||||
db->setOccupancy(halfWay + 1);
|
||||
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), occ(cap, halfWay + 1));
|
||||
|
||||
db->setOccupancy(cap);
|
||||
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), occ(cap, cap));
|
||||
TEST_ASSERT_TRUE_MESSAGE(db->isFull(), occ(cap, cap));
|
||||
}
|
||||
}
|
||||
|
||||
// The band is the point of the change: above the half-way mark and below the cap, admission
|
||||
// continues (!isFull) while greeting has stopped (!isHalfEmpty). The two are never both true. If
|
||||
// either predicate drifts the band closes, and greeting either runs to the last slot again or stops
|
||||
// when admission does.
|
||||
static void test_halfEmpty_theBandWhereAdmissionOutlivesGreeting(void)
|
||||
{
|
||||
for (int cap : kCaps) {
|
||||
portduino_config.MaxNodes = cap;
|
||||
|
||||
int bandWidth = 0;
|
||||
for (int n = 0; n <= cap; n++) {
|
||||
db->setOccupancy(n);
|
||||
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty() && db->isFull(), occ(cap, n));
|
||||
if (n < cap && !db->isHalfEmpty()) {
|
||||
TEST_ASSERT_FALSE_MESSAGE(db->isFull(), occ(cap, n));
|
||||
bandWidth++;
|
||||
}
|
||||
}
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(cap - (cap / 2 + (cap % 2)), bandWidth, occ(cap, -1));
|
||||
}
|
||||
}
|
||||
|
||||
// The same occupancy changes answer when the cap moves underneath it, with no store change - what a
|
||||
// General.MaxNodes edit plus a restart does, and what a cached cap gets wrong.
|
||||
static void test_halfEmpty_followsACapChangedUnderneathIt(void)
|
||||
{
|
||||
db->setOccupancy(70);
|
||||
|
||||
portduino_config.MaxNodes = 120;
|
||||
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), "70 of 120 is past the half-way mark");
|
||||
|
||||
portduino_config.MaxNodes = 200;
|
||||
TEST_ASSERT_TRUE_MESSAGE(db->isHalfEmpty(), "70 of 200 leaves more than half free");
|
||||
|
||||
portduino_config.MaxNodes = 140;
|
||||
TEST_ASSERT_FALSE_MESSAGE(db->isHalfEmpty(), "70 of 140 is exactly half full, which is not half empty");
|
||||
|
||||
portduino_config.MaxNodes = 141;
|
||||
TEST_ASSERT_TRUE_MESSAGE(db->isHalfEmpty(), "70 of 141 leaves the spare slot free, so more than half");
|
||||
}
|
||||
#endif // ARCH_PORTDUINO
|
||||
|
||||
NDB_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
@@ -335,6 +449,11 @@ NDB_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_protectedCap_refusesBeyondLimit);
|
||||
RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb);
|
||||
RUN_TEST(test_removeNodeByNum_presentNodeOnFullDb);
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
RUN_TEST(test_halfEmpty_boundaryIsExclusiveAtEveryCap);
|
||||
RUN_TEST(test_halfEmpty_theBandWhereAdmissionOutlivesGreeting);
|
||||
RUN_TEST(test_halfEmpty_followsACapChangedUnderneathIt);
|
||||
#endif
|
||||
exit(UNITY_END());
|
||||
}
|
||||
NDB_TEST_ENTRY void loop() {}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// NodeInfoModule's send window and the routine-broadcast countdown: allocReply(),
|
||||
// sendOurNodeInfo() and runOnce() in src/modules/NodeInfoModule.cpp.
|
||||
//
|
||||
// Two contracts, both properties of the module rather than of the scaler it calls:
|
||||
//
|
||||
// 1. The non-interactive window has a 30 minute floor. allocReply() passes 30 * 60 as the base to
|
||||
// Default::getConfiguredOrDefaultMsScaled(); what that base becomes at mesh sizes over 40 nodes,
|
||||
// per modem preset and per role, is Default's own contract and is covered in test_default -
|
||||
// these tests pin the base and leave the multiplier alone. The interactive path (shorterTimeout,
|
||||
// used by a user-triggered send, a PKI decrypt failure and a completed key verification) keeps
|
||||
// its separate 60 second gate and must not inherit the floor.
|
||||
//
|
||||
// 2. A send that goes out re-arms the routine broadcast, and a send that is refused does not.
|
||||
// sendOurNodeInfo() calls setIntervalFromNow() with the configured broadcast interval once the
|
||||
// packet is queued, so an ad-hoc send is not followed minutes later by the periodic copy. The
|
||||
// reset sits on the return-true path deliberately: if a refused send could re-arm it, a node
|
||||
// that keeps attempting greetings inside the window would defer its broadcast indefinitely and
|
||||
// go silent - the opposite of the intent.
|
||||
//
|
||||
// A preset or channel change (radioGeneration) rides on the same path: runOnce() asks for replies
|
||||
// while currentGeneration != radioGeneration and copies the generation across only on a true
|
||||
// return, so a refused send has to leave the request pending for the next attempt.
|
||||
//
|
||||
// Regressions guarded: reverting the base to 10 * 60, the value this branch replaced; hoisting the
|
||||
// setIntervalFromNow() call above the veto checks or onto the false path; and moving the generation
|
||||
// copy out of `if (sendOurNodeInfo(...))`, which loses a preset change to a throttled send so the
|
||||
// mesh is never asked to re-introduce itself.
|
||||
//
|
||||
// The window probes step an injected clock (Time::setTestMillis) rather than sleeping;
|
||||
// TransmitHistory, which is where allocReply() reads "last sent" from, reads the same clock.
|
||||
#include "MeshTypes.h" // BEFORE TestUtil.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#define NI_TEST_ENTRY extern "C"
|
||||
#else
|
||||
#define NI_TEST_ENTRY
|
||||
#endif
|
||||
|
||||
#include "Default.h"
|
||||
#include "NodeStatus.h"
|
||||
#include "UptimeClock.h"
|
||||
#include "airtime.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/RadioInterface.h"
|
||||
#include "mesh/Router.h"
|
||||
#include "mesh/TransmitHistory.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include "support/MockMeshService.h"
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
// Exposes the protected periodic entry point. The countdown itself is read through NodeInfoModule's
|
||||
// own ForTests accessors: OSThread is a private base, so a shim cannot reach it. Reading the interval
|
||||
// rather than a deadline keeps these assertions off wall-clock millis(), which the test clock does not drive.
|
||||
class NodeInfoModuleTestShim : public NodeInfoModule
|
||||
{
|
||||
public:
|
||||
using NodeInfoModule::runOnce;
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// sendLocal() is not virtual and refuses to send with no interface attached, so the mock router
|
||||
// carries a stub one. Only send() and getPacketTime() are pure virtual, and init() - which is what
|
||||
// observes config and sleep notifications - is never called.
|
||||
class StubRadioInterface : public RadioInterface
|
||||
{
|
||||
public:
|
||||
ErrorCode send(meshtastic_MeshPacket *p) override
|
||||
{
|
||||
packetPool.release(p);
|
||||
return ERRNO_OK;
|
||||
}
|
||||
uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override { return 100; }
|
||||
};
|
||||
|
||||
class MockRouter : public Router
|
||||
{
|
||||
public:
|
||||
MockRouter() { addInterface(std::unique_ptr<RadioInterface>(new StubRadioInterface())); }
|
||||
|
||||
// Router's constructor asserts cryptLock is null before allocating it, so a per-test router can
|
||||
// only be rebuilt if the previous one hands the global back.
|
||||
~MockRouter()
|
||||
{
|
||||
delete cryptLock;
|
||||
cryptLock = nullptr;
|
||||
}
|
||||
|
||||
ErrorCode send(meshtastic_MeshPacket *p) override
|
||||
{
|
||||
sentPackets.push_back(*p);
|
||||
packetPool.release(p); // released here either way: the interface owns the packet it declined
|
||||
return sendResult;
|
||||
}
|
||||
|
||||
ErrorCode sendResult = ERRNO_OK;
|
||||
|
||||
// The broadcast loopback copy lands here; release rather than queue into fromRadioQueue, which
|
||||
// nothing drains in tests.
|
||||
void enqueueReceivedMessage(meshtastic_MeshPacket *p) override { packetPool.release(p); }
|
||||
|
||||
std::vector<meshtastic_MeshPacket> sentPackets;
|
||||
};
|
||||
|
||||
NodeInfoModuleTestShim *mod = nullptr;
|
||||
MockMeshService *mockSvc = nullptr;
|
||||
MockRouter *mockRouter = nullptr;
|
||||
AirTime *testAirTime = nullptr;
|
||||
|
||||
constexpr uint32_t kClockBaseMs = 60 * 60 * 1000; // an hour in, so no probe can underflow
|
||||
constexpr uint32_t kThirtyMinMs = 30 * 60 * 1000;
|
||||
constexpr uint32_t kThreeHoursMs = 3 * 60 * 60 * 1000;
|
||||
|
||||
// Stamp "we sent a NodeInfo just now", jump the clock forward, and report whether another send is
|
||||
// allowed. A permitted send re-stamps the history, which is why every probe stamps first.
|
||||
bool sendAllowedAfterMs(uint32_t elapsedMs, bool shorterTimeout = false)
|
||||
{
|
||||
transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP);
|
||||
Time::advanceTestMillis(elapsedMs);
|
||||
return mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, shorterTimeout);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
Time::resetMonotonicForTests();
|
||||
Time::setTestMillis(kClockBaseMs);
|
||||
Time::serviceMonotonic();
|
||||
|
||||
testAirTime = new AirTime();
|
||||
airTime = testAirTime;
|
||||
|
||||
mockSvc = new MockMeshService();
|
||||
service = mockSvc;
|
||||
|
||||
mockRouter = new MockRouter();
|
||||
router = mockRouter;
|
||||
|
||||
if (transmitHistory) {
|
||||
delete transmitHistory;
|
||||
transmitHistory = nullptr;
|
||||
}
|
||||
transmitHistory = TransmitHistory::getInstance(); // fresh: loadFromDisk() is not called
|
||||
|
||||
// The congestion coefficient is 1.0 at or below 40 online nodes, so the window under test is
|
||||
// the bare floor. Nothing wires the node-status observer in a test build, but assert it rather
|
||||
// than assume it - a non-zero count here would silently stretch every boundary below.
|
||||
TEST_ASSERT_NOT_NULL(nodeStatus);
|
||||
TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, nodeStatus->getNumOnline(), "these boundaries assume an unscaled window");
|
||||
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
config.device.node_info_broadcast_secs = 0; // 0 selects the default, 3 hours
|
||||
|
||||
owner.is_licensed = false;
|
||||
strncpy(owner.long_name, "send window", sizeof(owner.long_name) - 1);
|
||||
strncpy(owner.short_name, "sw", sizeof(owner.short_name) - 1);
|
||||
|
||||
radioGeneration = 1;
|
||||
mod = new NodeInfoModuleTestShim();
|
||||
nodeInfoModule = mod;
|
||||
|
||||
// Settle the startup generation so no test inherits a pending "ask for replies", then drop the
|
||||
// stamp that settling send left behind: every test starts unthrottled, and the ones that need a
|
||||
// refusal arm the floor themselves.
|
||||
mod->runOnce();
|
||||
mockRouter->sentPackets.clear();
|
||||
delete transmitHistory;
|
||||
transmitHistory = nullptr;
|
||||
transmitHistory = TransmitHistory::getInstance();
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
nodeInfoModule = nullptr;
|
||||
delete mod;
|
||||
mod = nullptr;
|
||||
|
||||
// sendToMesh() copies a queue status to the phone on every send; toPhoneQueue takes ownership
|
||||
// and nothing else drains it, so release them or LeakSanitizer aborts the run.
|
||||
if (mockSvc) {
|
||||
meshtastic_MeshPacket *p;
|
||||
while ((p = mockSvc->getForPhone()) != nullptr)
|
||||
mockSvc->releaseToPool(p);
|
||||
}
|
||||
|
||||
service = nullptr;
|
||||
delete mockSvc;
|
||||
mockSvc = nullptr;
|
||||
|
||||
router = nullptr;
|
||||
delete mockRouter;
|
||||
mockRouter = nullptr;
|
||||
|
||||
airTime = nullptr;
|
||||
delete testAirTime;
|
||||
testAirTime = nullptr;
|
||||
|
||||
delete transmitHistory;
|
||||
transmitHistory = nullptr;
|
||||
|
||||
Time::useRealClock();
|
||||
}
|
||||
|
||||
// The floor is 30 minutes, not the 10 it used to be: 10 and 29:59 are refused, 30:01 is not.
|
||||
static void test_sendWindow_floorIsThirtyMinutes(void)
|
||||
{
|
||||
TEST_ASSERT_FALSE_MESSAGE(sendAllowedAfterMs(10 * 60 * 1000), "10 min must be inside the window");
|
||||
TEST_ASSERT_FALSE_MESSAGE(sendAllowedAfterMs(kThirtyMinMs - 1000), "29:59 must be inside the window");
|
||||
TEST_ASSERT_TRUE_MESSAGE(sendAllowedAfterMs(kThirtyMinMs + 1000), "30:01 must be past the window");
|
||||
}
|
||||
|
||||
// The interactive path keeps its own 60 second gate. Raising the routine floor must not have raised
|
||||
// it, or a user-triggered send and a key verification would wait out half an hour.
|
||||
static void test_sendWindow_interactiveSendKeepsItsSixtySecondGate(void)
|
||||
{
|
||||
TEST_ASSERT_FALSE_MESSAGE(sendAllowedAfterMs(30 * 1000, /*shorterTimeout=*/true), "30 s is inside the 60 s gate");
|
||||
TEST_ASSERT_TRUE_MESSAGE(sendAllowedAfterMs(61 * 1000, /*shorterTimeout=*/true), "61 s is past the 60 s gate");
|
||||
TEST_ASSERT_TRUE_MESSAGE(sendAllowedAfterMs(5 * 60 * 1000, /*shorterTimeout=*/true),
|
||||
"5 min must pass the interactive gate while still inside the routine floor");
|
||||
}
|
||||
|
||||
// A send that goes out re-arms the countdown to a full interval - the default, and the configured
|
||||
// value when there is one. Arming 1 ms first means only the reset can produce the expected value.
|
||||
static void test_broadcastTimer_aSendRearmsTheRoutineCountdown(void)
|
||||
{
|
||||
mod->armBroadcastCountdownForTests(1);
|
||||
TEST_ASSERT_TRUE(mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, false));
|
||||
TEST_ASSERT_EQUAL_UINT32(Default::getConfiguredOrDefaultMs(0, default_node_info_broadcast_secs),
|
||||
(uint32_t)mod->broadcastCountdownMsForTests());
|
||||
TEST_ASSERT_EQUAL_UINT32(kThreeHoursMs, (uint32_t)mod->broadcastCountdownMsForTests());
|
||||
|
||||
config.device.node_info_broadcast_secs = 4 * 60 * 60;
|
||||
mod->armBroadcastCountdownForTests(1);
|
||||
Time::advanceTestMillis(kThirtyMinMs + 1000);
|
||||
TEST_ASSERT_TRUE(mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, false));
|
||||
TEST_ASSERT_EQUAL_UINT32(4 * 60 * 60 * 1000, (uint32_t)mod->broadcastCountdownMsForTests());
|
||||
}
|
||||
|
||||
// interval is not what the scheduler reads: shouldRun() keys off _cached_next_run, and
|
||||
// Thread::setInterval() recomputes that from last_run while setIntervalFromNow() recomputes it from
|
||||
// now. Age last_run by an hour first and the two answers differ by an hour, so this case fails if
|
||||
// the send ever re-arms the period without moving the deadline - which would fire the routine copy
|
||||
// straight after an ad-hoc send, the exact thing the reset exists to prevent.
|
||||
static void test_broadcastTimer_aSendMovesTheDeadlineNotJustThePeriod(void)
|
||||
{
|
||||
const unsigned long ageMs = 60 * 60 * 1000;
|
||||
mod->ageLastRunForTests(ageMs);
|
||||
TEST_ASSERT_TRUE(mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, false));
|
||||
|
||||
const unsigned long remaining = mod->broadcastDeadlineMsForTests() - millis();
|
||||
TEST_ASSERT_UINT32_WITHIN_MESSAGE(5000, kThreeHoursMs, (uint32_t)remaining,
|
||||
"the next routine broadcast is due a full interval from the send, not from the last tick");
|
||||
}
|
||||
|
||||
// An ad-hoc unicast - the shape of a greeting, a PKI decrypt failure or a completed key
|
||||
// verification - re-arms the countdown just as a broadcast does. Without it the routine copy
|
||||
// follows the ad-hoc one within minutes, putting two NodeInfos on the air for no gain.
|
||||
static void test_broadcastTimer_anAdHocUnicastRearmsItToo(void)
|
||||
{
|
||||
mod->armBroadcastCountdownForTests(1);
|
||||
|
||||
TEST_ASSERT_TRUE(mod->sendOurNodeInfo(0x12345678, true, 0, false));
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL_HEX32(0x12345678, mockRouter->sentPackets[0].to);
|
||||
TEST_ASSERT_EQUAL_UINT32(kThreeHoursMs, (uint32_t)mod->broadcastCountdownMsForTests());
|
||||
}
|
||||
|
||||
// A refused send must leave the countdown exactly where it was, or a node that keeps attempting
|
||||
// greetings inside the window defers its routine broadcast forever.
|
||||
static void test_broadcastTimer_aRefusedSendLeavesTheCountdownAlone(void)
|
||||
{
|
||||
transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP);
|
||||
Time::advanceTestMillis(60 * 1000); // a minute later: well inside the floor
|
||||
|
||||
const unsigned long sentinel = 4321;
|
||||
mod->armBroadcastCountdownForTests(sentinel);
|
||||
|
||||
TEST_ASSERT_FALSE_MESSAGE(mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, false), "the floor must refuse this send");
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(sentinel, (uint32_t)mod->broadcastCountdownMsForTests());
|
||||
}
|
||||
|
||||
// A licensed station announces its call sign on a regulatory interval - ham mode sets
|
||||
// node_info_broadcast_secs to 600 s for the FCC minimum - and the floor must not stretch that to 30
|
||||
// minutes. The unlicensed control below is the same configuration without the licence, so the
|
||||
// assertion cannot pass by the floor quietly disappearing for everyone.
|
||||
static void test_sendWindow_aLicensedStationKeepsItsCallSignInterval(void)
|
||||
{
|
||||
config.device.node_info_broadcast_secs = 600;
|
||||
|
||||
owner.is_licensed = true;
|
||||
TEST_ASSERT_FALSE_MESSAGE(sendAllowedAfterMs(5 * 60 * 1000), "5 min is inside the station's own 10 min interval");
|
||||
TEST_ASSERT_TRUE_MESSAGE(sendAllowedAfterMs(11 * 60 * 1000), "11 min is past it, and the floor must not override it");
|
||||
|
||||
owner.is_licensed = false;
|
||||
TEST_ASSERT_FALSE_MESSAGE(sendAllowedAfterMs(11 * 60 * 1000), "without a licence the 30 minute floor still applies");
|
||||
}
|
||||
|
||||
// A send the router declines never reached the air. It must not defer the routine broadcast, and it
|
||||
// must report failure so runOnce() does not treat a pending channel change as delivered.
|
||||
static void test_broadcastTimer_aRejectedSendLeavesTheCountdownAlone(void)
|
||||
{
|
||||
const unsigned long sentinel = 8765;
|
||||
mod->armBroadcastCountdownForTests(sentinel);
|
||||
mockRouter->sendResult = ERRNO_NO_INTERFACES;
|
||||
|
||||
TEST_ASSERT_FALSE_MESSAGE(mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, false), "a declined send is not a send");
|
||||
TEST_ASSERT_EQUAL_UINT32(sentinel, (uint32_t)mod->broadcastCountdownMsForTests());
|
||||
}
|
||||
|
||||
// The countdown is only half of it: allocReply() used to stamp TransmitHistory when it built the
|
||||
// packet, so a send the router then declined still started the window. With a 30 minute floor that
|
||||
// silences the node for half an hour over a packet that never left. The retry immediately after must
|
||||
// go out.
|
||||
static void test_sendWindow_aRejectedSendDoesNotStartTheWindow(void)
|
||||
{
|
||||
mockRouter->sendResult = ERRNO_NO_INTERFACES;
|
||||
TEST_ASSERT_FALSE_MESSAGE(mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, false), "the router declined this one");
|
||||
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, transmitHistory->getLastSentToMeshMillis(meshtastic_PortNum_NODEINFO_APP),
|
||||
"a declined send must leave no transmit stamp behind");
|
||||
|
||||
mockRouter->sendResult = ERRNO_OK;
|
||||
mockRouter->sentPackets.clear();
|
||||
TEST_ASSERT_TRUE_MESSAGE(mod->sendOurNodeInfo(NODENUM_BROADCAST, false, 0, false),
|
||||
"the retry must not be throttled by the send that failed");
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
|
||||
}
|
||||
|
||||
// A preset or channel change bumps radioGeneration, and only a send that goes out consumes it: the
|
||||
// refused attempt leaves the ask pending, the next successful one carries want_response, and the
|
||||
// one after that does not ask again.
|
||||
static void test_presetChange_isConsumedOnlyByASendThatGoesOut(void)
|
||||
{
|
||||
radioGeneration++;
|
||||
|
||||
// Arm the floor so the first attempt is refused, which is the case under test.
|
||||
transmitHistory->setLastSentToMesh(meshtastic_PortNum_NODEINFO_APP);
|
||||
Time::advanceTestMillis(60 * 1000);
|
||||
|
||||
mod->runOnce();
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
|
||||
|
||||
Time::advanceTestMillis(kThirtyMinMs + 1000);
|
||||
mod->runOnce();
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_TRUE_MESSAGE(mockRouter->sentPackets[0].decoded.want_response,
|
||||
"a refused send must not consume the preset change");
|
||||
TEST_ASSERT_EQUAL_HEX32(NODENUM_BROADCAST, mockRouter->sentPackets[0].to);
|
||||
|
||||
Time::advanceTestMillis(kThirtyMinMs + 1000);
|
||||
mod->runOnce();
|
||||
TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_FALSE_MESSAGE(mockRouter->sentPackets[1].decoded.want_response,
|
||||
"a settled generation must not keep asking for replies");
|
||||
}
|
||||
|
||||
NI_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
nodeDB = new NodeDB();
|
||||
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_sendWindow_floorIsThirtyMinutes);
|
||||
RUN_TEST(test_sendWindow_interactiveSendKeepsItsSixtySecondGate);
|
||||
RUN_TEST(test_broadcastTimer_aSendRearmsTheRoutineCountdown);
|
||||
RUN_TEST(test_broadcastTimer_aSendMovesTheDeadlineNotJustThePeriod);
|
||||
RUN_TEST(test_broadcastTimer_anAdHocUnicastRearmsItToo);
|
||||
RUN_TEST(test_broadcastTimer_aRefusedSendLeavesTheCountdownAlone);
|
||||
RUN_TEST(test_broadcastTimer_aRejectedSendLeavesTheCountdownAlone);
|
||||
RUN_TEST(test_sendWindow_aRejectedSendDoesNotStartTheWindow);
|
||||
RUN_TEST(test_sendWindow_aLicensedStationKeepsItsCallSignInterval);
|
||||
RUN_TEST(test_presetChange_isConsumedOnlyByASendThatGoesOut);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
NI_TEST_ENTRY void loop() {}
|
||||
@@ -1091,6 +1091,106 @@ void test_B13_licensed_port_and_destination_signing_matrix(void)
|
||||
}
|
||||
}
|
||||
|
||||
// B14: PKI needs only the two keys, so a DM can arrive over a channel we do not carry. Its ack is a
|
||||
// ROUTING packet, which is PKC-excluded, so it would be channel-encoded and die with NO_CHANNEL -
|
||||
// and the sender would then retransmit to exhaustion for a message that WAS delivered. Fall back to
|
||||
// PKC for exactly that case.
|
||||
void test_B14_ack_with_no_usable_channel_falls_back_to_pkc(void)
|
||||
{
|
||||
uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
|
||||
crypto->generateKeyPair(localPub, localPriv);
|
||||
crypto->generateKeyPair(remotePub, remotePriv);
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
|
||||
memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
|
||||
config.security.private_key.size = sizeof(localPriv);
|
||||
crypto->setDHPrivateKey(localPriv);
|
||||
|
||||
// A secondary channel index that does not resolve - otherwise the test proves nothing.
|
||||
const ChannelIndex deadChannel = 1;
|
||||
TEST_ASSERT_LESS_THAN_MESSAGE(0, channels.getHash(deadChannel), "test needs an unusable channel index");
|
||||
|
||||
meshtastic_MeshPacket ack = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
|
||||
ack.decoded.request_id = 0xFEED5150;
|
||||
ack.channel = deadChannel;
|
||||
|
||||
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NONE, perhapsEncode(&ack),
|
||||
"ack on an unusable channel must not fail to send");
|
||||
TEST_ASSERT_TRUE_MESSAGE(ack.pki_encrypted, "it must have gone out over PKC");
|
||||
}
|
||||
|
||||
// The fallback must not paper over a genuinely unsendable ack: with no key for the destination there
|
||||
// is nothing to encrypt to, and NO_CHANNEL is still the honest answer.
|
||||
void test_B15_ack_with_no_channel_and_no_key_still_fails(void)
|
||||
{
|
||||
uint8_t localPub[32], localPriv[32];
|
||||
crypto->generateKeyPair(localPub, localPriv);
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
|
||||
memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
|
||||
config.security.private_key.size = sizeof(localPriv);
|
||||
crypto->setDHPrivateKey(localPriv);
|
||||
// REMOTE_NODE deliberately absent from the DB, so we hold no key for it.
|
||||
|
||||
const ChannelIndex deadChannel = 1;
|
||||
TEST_ASSERT_LESS_THAN(0, channels.getHash(deadChannel));
|
||||
|
||||
meshtastic_MeshPacket ack = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
|
||||
ack.decoded.request_id = 0xFEED5150;
|
||||
ack.channel = deadChannel;
|
||||
|
||||
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NO_CHANNEL, perhapsEncode(&ack),
|
||||
"without a destination key the ack is genuinely unsendable");
|
||||
}
|
||||
|
||||
// The fallback is scoped to acks: a non-ROUTING unicast on an unusable channel still fails, so this
|
||||
// does not quietly turn every channel-less packet into a PKC packet.
|
||||
void test_B16_non_ack_on_unusable_channel_still_fails(void)
|
||||
{
|
||||
uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
|
||||
crypto->generateKeyPair(localPub, localPriv);
|
||||
crypto->generateKeyPair(remotePub, remotePriv);
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
|
||||
memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
|
||||
config.security.private_key.size = sizeof(localPriv);
|
||||
crypto->setDHPrivateKey(localPriv);
|
||||
|
||||
const ChannelIndex deadChannel = 1;
|
||||
TEST_ASSERT_LESS_THAN(0, channels.getHash(deadChannel));
|
||||
|
||||
// TRACEROUTE is PKC-excluded like ROUTING, but carries no request_id and is not an ack.
|
||||
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TRACEROUTE_APP, SMALL_PAYLOAD);
|
||||
p.channel = deadChannel;
|
||||
|
||||
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NO_CHANNEL, perhapsEncode(&p), "the PKC fallback must apply to acks only");
|
||||
}
|
||||
|
||||
// A ROUTING packet on a channel that DOES resolve keeps taking the channel path, so relays retain
|
||||
// the readable acks they use for next-hop learning and retransmission cancel.
|
||||
void test_B17_ack_on_a_usable_channel_stays_on_the_channel(void)
|
||||
{
|
||||
uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32];
|
||||
crypto->generateKeyPair(localPub, localPriv);
|
||||
crypto->generateKeyPair(remotePub, remotePriv);
|
||||
mockNodeDB->addNode(LOCAL_NODE);
|
||||
mockNodeDB->setPublicKey(LOCAL_NODE, localPub);
|
||||
mockNodeDB->addNode(REMOTE_NODE);
|
||||
mockNodeDB->setPublicKey(REMOTE_NODE, remotePub);
|
||||
memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv));
|
||||
config.security.private_key.size = sizeof(localPriv);
|
||||
crypto->setDHPrivateKey(localPriv);
|
||||
|
||||
meshtastic_MeshPacket ack = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
|
||||
ack.decoded.request_id = 0xFEED5150;
|
||||
ack.channel = 0; // the primary, which initDefaults() made usable
|
||||
|
||||
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&ack));
|
||||
TEST_ASSERT_FALSE_MESSAGE(ack.pki_encrypted, "a sendable ack must stay readable to relays");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group C - routing pipeline and NodeInfo authentication ordering
|
||||
// ===========================================================================
|
||||
@@ -2185,6 +2285,10 @@ void setup()
|
||||
RUN_TEST(test_B11_normal_unicast_still_uses_pki);
|
||||
RUN_TEST(test_B12_licensed_receiver_does_not_decrypt_pki);
|
||||
RUN_TEST(test_B13_licensed_port_and_destination_signing_matrix);
|
||||
RUN_TEST(test_B14_ack_with_no_usable_channel_falls_back_to_pkc);
|
||||
RUN_TEST(test_B15_ack_with_no_channel_and_no_key_still_fails);
|
||||
RUN_TEST(test_B16_non_ack_on_unusable_channel_still_fails);
|
||||
RUN_TEST(test_B17_ack_on_a_usable_channel_stays_on_the_channel);
|
||||
|
||||
printf("\n=== Group C: routing pipeline authentication ordering ===\n");
|
||||
RUN_TEST(test_C1_invalid_first_copy_does_not_poison_valid_same_id);
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// What this node carries for other nodes, per DeviceConfig.rebroadcast_mode - the one knob that
|
||||
// governs relaying, for packets it can read and for packets it cannot (a PKI unicast between two
|
||||
// other nodes, a channel it does not hold). The signature policy plays no part here and is varied
|
||||
// across the cases to prove it. Each expected outcome is a contract, and the assertion messages
|
||||
// say what breaks if it is not met.
|
||||
//
|
||||
// opaque, per mode ALL / ALL_SKIP_DECODING / CORE_PORTNUMS_ONLY carry; KNOWN_ONLY and LOCAL_ONLY
|
||||
// carry a PKI-shaped unicast with one known party, and nothing else - not a
|
||||
// unicast between two strangers, not an unreadable broadcast; NONE carries
|
||||
// nothing. Both directions are load-bearing: dropping the modes from
|
||||
// relayOpaquePacket()'s list fails the known-party cases, and dropping the
|
||||
// identity/PKI-shape qualifier fails the stranger and foreign-mesh cases.
|
||||
// licensed node never carries ciphertext; carries plaintext unless a party is known unlicensed
|
||||
// header gates hop_limit 0, id 0, someone else's next_hop, CLIENT_MUTE each stop a relay
|
||||
//
|
||||
// Every case builds a real PKI frame, so the suite runs only where PKI is compiled in.
|
||||
|
||||
#include "MeshTypes.h" // BEFORE TestUtil.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
|
||||
// Inside the guard: the harness builds real PKI frames through CryptoEngine entry points that a
|
||||
// PKI-excluded build does not declare.
|
||||
#include "support/AuthPipelineHarness.h"
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
pipelineHarnessSetUp();
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
pipelineHarnessTearDown();
|
||||
}
|
||||
|
||||
// Remote admin from the operator's node to a node behind us, both of them known to us. This is
|
||||
// what a router in the field does all day. Anyone who makes this assertion fail in any mode but
|
||||
// NONE is turning a stock ROUTER (whose default is CORE_PORTNUMS_ONLY) into a black hole for
|
||||
// remote administration, direct messages and key verification, and owes an explanation for it.
|
||||
void test_remote_admin_between_other_nodes_relays_in_every_mode(void)
|
||||
{
|
||||
setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT);
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
mockNodeDB->addNode(ADMIN_NODE);
|
||||
mockNodeDB->setPublicKey(ADMIN_NODE, admin.pub);
|
||||
mockNodeDB->markHasUser(ADMIN_NODE);
|
||||
mockNodeDB->addNode(TARGET_NODE);
|
||||
mockNodeDB->setPublicKey(TARGET_NODE, target.pub);
|
||||
mockNodeDB->markHasUser(TARGET_NODE);
|
||||
const meshtastic_MeshPacket adminPacket =
|
||||
makePkiUnicastBetween(admin, target, meshtastic_PortNum_ADMIN_APP, 0xADA10001, /*wantAck=*/true);
|
||||
|
||||
// Sanity: the frame really is opaque to us, not merely undecodable.
|
||||
meshtastic_MeshPacket probe = adminPacket;
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast<int>(passesRoutingAuthGate(&probe)));
|
||||
|
||||
for (const auto mode : ALL_MODES) {
|
||||
const bool expect = mode != meshtastic_Config_DeviceConfig_RebroadcastMode_NONE;
|
||||
assertOpaqueRelay(adminPacket, mode, expect,
|
||||
expect ? "a relay must carry remote admin it cannot read - justify any change to this"
|
||||
: "NONE relays nothing");
|
||||
}
|
||||
// NodeDB is untouched by all of it: no last_heard, no new entries.
|
||||
TEST_ASSERT_EQUAL(0, mockNodeDB->getMeshNode(ADMIN_NODE)->last_heard);
|
||||
}
|
||||
|
||||
// The same admin packet between two nodes we have never heard of. Modes that key on identity
|
||||
// (KNOWN_ONLY, LOCAL_ONLY) decline; the port-based and unconditional modes still carry it.
|
||||
void test_pki_unicast_between_strangers_relays_unless_mode_needs_identity(void)
|
||||
{
|
||||
setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE);
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
const meshtastic_MeshPacket p = makePkiUnicastBetween(admin, target, meshtastic_PortNum_ADMIN_APP, 0xADA20002);
|
||||
|
||||
for (const auto mode : ALL_MODES) {
|
||||
const bool needsIdentity = IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY);
|
||||
const bool expect = mode != meshtastic_Config_DeviceConfig_RebroadcastMode_NONE && !needsIdentity;
|
||||
assertOpaqueRelay(p, mode, expect,
|
||||
expect ? "a PKI unicast between strangers is still carried by a port- or unconditional-mode relay"
|
||||
: "this mode only carries PKI traffic with a party we know");
|
||||
}
|
||||
}
|
||||
|
||||
// One party known is enough for KNOWN_ONLY / LOCAL_ONLY. The known party is the destination here,
|
||||
// so the sender is a stranger and KNOWN_ONLY's decode short-circuit fires; the packet must still
|
||||
// reach the relay decision.
|
||||
void test_known_destination_satisfies_known_only_and_local_only(void)
|
||||
{
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
mockNodeDB->addNode(TARGET_NODE);
|
||||
mockNodeDB->setPublicKey(TARGET_NODE, target.pub);
|
||||
mockNodeDB->markHasUser(TARGET_NODE);
|
||||
const meshtastic_MeshPacket p = makePkiUnicastBetween(admin, target, meshtastic_PortNum_TEXT_MESSAGE_APP, 0xADA30003);
|
||||
|
||||
assertOpaqueRelay(p, meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY, true,
|
||||
"KNOWN_ONLY carries a PKI unicast to a node we know, whoever sent it");
|
||||
assertOpaqueRelay(p, meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY, true,
|
||||
"LOCAL_ONLY carries a PKI unicast to a node we know, whoever sent it");
|
||||
}
|
||||
|
||||
// The mirror of the case above, and the only one that makes the `from` half of the identity test do
|
||||
// any work: a known SENDER, a destination we have never heard of. A qualifier rewritten to consult
|
||||
// only p->to passes every other case in this suite and fails this one.
|
||||
void test_known_source_satisfies_known_only_and_local_only(void)
|
||||
{
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
mockNodeDB->addNode(ADMIN_NODE);
|
||||
mockNodeDB->setPublicKey(ADMIN_NODE, admin.pub);
|
||||
mockNodeDB->markHasUser(ADMIN_NODE); // the sender is known; the destination is not in the DB at all
|
||||
const meshtastic_MeshPacket p = makePkiUnicastBetween(admin, target, meshtastic_PortNum_TEXT_MESSAGE_APP, 0xADA30013);
|
||||
|
||||
assertOpaqueRelay(p, meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY, true,
|
||||
"KNOWN_ONLY carries a PKI unicast from a node we know, whoever it is addressed to");
|
||||
assertOpaqueRelay(p, meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY, true,
|
||||
"LOCAL_ONLY carries a PKI unicast from a node we know, whoever it is addressed to");
|
||||
}
|
||||
|
||||
// The only case that makes the channel-0 half do any work. A unicast on a channel hash we do not
|
||||
// hold is not PKI - it is someone else's channel traffic, addressed - so these modes decline it even
|
||||
// though a party is known. The broadcast case cannot pin this: !isBroadcast() already rejects that.
|
||||
void test_known_party_on_a_foreign_channel_is_declined(void)
|
||||
{
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
mockNodeDB->addNode(TARGET_NODE);
|
||||
mockNodeDB->setPublicKey(TARGET_NODE, target.pub);
|
||||
mockNodeDB->markHasUser(TARGET_NODE);
|
||||
meshtastic_MeshPacket p = makePkiUnicastBetween(admin, target, meshtastic_PortNum_TEXT_MESSAGE_APP, 0xADA30023);
|
||||
p.channel = 0x2B; // a channel hash we do not hold, so nothing about this frame says PKI
|
||||
|
||||
assertOpaqueRelay(p, meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY, false,
|
||||
"KNOWN_ONLY declines an addressed frame on a foreign channel, known party or not");
|
||||
assertOpaqueRelay(p, meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY, false,
|
||||
"LOCAL_ONLY declines an addressed frame on a foreign channel, known party or not");
|
||||
}
|
||||
|
||||
// An opaque *broadcast* (a channel we do not hold) is not PKI-shaped. CORE relays it - the
|
||||
// port list cannot apply to a packet with no readable port - and only KNOWN/LOCAL/NONE decline.
|
||||
void test_unknown_channel_broadcast_relays_in_core_portnums_only(void)
|
||||
{
|
||||
const meshtastic_MeshPacket foreign = makeUnknownChannelBroadcast(0xADA40004);
|
||||
for (const auto mode : ALL_MODES) {
|
||||
const bool expect = IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING,
|
||||
meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY);
|
||||
pipelineRadio->reset();
|
||||
config.device.rebroadcast_mode = mode;
|
||||
meshtastic_MeshPacket copy = foreign;
|
||||
copy.id += (uint32_t)mode;
|
||||
runPipelineIngress(copy);
|
||||
while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) // phone delivery: test_packet_signing C22
|
||||
packetPool.release(queued);
|
||||
char msg[120];
|
||||
snprintf(msg, sizeof(msg), "%s: %s", modeName(mode),
|
||||
expect ? "an unreadable broadcast is carried" : "an unreadable broadcast is not PKI-shaped, so declined");
|
||||
TEST_ASSERT_EQUAL_MESSAGE(expect ? 1 : 0, pipelineRadio->sendCalls, msg);
|
||||
TEST_ASSERT_FALSE(pipelineRouter->historyContains(©));
|
||||
}
|
||||
}
|
||||
|
||||
// A licensed (ham) node must not relay traffic it cannot read - encryption is not permitted
|
||||
// on its band, and it cannot tell what it is carrying. Every mode, no exceptions; its own inbound
|
||||
// handling (phone delivery) is unaffected because that is about what was sent *to* it.
|
||||
void test_licensed_node_never_relays_opaque_traffic(void)
|
||||
{
|
||||
owner.is_licensed = true;
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
mockNodeDB->addNode(ADMIN_NODE);
|
||||
mockNodeDB->markLicenseStatus(ADMIN_NODE, true);
|
||||
mockNodeDB->addNode(TARGET_NODE);
|
||||
mockNodeDB->markLicenseStatus(TARGET_NODE, true);
|
||||
const meshtastic_MeshPacket p = makePkiUnicastBetween(admin, target, meshtastic_PortNum_ADMIN_APP, 0xADAB000B);
|
||||
for (const auto mode : ALL_MODES)
|
||||
assertOpaqueRelay(p, mode, false, "a licensed node does not carry ciphertext, whoever the parties are");
|
||||
|
||||
const meshtastic_MeshPacket foreign = makeUnknownChannelBroadcast(0xADAB001B);
|
||||
for (const auto mode : ALL_MODES) {
|
||||
pipelineRadio->reset();
|
||||
config.device.rebroadcast_mode = mode;
|
||||
meshtastic_MeshPacket copy = foreign;
|
||||
copy.id += (uint32_t)mode;
|
||||
runPipelineIngress(copy);
|
||||
drainPhoneQueue();
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "a licensed node does not carry an unreadable broadcast either");
|
||||
}
|
||||
}
|
||||
|
||||
// What a licensed node does with traffic it *can* read: it will not relay for a party it knows to
|
||||
// be unlicensed, in either direction; a licensed or unknown party is carried (RoutingModule).
|
||||
void test_licensed_node_relays_decoded_unless_a_party_is_known_unlicensed(void)
|
||||
{
|
||||
owner.is_licensed = true;
|
||||
setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE);
|
||||
constexpr NodeNum LICENSED_PEER = 0x0E0E0E0E, UNLICENSED_PEER = 0x0F0F0F0F, UNKNOWN_PEER = 0x01010101;
|
||||
mockNodeDB->addNode(LICENSED_PEER);
|
||||
mockNodeDB->markLicenseStatus(LICENSED_PEER, true);
|
||||
mockNodeDB->addNode(UNLICENSED_PEER);
|
||||
mockNodeDB->markLicenseStatus(UNLICENSED_PEER, false);
|
||||
|
||||
struct Case {
|
||||
NodeNum from, to;
|
||||
bool relayed;
|
||||
const char *why;
|
||||
} cases[] = {
|
||||
{LICENSED_PEER, NODENUM_BROADCAST, true, "broadcast from a licensed peer is carried"},
|
||||
{UNKNOWN_PEER, NODENUM_BROADCAST, true, "broadcast from a peer of unknown status is carried"},
|
||||
{UNLICENSED_PEER, NODENUM_BROADCAST, false, "broadcast from a known-unlicensed peer is not carried"},
|
||||
{LICENSED_PEER, UNLICENSED_PEER, false, "unicast to a known-unlicensed peer is not carried"},
|
||||
{LICENSED_PEER, UNKNOWN_PEER, true, "unicast to a peer of unknown status is carried"},
|
||||
};
|
||||
uint32_t n = 0;
|
||||
for (const auto &c : cases) {
|
||||
pipelineRadio->reset();
|
||||
pipelineRouting->reset();
|
||||
const meshtastic_MeshPacket p = makeChannelBroadcastFrom(c.from, c.to, 0xADAC0100 + ++n);
|
||||
runPipelineIngress(p);
|
||||
drainPhoneQueue();
|
||||
TEST_ASSERT_EQUAL_MESSAGE(c.relayed ? 1 : 0, pipelineRadio->sendCalls, c.why);
|
||||
}
|
||||
}
|
||||
|
||||
// The header gates on the opaque path that never changed and must not: a spent hop budget, an
|
||||
// id of 0, a next_hop naming someone else, and the CLIENT_MUTE role each stop a relay on their own.
|
||||
void test_opaque_relay_header_gates_hold(void)
|
||||
{
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
const meshtastic_MeshPacket base = makePkiUnicastBetween(admin, target, meshtastic_PortNum_ADMIN_APP, 0xADAD000D);
|
||||
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL;
|
||||
|
||||
meshtastic_MeshPacket spent = base;
|
||||
spent.hop_limit = 0;
|
||||
runPipelineIngress(spent);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "hop_limit 0: nothing left to spend");
|
||||
|
||||
pipelineRadio->reset();
|
||||
meshtastic_MeshPacket noId = base;
|
||||
noId.id = 0;
|
||||
runPipelineIngress(noId);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "id 0 cannot be deduplicated, so it is never relayed");
|
||||
|
||||
pipelineRadio->reset();
|
||||
meshtastic_MeshPacket notOurHop = base;
|
||||
notOurHop.id++;
|
||||
notOurHop.next_hop = (uint8_t)(nodeDB->getLastByteOfNodeNum(LOCAL_NODE) ^ 0x01);
|
||||
runPipelineIngress(notOurHop);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "next_hop names another relay");
|
||||
|
||||
pipelineRadio->reset();
|
||||
meshtastic_MeshPacket ourHop = base;
|
||||
ourHop.id += 2;
|
||||
ourHop.next_hop = nodeDB->getLastByteOfNodeNum(LOCAL_NODE);
|
||||
runPipelineIngress(ourHop);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(1, pipelineRadio->sendCalls, "next_hop names us");
|
||||
|
||||
pipelineRadio->reset();
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE;
|
||||
meshtastic_MeshPacket muted = base;
|
||||
muted.id += 3;
|
||||
runPipelineIngress(muted);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "CLIENT_MUTE relays nothing, opaque included");
|
||||
}
|
||||
|
||||
// Hearing the same frame again is not a reason to carry it again - except from the originator, which
|
||||
// only retransmits because it heard no rebroadcast of ours, and not even then while ours is queued.
|
||||
void test_opaque_relay_carries_a_frame_once_unless_the_originator_repeats_it(void)
|
||||
{
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
const meshtastic_MeshPacket base = makePkiUnicastBetween(admin, target, meshtastic_PortNum_ADMIN_APP, 0xADAE000E);
|
||||
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL;
|
||||
|
||||
runPipelineIngress(base);
|
||||
runPipelineIngress(makeRelayedCopy(base));
|
||||
TEST_ASSERT_EQUAL_MESSAGE(1, pipelineRadio->sentCountFor(ADMIN_NODE, base.id),
|
||||
"a neighbour's rebroadcast of a frame we carried is not carried again");
|
||||
|
||||
meshtastic_MeshPacket retx = base;
|
||||
retx.hop_limit = retx.hop_start;
|
||||
runPipelineIngress(retx);
|
||||
TEST_ASSERT_EQUAL_MESSAGE(2, pipelineRadio->sentCountFor(ADMIN_NODE, base.id),
|
||||
"the originator's own retransmission is carried again");
|
||||
|
||||
// A third case - our own queued copy suppressing the repeat - needs relayOpaquePacket() to
|
||||
// consult the TX queue, which it does not do. Not this change's to add.
|
||||
}
|
||||
|
||||
// None of the above depends on packet_signature_policy: the remote-admin case ran STRICT, the
|
||||
// strangers case COMPATIBLE. Here the same admin packet goes under every policy in turn.
|
||||
void test_relay_decision_ignores_signature_policy(void)
|
||||
{
|
||||
const RelayIdentity admin = makeIdentity(ADMIN_NODE);
|
||||
const RelayIdentity target = makeIdentity(TARGET_NODE);
|
||||
const meshtastic_MeshPacket p = makePkiUnicastBetween(admin, target, meshtastic_PortNum_ADMIN_APP, 0xADAA000A);
|
||||
const meshtastic_Config_SecurityConfig_PacketSignaturePolicy policies[] = {
|
||||
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE,
|
||||
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED,
|
||||
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT,
|
||||
};
|
||||
uint32_t salt = 0;
|
||||
for (const auto policy : policies) {
|
||||
setPolicy(policy);
|
||||
meshtastic_MeshPacket copy = p;
|
||||
copy.id += 0x100 * ++salt;
|
||||
assertOpaqueRelay(copy, meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY, true,
|
||||
"the signature policy governs what we admit, not what we carry");
|
||||
}
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
pipelineHarnessCreate();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_remote_admin_between_other_nodes_relays_in_every_mode);
|
||||
RUN_TEST(test_pki_unicast_between_strangers_relays_unless_mode_needs_identity);
|
||||
RUN_TEST(test_known_destination_satisfies_known_only_and_local_only);
|
||||
RUN_TEST(test_known_source_satisfies_known_only_and_local_only);
|
||||
RUN_TEST(test_known_party_on_a_foreign_channel_is_declined);
|
||||
RUN_TEST(test_unknown_channel_broadcast_relays_in_core_portnums_only);
|
||||
RUN_TEST(test_licensed_node_never_relays_opaque_traffic);
|
||||
RUN_TEST(test_licensed_node_relays_decoded_unless_a_party_is_known_unlicensed);
|
||||
RUN_TEST(test_opaque_relay_header_gates_hold);
|
||||
RUN_TEST(test_opaque_relay_carries_a_frame_once_unless_the_originator_repeats_it);
|
||||
RUN_TEST(test_relay_decision_ignores_signature_policy);
|
||||
const int result = UNITY_END();
|
||||
pipelineHarnessDestroy();
|
||||
exit(result);
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
|
||||
#else // XEdDSA or PKI excluded
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
exit(UNITY_END());
|
||||
}
|
||||
void loop() {}
|
||||
|
||||
#endif
|
||||
@@ -64,6 +64,46 @@ static void test_setDirection_rejectsReversal()
|
||||
TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT));
|
||||
}
|
||||
|
||||
// turn() is the shoulder-button steering: a quarter turn relative to the current heading rather
|
||||
// than an absolute direction. A quarter turn is never a reversal, so it always takes.
|
||||
static void test_turn_cyclesThroughHeadings()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed); // heading right
|
||||
game.placeFoodAt(0, 0);
|
||||
|
||||
// Clockwise on screen (y grows downward): RIGHT -> DOWN -> LEFT -> UP -> RIGHT.
|
||||
const SnakeGame::Direction cw[] = {SnakeGame::DIR_DOWN, SnakeGame::DIR_LEFT, SnakeGame::DIR_UP, SnakeGame::DIR_RIGHT};
|
||||
for (SnakeGame::Direction want : cw) {
|
||||
game.turn(true);
|
||||
TEST_ASSERT_TRUE(game.step()); // commit the pending turn
|
||||
TEST_ASSERT_EQUAL_INT(want, game.direction());
|
||||
}
|
||||
|
||||
// Counter-clockwise runs the cycle backwards: RIGHT -> UP -> LEFT -> DOWN -> RIGHT.
|
||||
const SnakeGame::Direction ccw[] = {SnakeGame::DIR_UP, SnakeGame::DIR_LEFT, SnakeGame::DIR_DOWN, SnakeGame::DIR_RIGHT};
|
||||
for (SnakeGame::Direction want : ccw) {
|
||||
game.turn(false);
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
TEST_ASSERT_EQUAL_INT(want, game.direction());
|
||||
}
|
||||
}
|
||||
|
||||
// Two turns inside one tick must not chain into a 180 that runs the head into its own neck --
|
||||
// the second turn is taken from the committed heading, not the pending one.
|
||||
static void test_turn_twiceInOneTickIsNotAReversal()
|
||||
{
|
||||
SnakeGame game;
|
||||
game.reset(kSeed); // heading right
|
||||
game.placeFoodAt(0, 0);
|
||||
|
||||
game.turn(true);
|
||||
game.turn(true); // would be RIGHT -> DOWN -> LEFT if it chained
|
||||
TEST_ASSERT_TRUE(game.step());
|
||||
TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_DOWN, game.direction());
|
||||
TEST_ASSERT_TRUE(game.isPlaying());
|
||||
}
|
||||
|
||||
static void test_step_movesAndTailFollows()
|
||||
{
|
||||
SnakeGame game;
|
||||
@@ -168,6 +208,8 @@ void setup()
|
||||
RUN_TEST(test_reset_initialState);
|
||||
RUN_TEST(test_food_isValidAndOffBody);
|
||||
RUN_TEST(test_setDirection_rejectsReversal);
|
||||
RUN_TEST(test_turn_cyclesThroughHeadings);
|
||||
RUN_TEST(test_turn_twiceInOneTickIsNotAReversal);
|
||||
RUN_TEST(test_step_movesAndTailFollows);
|
||||
RUN_TEST(test_eat_growsAndScores);
|
||||
RUN_TEST(test_wallCollision_endsGame);
|
||||
|
||||
@@ -35,7 +35,7 @@ build_flags =
|
||||
; -D COMPASS_SENSOR_DEBUG=1
|
||||
lib_deps = ${esp32s3_base.lib_deps}
|
||||
# renovate: datasource=custom depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||
lovyan03/LovyanGFX@1.2.26
|
||||
lovyan03/LovyanGFX@1.2.28
|
||||
# renovate: datasource=git-refs depName=meshtastic-PCF8xRTC packageName=https://github.com/meshtastic/PCF8xRTC gitBranch=main
|
||||
https://github.com/meshtastic/PCF8xRTC/archive/efe1d9c92f79d8413f8ac4e9bdce0c48410600de.zip
|
||||
|
||||
@@ -83,7 +83,8 @@ build_flags =
|
||||
-D LGFX_SCREEN_WIDTH=240
|
||||
-D LGFX_SCREEN_HEIGHT=320
|
||||
-D LGFX_INVERT_LIGHT=true
|
||||
; -D MAP_FULL_REDRAW
|
||||
-D MAP_FULL_REDRAW
|
||||
-D FTP_BUF_SIZE=4096
|
||||
-D MUI_WIFI_PS_MIN_MODEM
|
||||
-D DEFAULT_FTP_SERVER_NETWORK_TYPE_ESP32=NETWORK_ESP32
|
||||
-D DEFAULT_STORAGE_TYPE_ESP32=STORAGE_SD
|
||||
@@ -91,9 +92,14 @@ build_flags =
|
||||
|
||||
lib_deps =
|
||||
${thinknode_m9_base.lib_deps}
|
||||
https://github.com/meshtastic/device-ui/archive/70a9967f202a69390460c908a1321e475d3d4cdf.zip ; PR314 input-policy
|
||||
https://github.com/meshtastic/device-ui/archive/24c8ea5da1537ce796069a5ccebc0c583cd9b23a.zip ; PR314 input-policy
|
||||
https://github.com/mverch67/MultiFTPServer/archive/0e854335b9916ed9f2d3bcfe68975ce746992ccd.zip
|
||||
|
||||
custom_sdkconfig =
|
||||
${esp32s3_base.custom_sdkconfig}
|
||||
${device-ui_base.custom_sdkconfig}
|
||||
|
||||
extra_scripts =
|
||||
${esp32s3_base.extra_scripts}
|
||||
pre:extra_scripts/esp32_fatfs_exfat.py
|
||||
post:extra_scripts/esp32_fatfs_exfat.py
|
||||
@@ -3,6 +3,11 @@
|
||||
#include "SPILock.h"
|
||||
#include "Wire.h"
|
||||
|
||||
#define KBD_ADDR_M9_VERSION1_0 0x6C
|
||||
#define KBD_ADDR_M9_VERSION1_1 0x6D
|
||||
|
||||
int m9Version = 0;
|
||||
|
||||
void earlyInitVariant()
|
||||
{
|
||||
pinMode(LORA_CS, OUTPUT);
|
||||
@@ -11,23 +16,42 @@ void earlyInitVariant()
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
pinMode(TFT_CS, OUTPUT);
|
||||
digitalWrite(TFT_CS, HIGH);
|
||||
pinMode(PIN_GPS_EN, OUTPUT);
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
pinMode(GPS_RTC_INT, OUTPUT);
|
||||
digitalWrite(GPS_RTC_INT, LOW);
|
||||
delay(100);
|
||||
}
|
||||
|
||||
void lateInitVariant()
|
||||
void initVariant()
|
||||
{
|
||||
// configure keyboard long-press time
|
||||
const uint16_t ms = 700;
|
||||
concurrency::LockGuard g(spiLock);
|
||||
Wire.beginTransmission(0x6C);
|
||||
Wire.write(0x03);
|
||||
Wire.write((ms >> 8) & 0xFF);
|
||||
Wire.write(ms & 0xFF);
|
||||
Wire.endTransmission();
|
||||
// determine M9 version
|
||||
Wire.begin(SDA, SCL);
|
||||
Wire.beginTransmission(KBD_ADDR_M9_VERSION1_0);
|
||||
if (Wire.endTransmission() == 0) {
|
||||
m9Version = 1;
|
||||
}
|
||||
Wire.beginTransmission(KBD_ADDR_M9_VERSION1_1);
|
||||
if (Wire.endTransmission() == 0) {
|
||||
m9Version = 2;
|
||||
}
|
||||
|
||||
if (m9Version > 0) {
|
||||
// configure keyboard long-press time
|
||||
const uint16_t ms = 700;
|
||||
Wire.beginTransmission(m9Version == 1 ? KBD_ADDR_M9_VERSION1_0 : KBD_ADDR_M9_VERSION1_1);
|
||||
Wire.write(0x03);
|
||||
Wire.write((ms >> 8) & 0xFF);
|
||||
Wire.write(ms & 0xFF);
|
||||
Wire.endTransmission();
|
||||
}
|
||||
Wire.end();
|
||||
}
|
||||
|
||||
void variant_shutdown()
|
||||
{
|
||||
pinMode(PIN_GPS_EN, OUTPUT);
|
||||
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
|
||||
uint64_t gpioMask = (1ULL << KB_INT);
|
||||
gpio_pulldown_en((gpio_num_t)KB_INT);
|
||||
esp_sleep_enable_ext1_wakeup(gpioMask, ESP_EXT1_WAKEUP_ANY_HIGH);
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
/*Power*/
|
||||
#define VEXT_ENABLE 18
|
||||
#define VEXT_ON_VALUE LOW
|
||||
#define PIN_GPS_EN 11
|
||||
#define GPS_EN_ACTIVE LOW
|
||||
|
||||
#define USE_POWERSAVE
|
||||
#define SLEEP_TIME 120
|
||||
@@ -31,12 +29,16 @@
|
||||
#define EXT_PWR_DETECT_VALUE LOW
|
||||
|
||||
/*GPS*/
|
||||
extern int m9Version;
|
||||
#define HAS_GPS 1
|
||||
#define GPS_BAUDRATE 115200
|
||||
#define PIN_GPS_RESET 5
|
||||
#define PIN_GPS_PPS 4
|
||||
#define GPS_TX_PIN 3
|
||||
#define GPS_RX_PIN 2
|
||||
#define GPS_RTC_INT 10
|
||||
#define PIN_GPS_EN 11
|
||||
#define GPS_EN_ACTIVE (m9Version > 1 ? HIGH : LOW)
|
||||
#define GPS_THREAD_INTERVAL 50
|
||||
|
||||
/*SPI*/
|
||||
|
||||
@@ -28,7 +28,7 @@ lib_deps =
|
||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||
lovyan03/LovyanGFX@1.2.28
|
||||
; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/meshtastic/libch341-spi-userspace gitBranch=main
|
||||
https://github.com/meshtastic/libch341-spi-userspace/archive/eaaef01997788e6cbd2432eee15ea57d5300a06a.zip
|
||||
https://github.com/meshtastic/libch341-spi-userspace/archive/d85aceb760da291d90e0ff526ba17b2e1323d988.zip
|
||||
# renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library
|
||||
adafruit/Adafruit seesaw Library@1.7.9
|
||||
# renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main
|
||||
|
||||
Reference in new issue
Block a user