This commit is contained in:
fhs52267
2026-03-23 22:12:40 +01:00
commit 02a1852b17
11 changed files with 720 additions and 0 deletions

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
root = true
[*]
indent_style = space
root = true
[*]
indent_style = space
indent_size = 2

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
src/secrets.h

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

17
README.md Normal file
View File

@@ -0,0 +1,17 @@
# Fingerbot Meshtastic Bridge
Small PlatformIO project that processes Meshtastic messages as commands, forwarding them to preconfigured Fingerbot nodes.
## Available commands
Commands are parsed from Meshtastic messages prefixed with `FB`.
- `ping`: `PONG`
- `help`: returns command help text
- `status`: queue, uptime, configured devices
- `push [device] [ms|short|long] ...`: queue push pattern
- `identify [device]`: trigger identify on target device
## Secrets setup
Sensitive values are stored in `src/secrets.h`. Use `src/secrets.example.h` as a template and enter your values.

12
platformio.ini Normal file
View File

@@ -0,0 +1,12 @@
[env:fb-mt-bridge]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
h2zero/NimBLE-Arduino @ ^1.4.1
bblanchon/ArduinoJson @ ^7.0.0
meshtastic/Meshtastic @ ^0.0.7

77
src/main.cpp Normal file
View File

@@ -0,0 +1,77 @@
#include <Arduino.h>
#include "utils/queue.h"
#include "utils/meshtastic.h"
#include "utils/ble.h"
#include "utils/commands.h"
#include "secrets.h"
#include <string>
// Meshtastic settings
#define MT_CHANNEL_DEFAULT 1
#define MT_CHANNEL_CMD 0
#define MT_QUEUE_SIZE 10
// Meshtastic serial settings
#define PIN_RX 4
#define PIN_TX 3
#define PIN_LED 8
#define BAUD_RATE 115200
// Fingerbot settings
#define FB_CMD_PREFIX "FB "
#define FB_PUSH_MS_DEFAULT 2000
MeshtasticBridge *MeshtasticBridge::activeInstance_ = nullptr;
namespace
{
const CommandConfig kCommandConfig = {
.defaultPushMs = FB_PUSH_MS_DEFAULT,
.deviceMap = secrets::COMMAND_DEVICES,
.deviceMapSize = sizeof(secrets::COMMAND_DEVICES) / sizeof(secrets::COMMAND_DEVICES[0])};
const BleConfig kBleConfig = {
.deviceName = secrets::BLE_DEVICE_NAME,
.serviceUuid = secrets::BLE_SERVICE_UUID,
.characteristicUuid = secrets::BLE_CHAR_UUID};
const MeshtasticConfig kMeshtasticConfig = {
.pinRx = PIN_RX,
.pinTx = PIN_TX,
.baudRate = BAUD_RATE,
.pinLed = PIN_LED,
.defaultChannel = MT_CHANNEL_DEFAULT,
.commandChannel = MT_CHANNEL_CMD,
.commandPrefix = FB_CMD_PREFIX};
MessageQueue gMessageQueue(MT_QUEUE_SIZE);
CommandProcessor gCommands(kCommandConfig, gMessageQueue);
MeshtasticBridge gMeshtastic(kMeshtasticConfig, gMessageQueue, gCommands);
BleBridge gBle(kBleConfig);
}
void setup()
{
Serial.begin(BAUD_RATE);
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, HIGH);
delay(1000);
gBle.init();
gMeshtastic.setup();
}
void loop()
{
uint32_t now = millis();
gMeshtastic.loop(now);
std::string targetMacAddr;
PendingBleRequest request;
if (gCommands.takeBleRequest(targetMacAddr, request))
{
gBle.executeRequest(targetMacAddr.c_str(), request);
}
}

17
src/secrets.h.example Normal file
View File

@@ -0,0 +1,17 @@
#ifndef FB_MT_BRIDGE_SECRETS_H
#define FB_MT_BRIDGE_SECRETS_H
#include "utils/commands.h"
namespace secrets
{
constexpr char BLE_DEVICE_NAME[] = "REPLACE_ME_DEVICE_NAME";
constexpr char BLE_SERVICE_UUID[] = "00000000-0000-0000-0000-000000000000";
constexpr char BLE_CHAR_UUID[] = "00000000-0000-0000-0000-000000000000";
constexpr CommandDevice COMMAND_DEVICES[] = {
{"n1", "aa:bb:cc:dd:ee:ff"},
{"n2", "aa:bb:cc:dd:ee:00"}};
}
#endif // FB_MT_BRIDGE_SECRETS_H

83
src/utils/ble.h Normal file
View File

@@ -0,0 +1,83 @@
#ifndef FB_MT_BRIDGE_BLE_H
#define FB_MT_BRIDGE_BLE_H
#include <NimBLEDevice.h>
#include <Arduino.h>
#include <cstring>
#include <vector>
#include "commands.h"
struct BleConfig
{
const char *deviceName;
const char *serviceUuid;
const char *characteristicUuid;
};
class BleBridge
{
public:
explicit BleBridge(const BleConfig &config) : config_(config) {}
void init()
{
NimBLEDevice::init(config_.deviceName);
Serial.println("[INF] [BLE] NimBLE initialized");
}
void executePush(const char *macAddr, uint32_t pushMs)
{
PendingBleRequest request = {"pattern " + std::to_string(pushMs)};
executeRequest(macAddr, request);
}
void executeRequest(const char *macAddr, const PendingBleRequest &request)
{
NimBLEUUID serviceUUID(config_.serviceUuid);
NimBLEUUID charUUID(config_.characteristicUuid);
Serial.printf("[INF] [BLE] Connecting to target node: %s\n", macAddr);
NimBLEAddress targetAddress(macAddr);
NimBLEClient *pClient = NimBLEDevice::createClient();
if (!pClient->connect(targetAddress))
{
Serial.println("[ERR] [BLE] Failed to connect to target node");
}
else
{
NimBLERemoteService *pRemoteService = pClient->getService(serviceUUID);
if (pRemoteService == nullptr)
{
Serial.println("[ERR] [BLE] Service not found");
}
else
{
NimBLERemoteCharacteristic *pRemoteCharacteristic = pRemoteService->getCharacteristic(charUUID);
if (pRemoteCharacteristic == nullptr)
{
Serial.println("[ERR] [BLE] Characteristic not found");
}
else
{
pRemoteCharacteristic->writeValue(request.payload, false);
Serial.printf("[INF] [BLE] Successfully sent payload '%s'\n", request.payload.c_str());
}
}
pClient->disconnect();
Serial.println("[INF] [BLE] Disconnected from target node");
}
NimBLEDevice::deleteClient(pClient);
}
private:
const BleConfig config_;
};
#endif // BLE_H

268
src/utils/commands.h Normal file
View File

@@ -0,0 +1,268 @@
#ifndef FB_MT_BRIDGE_COMMANDS_H
#define FB_MT_BRIDGE_COMMANDS_H
#include <cstdint>
#include "queue.h"
#include <cstring>
#include <cctype>
#include <Arduino.h>
#include <string>
#include <vector>
#include <sstream>
struct CommandDevice
{
const char *shortname;
const char *mac;
};
struct CommandConfig
{
uint32_t defaultPushMs;
const CommandDevice *deviceMap;
size_t deviceMapSize;
};
struct PendingBleRequest
{
std::string payload;
};
class CommandProcessor
{
public:
CommandProcessor(const CommandConfig &config, MessageQueue &queue)
: config_(config), queue_(queue), triggerBle_(false), pendingRequest_({""}), targetMacAddr_("")
{
}
const char *handleFb(const char *command, bool readonly)
{
if (command == nullptr || *command == '\0')
{
return "ERR EMPTY";
}
std::vector<std::string> tokens = tokenize(command);
if (tokens.empty())
{
return "ERR EMPTY";
}
if (strcasecmp(tokens[0].c_str(), "ping") == 0)
{
return "PONG";
}
if (strcasecmp(tokens[0].c_str(), "help") == 0 || strcasecmp(tokens[0].c_str(), "?") == 0)
{
static char buffer[224];
snprintf(buffer, sizeof(buffer), "HELP ping | status | push [device] [ms|short|long] | identify [device]");
return buffer;
}
if (strcasecmp(tokens[0].c_str(), "status") == 0)
{
static char buffer[224];
size_t offset = snprintf(buffer, sizeof(buffer), "STATUS queue=%d uptime=%lu devices=", queue_.size(), millis());
for (size_t i = 0; i < config_.deviceMapSize && offset < sizeof(buffer) - 1; i++)
{
offset += snprintf(buffer + offset, sizeof(buffer) - offset, "%s%s", (i == 0) ? "" : ",", config_.deviceMap[i].shortname);
}
return buffer;
}
if (strcasecmp(tokens[0].c_str(), "push") == 0)
{
if (readonly)
{
return "ERR RO";
}
const CommandDevice *targetDevice = &config_.deviceMap[0];
size_t startIndex = 1;
if (tokens.size() > 1 && !isDurationToken(tokens[1].c_str()))
{
targetDevice = findDeviceByName(tokens[1].c_str());
if (!targetDevice)
{
return "ERR UNKNOWN DEVICE";
}
startIndex = 2;
}
std::vector<uint32_t> durations;
durations.reserve((tokens.size() > startIndex) ? (tokens.size() - startIndex) : 1);
for (size_t i = startIndex; i < tokens.size(); i++)
{
uint32_t durationMs = 0;
if (!parseDurationToken(tokens[i].c_str(), durationMs))
{
return "ERR PUSH ARG";
}
durations.push_back(durationMs);
}
if (durations.empty())
{
durations.push_back(config_.defaultPushMs);
}
pendingRequest_.payload = "push";
for (uint32_t duration : durations)
{
pendingRequest_.payload += " ";
pendingRequest_.payload += std::to_string(duration);
}
targetMacAddr_ = targetDevice->mac;
triggerBle_ = true;
static char buffer[96];
snprintf(buffer, sizeof(buffer), "PUSH %d steps to %s (BLE Queued)", static_cast<int>(durations.size()), targetDevice->shortname);
return buffer;
}
if (strcasecmp(tokens[0].c_str(), "identify") == 0)
{
if (readonly)
{
return "ERR RO";
}
const CommandDevice *targetDevice = &config_.deviceMap[0];
if (tokens.size() > 2)
{
return "ERR IDENT ARG";
}
if (tokens.size() == 2)
{
targetDevice = findDeviceByName(tokens[1].c_str());
if (!targetDevice)
{
return "ERR UNKNOWN DEVICE";
}
}
pendingRequest_.payload = "identify";
targetMacAddr_ = targetDevice->mac;
triggerBle_ = true;
static char buffer[96];
snprintf(buffer, sizeof(buffer), "IDENTIFY to %s (BLE Queued)", targetDevice->shortname);
return buffer;
}
return "ERR UNKNOWN";
}
bool takeBleRequest(std::string &targetMac, PendingBleRequest &request)
{
if (!triggerBle_)
{
return false;
}
triggerBle_ = false;
targetMac = targetMacAddr_;
request = pendingRequest_;
return true;
}
private:
static std::vector<std::string> tokenize(const char *command)
{
std::vector<std::string> tokens;
std::istringstream stream(command);
std::string token;
while (stream >> token)
{
tokens.push_back(token);
}
return tokens;
}
static bool isDurationToken(const char *token)
{
return isNumberToken(token) ||
strcasecmp(token, "short") == 0 ||
strcasecmp(token, "long") == 0;
}
static bool parseDurationToken(const char *token, uint32_t &durationMs)
{
if (!token || *token == '\0')
{
return false;
}
if (isNumberToken(token))
{
durationMs = static_cast<uint32_t>(atoi(token));
return true;
}
if (strcasecmp(token, "short") == 0)
{
durationMs = 700;
return true;
}
if (strcasecmp(token, "long") == 0)
{
durationMs = 5000;
return true;
}
return false;
}
static bool isNumberToken(const char *token)
{
if (!token || *token == '\0')
{
return false;
}
for (const char *p = token; *p; ++p)
{
if (!isdigit(static_cast<unsigned char>(*p)))
{
return false;
}
}
return true;
}
const CommandDevice *findDeviceByName(const char *name) const
{
if (!name || *name == '\0')
{
return nullptr;
}
for (size_t i = 0; i < config_.deviceMapSize; i++)
{
if (strcasecmp(config_.deviceMap[i].shortname, name) == 0)
{
return &config_.deviceMap[i];
}
}
return nullptr;
}
const CommandConfig config_;
MessageQueue &queue_;
bool triggerBle_;
PendingBleRequest pendingRequest_;
std::string targetMacAddr_;
};
#endif // COMMANDS_H

146
src/utils/meshtastic.h Normal file
View File

@@ -0,0 +1,146 @@
#ifndef FB_MT_BRIDGE_MESHTASTIC_H
#define FB_MT_BRIDGE_MESHTASTIC_H
#include <cstdint>
#include "queue.h"
#include "commands.h"
#include <Meshtastic.h>
#include <cstring>
#include <Arduino.h>
struct MeshtasticConfig
{
int pinRx;
int pinTx;
uint32_t baudRate;
uint8_t pinLed;
uint8_t defaultChannel;
uint8_t commandChannel;
const char *commandPrefix;
};
class MeshtasticBridge
{
public:
MeshtasticBridge(const MeshtasticConfig &config, MessageQueue &queue, CommandProcessor &commands)
: config_(config), queue_(queue), commands_(commands), isConnected_(false)
{
}
void setup()
{
activeInstance_ = this;
mt_serial_init(config_.pinRx, config_.pinTx, config_.baudRate);
mt_request_node_report(onConnectedStatic);
set_text_message_callback(onMessageStatic);
}
void loop(uint32_t now)
{
bool canSend = ::mt_loop(now);
bool queueHasItems = queue_.size() > 0;
if (queueHasItems > 0)
{
digitalWrite(config_.pinLed, LOW);
if (canSend)
{
Message currentJob;
if (queue_.dequeue(currentJob))
{
mt_send_text(currentJob.response, currentJob.dest, currentJob.channel);
}
}
} else {
digitalWrite(config_.pinLed, HIGH);
}
}
private:
static MeshtasticBridge *activeInstance_;
static void onConnectedStatic(mt_node_t *node, mt_nr_progress_t progress)
{
(void)node;
(void)progress;
if (activeInstance_)
{
activeInstance_->onConnected();
}
}
static void onMessageStatic(uint32_t from, uint32_t to, uint8_t channel, const char *text)
{
if (activeInstance_)
{
activeInstance_->onMessage(from, to, channel, text);
}
}
void onConnected()
{
if (!isConnected_)
{
Serial.println("[INF] [MT] Connected to Meshtastic");
isConnected_ = true;
}
}
void onMessage(uint32_t from, uint32_t to, uint8_t channel, const char *text)
{
Serial.printf("[INF] [MT] Received message (ch=%u, from=!%08x, to=!%08x)\n", channel, from, to);
if (text == nullptr)
{
Serial.println("[INF] [MT] Ignoring message: empty payload");
return;
}
if (to == BROADCAST_ADDR && channel == config_.defaultChannel)
{
Serial.println("[INF] [MT] Ignoring broadcast on default/public channel");
return;
}
size_t prefixLen = strlen(config_.commandPrefix);
if (strncasecmp(text, config_.commandPrefix, prefixLen) != 0)
{
Serial.printf("[INF] [MT] Ignoring message: missing prefix '%s'\n", config_.commandPrefix);
return;
}
// Parse command
digitalWrite(config_.pinLed, LOW);
const char *command = text + prefixLen;
while (*command == ' ')
command++;
Serial.printf("[INF] [MT] Accepted command text: '%s'\n", command);
bool readonly = to != BROADCAST_ADDR || channel != config_.commandChannel;
Serial.printf("[INF] [MT] Command mode: %s\n", readonly ? "readonly" : "execute");
const char *response = commands_.handleFb(command, readonly);
Serial.printf("[INF] [MT] Command response: '%s'\n", response);
if (to == BROADCAST_ADDR)
{
Serial.printf("[INF] [MT] Routing response to broadcast on channel %u\n", channel);
queue_.enqueue(BROADCAST_ADDR, channel, response);
}
else
{
Serial.printf("[INF] [MT] Routing response to sender !%08x on channel %u\n", from, config_.defaultChannel);
queue_.enqueue(from, config_.defaultChannel, response);
}
}
const MeshtasticConfig config_;
MessageQueue &queue_;
CommandProcessor &commands_;
bool isConnected_;
};
#endif // MESHTASTIC_H

73
src/utils/queue.h Normal file
View File

@@ -0,0 +1,73 @@
#ifndef FB_MT_BRIDGE_QUEUE_H
#define FB_MT_BRIDGE_QUEUE_H
#include <cstdint>
#include <cstring>
#include <Arduino.h>
struct Message
{
uint32_t dest;
uint8_t channel;
char response[128];
};
class MessageQueue
{
public:
static constexpr int kMaxCapacity = 16;
explicit MessageQueue(int capacity)
: capacity_(capacity > kMaxCapacity ? kMaxCapacity : capacity), head_(0), tail_(0), count_(0)
{
if (capacity_ <= 0)
{
capacity_ = 1;
}
}
void enqueue(uint32_t dest, uint8_t channel, const char *response)
{
if (count_ >= capacity_)
{
Serial.printf("[ERR] [Q] Queue full (size=%d), dropping response\n", capacity_);
return;
}
queue_[head_].dest = dest;
queue_[head_].channel = channel;
strncpy(queue_[head_].response, response, sizeof(queue_[head_].response) - 1);
queue_[head_].response[sizeof(queue_[head_].response) - 1] = '\0';
head_ = (head_ + 1) % capacity_;
count_++;
Serial.printf("[INF] [Q] Queued response (head=%d, count=%d)\n", head_, count_);
}
bool dequeue(Message &out)
{
if (count_ == 0)
return false;
out = queue_[tail_];
tail_ = (tail_ + 1) % capacity_;
count_--;
return true;
}
int size() const
{
return count_;
}
private:
Message queue_[kMaxCapacity];
int capacity_;
int head_;
int tail_;
int count_;
};
#endif // QUEUE_H