Games joystick input (#11917)

* fix(games): correct the high-score announcement argument order

GAMES_HIGH_SCORE_STRING is "New %s high score %lu by %s!" but the arguments
were passed as (name, initials, score): the initials string was formatted
through %lu and the score integer through %s. That is a format/argument
mismatch, so the announcement printed garbage at best and dereferenced the
score as a pointer at worst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(input): report which physical gamepad button produced an event

A joystick event only carried the action it was mapped to, so a consumer could
not tell two buttons apart once they shared one action, and games were limited
to the handful of actions the broker defines.

Carry the originating evdev button code in InputEvent::kbchar, encoded into a
reserved 0xC0..0xDF range that misses printable ASCII and every
INPUT_BROKER_MSG_ value (SystemCommands switches on kbchar without looking at
inputEvent, so a collision there would reboot the node rather than move a
paddle). D-pad events are axes, not buttons, and keep leaving kbchar at 0 --
which is exactly what lets a consumer tell stick from button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(portduino): let one joystick action bind several buttons

Input.JoystickButtons took a single evdev code per action, so a pad's A and Y
could not both select, and the shoulder buttons could not sit alongside the
D-pad. Accept a list of codes as well as a bare scalar; the config writer
inverts its code->action map back out, emitting a list only where an action
has more than one button.

ConfigCheck gains a real checker for the section (it was previously waved
through as free-form) covering the three ways a mapping silently does nothing:
an action name the driver does not know, an evdev name where the numeric code
belongs, and one code claimed by two actions. Two fixtures and shell-test
cases cover the clean list form and those three faults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(games): use the gamepad's extra buttons, and return home when idle

Games now receive the physical button alongside the action, so a pad with more
than two usable buttons controls more than two things:

- Snake: a shoulder button mapped to left/right turns relative to the snake's
  heading (L counter-clockwise, R clockwise) while the D-pad keeps steering
  absolutely. The two are told apart by kbchar, not by hardcoding one pad's
  codes.
- Breakout: the ball now rides the paddle after each serve until the player
  fires it with B or A, so a life is not lost to a ball already in flight when
  the player looks up. The paddle also keeps its position between lives. A game
  can claim BACK for the duration (Game::wantsBackButton) so B serves instead
  of pausing, and releases it once the ball is live.
- Start (BTN_BASE4 / BTN_START) is mapped to select like any other button, so
  it launches games and drives the menus; inside a running game GamesModule
  picks it out of kbchar and pauses instead.

Separately, the games frame no longer holds a walked-away device hostage: after
15 s with no input it returns to the home frame, so the device still reads as a
Meshtastic node. The timer is suspended while a picker or banner is up (e.g.
high-score initials entry, which the input handler never sees) so it cannot
yank the user out mid-entry.

Screen::isInteractionBusy() generalises the old module-intercept check --
modal module, intercepting module, game, or an open interactive overlay --
and MessageRenderer uses it before popping an incoming-message banner. A
transient banner REPLACES an active overlay, so an arriving message could
otherwise discard a half-entered high score. The message is still stored, its
thread still selected, and the unread indicator still set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ui): compose freetext on the on-screen keyboard from a gamepad

A gamepad can drive the on-screen keyboard but cannot type, so on a host with
a joystick and no configured keyboard device the OSK is the only way to compose
freetext. Set osk_found there, and gate the "Freetext" menu entries on whether
the device can enter text at all (physical keyboard, OSK, or touchscreen
virtual keyboard) rather than on kb_found alone -- those entries were hidden on
exactly the devices that needed them.

The OSK prompt that CannedMessageModule already had inline in the message
selector becomes showOnScreenKeyboard(), so the menu path can reach it too.
Menus call in from a banner callback and the banner is torn down as soon as
that callback returns, which would take the keyboard down with it, so the menu
path defers the launch to runOnce().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(games): address review on frame fallback and joystick input gating

Breakout: the paddle suppression was far too broad. aLinuxJoystick is
constructed on every Linux host whether or not a gamepad is configured
(InputBroker.cpp), so `aLinuxJoystick && kbchar == 0` was true everywhere and
swallowed LEFT/RIGHT from the keyboard, trackball and ExpressLRS -- on a host
with no joystick attached at all. Gate on the stick actually driving the paddle
instead: LinuxJoystick assigns heldX before it emits and only auto-repeats while
heldX is set, so every axis LEFT/RIGHT arrives with a zone held and nothing else
does. kbchar == 0 still distinguishes an axis from a shoulder button mapped to
left/right, which must keep nudging the paddle.

Screen: showHomeFrame() did nothing when the home frame was hidden, since
setFrames() only assigns positions.home for !hiddenFrames.home. That stranded
the games inactivity bounce on the frame it was trying to leave. Fall back to
the messages frame, which setFrames() always adds.

Test: rename test_ballWaitsOnPaddleUntilLaunched to
test_ball_waitsOnPaddleUntilLaunched, matching the repo convention and its
neighbours in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(games): give games the whole InputEvent so Breakout can identify the source

Follow-up to review on #11917. The previous narrowing still could not tell
sources apart: kbchar == 0 is shared by the joystick's D-pad axis and by every
other driver that sends a bare LEFT/RIGHT, so while the D-pad was held a
keyboard or touchscreen press was still discarded. heldXZone() proves the axis
is driving, not that this particular event came from it.

Pass the event itself to Game::handleInput() rather than (ev, kbchar). Games
that only care about the action read event->inputEvent; Snake keeps using
kbchar for shoulder steering; Breakout now also checks event->source against
LinuxJoystick's origin name, so only that driver's own axis repeats are
suppressed.

Chose the event over a third positional parameter so the signature does not
have to grow again the next time a game needs something the event already
carries.

All three conditions in Breakout are load-bearing: source says it came from
this gamepad, kbchar == 0 says it is the axis rather than a shoulder button
mapped to left/right, and heldXZone() != 0 says the axis is what is driving
right now so tick() already has it covered.

LinuxJoystick::originName() exposes the name the driver stamps into
InputEvent::source, alongside the existing heldXZone()/heldYZone() accessors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan BennettandClaude Opus 5 authored and GitHub committed 2026-09-20 04:11:18 +00:00
1 parent fa87e7d29c
commit ee02cc3426
31 files changed
+689 -131

No files matched your search

+11 -3
View File
@@ -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
+12
View File
@@ -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
+43 -12
View File
@@ -2275,18 +2275,8 @@ int Screen::handleInputEvent(const InputEvent *event)
// so long as a mesh module isn't using these events for some other purpose
if (showingNormalScreen) {
// Ask any MeshModules if they're handling keyboard input right now
bool inputIntercepted = false;
for (MeshModule *module : moduleFrames) {
if (module && module->interceptingKeyboardInput())
inputIntercepted = true;
}
#if BASEUI_HAS_GAMES
// The games frame isn't a moduleFrame, so check it explicitly: while a game is running it
// owns the D-pad (turns/pause) and we must not switch frames or open menus underneath it.
if (gamesModule && gamesModule->interceptingKeyboardInput())
inputIntercepted = true;
#endif
// Ask any MeshModules (and the games frame) if they're handling keyboard input right now
const bool inputIntercepted = anyModuleInterceptingInput();
// If no modules are using the input, move between frames
if (!inputIntercepted) {
@@ -2449,6 +2439,47 @@ bool Screen::isGamesFrameShown()
return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games;
}
void Screen::showHomeFrame()
{
if (!ui)
return;
// Home is optional -- setFrames() only adds it when !hiddenFrames.home, leaving the position
// 255. Bouncing to nothing would strand the caller on the frame it wanted to leave, so fall
// back to the messages frame, which setFrames() always adds.
const uint8_t target =
(framesetInfo.positions.home != 255) ? framesetInfo.positions.home : framesetInfo.positions.textMessage;
if (target != 255)
ui->switchToFrame(target);
}
bool Screen::anyModuleInterceptingInput()
{
for (MeshModule *module : moduleFrames) {
if (module && module->interceptingKeyboardInput())
return true;
}
#if BASEUI_HAS_GAMES
// The games frame isn't a moduleFrame, so check it explicitly: while a game is running it owns
// the D-pad (turns/pause) and we must not switch frames or open menus underneath it.
if (gamesModule && gamesModule->interceptingKeyboardInput())
return true;
#endif
return false;
}
bool Screen::isInteractionBusy()
{
// Something is holding the D-pad -- the user is mid-interaction. A modal module owns the whole
// screen; an intercepting one owns the keys on its own frame.
if (hasModalModule() || anyModuleInterceptingInput())
return true;
// An interactive overlay (picker / text entry) is open. Showing a transient banner REPLACES the
// active overlay, so this would silently discard whatever the user was entering. A plain
// text_banner is itself transient, so superseding one of those is fine.
const notificationTypeEnum nt = NotificationRenderer::current_notification_type;
return nt != notificationTypeEnum::none && nt != notificationTypeEnum::text_banner;
}
} // namespace graphics
#else
+16 -1
View File
@@ -260,7 +260,7 @@ class Screen : public concurrency::OSThread
std::vector<const uint8_t *> indicatorIcons; // Per-frame custom icon pointers
#if defined(OLED_COMPACT_UI)
std::vector<const char *> frameTitles; // Per-frame short labels, parallel to indicatorIcons
std::vector<const char *> frameTitles; // Per-frame short labels, parallel to indicatorIcons
#endif
Screen(const Screen &) = delete;
Screen &operator=(const Screen &) = delete;
@@ -294,6 +294,17 @@ class Screen : public concurrency::OSThread
// ignore D-pad input when the player has navigated to a different frame.
bool isGamesFrameShown();
// Jump straight to the home (device-focused) frame. Used to bounce back to a clearly "this is a
// Meshtastic node" screen after a game is left idle. Home is optional, so when it is hidden this
// falls back to the messages frame rather than staying put.
void showHomeFrame();
// True when the user is in the middle of something that must not be interrupted: a module (or
// game) is holding the D-pad, or an interactive overlay (picker / text entry) is open. Callers
// that would pop a transient banner should check this first -- a banner both covers the screen
// and REPLACES any interactive overlay, discarding a half-finished entry.
bool isInteractionBusy();
bool isScreenOn() { return screenOn; }
// Stores the last 4 of our hardware ID, to make finding the device for pairing easier
@@ -850,6 +861,10 @@ class Screen : public concurrency::OSThread
#endif
/// UI helper for rendering to frames and switching between them
// True if any module frame -- or the games frame, which is not a moduleFrame -- is currently
// holding the D-pad. Shared by the input router and isInteractionBusy().
bool anyModuleInterceptingInput();
OLEDDisplayUi *ui;
};
+17 -10
View File
@@ -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;
}
+7 -2
View File
@@ -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
+42
View File
@@ -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;
+6 -3
View File
@@ -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));
}
}
}
+5 -1
View File
@@ -37,11 +37,15 @@ class LinuxJoystick : public Observable<const InputEvent *>, public concurrency:
int heldXZone() const { return heldX; }
int heldYZone() const { return heldY; }
// The name this driver stamps into InputEvent::source. Lets a consumer tell an event that came
// from this gamepad from one that came from a keyboard or touchscreen carrying the same action.
const char *originName() const { return _originName; }
protected:
virtual int32_t runOnce() override;
private:
void emitEvent(input_broker_event event);
void emitEvent(input_broker_event event, unsigned char kbchar = 0);
const char *_originName;
bool firstTime = true;
+8 -1
View File
@@ -1082,6 +1082,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
@@ -1188,7 +1195,7 @@ void setup()
#ifndef ARCH_PORTDUINO
// Initialize Wifi
// Initialize Wifi
#if HAS_WIFI
initWifi();
#endif
+79 -50
View File
@@ -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;
+5
View File
@@ -190,6 +190,11 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
int handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
bool handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
bool handleFreeTextInput(const InputEvent *event);
// Opens the on-screen keyboard prompt for the current destination.
// Returns false when this device has no on-screen keyboard.
bool showOnScreenKeyboard();
// Set when a menu requested on-screen-keyboard compose; serviced by runOnce().
bool pendingOskLaunch = false;
#if defined(USE_VIRTUAL_KEYBOARD)
Letter keyboard[2][4][10] = {{{{"Q", 20, 0, 0, 0, 0},
+57 -11
View File
@@ -1,4 +1,5 @@
#include "Breakout.h"
#include <cstring> // strcmp, for the joystick-source test in handleInput()
// ===========================================================================
// Pure BreakoutGame logic (no display/FS dependencies; always compiled)
@@ -26,11 +27,18 @@ void BreakoutGame::serveBall()
{
// Centre the paddle and launch the ball upward from just above it, at a slight angle whose
// side is chosen randomly so successive serves are not identical.
paddleLeft = (BOARD_W - PADDLE_W) / 2;
ballPxX = static_cast<int32_t>(BOARD_W / 2) * SUBPX;
ballPxY = static_cast<int32_t>(PADDLE_Y - 2) * SUBPX;
ballVx = (nextRandom() & 1u) ? 28 : -28;
ballVy = -BALL_VY;
ballDocked = true; // wait for the player to fire before the ball moves
}
void BreakoutGame::launchBall()
{
if (alive)
ballDocked = false;
}
void BreakoutGame::nextLevel()
@@ -48,6 +56,9 @@ void BreakoutGame::reset(uint32_t seed)
levelNum = 1;
alive = true;
ballTick = false;
// A new game starts centred. serveBall() deliberately does NOT re-centre, so the paddle keeps
// the player's position between lives.
paddleLeft = (BOARD_W - PADDLE_W) / 2;
buildBricks();
serveBall();
}
@@ -78,6 +89,14 @@ bool BreakoutGame::step()
if (!alive)
return false;
// Waiting to serve: the ball rides the centre of the paddle (so it still aims by sliding) and
// no physics, collisions or life losses happen until the player fires it.
if (ballDocked) {
ballPxX = static_cast<int32_t>(paddleLeft + PADDLE_W / 2) * SUBPX;
ballPxY = static_cast<int32_t>(PADDLE_Y - 2) * SUBPX;
return true;
}
// The ball advances on every other step() so the caller can tick (and poll the paddle) at twice
// the ball's rate -- this keeps the ball speed constant while paddle control refreshes faster.
ballTick = !ballTick;
@@ -224,20 +243,41 @@ bool Breakout::tick()
return game.step();
}
void Breakout::handleInput(input_broker_event ev)
void Breakout::handleInput(const InputEvent *event)
{
#if ARCH_PORTDUINO && defined(__linux__)
// When a joystick is present the paddle is polled continuously in tick(); ignore the discrete
// (and slow) repeat events so we don't double-move.
if (aLinuxJoystick)
return;
#endif
const input_broker_event ev = event->inputEvent;
switch (ev) {
case INPUT_BROKER_LEFT:
game.moveLeft();
break;
case INPUT_BROKER_RIGHT:
game.moveRight();
#if ARCH_PORTDUINO && defined(__linux__)
// While the stick is held, tick() polls heldXZone() and moves the paddle itself, so the
// joystick's own discrete (and slow) repeat events would double-move. Suppress exactly
// those and nothing else.
//
// All three parts are load-bearing:
// source -- the event actually came from this gamepad. Without it, LEFT/RIGHT from the
// keyboard or touchscreen is swallowed too; aLinuxJoystick is constructed on
// every Linux host whether or not a gamepad is configured, so a pointer check
// alone is true even with nothing attached.
// kbchar -- no button produced it, so it is the D-pad axis. A shoulder button mapped to
// left/right is a discrete press the axis poll knows nothing about, and must
// still nudge the paddle.
// heldX -- the axis is what is driving right now, so tick() already has it covered.
if (aLinuxJoystick && event->kbchar == 0 && event->source && aLinuxJoystick->originName() &&
strcmp(event->source, aLinuxJoystick->originName()) == 0 && aLinuxJoystick->heldXZone() != 0)
break;
#endif
if (ev == INPUT_BROKER_LEFT)
game.moveLeft();
else
game.moveRight();
break;
// Serve: B (CANCEL/BACK, forwarded here via wantsBackButton) or A (SELECT) fires a docked ball.
case INPUT_BROKER_CANCEL:
case INPUT_BROKER_BACK:
case INPUT_BROKER_SELECT:
case INPUT_BROKER_SELECT_LONG:
game.launchBall();
break;
default:
break;
@@ -298,6 +338,12 @@ void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
// Ball.
display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2);
// Waiting to serve: prompt the player (the ball rides the paddle until then).
if (game.isBallDocked()) {
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(x + display->getWidth() / 2, y + BreakoutGame::PADDLE_Y - FONT_HEIGHT_SMALL - 4, "B TO SERVE");
}
#if GRAPHICS_TFT_COLORING_ENABLED
// Colour the wall by row, plus a blue paddle and white ball. One region per brick row (the row's
// lit bricks take the colour; cleared cells and gaps stay background). Paddle then ball register
+11 -2
View File
@@ -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;
+2 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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;
+15 -1
View File
@@ -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
+69 -6
View File
@@ -56,10 +56,19 @@ void GamesModule::startPlaying()
active->start(static_cast<uint32_t>(random()) ^ millis());
uiState = GAMES_PLAYING;
lastAwakeKickMs = millis();
noteActivity();
kickTick();
requestRedraw();
}
void GamesModule::goHome()
{
// Left idle too long: drop any game and return to the clearly-Meshtastic home frame.
exitToIdle();
if (screen)
screen->showHomeFrame();
}
void GamesModule::enterGameOver()
{
lastScore = active ? active->score() : 0;
@@ -113,7 +122,7 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score)
// One shared message for every game, with the game's name spliced in. ASCII only -- avoids tofu
// if a receiving node's font lacks a glyph.
p->decoded.payload.size = snprintf(reinterpret_cast<char *>(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes),
GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast<unsigned long>(score));
GAMES_HIGH_SCORE_STRING, active->name(), static_cast<unsigned long>(score), initials);
service->sendToMesh(p);
LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast<unsigned long>(score));
}
@@ -151,6 +160,27 @@ void GamesModule::kickTick()
int32_t GamesModule::runOnce()
{
const uint32_t now = millis();
// Whether the games UI is actually in front of the player: a game is active (which forces the
// games frame), or the attract screen is the current frame. When it is, an idle stretch bounces
// back to the home frame so a walked-away device reads as a Meshtastic node.
const bool gamesVisible = (uiState != GAMES_IDLE) || (screen && screen->isGamesFrameShown());
if (gamesVisible) {
if (screen && screen->isOverlayBannerShowing()) {
// A picker or banner is up (e.g. high-score initials entry, which our handleInputEvent
// never sees). The user is busy with it -- don't time out and yank them away.
lastActivityMs = now;
} else if (now - lastActivityMs >= INACTIVITY_TIMEOUT_MS) {
goHome();
return disable();
}
} else {
// Not in front of the player (attract screen is just one of the rotating frames, and the
// player is elsewhere): keep the timer fresh so a later visit starts a full 15 s.
lastActivityMs = now;
}
if (uiState == GAMES_PLAYING && active) {
if (!active->tick()) {
enterGameOver();
@@ -158,7 +188,6 @@ int32_t GamesModule::runOnce()
}
// Keep the display awake through long runs that generate no key presses.
const uint32_t now = millis();
if (now - lastAwakeKickMs > 1500) {
powerFSM.trigger(EVENT_PRESS);
lastAwakeKickMs = now;
@@ -168,13 +197,18 @@ int32_t GamesModule::runOnce()
return active->tickIntervalMs();
}
// Idle: service any game that broadcasts periodically; sleep until the soonest one is due.
// Idle-ish (attract / paused / game-over / high scores): service any periodic mesh broadcast,
// and while the games UI is visible keep a slow poll running so the inactivity timeout fires.
int32_t next = -1;
for (Game *g : games) {
const int32_t due = g->meshTick(*this);
if (due >= 0 && (next < 0 || due < next))
next = due;
}
if (gamesVisible) {
const int32_t poll = 1000;
return (next >= 0 && next < poll) ? next : poll;
}
return next < 0 ? disable() : next;
}
@@ -191,9 +225,16 @@ int GamesModule::handleInputEvent(const InputEvent *event)
if (screen->isOverlayBannerShowing())
return 0; // a menu banner is up; don't steal its input
noteActivity(); // any input on the games frame resets the return-to-home timer
const input_broker_event ev = event->inputEvent;
const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK);
// Start is mapped to select like any other button, so it launches games and works the menus.
// Inside a running game it means pause/resume instead, which we can tell only because kbchar
// names the physical button behind the action.
const bool isPauseButton = (ev == INPUT_BROKER_SELECT && isJoyStartButton(event->kbchar));
switch (uiState) {
case GAMES_IDLE:
// Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it;
@@ -214,12 +255,22 @@ int GamesModule::handleInputEvent(const InputEvent *event)
return 0;
case GAMES_PLAYING:
if (isBack) {
// Start pauses, and is never forwarded to the game: unlike BACK it has no second meaning
// in play, so a game cannot claim it the way Breakout claims BACK to serve.
if (isPauseButton) {
uiState = GAMES_PAUSED;
disable();
requestRedraw();
return 1;
}
// BACK pauses, unless the active game has temporarily claimed that button (see
// Game::wantsBackButton) -- then it is forwarded like any other key.
if (isBack && !(active && active->wantsBackButton())) {
uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit
disable();
requestRedraw();
} else if (active) {
active->handleInput(ev);
active->handleInput(event);
if (!active->isPlaying()) {
enterGameOver();
return 1;
@@ -232,7 +283,7 @@ int GamesModule::handleInputEvent(const InputEvent *event)
if (isBack) {
exitToIdle(); // quit from pause
} else if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_UP || ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_LEFT ||
ev == INPUT_BROKER_RIGHT) {
ev == INPUT_BROKER_RIGHT) { // Start arrives as SELECT, so it resumes too
uiState = GAMES_PLAYING;
kickTick();
requestRedraw();
@@ -317,6 +368,18 @@ void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/
{
display->setColor(WHITE);
// drawFrame runs only while the games frame is the current frame. Several idle-ish states
// (attract / paused / game-over / high-scores) otherwise leave the tick thread asleep, so use
// this render as the trigger to (re)start the slow inactivity poll and reset the timer -- so
// arriving on such a screen gets a fresh 15 s before we bounce back home, and walking away from
// it eventually does. (While a game is PLAYING the thread is already ticking, so !enabled is
// false here and the play-time timer is left to run.)
if (!enabled) {
noteActivity();
enabled = true;
setIntervalFromNow(1000);
}
switch (uiState) {
case GAMES_IDLE:
if (!games.empty())
+7
View File
@@ -67,12 +67,18 @@ class GamesModule : public SinglePortModule, public Observable<const UIFrameEven
CallbackObserver<GamesModule, const InputEvent *> inputObserver =
CallbackObserver<GamesModule, const InputEvent *>(this, &GamesModule::handleInputEvent);
// After this long with no input while the games frame is up, bounce back to the home frame so a
// walked-away device clearly reads as a Meshtastic node rather than sitting on a game screen.
static constexpr uint32_t INACTIVITY_TIMEOUT_MS = 15000;
// === State transitions ===
void startPlaying();
void enterGameOver();
void exitToIdle();
void requestRedraw();
void kickTick();
void noteActivity() { lastActivityMs = millis(); } // reset the inactivity timer
void goHome(); // exit any game and switch to the home frame
// === Shared game-over / high-score flow ===
void promptForInitials();
@@ -94,6 +100,7 @@ class GamesModule : public SinglePortModule, public Observable<const UIFrameEven
int lastRank = -1; // rank achieved last game (-1 == didn't place)
bool lastWasNewTop = false; // last game set a new all-time #1
uint32_t lastAwakeKickMs = 0; // throttles the power-FSM wake nudge during long runs
uint32_t lastActivityMs = 0; // millis() of the last input / becoming-visible (inactivity timer)
};
extern GamesModule *gamesModule;
+22 -1
View File
@@ -45,6 +45,15 @@ bool SnakeGame::setDirection(Direction d)
return true;
}
void SnakeGame::turn(bool clockwise)
{
// Indexed by Direction (UP, DOWN, LEFT, RIGHT). Screen space has y growing downward, so the
// clockwise cycle the player sees is UP -> RIGHT -> DOWN -> LEFT.
static constexpr Direction CW[4] = {DIR_RIGHT, DIR_LEFT, DIR_UP, DIR_DOWN};
static constexpr Direction CCW[4] = {DIR_LEFT, DIR_RIGHT, DIR_DOWN, DIR_UP};
setDirection(clockwise ? CW[dir] : CCW[dir]);
}
uint32_t SnakeGame::nextRandom()
{
uint32_t x = rng;
@@ -172,8 +181,20 @@ int32_t Snake::tickIntervalMs() const
return iv < 70 ? 70 : iv;
}
void Snake::handleInput(input_broker_event ev)
void Snake::handleInput(const InputEvent *event)
{
const input_broker_event ev = event->inputEvent;
const unsigned char kbchar = event->kbchar;
// Shoulder-button steering: a gamepad button mapped to left/right turns the snake relative to
// where it is already heading (L counter-clockwise, R clockwise) rather than setting an
// absolute heading. The D-pad and keyboard keep steering absolutely -- the D-pad is an axis,
// so it arrives with no button in kbchar, which is exactly what tells the two apart.
if (isJoyButton(kbchar) && (ev == INPUT_BROKER_LEFT || ev == INPUT_BROKER_RIGHT)) {
game.turn(ev == INPUT_BROKER_RIGHT);
return;
}
switch (ev) {
case INPUT_BROKER_UP:
game.setDirection(SnakeGame::DIR_UP);
+13 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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;
+59 -1
View File
@@ -37,6 +37,9 @@ constexpr int MAX_NODES_SANITY_CEILING = 16000;
const std::set<std::string> kLoraPinKeys = {"CS", "IRQ", "Busy", "Reset", "TXen", "RXen", "SX126X_ANT_SW", "GPIO_DETECT_PA"};
// Action names LinuxJoystick understands; anything else leaves the button unmapped.
const std::set<std::string> kJoystickActions = {"select", "cancel", "back", "up", "down", "left", "right", "user", "userpress"};
const std::map<std::string, std::set<std::string>> &schema()
{
static const std::map<std::string, std::set<std::string>> s = {
@@ -366,6 +369,61 @@ void checkPinNode(const std::string &file, const std::string &path, const YAML::
}
}
// Keyed by action, not by button, so one action can list several codes and have every one of
// those buttons drive it. The value is a single evdev code or a list of them.
void checkJoystickButtons(const std::string &file, const YAML::Node &node, std::vector<Finding> &findings)
{
if (!node.IsMap()) {
findings.push_back(
{kError, file, lineOf(node), "Input.JoystickButtons must be a mapping of action name to evdev button code"});
return;
}
std::map<int, std::string> owner; // code -> the action that claimed it first
for (const auto &entry : node) {
std::string action = entry.first.as<std::string>("");
for (auto &c : action)
c = tolower(c);
if (!kJoystickActions.count(action)) {
findings.push_back({kWarn, file, lineOf(entry.first),
"Input.JoystickButtons: '" + action +
"' is not a recognised action, so those buttons do nothing. Valid actions are select, "
"cancel, back, up, down, left, right and user"});
continue;
}
std::vector<YAML::Node> codeNodes;
if (entry.second.IsSequence())
for (const auto &codeNode : entry.second)
codeNodes.push_back(codeNode);
else
codeNodes.push_back(entry.second);
for (const auto &codeNode : codeNodes) {
const std::string raw = codeNode.as<std::string>("");
int code = 0;
try {
code = std::stoi(raw, nullptr, 0);
} catch (const std::exception &) {
code = 0;
}
if (code == 0) {
findings.push_back({kWarn, file, lineOf(codeNode),
"Input.JoystickButtons." + action + ": '" + raw +
"' is not an evdev button code (hex like 0x121, or decimal), so it is unmapped"});
continue;
}
// One button cannot do two things: the later action silently replaces the earlier one.
const auto claimed = owner.find(code);
if (claimed != owner.end() && claimed->second != action)
findings.push_back({kWarn, file, lineOf(codeNode),
"Input.JoystickButtons: button " + raw + " is mapped to both '" + claimed->second +
"' and '" + action + "'. Only '" + action + "' takes effect"});
owner[code] = action;
}
}
}
void checkRfSwitchTable(const std::string &file, const YAML::Node &table, std::vector<Finding> &findings)
{
if (!table.IsMap()) {
@@ -837,7 +895,7 @@ void checkSection(const std::string &file, const std::string &section, 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")) ||
+22 -10
View File
@@ -1297,21 +1297,33 @@ bool loadConfig(const char *configPath)
portduino_config.pointerDevice = (yamlConfig["Input"]["PointerDevice"]).as<std::string>("");
portduino_config.joystickDevice = (yamlConfig["Input"]["JoystickDevice"]).as<std::string>("");
if (yamlConfig["Input"]["JoystickButtons"]) {
// action name -> evdev button code (hex like 0x122 or decimal); stored inverted
// as code -> lowercase action name for the driver to look up per keypress.
// action name -> evdev button code (hex like 0x122 or decimal), or a list of codes
// so several physical buttons drive the same action. Stored inverted as
// code -> lowercase action name for the driver to look up per keypress.
for (const auto &button : yamlConfig["Input"]["JoystickButtons"]) {
std::string action = button.first.as<std::string>("");
for (auto &c : action)
c = tolower(c);
int code = 0;
try {
// base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry.
code = std::stoi(button.second.as<std::string>(""), nullptr, 0);
} catch (const std::exception &) {
code = 0;
if (action == "")
continue;
// A bare scalar is just a one-entry list.
std::vector<YAML::Node> codeNodes;
if (button.second.IsSequence())
for (const auto &codeNode : button.second)
codeNodes.push_back(codeNode);
else
codeNodes.push_back(button.second);
for (const auto &codeNode : codeNodes) {
int code = 0;
try {
// base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry.
code = std::stoi(codeNode.as<std::string>(""), nullptr, 0);
} catch (const std::exception &) {
code = 0;
}
if (code != 0)
portduino_config.joystickButtons[code] = action;
}
if (code != 0 && action != "")
portduino_config.joystickButtons[code] = action;
}
}
+16 -2
View File
@@ -545,9 +545,23 @@ extern struct portduino_config_struct {
if (joystickDevice != "")
out << YAML::Key << "JoystickDevice" << YAML::Value << joystickDevice;
if (!joystickButtons.empty()) {
out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap;
// Stored as code -> action; invert so each action lists every code bound to it.
// Several buttons may share one action, so a multi-code action emits a list.
std::map<std::string, std::vector<int>> codesByAction;
for (const auto &button : joystickButtons)
out << YAML::Key << button.second << YAML::Value << button.first;
codesByAction[button.second].push_back(button.first);
out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap;
for (const auto &action : codesByAction) {
out << YAML::Key << action.first << YAML::Value;
if (action.second.size() == 1) {
out << action.second.front();
} else {
out << YAML::Flow << YAML::BeginSeq;
for (const int code : action.second)
out << code;
out << YAML::EndSeq;
}
}
out << YAML::EndMap;
}
+10
View File
@@ -161,6 +161,16 @@ if those yield nothing meshtasticd exits with "Blank MAC Address not allowed!".
| `mac-malformed.yaml` | `AA:BB:CC` is under 12 hex digits, so it is silently dropped. |
| `mac-source-missing.yaml` | Names an interface with no `/sys/class/net/<n>/address`. Warning, not an error: it is machine-dependent and may be checked on another host. |
## Joystick buttons
`Input.JoystickButtons` is keyed by action, not by button, so an action can name a
single evdev code or a list of them and every one of those buttons drives it.
| File | Expected |
| --------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `joystick-buttons.yaml` | **Clean, and a regression guard** - four actions, two of them with several codes. |
| `joystick-buttons-bad.yaml` | Three silent no-ops: an action name nothing reads, an evdev name where a code belongs, one code claimed twice. |
## CH341 USB-SPI adapters
`spidev: ch341` is a different hardware model, not a variant of the same one. The Lora
@@ -0,0 +1,16 @@
# FAULT: three ways a joystick mapping silently does nothing. 'fire' is not an action
# LinuxJoystick knows, so those buttons are dropped; 'BTN_SOUTH' is a name rather than the
# numeric evdev code the loader parses; and 0x121 appears under two actions, where the
# later one wins and the earlier binding is lost.
Lora:
Module: sx1262
CS: 21
IRQ: 16
Busy: 20
Reset: 18
Input:
JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick
JoystickButtons:
select: [0x121, BTN_SOUTH]
fire: 0x123
cancel: [0x122, 0x121]
+17
View File
@@ -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
+56 -8
View File
@@ -2,11 +2,21 @@
#include "modules/games/Breakout.h"
#include <unity.h>
// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on
// a straight-up serve, and the ball staying within the board. No device globals or display stack.
// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, the ball waiting
// on the paddle until launched, brick-clearing on a straight-up serve, and the ball staying within
// the board. No device globals or display stack.
static const uint32_t kSeed = 0xC0FFEEu;
// The ball docks on the paddle after every serve (including after losing a life), so a test that
// wants continuous play has to fire it whenever it is waiting.
static void stepLaunched(BreakoutGame &game)
{
if (game.isBallDocked())
game.launchBall();
game.step();
}
void test_reset_initialState()
{
BreakoutGame game;
@@ -21,6 +31,37 @@ void test_reset_initialState()
TEST_ASSERT_EQUAL_INT16((BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W) / 2, game.paddleX());
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
TEST_ASSERT_TRUE(game.ballY() >= 0 && game.ballY() < BreakoutGame::BOARD_H);
// The ball waits on the paddle until the player serves it.
TEST_ASSERT_TRUE(game.isBallDocked());
}
void test_ball_waitsOnPaddleUntilLaunched()
{
BreakoutGame game;
game.reset(kSeed);
const int16_t restY = game.ballY();
// Stepping without serving must not move the ball vertically, lose a life, or end the run.
for (int i = 0; i < 50; i++)
game.step();
TEST_ASSERT_TRUE(game.isBallDocked());
TEST_ASSERT_EQUAL_INT16(restY, game.ballY());
TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives());
TEST_ASSERT_TRUE(game.isPlaying());
// A docked ball tracks the paddle, so it can still be aimed before serving.
const int16_t beforeX = game.ballX();
for (int i = 0; i < 5; i++)
game.moveLeft();
game.step();
TEST_ASSERT_TRUE(game.ballX() < beforeX);
// Once launched it is in play and starts climbing toward the bricks.
game.launchBall();
TEST_ASSERT_FALSE(game.isBallDocked());
for (int i = 0; i < 4; i++)
game.step();
TEST_ASSERT_TRUE(game.ballY() < restY);
}
void test_paddle_clampsToEdges()
@@ -39,8 +80,9 @@ void test_serve_clearsABrickAndScores()
{
BreakoutGame game;
game.reset(kSeed);
// The ball serves upward from just above the paddle straight into the brick field; within a
// few dozen steps it must clear at least one brick and score.
// Once served, the ball travels upward from just above the paddle straight into the brick field;
// within a few dozen steps it must clear at least one brick and score.
game.launchBall();
for (int i = 0;
i < 60 && game.bricksRemaining() == static_cast<uint16_t>(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++)
game.step();
@@ -59,7 +101,7 @@ void test_ball_staysInBounds()
game.moveLeft();
else
game.moveRight();
game.step();
stepLaunched(game);
TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
TEST_ASSERT_TRUE(game.ballY() >= 0);
}
@@ -69,10 +111,15 @@ void test_deadGame_stepIsNoOp()
{
BreakoutGame game;
game.reset(kSeed);
// Park the paddle in a corner and never move it; the ball is eventually lost every life.
game.moveLeft();
// Serve each ball, then steer the paddle AWAY from it so every ball is missed and all lives
// drain. (The ball re-docks after each loss, so it has to be re-served. Note it now serves from
// the paddle's centre, so simply parking the paddle would let it rally instead of dying.)
for (int i = 0; i < 20000 && game.isPlaying(); i++) {
for (int j = 0; j < 40; j++) // hold the paddle pinned left
if (game.isBallDocked())
game.launchBall();
else if (game.ballX() < game.paddleX())
game.moveRight();
else
game.moveLeft();
game.step();
}
@@ -92,6 +139,7 @@ void setup()
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_reset_initialState);
RUN_TEST(test_ball_waitsOnPaddleUntilLaunched);
RUN_TEST(test_paddle_clampsToEdges);
RUN_TEST(test_serve_clearsABrickAndScores);
RUN_TEST(test_ball_staysInBounds);
+42
View File
@@ -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);