Compare commits

...

8 Commits

Author SHA1 Message Date
Eric Sesterhenn
94b7149958 Remove unused hmx variable (#9529)
The variable is not used at all in the function, remove it to
silence the compiler warning.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-02-04 17:11:44 -06:00
Jonathan Bennett
ac611c4b62 Add agc reset attempt (#8163)
* Add agc reset attempt

* Add radioLibInterface include

* Trunk

* AGC reset don't crash, don't naively call

* Update src/main.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Use Throttle function

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 14:47:44 -06:00
Max
89df5ef669 Undefine LED_BUILTIN (#9531)
Keep variant in sync with 
https://github.com/meshtastic/firmware/commit/df40085

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-02-04 11:15:49 -06:00
Jason P
bfc3eebd54 HotFix for ReplyBot - Modules.cpp included and moved configuration.h (#9532) 2026-02-04 08:56:50 -06:00
Mattatat25
538a5f0dfc Add reply bot module with DM-only responses and rate limiting (#9456)
* Implement Meshtastic reply bot module with ping and status features

Adds a reply bot module that listens for /ping, /hello, and /test commands received via direct messages or broadcasts on the primary channel. The module always replies via direct message to the sender only, reporting hop count, RSSI, and SNR. Per-sender cooldowns are enforced to reduce network spam, and the module can be excluded at build time via a compile flag. Updates include the new module source files and required build configuration changes.

* Update ReplyBotModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/ReplyBotModule.h

Match the existing MESHTASTIC_EXCLUDE_* guard pattern so the module is excluded by default.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/ReplyBotModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Tidying up

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-02-03 20:45:02 -06:00
Vortetty
b7db22055d Inkhud battery icon improvements. (#9513)
* Inkhud battery icon improvements.
Fixes the battery icon draining from the flat side towards the bump, which is backwards from general design language seen on most devices
By request of kr0n05_ on discord, adds the ability to mirror the battery icon which fixes that issue in another way, and is also a common design seen on other devices.

* Remove option for icon mirroring

* Add border + dither to battery to prevent font overlap

* Fix trunk format

* Code cleanup, courtesy of Xaositek.
2026-02-03 20:02:54 -05:00
Eric Sesterhenn
0703e0e6d7 Make sure we always return a value in NodeDB::restorePreferences() (#9516)
In case FScom is not defined there is no return statement. This
moves the return outside of the ifdef to make sure a defined
value is returned.
2026-02-03 06:22:33 -06:00
Jonathan Bennett
f514bc230b Prefer EXT_PWR_DETECT pin over chargingVolt to detect power unplugged (#9511) 2026-02-03 00:13:49 -06:00
14 changed files with 254 additions and 29 deletions

View File

@@ -50,6 +50,7 @@ build_flags = -Wno-missing-field-initializers
-DRADIOLIB_EXCLUDE_APRS=1 -DRADIOLIB_EXCLUDE_APRS=1
-DRADIOLIB_EXCLUDE_LORAWAN=1 -DRADIOLIB_EXCLUDE_LORAWAN=1
-DMESHTASTIC_EXCLUDE_DROPZONE=1 -DMESHTASTIC_EXCLUDE_DROPZONE=1
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
-DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1 -DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1
-DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1 -DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware

View File

@@ -459,6 +459,8 @@ class AnalogBatteryLevel : public HasBatteryLevel
} }
// if it's not HIGH - check the battery // if it's not HIGH - check the battery
#endif #endif
// If we have an EXT_PWR_DETECT pin and it indicates no external power, believe it.
return false;
// technically speaking this should work for all(?) NRF52 boards // technically speaking this should work for all(?) NRF52 boards
// but needs testing across multiple devices. NRF52 USB would not even work if // but needs testing across multiple devices. NRF52 USB would not even work if

View File

@@ -221,7 +221,6 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
if (rtc_sec > 0) { if (rtc_sec > 0) {
// === Build Time String === // === Build Time String ===
long hms = (rtc_sec % SEC_PER_DAY + SEC_PER_DAY) % SEC_PER_DAY;
int hour, minute, second; int hour, minute, second;
graphics::decomposeTime(rtc_sec, hour, minute, second); graphics::decomposeTime(rtc_sec, hour, minute, second);
snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute); snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute);

View File

@@ -1,3 +1,5 @@
#include "graphics/niche/InkHUD/Tile.h"
#include <cstdint>
#ifdef MESHTASTIC_INCLUDE_INKHUD #ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./Applet.h" #include "./Applet.h"
@@ -785,6 +787,16 @@ void InkHUD::Applet::drawHeader(std::string text)
drawPixel(x, 0, BLACK); drawPixel(x, 0, BLACK);
drawPixel(x, headerDivY, BLACK); // Dotted 50% drawPixel(x, headerDivY, BLACK); // Dotted 50%
} }
// Dither near battery
if (settings->optionalFeatures.batteryIcon) {
constexpr uint16_t ditherSizePx = 4;
Tile *batteryTile = ((Applet *)inkhud->getSystemApplet("BatteryIcon"))->getTile();
const uint16_t batteryTileLeft = batteryTile->getLeft();
const uint16_t batteryTileTop = batteryTile->getTop();
const uint16_t batteryTileHeight = batteryTile->getHeight();
hatchRegion(batteryTileLeft - ditherSizePx, batteryTileTop, ditherSizePx, batteryTileHeight, 2, WHITE);
}
} }
// Get the height of the standard applet header // Get the height of the standard applet header

View File

@@ -48,37 +48,27 @@ int InkHUD::BatteryIconApplet::onPowerStatusUpdate(const meshtastic::Status *sta
void InkHUD::BatteryIconApplet::onRender(bool full) void InkHUD::BatteryIconApplet::onRender(bool full)
{ {
// Fill entire tile // Clear the region beneath the tile, including the border
// - size of icon controlled by size of tile
int16_t l = 0;
int16_t t = 0;
uint16_t w = width();
int16_t h = height();
// Clear the region beneath the tile
// Most applets are drawing onto an empty frame buffer and don't need to do this // Most applets are drawing onto an empty frame buffer and don't need to do this
// We do need to do this with the battery though, as it is an "overlay" // We do need to do this with the battery though, as it is an "overlay"
fillRect(l, t, w, h, WHITE); fillRect(0, 0, width(), height(), WHITE);
// Vertical centerline
const int16_t m = t + (h / 2);
// ===================== // =====================
// Draw battery outline // Draw battery outline
// ===================== // =====================
// Positive terminal "bump" // Positive terminal "bump"
const int16_t &bumpL = l;
const uint16_t bumpH = h / 2;
const int16_t bumpT = m - (bumpH / 2);
constexpr uint16_t bumpW = 2; constexpr uint16_t bumpW = 2;
const int16_t &bumpL = 1;
const uint16_t bumpH = (height() - 2) / 2;
const int16_t bumpT = (1 + ((height() - 2) / 2)) - (bumpH / 2);
fillRect(bumpL, bumpT, bumpW, bumpH, BLACK); fillRect(bumpL, bumpT, bumpW, bumpH, BLACK);
// Main body of battery // Main body of battery
const int16_t bodyL = bumpL + bumpW; const int16_t bodyL = 1 + bumpW;
const int16_t &bodyT = t; const int16_t &bodyT = 1;
const int16_t &bodyH = h; const int16_t &bodyH = height() - 2; // Handle top/bottom padding
const int16_t bodyW = w - bumpW; const int16_t bodyW = (width() - 1) - bumpW; // Handle 1px left pad
drawRect(bodyL, bodyT, bodyW, bodyH, BLACK); drawRect(bodyL, bodyT, bodyW, bodyH, BLACK);
// Erase join between bump and body // Erase join between bump and body
@@ -89,12 +79,13 @@ void InkHUD::BatteryIconApplet::onRender(bool full)
// =================== // ===================
constexpr int16_t slicePad = 2; constexpr int16_t slicePad = 2;
const int16_t sliceL = bodyL + slicePad; int16_t sliceL = bodyL + slicePad;
const int16_t sliceT = bodyT + slicePad; const int16_t sliceT = bodyT + slicePad;
const uint16_t sliceH = bodyH - (slicePad * 2); const uint16_t sliceH = bodyH - (slicePad * 2);
uint16_t sliceW = bodyW - (slicePad * 2); uint16_t sliceW = bodyW - (slicePad * 2);
sliceW = (sliceW * socRounded) / 100; // Apply percentage sliceW = (sliceW * socRounded) / 100; // Apply percentage
sliceL += ((bodyW - (slicePad * 2)) - sliceW); // Shift slice to the battery's negative terminal, correcting drain direction
hatchRegion(sliceL, sliceT, sliceW, sliceH, 2, BLACK); hatchRegion(sliceL, sliceT, sliceW, sliceH, 2, BLACK);
drawRect(sliceL, sliceT, sliceW, sliceH, BLACK); drawRect(sliceL, sliceT, sliceW, sliceH, BLACK);

View File

@@ -510,10 +510,10 @@ void InkHUD::WindowManager::placeSystemTiles()
const uint16_t batteryIconWidth = batteryIconHeight * 1.8; const uint16_t batteryIconWidth = batteryIconHeight * 1.8;
inkhud->getSystemApplet("BatteryIcon") inkhud->getSystemApplet("BatteryIcon")
->getTile() ->getTile()
->setRegion(inkhud->width() - batteryIconWidth, // x ->setRegion(inkhud->width() - batteryIconWidth - 1, // x
2, // y 1, // y
batteryIconWidth, // width batteryIconWidth + 1, // width
batteryIconHeight); // height batteryIconHeight + 2); // height
// Note: the tiles of placeholder and menu applets are manipulated specially // Note: the tiles of placeholder and menu applets are manipulated specially
// - menuApplet borrows user tiles // - menuApplet borrows user tiles

View File

@@ -7,6 +7,7 @@
#include "NodeDB.h" #include "NodeDB.h"
#include "PowerFSM.h" #include "PowerFSM.h"
#include "PowerMon.h" #include "PowerMon.h"
#include "RadioLibInterface.h"
#include "ReliableRouter.h" #include "ReliableRouter.h"
#include "airtime.h" #include "airtime.h"
#include "buzz.h" #include "buzz.h"
@@ -193,6 +194,8 @@ bool kb_found = false;
// global bool to record that on-screen keyboard (OSK) is present // global bool to record that on-screen keyboard (OSK) is present
bool osk_found = false; bool osk_found = false;
unsigned long last_listen = 0;
// The I2C address of the RTC Module (if found) // The I2C address of the RTC Module (if found)
ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE; ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE;
// The I2C address of the Accelerometer (if found) // The I2C address of the Accelerometer (if found)
@@ -1166,6 +1169,12 @@ void loop()
#endif #endif
power->powerCommandsCheck(); power->powerCommandsCheck();
if (RadioLibInterface::instance != nullptr && !Throttle::isWithinTimespanMs(last_listen, 1000 * 60) &&
!(RadioLibInterface::instance->isSending() || RadioLibInterface::instance->isActivelyReceiving())) {
RadioLibInterface::instance->startReceive();
LOG_DEBUG("attempting AGC reset");
}
#ifdef DEBUG_STACK #ifdef DEBUG_STACK
static uint32_t lastPrint = 0; static uint32_t lastPrint = 0;
if (!Throttle::isWithinTimespanMs(lastPrint, 10 * 1000L)) { if (!Throttle::isWithinTimespanMs(lastPrint, 10 * 1000L)) {

View File

@@ -33,6 +33,7 @@ extern ScanI2C::DeviceAddress cardkb_found;
extern uint8_t kb_model; extern uint8_t kb_model;
extern bool kb_found; extern bool kb_found;
extern bool osk_found; extern bool osk_found;
extern unsigned long last_listen;
extern ScanI2C::DeviceAddress rtc_found; extern ScanI2C::DeviceAddress rtc_found;
extern ScanI2C::DeviceAddress accelerometer_found; extern ScanI2C::DeviceAddress accelerometer_found;
extern ScanI2C::FoundDevice rgb_found; extern ScanI2C::FoundDevice rgb_found;

View File

@@ -2223,8 +2223,8 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location,
} else if (location == meshtastic_AdminMessage_BackupLocation_SD) { } else if (location == meshtastic_AdminMessage_BackupLocation_SD) {
// TODO: After more mainline SD card support // TODO: After more mainline SD card support
} }
return success;
#endif #endif
return success;
} }
/// Record an error that should be reported via analytics /// Record an error that should be reported via analytics

View File

@@ -514,6 +514,8 @@ void RadioLibInterface::handleReceiveInterrupt()
void RadioLibInterface::startReceive() void RadioLibInterface::startReceive()
{ {
// Note the updated timestamp, to avoid unneeded AGC resets
last_listen = millis();
isReceiving = true; isReceiving = true;
powerMon->setState(meshtastic_PowerMon_State_Lora_RXOn); powerMon->setState(meshtastic_PowerMon_State_Lora_RXOn);
} }

View File

@@ -4,6 +4,9 @@
#include "modules/StatusLEDModule.h" #include "modules/StatusLEDModule.h"
#include "modules/SystemCommandsModule.h" #include "modules/SystemCommandsModule.h"
#endif #endif
#if !MESHTASTIC_EXCLUDE_REPLYBOT
#include "ReplyBotModule.h"
#endif
#if !MESHTASTIC_EXCLUDE_PKI #if !MESHTASTIC_EXCLUDE_PKI
#include "KeyVerificationModule.h" #include "KeyVerificationModule.h"
#endif #endif
@@ -112,7 +115,9 @@ void setupModules()
#if defined(LED_CHARGE) || defined(LED_PAIRING) #if defined(LED_CHARGE) || defined(LED_PAIRING)
statusLEDModule = new StatusLEDModule(); statusLEDModule = new StatusLEDModule();
#endif #endif
#if !MESHTASTIC_EXCLUDE_REPLYBOT
new ReplyBotModule();
#endif
#if !MESHTASTIC_EXCLUDE_ADMIN #if !MESHTASTIC_EXCLUDE_ADMIN
adminModule = new AdminModule(); adminModule = new AdminModule();
#endif #endif

View File

@@ -0,0 +1,183 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_REPLYBOT
/*
* ReplyBotModule.cpp
*
* This module implements a simple reply bot for the Meshtastic firmware. It listens for
* specific text commands ("/ping", "/hello" and "/test") delivered either via a direct
* message (DM) or a broadcast on the primary channel. When a supported command is
* received the bot responds with a short status message that includes the hop count
* (minimum number of relays), RSSI and SNR of the received packet. To avoid spamming
* the network it enforces a persender cooldown between responses. By default the
* module is enabled; define MESHTASTIC_EXCLUDE_REPLYBOT at build time to exclude it
* entirely. See the official firmware documentation for guidance on adding modules.
*/
#include "Channels.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "ReplyBotModule.h"
#include "mesh/MeshTypes.h"
#include <Arduino.h>
#include <cctype>
#include <cstring>
//
// Rate limiting data structures
//
// Each sender is tracked in a small ring buffer. When a message arrives from a
// sender we check the last time we responded to them. If the difference is
// less than the configured cooldown (different values for DM vs broadcast)
// the message is ignored; otherwise we update the last response time and
// proceed with replying.
struct ReplyBotCooldownEntry {
uint32_t from = 0;
uint32_t lastMs = 0;
};
static constexpr uint8_t REPLYBOT_COOLDOWN_SLOTS = 8; // ring buffer size
static constexpr uint32_t REPLYBOT_DM_COOLDOWN_MS = 15 * 1000; // 15 seconds for DMs
static constexpr uint32_t REPLYBOT_LF_COOLDOWN_MS = 60 * 1000; // 60 seconds for LongFast broadcasts
static ReplyBotCooldownEntry replybotCooldown[REPLYBOT_COOLDOWN_SLOTS];
static uint8_t replybotCooldownIdx = 0;
// Return true if a reply should be ratelimited for this sender, updating the
// entry table as needed.
static bool replybotRateLimited(uint32_t from, uint32_t cooldownMs)
{
const uint32_t now = millis();
for (auto &e : replybotCooldown) {
if (e.from == from) {
// Found existing entry; check if cooldown expired
if ((uint32_t)(now - e.lastMs) < cooldownMs) {
return true;
}
e.lastMs = now;
return false;
}
}
// No entry found insert new sender into the ring
replybotCooldown[replybotCooldownIdx].from = from;
replybotCooldown[replybotCooldownIdx].lastMs = now;
replybotCooldownIdx = (replybotCooldownIdx + 1) % REPLYBOT_COOLDOWN_SLOTS;
return false;
}
// Constructor registers a single text port and marks the module promiscuous
// so that broadcast messages on the primary channel are visible.
ReplyBotModule::ReplyBotModule() : SinglePortModule("replybot", meshtastic_PortNum_TEXT_MESSAGE_APP)
{
isPromiscuous = true;
}
void ReplyBotModule::setup()
{
// In future we may add a protobuf configuration; for now the module is
// always enabled when compiled in.
}
// Determine whether we want to process this packet. We only care about
// plain text messages addressed to our port.
bool ReplyBotModule::wantPacket(const meshtastic_MeshPacket *p)
{
return (p && p->decoded.portnum == ourPortNum);
}
ProcessMessage ReplyBotModule::handleReceived(const meshtastic_MeshPacket &mp)
{
// Accept only direct messages to us or broadcasts on the Primary channel
// (regardless of modem preset: LongFast, MediumFast, etc).
const uint32_t ourNode = nodeDB->getNodeNum();
const bool isDM = (mp.to == ourNode);
const bool isPrimaryChannel = (mp.channel == channels.getPrimaryIndex()) && isBroadcast(mp.to);
if (!isDM && !isPrimaryChannel) {
return ProcessMessage::CONTINUE;
}
// Ignore empty payloads
if (mp.decoded.payload.size == 0) {
return ProcessMessage::CONTINUE;
}
// Copy payload into a nullterminated buffer
char buf[260];
memset(buf, 0, sizeof(buf));
size_t n = mp.decoded.payload.size;
if (n > sizeof(buf) - 1)
n = sizeof(buf) - 1;
memcpy(buf, mp.decoded.payload.bytes, n);
// React only to supported slash commands
if (!isCommand(buf)) {
return ProcessMessage::CONTINUE;
}
// Apply rate limiting per sender depending on DM/broadcast
const uint32_t cooldownMs = isDM ? REPLYBOT_DM_COOLDOWN_MS : REPLYBOT_LF_COOLDOWN_MS;
if (replybotRateLimited(mp.from, cooldownMs)) {
return ProcessMessage::CONTINUE;
}
// Compute hop count indicator if the relay_node is nonzero we know
// there was at least one relay. Some firmware builds support a hop_start
// field which could be used for more accurate counts, but here we use
// the available relay_node flag only.
// int hopsAway = mp.hop_start - mp.hop_limit;
int hopsAway = getHopsAway(mp);
// Normalize RSSI: if positive adjust down by 200 to align with typical values
int rssi = mp.rx_rssi;
if (rssi > 0) {
rssi -= 200;
}
float snr = mp.rx_snr;
// Build the reply message and send it back via DM
char reply[96];
snprintf(reply, sizeof(reply), "🎙️ Mic Check : %d Hops away | RSSI %d | SNR %.1f", hopsAway, rssi, snr);
sendDm(mp, reply);
return ProcessMessage::CONTINUE;
}
// Check if the message starts with one of the supported commands. Leading
// whitespace is skipped and commands must be followed by endofstring or
// whitespace.
bool ReplyBotModule::isCommand(const char *msg) const
{
if (!msg)
return false;
while (*msg == ' ' || *msg == '\t')
msg++;
auto isEndOrSpace = [](char c) { return c == '\0' || std::isspace(static_cast<unsigned char>(c)); };
if (strncmp(msg, "/ping", 5) == 0 && isEndOrSpace(msg[5]))
return true;
if (strncmp(msg, "/hello", 6) == 0 && isEndOrSpace(msg[6]))
return true;
if (strncmp(msg, "/test", 5) == 0 && isEndOrSpace(msg[5]))
return true;
return false;
}
// Send a direct message back to the originating node.
void ReplyBotModule::sendDm(const meshtastic_MeshPacket &rx, const char *text)
{
if (!text)
return;
meshtastic_MeshPacket *p = allocDataPacket();
p->to = rx.from;
p->channel = rx.channel;
p->want_ack = false;
p->decoded.want_response = false;
size_t len = strlen(text);
if (len > sizeof(p->decoded.payload.bytes)) {
len = sizeof(p->decoded.payload.bytes);
}
p->decoded.payload.size = len;
memcpy(p->decoded.payload.bytes, text, len);
service->sendToMesh(p);
}
#endif // MESHTASTIC_EXCLUDE_REPLYBOT

View File

@@ -0,0 +1,19 @@
#pragma once
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_REPLYBOT
#include "SinglePortModule.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
class ReplyBotModule : public SinglePortModule
{
public:
ReplyBotModule();
void setup() override;
bool wantPacket(const meshtastic_MeshPacket *p) override;
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
protected:
bool isCommand(const char *msg) const;
void sendDm(const meshtastic_MeshPacket &rx, const char *text);
};
#endif // MESHTASTIC_EXCLUDE_REPLYBOT

View File

@@ -7,4 +7,5 @@ build_flags =
-D TLORA_V2_1_16 -D TLORA_V2_1_16
-I variants/esp32/tlora_v2_1_16 -I variants/esp32/tlora_v2_1_16
-D LORA_TCXO_GPIO=33 -D LORA_TCXO_GPIO=33
upload_speed = 115200 -ULED_BUILTIN
upload_speed = 115200