437 lines
19 KiB
C++
437 lines
19 KiB
C++
#include "CommonCLI.h"
|
|
#include "ManagementReporter.h"
|
|
#include "FileRead.h"
|
|
#include "PersistentStoreFormat.h"
|
|
#include "HilStartupTrace.h"
|
|
#include <new>
|
|
#include <stdlib.h>
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
#include "AtomicFileWriter.h"
|
|
#else
|
|
#include "ContactFileTransaction.h"
|
|
#endif
|
|
|
|
namespace mesh {
|
|
static constexpr char DATA_ROUTE_FILE[] = "/data_tx";
|
|
static constexpr size_t DATA_ROUTE_SIZE = 108;
|
|
enum DataRegionMode : uint8_t {
|
|
DATA_REGION_AUTO = 0,
|
|
DATA_REGION_DEFAULT = 1,
|
|
DATA_REGION_NAMED = 2,
|
|
DATA_REGION_NONE = 3,
|
|
};
|
|
|
|
struct DataRouteState {
|
|
FILESYSTEM* fs = nullptr;
|
|
RegionMap* regions = nullptr;
|
|
uint8_t path[MAX_PATH_SIZE] = {};
|
|
uint8_t path_len = 0; // safe fresh-install default: zero-hop direct
|
|
uint8_t region_mode = DATA_REGION_AUTO;
|
|
char region[31] = {};
|
|
bool persisted = false;
|
|
bool healthy = true;
|
|
};
|
|
|
|
static uint32_t readRoute32(const uint8_t* p) {
|
|
return uint32_t(p[0]) | (uint32_t(p[1]) << 8)
|
|
| (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24);
|
|
}
|
|
|
|
static void writeRoute32(uint8_t* p, uint32_t value) {
|
|
p[0] = uint8_t(value); p[1] = uint8_t(value >> 8);
|
|
p[2] = uint8_t(value >> 16); p[3] = uint8_t(value >> 24);
|
|
}
|
|
|
|
static uint8_t nibble(char c) {
|
|
if (c >= '0' && c <= '9') return uint8_t(c - '0');
|
|
if (c >= 'a' && c <= 'f') return uint8_t(c - 'a' + 10);
|
|
if (c >= 'A' && c <= 'F') return uint8_t(c - 'A' + 10);
|
|
return 0xff;
|
|
}
|
|
|
|
static char* trimDataRoute(char* text) {
|
|
while (*text == ' ') ++text;
|
|
char* end = text + strlen(text);
|
|
while (end > text && end[-1] == ' ') --end;
|
|
*end = 0;
|
|
return text;
|
|
}
|
|
|
|
static bool parseDataPath(char* raw, uint8_t path[MAX_PATH_SIZE],
|
|
uint8_t& path_len, const char*& error) {
|
|
char* spec = trimDataRoute(raw);
|
|
memset(path, 0, MAX_PATH_SIZE);
|
|
if (!strcmp(spec, "direct")) { path_len = 0; return true; }
|
|
if (!strcmp(spec, "none") || !strcmp(spec, "clear") || !strcmp(spec, "-")) {
|
|
path_len = OUT_PATH_UNKNOWN; return true;
|
|
}
|
|
|
|
// Accept the compact management form (2:12abcd34) as well as the normal
|
|
// telemetry/outpath comma form (12ab,cd34).
|
|
if (spec[0] >= '1' && spec[0] <= '3' && spec[1] == ':') {
|
|
const uint8_t width = uint8_t(spec[0] - '0');
|
|
const char* hex = spec + 2;
|
|
const size_t chars = strlen(hex);
|
|
if (!chars || chars % (width * 2) || chars / 2 > MAX_PATH_SIZE) {
|
|
error = "Err - path must be direct, none, or 1|2|3:hex"; return false;
|
|
}
|
|
const size_t bytes = chars / 2, hops = bytes / width;
|
|
if (!hops || hops > 63) { error = "Err - path too long"; return false; }
|
|
for (size_t i = 0; i < bytes; ++i) {
|
|
const uint8_t hi = nibble(hex[i * 2]), lo = nibble(hex[i * 2 + 1]);
|
|
if (hi > 15 || lo > 15) { error = "Err - invalid path hex"; return false; }
|
|
path[i] = uint8_t((hi << 4) | lo);
|
|
}
|
|
path_len = uint8_t(((width - 1) << 6) | hops);
|
|
return Packet::isValidPathLen(path_len);
|
|
}
|
|
|
|
uint8_t width = 0, hops = 0;
|
|
char* token = spec;
|
|
while (token && *token) {
|
|
char* comma = strchr(token, ',');
|
|
if (comma) {
|
|
if (!comma[1]) { error = "Err - missing path hash after comma"; return false; }
|
|
*comma = 0;
|
|
}
|
|
token = trimDataRoute(token);
|
|
const size_t chars = strlen(token);
|
|
const uint8_t token_width = uint8_t(chars / 2);
|
|
if ((chars != 2 && chars != 4 && chars != 6)
|
|
|| (width && width != token_width) || hops >= 63
|
|
|| size_t(hops + 1) * token_width > MAX_PATH_SIZE) {
|
|
error = "Err - path hashes must have one consistent 1-3 byte width";
|
|
return false;
|
|
}
|
|
if (!width) width = token_width;
|
|
for (uint8_t i = 0; i < width; ++i) {
|
|
const uint8_t hi = nibble(token[i * 2]), lo = nibble(token[i * 2 + 1]);
|
|
if (hi > 15 || lo > 15) { error = "Err - invalid path hex"; return false; }
|
|
path[hops * width + i] = uint8_t((hi << 4) | lo);
|
|
}
|
|
++hops; token = comma ? comma + 1 : nullptr;
|
|
}
|
|
if (!width || !hops) { error = "Err - missing path"; return false; }
|
|
path_len = uint8_t(((width - 1) << 6) | hops);
|
|
return Packet::isValidPathLen(path_len);
|
|
}
|
|
|
|
static void formatDataPath(const DataRouteState& route, char* out, size_t size) {
|
|
if (route.path_len == OUT_PATH_UNKNOWN) { snprintf(out, size, "none"); return; }
|
|
if (!Packet::isValidPathLen(route.path_len)) { snprintf(out, size, "invalid"); return; }
|
|
const uint8_t hops = route.path_len & 63;
|
|
if (!hops) { snprintf(out, size, "direct"); return; }
|
|
const uint8_t width = (route.path_len >> 6) + 1;
|
|
size_t used = snprintf(out, size, "%u:", unsigned(width));
|
|
for (unsigned i = 0; i < unsigned(hops) * width && used + 2 < size; ++i) {
|
|
used += snprintf(out + used, size - used, "%02x", route.path[i]);
|
|
}
|
|
}
|
|
|
|
static bool dataRouteSave(DataRouteState& route) {
|
|
if (!route.fs) return false;
|
|
uint8_t data[DATA_ROUTE_SIZE] = {};
|
|
memcpy(data, "DTX1", 4); data[4] = route.path_len; data[5] = route.region_mode;
|
|
memcpy(data + 8, route.path, MAX_PATH_SIZE);
|
|
memcpy(data + 72, route.region, sizeof(route.region));
|
|
writeRoute32(data + 104, storage::updateCRC32(0xffffffff, data, 104));
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
AtomicFileWriter writer(route.fs, DATA_ROUTE_FILE);
|
|
#else
|
|
ContactFileTransaction writer(route.fs, DATA_ROUTE_FILE);
|
|
#endif
|
|
const bool ok = writer && writer.write(data, sizeof(data)) == sizeof(data)
|
|
&& writer.commit();
|
|
if (ok) route.persisted = true;
|
|
return ok;
|
|
}
|
|
|
|
static bool dataRouteLoad(DataRouteState& route) {
|
|
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
|
|
if (!ContactFileTransaction::recover(route.fs, DATA_ROUTE_FILE)) return false;
|
|
#endif
|
|
if (!route.fs->exists(DATA_ROUTE_FILE)) return true;
|
|
uint8_t data[DATA_ROUTE_SIZE] = {};
|
|
for (unsigned retry = 0; retry < 3; ++retry) {
|
|
auto file = openFileRead(route.fs, DATA_ROUTE_FILE);
|
|
const bool read = file && file.size() == sizeof(data)
|
|
&& file.read(data, sizeof(data)) == sizeof(data);
|
|
if (file) file.close();
|
|
const bool named = data[5] == DATA_REGION_NAMED;
|
|
if (!read || memcmp(data, "DTX1", 4)
|
|
|| (data[4] != OUT_PATH_UNKNOWN && !Packet::isValidPathLen(data[4]))
|
|
|| data[5] > DATA_REGION_NONE || (named && !data[72])
|
|
|| data[102] != 0
|
|
|| readRoute32(data + 104)
|
|
!= storage::updateCRC32(0xffffffff, data, 104)) continue;
|
|
route.path_len = data[4]; route.region_mode = data[5];
|
|
memcpy(route.path, data + 8, MAX_PATH_SIZE);
|
|
memcpy(route.region, data + 72, sizeof(route.region));
|
|
route.region[sizeof(route.region) - 1] = 0;
|
|
route.persisted = true;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static uint8_t regionDepth(const RegionMap& regions, const RegionEntry* region) {
|
|
if (!region || region->isWildcard()) return 0;
|
|
uint8_t depth = 1;
|
|
uint16_t parent = region->parent;
|
|
for (int walked = 0; parent; ++walked) {
|
|
if (walked >= regions.getCount()) return 0;
|
|
const RegionEntry* entry = const_cast<RegionMap&>(regions).findById(parent);
|
|
if (!entry || entry->isWildcard()) return 0;
|
|
++depth; parent = entry->parent;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
static bool usableScope(DataRouteState& route, const RegionEntry* region,
|
|
TransportKey& scope) {
|
|
return region && !region->isWildcard() && !(region->flags & REGION_DENY_FLOOD)
|
|
&& route.regions->getTransportKeysFor(*region, &scope, 1) > 0
|
|
&& !scope.isNull();
|
|
}
|
|
|
|
static bool resolveDataScope(DataRouteState& route, TransportKey& scope,
|
|
char* resolved, size_t resolved_size,
|
|
bool* ambiguity) {
|
|
if (resolved && resolved_size) resolved[0] = 0;
|
|
if (ambiguity) *ambiguity = false;
|
|
const RegionEntry* selected = nullptr;
|
|
if (route.region_mode == DATA_REGION_NONE) return false;
|
|
if (route.region_mode == DATA_REGION_DEFAULT) {
|
|
selected = route.regions->getDefaultRegion();
|
|
} else if (route.region_mode == DATA_REGION_NAMED) {
|
|
selected = route.regions->findByName(route.region);
|
|
} else {
|
|
// Auto follows an explicit radio default when it is usable. Radios
|
|
// without one fall back to the single narrowest usable region in the
|
|
// definition, which keeps fresh installs deterministic without silently
|
|
// choosing between equally specific scopes.
|
|
selected = route.regions->getDefaultRegion();
|
|
TransportKey default_scope;
|
|
if (usableScope(route, selected, default_scope)) {
|
|
scope = default_scope;
|
|
if (resolved && resolved_size) {
|
|
strncpy(resolved, selected->name, resolved_size - 1);
|
|
resolved[resolved_size - 1] = 0;
|
|
}
|
|
return true;
|
|
}
|
|
selected = nullptr;
|
|
uint8_t best_depth = 0;
|
|
bool tied = false;
|
|
for (int i = 0; i < route.regions->getCount(); ++i) {
|
|
const RegionEntry* candidate = route.regions->getByIdx(i);
|
|
TransportKey candidate_scope;
|
|
if (!usableScope(route, candidate, candidate_scope)) continue;
|
|
const uint8_t depth = regionDepth(*route.regions, candidate);
|
|
if (depth > best_depth) { selected = candidate; best_depth = depth; tied = false; }
|
|
else if (depth && depth == best_depth) tied = true;
|
|
}
|
|
if (tied) { if (ambiguity) *ambiguity = true; return false; }
|
|
}
|
|
if (!usableScope(route, selected, scope)) return false;
|
|
if (resolved && resolved_size) {
|
|
strncpy(resolved, selected->name, resolved_size - 1);
|
|
resolved[resolved_size - 1] = 0;
|
|
}
|
|
return true;
|
|
}
|
|
} // namespace mesh
|
|
|
|
void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) {
|
|
_management_mesh = &mesh; _management_fs = fs;
|
|
_management_last_ms = millis(); _management_uptime_ms = _management_last_ms;
|
|
#if MESH_ENABLE_FLEET_CONTROL
|
|
if (_callbacks->supportsFleetControl()
|
|
&& (fs->exists("/fleet_channel") || fs->exists("/fleet_channel.bak"))) {
|
|
_fleet_channel = new (std::nothrow) mesh::FleetChannel(fs, _callbacks, _region_map);
|
|
}
|
|
#endif
|
|
if (!_data_route) {
|
|
mesh::hilStartupTrace("management_route_begin");
|
|
_data_route = new (std::nothrow) mesh::DataRouteState;
|
|
if (_data_route) {
|
|
_data_route->fs = fs; _data_route->regions = _region_map;
|
|
_data_route->healthy = mesh::dataRouteLoad(*_data_route);
|
|
}
|
|
mesh::hilStartupTrace("management_route_ready");
|
|
}
|
|
// No reporter/history allocation for the default unconfigured installation.
|
|
mesh::hilStartupTrace("management_probe_begin");
|
|
if (fs->exists("/management") || fs->exists("/management.bak")) {
|
|
mesh::hilStartupTrace("management_reporter_begin");
|
|
_management = new (std::nothrow) mesh::ManagementReporter(
|
|
mesh, *_board, *_sensors, *_acl, *_prefs, *_callbacks, *this, fs);
|
|
mesh::hilStartupTrace("management_reporter_ready");
|
|
}
|
|
mesh::hilStartupTrace("management_probe_ready");
|
|
}
|
|
|
|
#if MESH_ENABLE_FLEET_CONTROL
|
|
bool CommonCLI::handleFleetCommand(const char* command, char* reply) {
|
|
const bool fleet_command = !strncmp(command, "get fleet.", 10)
|
|
|| !strncmp(command, "set fleet.", 10);
|
|
if (!fleet_command) return false;
|
|
if (!_callbacks->supportsFleetControl()) {
|
|
strcpy(reply, "Err - fleet control is available on infrastructure roles"); return true;
|
|
}
|
|
if (!_fleet_channel) _fleet_channel = new (std::nothrow) mesh::FleetChannel(_management_fs, _callbacks, _region_map);
|
|
if (!_fleet_channel) {
|
|
strcpy(reply, "Err - fleet control memory unavailable"); return true;
|
|
}
|
|
if (!_fleet_channel->handleConfig(command, reply, 160)) {
|
|
strcpy(reply, "Err - use fleet.channel, fleet.controller, or fleet.stats");
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
void CommonCLI::loopManagement() {
|
|
const uint32_t now = millis();
|
|
_management_uptime_ms += uint32_t(now - _management_last_ms); _management_last_ms = now;
|
|
if (_management) _management->loop(_management_uptime_ms / 1000);
|
|
}
|
|
|
|
bool CommonCLI::getDataTxPath(const uint8_t*& path, uint8_t& path_len) const {
|
|
static const uint8_t direct[MAX_PATH_SIZE] = {};
|
|
if (!_data_route || !_data_route->healthy) { path = direct; path_len = OUT_PATH_UNKNOWN; return false; }
|
|
path = _data_route->path; path_len = _data_route->path_len;
|
|
return path_len != OUT_PATH_UNKNOWN && mesh::Packet::isValidPathLen(path_len);
|
|
}
|
|
|
|
bool CommonCLI::resolveDataTxScope(TransportKey& scope, char* name,
|
|
size_t name_size, bool* ambiguous) const {
|
|
return _data_route && _data_route->healthy
|
|
&& mesh::resolveDataScope(*_data_route, scope, name, name_size, ambiguous);
|
|
}
|
|
|
|
bool CommonCLI::adoptLegacyDataTxPath(const uint8_t* path, uint8_t path_len) {
|
|
if (!_data_route || !_data_route->healthy) return false;
|
|
// A saved shared route is authoritative, including an explicit "none".
|
|
// This is successful migration, not a storage failure to fault on.
|
|
if (_data_route->persisted) return true;
|
|
if (path_len == OUT_PATH_UNKNOWN || !mesh::Packet::isValidPathLen(path_len)
|
|
|| ((path_len & 63) && !path)) return false;
|
|
const uint8_t previous_len = _data_route->path_len;
|
|
uint8_t previous[MAX_PATH_SIZE]; memcpy(previous, _data_route->path, sizeof(previous));
|
|
_data_route->path_len = path_len; memset(_data_route->path, 0, sizeof(_data_route->path));
|
|
if (path_len & 63) mesh::Packet::copyPath(_data_route->path, path, path_len);
|
|
if (mesh::dataRouteSave(*_data_route)) return true;
|
|
_data_route->path_len = previous_len; memcpy(_data_route->path, previous, sizeof(previous));
|
|
return false;
|
|
}
|
|
|
|
bool CommonCLI::setDataTxPath(const char* spec, char* reply, size_t reply_size) {
|
|
if (!spec || strlen(spec) > 140) {
|
|
snprintf(reply, reply_size, "Err - invalid data.tx path"); return false;
|
|
}
|
|
char command[160];
|
|
snprintf(command, sizeof(command), "set data.tx path %s", spec);
|
|
char result[160] = {};
|
|
const bool handled = handleDataTxCommand(command, result);
|
|
snprintf(reply, reply_size, "%s", result);
|
|
return handled && !strncmp(result, "OK", 2);
|
|
}
|
|
|
|
bool CommonCLI::handleDataTxCommand(char* command, char* reply) {
|
|
const bool get_all = !strcmp(command, "get data.tx");
|
|
const bool get_path = !strcmp(command, "get data.tx path") || !strcmp(command, "get data.tx.path");
|
|
const bool get_region = !strcmp(command, "get data.tx region") || !strcmp(command, "get data.tx.region");
|
|
const char* path_prefix = !strncmp(command, "set data.tx path ", 17) ? "set data.tx path "
|
|
: (!strncmp(command, "set data.tx.path ", 17) ? "set data.tx.path " : nullptr);
|
|
const char* region_prefix = !strncmp(command, "set data.tx region ", 19) ? "set data.tx region "
|
|
: (!strncmp(command, "set data.tx.region ", 19) ? "set data.tx.region " : nullptr);
|
|
if (!get_all && !get_path && !get_region && !path_prefix && !region_prefix) return false;
|
|
if (!_data_route) { strcpy(reply, "Err - data.tx unavailable"); return true; }
|
|
if (!_data_route->healthy) { strcpy(reply, "Err - data.tx storage fault"); return true; }
|
|
|
|
char path_text[132]; mesh::formatDataPath(*_data_route, path_text, sizeof(path_text));
|
|
if (get_path) { snprintf(reply, 160, "> %s", path_text); return true; }
|
|
if (get_region) {
|
|
if (_data_route->region_mode == mesh::DATA_REGION_AUTO) {
|
|
char resolved[31]; bool ambiguous = false; TransportKey scope;
|
|
const bool ok = resolveDataTxScope(scope, resolved, sizeof(resolved), &ambiguous);
|
|
snprintf(reply, 160, "> auto (%s)", ok ? resolved : ambiguous ? "ambiguous" : "unresolved");
|
|
} else if (_data_route->region_mode == mesh::DATA_REGION_DEFAULT) strcpy(reply, "> default");
|
|
else if (_data_route->region_mode == mesh::DATA_REGION_NONE) strcpy(reply, "> none");
|
|
else snprintf(reply, 160, "> %s", _data_route->region);
|
|
return true;
|
|
}
|
|
if (get_all) {
|
|
char resolved[31]; bool ambiguous = false; TransportKey scope;
|
|
const bool scoped = resolveDataTxScope(scope, resolved, sizeof(resolved), &ambiguous);
|
|
const char* mode = _data_route->region_mode == mesh::DATA_REGION_AUTO ? "auto"
|
|
: _data_route->region_mode == mesh::DATA_REGION_DEFAULT ? "default"
|
|
: _data_route->region_mode == mesh::DATA_REGION_NONE ? "none" : _data_route->region;
|
|
snprintf(reply, 160, "> path=%s region=%s resolved=%s", path_text, mode,
|
|
scoped ? resolved : ambiguous ? "ambiguous" : "none");
|
|
return true;
|
|
}
|
|
|
|
const mesh::DataRouteState previous = *_data_route;
|
|
if (path_prefix) {
|
|
char* value = command + strlen(path_prefix); const char* error = nullptr;
|
|
uint8_t candidate[MAX_PATH_SIZE], encoded = OUT_PATH_UNKNOWN;
|
|
if (!mesh::parseDataPath(value, candidate, encoded, error)) {
|
|
snprintf(reply, 160, "%s", error ? error : "Err - invalid path"); return true;
|
|
}
|
|
_data_route->path_len = encoded; memcpy(_data_route->path, candidate, sizeof(candidate));
|
|
} else {
|
|
char* value = mesh::trimDataRoute(command + strlen(region_prefix));
|
|
if (!strcmp(value, "auto")) { _data_route->region_mode = mesh::DATA_REGION_AUTO; _data_route->region[0] = 0; }
|
|
else if (!strcmp(value, "default")) { _data_route->region_mode = mesh::DATA_REGION_DEFAULT; _data_route->region[0] = 0; }
|
|
else if (!strcmp(value, "none") || !strcmp(value, "clear") || !strcmp(value, "-")) {
|
|
_data_route->region_mode = mesh::DATA_REGION_NONE; _data_route->region[0] = 0;
|
|
} else {
|
|
RegionEntry* region = _region_map->findByNamePrefix(value);
|
|
TransportKey scope;
|
|
if (!region || !mesh::usableScope(*_data_route, region, scope)) {
|
|
strcpy(reply, "Err - unknown, ambiguous, denied, or unusable region"); return true;
|
|
}
|
|
_data_route->region_mode = mesh::DATA_REGION_NAMED;
|
|
strncpy(_data_route->region, region->name, sizeof(_data_route->region) - 1);
|
|
_data_route->region[sizeof(_data_route->region) - 1] = 0;
|
|
}
|
|
}
|
|
if (!mesh::dataRouteSave(*_data_route)) {
|
|
*_data_route = previous; strcpy(reply, "Err - unable to save data.tx"); return true;
|
|
}
|
|
strcpy(reply, "OK"); return true;
|
|
}
|
|
|
|
bool CommonCLI::handleManagementCommand(char* command, char* reply) {
|
|
if (handleDataTxCommand(command, reply)) return true;
|
|
if (!strcmp(command, "get mgmt.path")) {
|
|
char alias[] = "get data.tx path";
|
|
return handleDataTxCommand(alias, reply);
|
|
}
|
|
if (!strncmp(command, "set mgmt.path ", 14)) {
|
|
char alias[160];
|
|
const int length = snprintf(alias, sizeof(alias), "set data.tx path %s", command + 14);
|
|
if (length < 0 || size_t(length) >= sizeof(alias)) {
|
|
strcpy(reply, "Err - data.tx path too long"); return true;
|
|
}
|
|
return handleDataTxCommand(alias, reply);
|
|
}
|
|
if (strcmp(command, "get mgmt") && strncmp(command, "get mgmt.", 9)
|
|
&& strncmp(command, "set mgmt.", 9)) return false;
|
|
if (!_management && _management_mesh && _management_fs) {
|
|
_management = new (std::nothrow) mesh::ManagementReporter(
|
|
*_management_mesh, *_board, *_sensors, *_acl, *_prefs, *_callbacks, *this,
|
|
_management_fs);
|
|
}
|
|
if (!_management) {
|
|
// No reporter can consume/wipe the value when initialization or its
|
|
// nothrow allocation failed. Apply the same recognized-password hygiene.
|
|
if (!strncmp(command, "set mgmt.password ", 18))
|
|
mesh::management::erase(command + 18, strlen(command + 18));
|
|
strcpy(reply, "ERR: management unavailable"); return true;
|
|
}
|
|
if (!_management->command(command, reply, 160)) strcpy(reply, "ERR: unknown management setting");
|
|
return true;
|
|
}
|