diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index bf2b87afd..34f1f3f20 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -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: diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index cf0d56da2..276808045 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -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 diff --git a/bin/test-config-check.sh b/bin/test-config-check.sh index b39f19277..7cee12d7a 100755 --- a/bin/test-config-check.sh +++ b/bin/test-config-check.sh @@ -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 diff --git a/src/configuration.h b/src/configuration.h index 45e18bf6f..6d5587d88 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -265,7 +265,8 @@ along with this program. If not, see . #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 diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 767548998..26190462e 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -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); diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8d9c35ae4..e19400c30 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -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 diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 7a99c4820..59548e34e 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -260,7 +260,7 @@ class Screen : public concurrency::OSThread std::vector indicatorIcons; // Per-frame custom icon pointers #if defined(OLED_COMPACT_UI) - std::vector frameTitles; // Per-frame short labels, parallel to indicatorIcons + std::vector 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; }; diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 762b61823..0b721bfc2 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -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 diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 45a3e9760..da723eb6c 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -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; } diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 284bfc6c4..0790bd234 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -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 diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index e30e84ff7..2d5bdfc1e 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -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; diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index d8f99ccec..826e49ad9 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -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)); } } } diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h index 7e309b961..7413978d7 100644 --- a/src/input/LinuxJoystick.h +++ b/src/input/LinuxJoystick.h @@ -37,11 +37,15 @@ class LinuxJoystick : public Observable, 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; diff --git a/src/input/STC8HKeyboard.h b/src/input/STC8HKeyboard.h index 7de3b3d28..ca2cb8ea4 100644 --- a/src/input/STC8HKeyboard.h +++ b/src/input/STC8HKeyboard.h @@ -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; diff --git a/src/input/kbI2cBase.cpp b/src/input/kbI2cBase.cpp index d4851c7f6..2d47d7282 100644 --- a/src/input/kbI2cBase.cpp +++ b/src/input/kbI2cBase.cpp @@ -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) { diff --git a/src/main.cpp b/src/main.cpp index 1f14f3b93..a4506d382 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index e3803c43a..9d7b71b5d 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -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)) { diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 1e7ddc9d4..77ebd796d 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -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 diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 66f09dba6..b08d5bd84 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -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++) diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index d36fd7ca0..c65bd6def 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -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) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 9b95a57eb..a72a14dd8 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -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; diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index ea6f5ea96..84694fcc0 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -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; diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index fe12bd468..e67d137e8 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -190,6 +190,11 @@ class CannedMessageModule : public SinglePortModule, public ObservablecancelSending(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); } diff --git a/src/modules/NodeInfoModule.h b/src/modules/NodeInfoModule.h index 6cdb8caa7..1c0933d00 100644 --- a/src/modules/NodeInfoModule.h +++ b/src/modules/NodeInfoModule.h @@ -30,6 +30,18 @@ class NodeInfoModule : public ProtobufModule, 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, 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(). diff --git a/src/modules/games/Breakout.cpp b/src/modules/games/Breakout.cpp index f60f4d8bf..675a9569f 100644 --- a/src/modules/games/Breakout.cpp +++ b/src/modules/games/Breakout.cpp @@ -1,4 +1,5 @@ #include "Breakout.h" +#include // 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(BOARD_W / 2) * SUBPX; ballPxY = static_cast(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(paddleLeft + PADDLE_W / 2) * SUBPX; + ballPxY = static_cast(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 diff --git a/src/modules/games/Breakout.h b/src/modules/games/Breakout.h index 9c4fe8dfb..3d4fe6dbd 100644 --- a/src/modules/games/Breakout.h +++ b/src/modules/games/Breakout.h @@ -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; diff --git a/src/modules/games/ChirpyRunner.cpp b/src/modules/games/ChirpyRunner.cpp index 4e1a18521..3caba0b7a 100644 --- a/src/modules/games/ChirpyRunner.cpp +++ b/src/modules/games/ChirpyRunner.cpp @@ -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(); } diff --git a/src/modules/games/ChirpyRunner.h b/src/modules/games/ChirpyRunner.h index b86e1d775..6d4f1fb98 100644 --- a/src/modules/games/ChirpyRunner.h +++ b/src/modules/games/ChirpyRunner.h @@ -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; diff --git a/src/modules/games/Game.h b/src/modules/games/Game.h index 751b2d6b5..277252973 100644 --- a/src/modules/games/Game.h +++ b/src/modules/games/Game.h @@ -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 diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp index 606d1d505..2a5ce6dfa 100644 --- a/src/modules/games/GamesModule.cpp +++ b/src/modules/games/GamesModule.cpp @@ -56,10 +56,19 @@ void GamesModule::startPlaying() active->start(static_cast(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(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes), - GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast(score)); + GAMES_HIGH_SCORE_STRING, active->name(), static_cast(score), initials); service->sendToMesh(p); LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast(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()) diff --git a/src/modules/games/GamesModule.h b/src/modules/games/GamesModule.h index eb4642f19..f24ec0080 100644 --- a/src/modules/games/GamesModule.h +++ b/src/modules/games/GamesModule.h @@ -67,12 +67,18 @@ class GamesModule : public SinglePortModule, public Observable inputObserver = CallbackObserver(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 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); diff --git a/src/modules/games/Snake.h b/src/modules/games/Snake.h index 293707ff4..c32258d6e 100644 --- a/src/modules/games/Snake.h +++ b/src/modules/games/Snake.h @@ -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; diff --git a/src/modules/games/Tetris.cpp b/src/modules/games/Tetris.cpp index 7c24b3269..1a39ca59d 100644 --- a/src/modules/games/Tetris.cpp +++ b/src/modules/games/Tetris.cpp @@ -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; diff --git a/src/modules/games/Tetris.h b/src/modules/games/Tetris.h index 4b429ec2e..1844769bd 100644 --- a/src/modules/games/Tetris.h +++ b/src/modules/games/Tetris.h @@ -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; diff --git a/src/platform/portduino/ConfigCheck.cpp b/src/platform/portduino/ConfigCheck.cpp index d03ca7c09..950c063b9 100644 --- a/src/platform/portduino/ConfigCheck.cpp +++ b/src/platform/portduino/ConfigCheck.cpp @@ -37,6 +37,9 @@ constexpr int MAX_NODES_SANITY_CEILING = 16000; const std::set 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 kJoystickActions = {"select", "cancel", "back", "up", "down", "left", "right", "user", "userpress"}; + const std::map> &schema() { static const std::map> 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 &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 owner; // code -> the action that claimed it first + for (const auto &entry : node) { + std::string action = entry.first.as(""); + 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 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(""); + 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 &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")) || diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 8fe452aa0..9c908a4bc 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1297,21 +1297,33 @@ bool loadConfig(const char *configPath) portduino_config.pointerDevice = (yamlConfig["Input"]["PointerDevice"]).as(""); portduino_config.joystickDevice = (yamlConfig["Input"]["JoystickDevice"]).as(""); 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(""); 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(""), nullptr, 0); - } catch (const std::exception &) { - code = 0; + if (action == "") + continue; + // A bare scalar is just a one-entry list. + std::vector 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(""), 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; } } diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 3e77894f5..31838d42a 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -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> 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; } diff --git a/test/fixtures/portduino-config/README.md b/test/fixtures/portduino-config/README.md index 729d794fa..f0e386ed3 100644 --- a/test/fixtures/portduino-config/README.md +++ b/test/fixtures/portduino-config/README.md @@ -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//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 diff --git a/test/fixtures/portduino-config/joystick-buttons-bad.yaml b/test/fixtures/portduino-config/joystick-buttons-bad.yaml new file mode 100644 index 000000000..c25aad54c --- /dev/null +++ b/test/fixtures/portduino-config/joystick-buttons-bad.yaml @@ -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] diff --git a/test/fixtures/portduino-config/joystick-buttons.yaml b/test/fixtures/portduino-config/joystick-buttons.yaml new file mode 100644 index 000000000..d325f6f24 --- /dev/null +++ b/test/fixtures/portduino-config/joystick-buttons.yaml @@ -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 diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index b37a5e581..5ad16db39 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -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 diff --git a/test/support/AuthPipelineHarness.h b/test/support/AuthPipelineHarness.h new file mode 100644 index 000000000..0a3c91529 --- /dev/null +++ b/test/support/AuthPipelineHarness.h @@ -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 +#include +#include +#include +#include +#include +#include + +// 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 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 &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 txQueue; + std::vector 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(); + 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(); +} diff --git a/test/test_breakout/test_main.cpp b/test/test_breakout/test_main.cpp index 1ca2da5e2..d1b7b8dbe 100644 --- a/test/test_breakout/test_main.cpp +++ b/test/test_breakout/test_main.cpp @@ -2,11 +2,21 @@ #include "modules/games/Breakout.h" #include -// 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(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); diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index 3825ed5ee..d2b7b72a3 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -14,6 +14,10 @@ #if WARM_NODE_COUNT > 0 #include "mesh/NodeDB.h" +#if defined(ARCH_PORTDUINO) +#include "platform/portduino/PortduinoGlue.h" +#endif +#include #include // 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() {} diff --git a/test/test_nodeinfo_send_window/test_main.cpp b/test/test_nodeinfo_send_window/test_main.cpp new file mode 100644 index 000000000..4a8b63751 --- /dev/null +++ b/test/test_nodeinfo_send_window/test_main.cpp @@ -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 + +#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 +#include + +// 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(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 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() {} diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 50162dc5c..2a5cfdc98 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -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); diff --git a/test/test_rebroadcast_mode/test_main.cpp b/test/test_rebroadcast_mode/test_main.cpp new file mode 100644 index 000000000..ad8231eef --- /dev/null +++ b/test/test_rebroadcast_mode/test_main.cpp @@ -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 + +#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(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast(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 diff --git a/test/test_snake/test_main.cpp b/test/test_snake/test_main.cpp index 8668e66a5..5fa576ac6 100644 --- a/test/test_snake/test_main.cpp +++ b/test/test_snake/test_main.cpp @@ -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); diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini index dd7dc4a09..32ffda44e 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -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 diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp index 279dedeb5..fd802f752 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp @@ -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); diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h index 543a69089..1c50e13de 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h @@ -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*/ diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 4564b621a..d0bfcc88c 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -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