mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-10-17 02:45:05 +00:00
509 lines
18 KiB
C++
509 lines
18 KiB
C++
// SPDX-License-Identifier: GPL-3.0-only
|
|
#include <mesh_meshtastic/meshtastic_backend.h>
|
|
#include <mesh_meshtastic/meshtastic_protocol.h>
|
|
|
|
#include <mesh/backend.h>
|
|
|
|
#include <TactilityCpp/Memory.h>
|
|
#include <TactilityCpp/std/deque.h>
|
|
#include <TactilityCpp/std/map.h>
|
|
#include <TactilityCpp/std/string.h>
|
|
#include <TactilityCpp/std/vector.h>
|
|
|
|
#include <tactility/log.h>
|
|
#include <tactility/paths.h>
|
|
#include <tactility/time.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <utility>
|
|
|
|
#ifdef ESP_PLATFORM
|
|
#include <esp_random.h>
|
|
#else
|
|
#include <random>
|
|
#endif
|
|
|
|
constexpr auto* TAG = "meshtastic";
|
|
|
|
namespace {
|
|
|
|
constexpr size_t MAX_CHANNELS = 8;
|
|
constexpr size_t SEEN_PACKETS_LIMIT = 64;
|
|
constexpr size_t MAX_KNOWN_NAMES = 128;
|
|
constexpr uint64_t NODE_INFO_INTERVAL_MS = 3ULL * 3600 * 1000;
|
|
constexpr uint64_t ACK_TIMEOUT_MS = 60 * 1000;
|
|
constexpr auto* DEFAULT_CHANNEL_NAME = "LongFast";
|
|
constexpr int32_t LONG_FAST_BANDWIDTH = 250000;
|
|
constexpr int32_t MAX_RADIO_POWER = 22;
|
|
|
|
/** Meshtastic's region table, used to place the channel's frequency slot */
|
|
struct RegionInfo {
|
|
RadioRegion region;
|
|
int32_t frequencyStart;
|
|
int32_t frequencyEnd;
|
|
int32_t powerLimit;
|
|
};
|
|
|
|
constexpr RegionInfo REGIONS[] = {
|
|
{ RADIO_REGION_US_915, 902000000, 928000000, 30 },
|
|
{ RADIO_REGION_EU_433, 433000000, 434000000, 12 },
|
|
{ RADIO_REGION_EU_868, 869400000, 869650000, 27 },
|
|
{ RADIO_REGION_AU_915, 915000000, 928000000, 30 },
|
|
{ RADIO_REGION_NZ_865, 864000000, 868000000, 36 },
|
|
{ RADIO_REGION_IN_865, 865000000, 867000000, 30 },
|
|
{ RADIO_REGION_RU_868, 868700000, 869200000, 20 },
|
|
{ RADIO_REGION_CN_470, 470000000, 510000000, 19 },
|
|
{ RADIO_REGION_JP_920, 920800000, 927800000, 16 },
|
|
};
|
|
|
|
struct Channel {
|
|
uint8_t index;
|
|
char name[MESH_NAME_MAX_LENGTH + 1];
|
|
MeshtasticChannelKey key;
|
|
uint8_t hash;
|
|
};
|
|
|
|
struct Backend {
|
|
MeshHost* host = nullptr;
|
|
uint32_t nodeNum = 0;
|
|
tt::std::vector<Channel> channels;
|
|
tt::std::deque<std::pair<uint32_t, uint32_t>> seenPackets;
|
|
tt::std::map<uint32_t, tt::std::string> names;
|
|
/** Packet id of a sent direct message, to the message's sequence number and deadline */
|
|
tt::std::map<uint32_t, std::pair<uint32_t, uint64_t>> pendingAcks;
|
|
uint64_t lastNodeInfoMs = 0;
|
|
};
|
|
|
|
uint64_t getMillis() {
|
|
return get_micros_since_boot() / 1000;
|
|
}
|
|
|
|
uint32_t randomU32() {
|
|
#ifdef ESP_PLATFORM
|
|
return esp_random();
|
|
#else
|
|
static std::random_device device;
|
|
return device();
|
|
#endif
|
|
}
|
|
|
|
uint32_t newPacketId() {
|
|
uint32_t id;
|
|
do {
|
|
id = randomU32();
|
|
} while (id == 0);
|
|
return id;
|
|
}
|
|
|
|
MeshAddress toAddress(uint32_t nodeNum) {
|
|
MeshAddress address {};
|
|
address.length = 4;
|
|
for (int i = 0; i < 4; i++) {
|
|
address.bytes[i] = (uint8_t)(nodeNum >> (8 * i));
|
|
}
|
|
return address;
|
|
}
|
|
|
|
// region Persistence
|
|
|
|
bool readFile(const char* path, tt::std::vector<uint8_t>& data) {
|
|
FILE* file = fopen(path, "rb");
|
|
if (file == nullptr) {
|
|
return false;
|
|
}
|
|
uint8_t buffer[256];
|
|
size_t read;
|
|
while ((read = fread(buffer, 1, sizeof(buffer), file)) > 0) {
|
|
data.insert(data.end(), buffer, buffer + read);
|
|
}
|
|
fclose(file);
|
|
return true;
|
|
}
|
|
|
|
bool writeFile(const char* path, const uint8_t* data, size_t length) {
|
|
FILE* file = fopen(path, "wb");
|
|
if (file == nullptr) {
|
|
return false;
|
|
}
|
|
const bool ok = fwrite(data, 1, length, file) == length;
|
|
fclose(file);
|
|
return ok;
|
|
}
|
|
|
|
void loadOrCreateNodeNum(Backend* backend) {
|
|
char path[FILE_MAX_PATH_STRING_LENGTH];
|
|
const bool hasPath = mesh_host_get_data_path(backend->host, "node.bin", path, sizeof(path)) == ERROR_NONE;
|
|
tt::std::vector<uint8_t> data;
|
|
if (hasPath && readFile(path, data) && data.size() == 4) {
|
|
backend->nodeNum = data[0] | (data[1] << 8) | (data[2] << 16) | ((uint32_t)data[3] << 24);
|
|
return;
|
|
}
|
|
// Low numbers are reserved and the broadcast address is not a node
|
|
do {
|
|
backend->nodeNum = randomU32();
|
|
} while (backend->nodeNum < 4 || backend->nodeNum == MESHTASTIC_BROADCAST);
|
|
const uint8_t encoded[4] = { (uint8_t)backend->nodeNum, (uint8_t)(backend->nodeNum >> 8), (uint8_t)(backend->nodeNum >> 16), (uint8_t)(backend->nodeNum >> 24) };
|
|
if (hasPath) {
|
|
writeFile(path, encoded, sizeof(encoded));
|
|
}
|
|
}
|
|
|
|
void addChannel(Backend* backend, uint8_t index, const char* name, const MeshtasticChannelKey& key) {
|
|
Channel channel {};
|
|
channel.index = index;
|
|
strncpy(channel.name, name, MESH_NAME_MAX_LENGTH);
|
|
channel.key = key;
|
|
channel.hash = meshtastic_channel_hash(name, key);
|
|
std::erase_if(backend->channels, [index](const Channel& existing) { return existing.index == index; });
|
|
backend->channels.push_back(channel);
|
|
std::sort(backend->channels.begin(), backend->channels.end(), [](const Channel& a, const Channel& b) { return a.index < b.index; });
|
|
}
|
|
|
|
// Record: index (1) + name (32) + key length (1) + key (32)
|
|
constexpr size_t CHANNEL_RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 1 + 32;
|
|
|
|
void loadChannels(Backend* backend) {
|
|
char path[FILE_MAX_PATH_STRING_LENGTH];
|
|
tt::std::vector<uint8_t> data;
|
|
if (mesh_host_get_data_path(backend->host, "channels.bin", path, sizeof(path)) == ERROR_NONE && readFile(path, data)) {
|
|
for (size_t offset = 0; offset + CHANNEL_RECORD_SIZE <= data.size(); offset += CHANNEL_RECORD_SIZE) {
|
|
char name[MESH_NAME_MAX_LENGTH + 1];
|
|
memcpy(name, data.data() + offset + 1, sizeof(name));
|
|
name[MESH_NAME_MAX_LENGTH] = '\0';
|
|
MeshtasticChannelKey key {};
|
|
key.length = data[offset + 1 + sizeof(name)];
|
|
memcpy(key.bytes, data.data() + offset + 2 + sizeof(name), 32);
|
|
if (key.length == 16 || key.length == 32) {
|
|
addChannel(backend, data[offset], name, key);
|
|
}
|
|
}
|
|
}
|
|
if (backend->channels.empty()) {
|
|
const uint8_t defaultPsk = 1;
|
|
MeshtasticChannelKey key {};
|
|
meshtastic_expand_psk(&defaultPsk, 1, &key);
|
|
addChannel(backend, 0, DEFAULT_CHANNEL_NAME, key);
|
|
}
|
|
}
|
|
|
|
void saveChannels(Backend* backend) {
|
|
char path[FILE_MAX_PATH_STRING_LENGTH];
|
|
if (mesh_host_get_data_path(backend->host, "channels.bin", path, sizeof(path)) != ERROR_NONE) {
|
|
return;
|
|
}
|
|
tt::std::vector<uint8_t> data;
|
|
for (const auto& channel : backend->channels) {
|
|
data.push_back(channel.index);
|
|
data.insert(data.end(), channel.name, channel.name + MESH_NAME_MAX_LENGTH + 1);
|
|
data.push_back(channel.key.length);
|
|
data.insert(data.end(), channel.key.bytes, channel.key.bytes + 32);
|
|
}
|
|
writeFile(path, data.data(), data.size());
|
|
}
|
|
|
|
// endregion
|
|
|
|
const Channel* findChannel(Backend* backend, uint8_t index) {
|
|
for (const auto& channel : backend->channels) {
|
|
if (channel.index == index) {
|
|
return &channel;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool transmit(Backend* backend, const Channel& channel, MeshtasticHeader header, const MeshtasticData& data, uint32_t seq) {
|
|
const tt::std::vector<uint8_t> plaintext = meshtastic_encode_data(data);
|
|
if (plaintext.size() > MESHTASTIC_MAX_PAYLOAD_SIZE) {
|
|
return false;
|
|
}
|
|
uint8_t frame[MESHTASTIC_HEADER_SIZE + MESHTASTIC_MAX_PAYLOAD_SIZE];
|
|
header.from = backend->nodeNum;
|
|
header.channelHash = channel.hash;
|
|
header.relayNode = (uint8_t)backend->nodeNum;
|
|
meshtastic_write_header(header, frame);
|
|
if (!meshtastic_crypt(channel.key, header.id, header.from, plaintext.data(), frame + MESHTASTIC_HEADER_SIZE, plaintext.size())) {
|
|
return false;
|
|
}
|
|
backend->seenPackets.emplace_back(header.from, header.id);
|
|
while (backend->seenPackets.size() > SEEN_PACKETS_LIMIT) {
|
|
backend->seenPackets.pop_front();
|
|
}
|
|
return mesh_host_transmit(backend->host, frame, MESHTASTIC_HEADER_SIZE + plaintext.size(), seq) == ERROR_NONE;
|
|
}
|
|
|
|
void sendNodeInfo(Backend* backend) {
|
|
char name[MESH_NAME_MAX_LENGTH + 1];
|
|
mesh_host_get_node_name(backend->host, name, sizeof(name));
|
|
MeshtasticUser user;
|
|
user.id = meshtastic_node_id(backend->nodeNum);
|
|
user.longName = name;
|
|
user.shortName = user.longName.substr(0, 4);
|
|
|
|
MeshtasticData data;
|
|
data.port = MeshtasticPort::NodeInfo;
|
|
data.payload = meshtastic_encode_user(user);
|
|
|
|
MeshtasticHeader header;
|
|
header.to = MESHTASTIC_BROADCAST;
|
|
header.id = newPacketId();
|
|
const Channel* primary = findChannel(backend, 0);
|
|
if (primary != nullptr && transmit(backend, *primary, header, data, 0)) {
|
|
LOG_I(TAG, "NodeInfo sent as %s \"%s\"", user.id.c_str(), name);
|
|
}
|
|
backend->lastNodeInfoMs = getMillis();
|
|
}
|
|
|
|
tt::std::string getSenderName(Backend* backend, uint32_t nodeNum) {
|
|
const auto iterator = backend->names.find(nodeNum);
|
|
return (iterator != backend->names.end()) ? iterator->second : meshtastic_node_id(nodeNum);
|
|
}
|
|
|
|
void sendRoutingAck(Backend* backend, const Channel& channel, uint32_t to, uint32_t requestId) {
|
|
MeshtasticData data;
|
|
data.port = MeshtasticPort::Routing;
|
|
data.payload = meshtastic_encode_routing_ack();
|
|
data.requestId = requestId;
|
|
MeshtasticHeader header;
|
|
header.to = to;
|
|
header.id = newPacketId();
|
|
transmit(backend, channel, header, data, 0);
|
|
}
|
|
|
|
/** Routing.error_reason, field 3 */
|
|
uint32_t getRoutingError(const tt::std::vector<uint8_t>& payload) {
|
|
if (payload.size() >= 2 && payload[0] == 0x18) {
|
|
return payload[1] & 0x7F;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void onData(Backend* backend, const Channel& channel, const MeshtasticHeader& header, const MeshtasticData& data, float rssi, float snr) {
|
|
const MeshAddress from = toAddress(header.from);
|
|
const bool toUs = header.to == backend->nodeNum;
|
|
|
|
switch (data.port) {
|
|
case MeshtasticPort::TextMessage: {
|
|
if (!toUs && header.to != MESHTASTIC_BROADCAST) {
|
|
return;
|
|
}
|
|
if (toUs && header.wantAck) {
|
|
sendRoutingAck(backend, channel, header.from, header.id);
|
|
}
|
|
const tt::std::string text(data.payload.begin(), data.payload.end());
|
|
const tt::std::string sender = getSenderName(backend, header.from);
|
|
// Channel messages have no peer, so they group by channel instead of by sender
|
|
mesh_host_receive_text(backend->host, toUs ? &from : nullptr, channel.index, sender.c_str(), text.c_str(), rssi, snr, 0);
|
|
break;
|
|
}
|
|
case MeshtasticPort::NodeInfo: {
|
|
MeshtasticUser user;
|
|
if (meshtastic_decode_user(data.payload.data(), data.payload.size(), &user) && !user.longName.empty()) {
|
|
if (backend->names.size() >= MAX_KNOWN_NAMES && !backend->names.contains(header.from)) {
|
|
backend->names.erase(backend->names.begin());
|
|
}
|
|
backend->names[header.from] = user.longName;
|
|
mesh_host_advert_heard(backend->host, &from, user.longName.c_str(), rssi, snr);
|
|
}
|
|
break;
|
|
}
|
|
case MeshtasticPort::Routing: {
|
|
const auto iterator = backend->pendingAcks.find(data.requestId);
|
|
if (toUs && iterator != backend->pendingAcks.end()) {
|
|
const uint32_t error = getRoutingError(data.payload);
|
|
mesh_host_set_delivery(backend->host, iterator->second.first, error == 0 ? MESH_DELIVERY_DELIVERED : MESH_DELIVERY_FAILED);
|
|
backend->pendingAcks.erase(iterator);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// region MeshBackendApi
|
|
|
|
void* create(MeshHost* host) {
|
|
auto* backend = tt::newObject<Backend>();
|
|
if (backend == nullptr) {
|
|
return nullptr;
|
|
}
|
|
backend->host = host;
|
|
loadOrCreateNodeNum(backend);
|
|
loadChannels(backend);
|
|
LOG_I(TAG, "Node %s", meshtastic_node_id(backend->nodeNum).c_str());
|
|
return backend;
|
|
}
|
|
|
|
void destroy(void* context) {
|
|
tt::deleteObject(static_cast<Backend*>(context));
|
|
}
|
|
|
|
error_t getRadioProfile(void* context, RadioRegion region, size_t, MeshRadioProfile* profile) {
|
|
auto* backend = static_cast<Backend*>(context);
|
|
for (const auto& info : REGIONS) {
|
|
if (info.region != region) {
|
|
continue;
|
|
}
|
|
// The LongFast preset, in the slot that the primary channel's name selects
|
|
const Channel* primary = findChannel(backend, 0);
|
|
const char* slotName = (primary != nullptr && primary->name[0] != '\0') ? primary->name : DEFAULT_CHANNEL_NAME;
|
|
*profile = {
|
|
.modulation = RADIO_MODULATION_LORA,
|
|
.frequency = meshtastic_channel_frequency(slotName, info.frequencyStart, info.frequencyEnd, LONG_FAST_BANDWIDTH),
|
|
.bandwidth = LONG_FAST_BANDWIDTH,
|
|
.spreading_factor = 11,
|
|
.coding_rate = 5,
|
|
.sync_word = 0x2B,
|
|
.preamble_length = 16,
|
|
.power = std::min(info.powerLimit, MAX_RADIO_POWER),
|
|
};
|
|
return ERROR_NONE;
|
|
}
|
|
return ERROR_NOT_SUPPORTED;
|
|
}
|
|
|
|
void onStart(void* context) {
|
|
sendNodeInfo(static_cast<Backend*>(context));
|
|
}
|
|
|
|
bool onReceive(void* context, const uint8_t* frame, size_t length, float rssi, float snr) {
|
|
auto* backend = static_cast<Backend*>(context);
|
|
MeshtasticHeader header;
|
|
if (!meshtastic_read_header(frame, length, &header) || length == MESHTASTIC_HEADER_SIZE ||
|
|
length - MESHTASTIC_HEADER_SIZE > MESHTASTIC_MAX_PAYLOAD_SIZE) {
|
|
return false;
|
|
}
|
|
if (header.from == backend->nodeNum) {
|
|
return true;
|
|
}
|
|
|
|
const auto key = std::make_pair(header.from, header.id);
|
|
if (std::find(backend->seenPackets.begin(), backend->seenPackets.end(), key) != backend->seenPackets.end()) {
|
|
return true;
|
|
}
|
|
backend->seenPackets.push_back(key);
|
|
while (backend->seenPackets.size() > SEEN_PACKETS_LIMIT) {
|
|
backend->seenPackets.pop_front();
|
|
}
|
|
|
|
const MeshAddress from = toAddress(header.from);
|
|
mesh_host_node_heard(backend->host, &from, nullptr, rssi, snr);
|
|
|
|
// The 1-byte hash can match several channels: the one that decodes to a valid Data message wins
|
|
const size_t payloadLength = length - MESHTASTIC_HEADER_SIZE;
|
|
uint8_t plaintext[MESHTASTIC_MAX_PAYLOAD_SIZE];
|
|
for (const auto& channel : backend->channels) {
|
|
if (channel.hash != header.channelHash) {
|
|
continue;
|
|
}
|
|
MeshtasticData data;
|
|
if (meshtastic_crypt(channel.key, header.id, header.from, frame + MESHTASTIC_HEADER_SIZE, plaintext, payloadLength) &&
|
|
meshtastic_decode_data(plaintext, payloadLength, &data)) {
|
|
onData(backend, channel, header, data, rssi, snr);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void onTick(void* context) {
|
|
auto* backend = static_cast<Backend*>(context);
|
|
const uint64_t nowMs = getMillis();
|
|
if (nowMs - backend->lastNodeInfoMs >= NODE_INFO_INTERVAL_MS) {
|
|
sendNodeInfo(backend);
|
|
}
|
|
for (auto iterator = backend->pendingAcks.begin(); iterator != backend->pendingAcks.end();) {
|
|
if (nowMs >= iterator->second.second) {
|
|
mesh_host_set_delivery(backend->host, iterator->second.first, MESH_DELIVERY_FAILED);
|
|
iterator = backend->pendingAcks.erase(iterator);
|
|
} else {
|
|
++iterator;
|
|
}
|
|
}
|
|
}
|
|
|
|
error_t sendText(void* context, uint32_t seq, const MeshAddress* to, uint8_t channelIndex, const char* text) {
|
|
auto* backend = static_cast<Backend*>(context);
|
|
|
|
MeshtasticData data;
|
|
data.port = MeshtasticPort::TextMessage;
|
|
data.payload.assign(text, text + strlen(text));
|
|
|
|
MeshtasticHeader header;
|
|
header.id = newPacketId();
|
|
const Channel* channel;
|
|
if (to == nullptr) {
|
|
header.to = MESHTASTIC_BROADCAST;
|
|
channel = findChannel(backend, channelIndex);
|
|
} else {
|
|
if (to->length != 4) {
|
|
return ERROR_INVALID_ARGUMENT;
|
|
}
|
|
header.to = to->bytes[0] | (to->bytes[1] << 8) | (to->bytes[2] << 16) | ((uint32_t)to->bytes[3] << 24);
|
|
header.wantAck = true;
|
|
channel = findChannel(backend, 0);
|
|
}
|
|
if (channel == nullptr) {
|
|
return ERROR_NOT_FOUND;
|
|
}
|
|
if (!transmit(backend, *channel, header, data, seq)) {
|
|
return ERROR_RESOURCE;
|
|
}
|
|
if (header.wantAck) {
|
|
backend->pendingAcks[header.id] = { seq, getMillis() + ACK_TIMEOUT_MS };
|
|
}
|
|
return ERROR_NONE;
|
|
}
|
|
|
|
error_t getChannels(void* context, MeshChannel* channels, size_t maxCount, size_t* count) {
|
|
auto* backend = static_cast<Backend*>(context);
|
|
*count = 0;
|
|
for (const auto& channel : backend->channels) {
|
|
if (*count >= maxCount) {
|
|
break;
|
|
}
|
|
MeshChannel& out = channels[(*count)++];
|
|
out = {};
|
|
out.index = channel.index;
|
|
strncpy(out.name, channel.name, MESH_NAME_MAX_LENGTH);
|
|
memcpy(out.key, channel.key.bytes, channel.key.length);
|
|
out.key_length = channel.key.length;
|
|
}
|
|
return ERROR_NONE;
|
|
}
|
|
|
|
error_t setChannel(void* context, const MeshChannel* channel) {
|
|
auto* backend = static_cast<Backend*>(context);
|
|
MeshtasticChannelKey key {};
|
|
if (!meshtastic_expand_psk(channel->key, channel->key_length, &key)) {
|
|
return ERROR_INVALID_ARGUMENT;
|
|
}
|
|
if (findChannel(backend, channel->index) == nullptr && backend->channels.size() >= MAX_CHANNELS) {
|
|
return ERROR_RESOURCE;
|
|
}
|
|
addChannel(backend, channel->index, channel->name, key);
|
|
saveChannels(backend);
|
|
return ERROR_NONE;
|
|
}
|
|
|
|
// endregion
|
|
|
|
} // namespace
|
|
|
|
const MeshBackendApi meshtastic_backend_api = {
|
|
.id = "meshtastic",
|
|
.create = create,
|
|
.destroy = destroy,
|
|
.presets = nullptr,
|
|
.get_radio_profile = getRadioProfile,
|
|
.on_start = onStart,
|
|
.on_receive = onReceive,
|
|
.on_tick = onTick,
|
|
.send_text = sendText,
|
|
.get_channels = getChannels,
|
|
.set_channel = setChannel,
|
|
};
|