Files
ZephCore/zephcore/helpers/CommonCLI.cpp
T
liquidraver a3244e2dc2 Three driver fixes for RX duty cycle, all applied to the existing patch
stolen from Zephyr main:

  1. Issue StopTimerOnPreamble=1 before SetRxDutyCycle so the chip's
     timer is not reset on every preamble detect (per §13.1 of the
     datasheet). Without this, duty cycle effectively never sleeps in
     noisy RF and current draw spikes.
  2. On IRQ_RX_TX_TIMEOUT during duty-cycle RX, re-arm via
     sx126x_restart_rx() instead of falling through to set_sleep().
     The old path silently killed duty cycle after the first preamble
     false-positive.
  3. On recv_duty_cycle(NULL) cancel, wake the radio before issuing
     SetStandby — BUSY stays asserted during the sleep phase and the
     standby command was being dropped.

Also adds a dc_timeout_restarts atomic counter incremented on the Fix 2
path, exposed end-to-end: sx126x_ext.h accessors → LoRaRadioBase vtable
→ SX126xRadio override → CommonCLICallbacks → RepeaterMesh. Query via
`get dc.restarts` on the repeater CLI; cleared by `clear stats`. High
values indicate a noisy environment or a too-loose preamble threshold.

(+increase ESP BT stack because future zephyr pin advance will trip that mine)
2026-04-20 13:36:45 +02:00

922 lines
44 KiB
C++

/*
* SPDX-License-Identifier: Apache-2.0
* CommonCLI - Common CLI command handlers for repeaters
*/
#include "CommonCLI.h"
#include <helpers/TxtDataHelpers.h>
#include <helpers/AdvertDataHelpers.h>
#include <adapters/board/ZephyrBoard.h>
#include <zephyr/fs/fs.h>
#include <zephyr/logging/log.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <time.h>
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
#include "wifi_ota.h"
#endif
LOG_MODULE_REGISTER(zephcore_cli, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL);
// Helper: robust atoi
static uint32_t _atoi(const char* sp) {
uint32_t n = 0;
while (*sp && *sp >= '0' && *sp <= '9') {
n *= 10;
n += (*sp++ - '0');
}
return n;
}
static bool isValidName(const char* n) {
while (*n) {
if (*n == '[' || *n == ']' || *n == '\\' || *n == ':' ||
*n == ',' || *n == '?' || *n == '*') return false;
n++;
}
return true;
}
// Constrain helper
template<typename T>
static T constrain(T value, T min_val, T max_val) {
if (value < min_val) return min_val;
if (value > max_val) return max_val;
return value;
}
/* Read exactly 'len' bytes; short/error read stops the chain via ok flag */
static inline bool prefs_read(struct fs_file_t *f, void *dest, size_t len) {
return fs_read(f, dest, len) == (ssize_t)len;
}
void CommonCLI::loadPrefs(const char* path) {
struct fs_file_t file;
fs_file_t_init(&file);
if (fs_open(&file, path, FS_O_READ) < 0) {
LOG_DBG("No prefs file at %s, using defaults", path);
return;
}
uint8_t pad[8];
bool ok = true;
/* Read fields in Arduino-compatible binary order.
* On truncated file, short-circuit at first failure
* so remaining fields keep their default values. */
ok = ok && prefs_read(&file, &_prefs->airtime_factor, sizeof(_prefs->airtime_factor)); // 0
ok = ok && prefs_read(&file, &_prefs->node_name, sizeof(_prefs->node_name)); // 4
ok = ok && prefs_read(&file, pad, 4); // 36
ok = ok && prefs_read(&file, &_prefs->node_lat, sizeof(_prefs->node_lat)); // 40
ok = ok && prefs_read(&file, &_prefs->node_lon, sizeof(_prefs->node_lon)); // 48
ok = ok && prefs_read(&file, &_prefs->password[0], sizeof(_prefs->password)); // 56
ok = ok && prefs_read(&file, &_prefs->freq, sizeof(_prefs->freq)); // 72
ok = ok && prefs_read(&file, &_prefs->tx_power_dbm, sizeof(_prefs->tx_power_dbm)); // 76
ok = ok && prefs_read(&file, &_prefs->disable_fwd, sizeof(_prefs->disable_fwd)); // 77
ok = ok && prefs_read(&file, &_prefs->advert_interval, sizeof(_prefs->advert_interval)); // 78
ok = ok && prefs_read(&file, pad, 1); // 79
ok = ok && prefs_read(&file, &_prefs->rx_delay_base, sizeof(_prefs->rx_delay_base)); // 80
ok = ok && prefs_read(&file, &_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84
ok = ok && prefs_read(&file, &_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88
ok = ok && prefs_read(&file, &_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104
ok = ok && prefs_read(&file, &_prefs->backoff_multiplier, sizeof(_prefs->backoff_multiplier)); // 108
ok = ok && prefs_read(&file, &_prefs->sf, sizeof(_prefs->sf)); // 112
ok = ok && prefs_read(&file, &_prefs->cr, sizeof(_prefs->cr)); // 113
ok = ok && prefs_read(&file, &_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114
ok = ok && prefs_read(&file, &_prefs->multi_acks, sizeof(_prefs->multi_acks)); // 115
ok = ok && prefs_read(&file, &_prefs->bw, sizeof(_prefs->bw)); // 116
ok = ok && prefs_read(&file, &_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval)); // 120
ok = ok && prefs_read(&file, &_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 121
ok = ok && prefs_read(&file, &_prefs->loop_detect, sizeof(_prefs->loop_detect)); // 122
ok = ok && prefs_read(&file, pad, 1); // 123
ok = ok && prefs_read(&file, &_prefs->flood_max, sizeof(_prefs->flood_max)); // 124
ok = ok && prefs_read(&file, &_prefs->flood_advert_interval, sizeof(_prefs->flood_advert_interval)); // 125
ok = ok && prefs_read(&file, &_prefs->interference_threshold, sizeof(_prefs->interference_threshold)); // 126
ok = ok && prefs_read(&file, pad, 1); // skip bridge_enabled // 127
ok = ok && prefs_read(&file, pad, 2); // skip bridge_delay // 128
ok = ok && prefs_read(&file, pad, 1); // skip bridge_pkt_src // 130
ok = ok && prefs_read(&file, pad, 4); // skip bridge_baud // 131
ok = ok && prefs_read(&file, pad, 1); // skip bridge_channel // 135
ok = ok && prefs_read(&file, pad, 16); // skip bridge_secret // 136
ok = ok && prefs_read(&file, &_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled)); // 152
ok = ok && prefs_read(&file, pad, 3); // 153
ok = ok && prefs_read(&file, &_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156
ok = ok && prefs_read(&file, &_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157
ok = ok && prefs_read(&file, &_prefs->advert_loc_policy, sizeof(_prefs->advert_loc_policy)); // 161
ok = ok && prefs_read(&file, &_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
ok = ok && prefs_read(&file, &_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
ok = ok && prefs_read(&file, _prefs->owner_info, sizeof(_prefs->owner_info)); // 170
ok = ok && prefs_read(&file, &_prefs->rx_boost, sizeof(_prefs->rx_boost)); // 290
ok = ok && prefs_read(&file, &_prefs->rx_duty_cycle, sizeof(_prefs->rx_duty_cycle)); // 291
ok = ok && prefs_read(&file, &_prefs->apc_enabled, sizeof(_prefs->apc_enabled)); // 292
ok = ok && prefs_read(&file, &_prefs->apc_margin, sizeof(_prefs->apc_margin)); // 293
if (!ok) {
LOG_WRN("Prefs file %s truncated, some fields use defaults", path);
}
fs_close(&file);
// Sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0.0f, 20.0f);
_prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0.0f, 2.0f);
_prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0.0f, 2.0f);
/* Migrate uninitialized pad bytes: NaN or out-of-range → default 0.5.
* 0.0 is valid (disables reactive backoff). Old firmware upgrading
* with zeroed pad bytes will get 0.0 = disabled; user can set explicitly. */
if (_prefs->backoff_multiplier != _prefs->backoff_multiplier ||
_prefs->backoff_multiplier < 0.0f || _prefs->backoff_multiplier > 10.0f) {
_prefs->backoff_multiplier = 0.5f;
}
_prefs->backoff_multiplier = constrain(_prefs->backoff_multiplier, 0.0f, 2.0f);
/* Migrate old AF multiplier (0-9) to duty cycle percentage (0-99) */
if (_prefs->airtime_factor > 0.0f && _prefs->airtime_factor <= 9.0f) {
_prefs->airtime_factor *= 10.0f;
}
_prefs->airtime_factor = constrain(_prefs->airtime_factor, 0.0f, 99.0f);
_prefs->freq = constrain(_prefs->freq, 150.0f, 2500.0f);
_prefs->bw = constrain(_prefs->bw, 7.8f, 500.0f);
_prefs->sf = constrain(_prefs->sf, (uint8_t)5, (uint8_t)12);
_prefs->cr = constrain(_prefs->cr, (uint8_t)5, (uint8_t)8);
_prefs->tx_power_dbm = constrain(_prefs->tx_power_dbm, (int8_t)-9, (int8_t)30);
#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM
if (_prefs->tx_power_dbm > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) {
_prefs->tx_power_dbm = (int8_t)CONFIG_ZEPHCORE_MAX_TX_POWER_DBM;
}
#endif
_prefs->multi_acks = constrain(_prefs->multi_acks, (uint8_t)0, (uint8_t)1);
_prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f);
_prefs->path_hash_mode = constrain(_prefs->path_hash_mode, (uint8_t)0, (uint8_t)2);
_prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, (uint8_t)0, (uint8_t)1);
_prefs->gps_enabled = constrain(_prefs->gps_enabled, (uint8_t)0, (uint8_t)1);
_prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, (uint8_t)0, (uint8_t)2);
_prefs->rx_boost = constrain(_prefs->rx_boost, (uint8_t)0, (uint8_t)1);
_prefs->rx_duty_cycle = constrain(_prefs->rx_duty_cycle, (uint8_t)0, (uint8_t)1);
_prefs->apc_enabled = constrain(_prefs->apc_enabled, (uint8_t)0, (uint8_t)1);
_prefs->apc_margin = constrain(_prefs->apc_margin, (uint8_t)6, (uint8_t)30);
LOG_INF("Loaded prefs from %s", path);
}
void CommonCLI::savePrefs(const char* path) {
// Remove old file first
fs_unlink(path);
struct fs_file_t file;
fs_file_t_init(&file);
if (fs_open(&file, path, FS_O_CREATE | FS_O_WRITE) < 0) {
LOG_ERR("Failed to open %s for write", path);
return;
}
uint8_t pad[16];
memset(pad, 0, sizeof(pad));
fs_write(&file, &_prefs->airtime_factor, sizeof(_prefs->airtime_factor));
fs_write(&file, &_prefs->node_name, sizeof(_prefs->node_name));
fs_write(&file, pad, 4);
fs_write(&file, &_prefs->node_lat, sizeof(_prefs->node_lat));
fs_write(&file, &_prefs->node_lon, sizeof(_prefs->node_lon));
fs_write(&file, &_prefs->password[0], sizeof(_prefs->password));
fs_write(&file, &_prefs->freq, sizeof(_prefs->freq));
fs_write(&file, &_prefs->tx_power_dbm, sizeof(_prefs->tx_power_dbm));
fs_write(&file, &_prefs->disable_fwd, sizeof(_prefs->disable_fwd));
fs_write(&file, &_prefs->advert_interval, sizeof(_prefs->advert_interval));
fs_write(&file, pad, 1);
fs_write(&file, &_prefs->rx_delay_base, sizeof(_prefs->rx_delay_base));
fs_write(&file, &_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor));
fs_write(&file, &_prefs->guest_password[0], sizeof(_prefs->guest_password));
fs_write(&file, &_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor));
fs_write(&file, &_prefs->backoff_multiplier, sizeof(_prefs->backoff_multiplier));
fs_write(&file, &_prefs->sf, sizeof(_prefs->sf));
fs_write(&file, &_prefs->cr, sizeof(_prefs->cr));
fs_write(&file, &_prefs->allow_read_only, sizeof(_prefs->allow_read_only));
fs_write(&file, &_prefs->multi_acks, sizeof(_prefs->multi_acks));
fs_write(&file, &_prefs->bw, sizeof(_prefs->bw));
fs_write(&file, &_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval));
fs_write(&file, &_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode));
fs_write(&file, &_prefs->loop_detect, sizeof(_prefs->loop_detect));
fs_write(&file, pad, 1);
fs_write(&file, &_prefs->flood_max, sizeof(_prefs->flood_max));
fs_write(&file, &_prefs->flood_advert_interval, sizeof(_prefs->flood_advert_interval));
fs_write(&file, &_prefs->interference_threshold, sizeof(_prefs->interference_threshold));
fs_write(&file, pad, 1); // bridge_enabled
fs_write(&file, pad, 2); // bridge_delay
fs_write(&file, pad, 1); // bridge_pkt_src
fs_write(&file, pad, 4); // bridge_baud
fs_write(&file, pad, 1); // bridge_channel
fs_write(&file, pad, 16); // bridge_secret
fs_write(&file, &_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled));
fs_write(&file, pad, 3);
fs_write(&file, &_prefs->gps_enabled, sizeof(_prefs->gps_enabled));
fs_write(&file, &_prefs->gps_interval, sizeof(_prefs->gps_interval));
fs_write(&file, &_prefs->advert_loc_policy, sizeof(_prefs->advert_loc_policy));
fs_write(&file, &_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp));
fs_write(&file, &_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier));
fs_write(&file, _prefs->owner_info, sizeof(_prefs->owner_info));
fs_write(&file, &_prefs->rx_boost, sizeof(_prefs->rx_boost));
fs_write(&file, &_prefs->rx_duty_cycle, sizeof(_prefs->rx_duty_cycle));
fs_write(&file, &_prefs->apc_enabled, sizeof(_prefs->apc_enabled));
fs_write(&file, &_prefs->apc_margin, sizeof(_prefs->apc_margin));
fs_close(&file);
LOG_INF("Saved prefs to %s", path);
}
#define MIN_LOCAL_ADVERT_INTERVAL 60
void CommonCLI::savePrefs() {
if (_prefs->advert_interval * 2 < MIN_LOCAL_ADVERT_INTERVAL) {
_prefs->advert_interval = 0; // turn off, now that device has been manually configured
}
_callbacks->savePrefs();
}
uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) {
AdvertDataBuilder builder(node_type, _prefs->node_name);
return builder.encodeTo(app_data);
} else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) {
AdvertDataBuilder builder(node_type, _prefs->node_name,
_callbacks->getNodeLat(), _callbacks->getNodeLon());
return builder.encodeTo(app_data);
} else {
AdvertDataBuilder builder(node_type, _prefs->node_name,
_prefs->node_lat, _prefs->node_lon);
return builder.encodeTo(app_data);
}
}
void CommonCLI::rebootWorkHandler(struct k_work *work)
{
struct k_work_delayable *dwork = k_work_delayable_from_work(work);
CommonCLI *self = CONTAINER_OF(dwork, CommonCLI, _reboot_work);
switch (self->_pending_reboot) {
case REBOOT_DFU:
static_cast<mesh::ZephyrBoard*>(self->_board)->rebootToBootloader();
break;
case REBOOT_OTA:
/* reply already sent; startOTAUpdate will reset */
char dummy[80];
self->_board->startOTAUpdate(self->_prefs->node_name, dummy);
break;
case REBOOT_NORMAL:
default:
self->_board->reboot();
break;
}
}
void CommonCLI::scheduleReboot(uint8_t type)
{
_pending_reboot = type;
/* 2 second delay - enough for LoRa reply to be transmitted */
k_work_schedule(&_reboot_work, K_SECONDS(2));
}
void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) {
if (strcmp(command, "start dfu") == 0) {
/* Reboot into UF2 bootloader for firmware update */
strcpy(reply, "OK - rebooting to UF2 DFU");
scheduleReboot(REBOOT_DFU);
} else if (memcmp(command, "start ota", 9) == 0) {
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
/* ESP32: Start WiFi AP + HTTP OTA server (no reboot) */
int ota_ret = wifi_ota_start(_prefs->node_name, _board->getManufacturerName());
if (ota_ret == 0) {
snprintf(reply, CLI_REPLY_SIZE, "Started: http://%s/update",
CONFIG_ZEPHCORE_OTA_AP_IP);
} else if (ota_ret == -EALREADY) {
strcpy(reply, "OTA already active");
} else {
snprintf(reply, CLI_REPLY_SIZE, "Error starting OTA: %d", ota_ret);
}
#else
/* nRF52: Reboot into Adafruit BLE OTA DFU mode */
strcpy(reply, "OK - rebooting to BLE OTA DFU");
scheduleReboot(REBOOT_OTA);
#endif
} else if (memcmp(command, "stop ota", 8) == 0) {
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
if (wifi_ota_is_active()) {
wifi_ota_stop();
strcpy(reply, "OTA stopped");
} else {
strcpy(reply, "OTA not active");
}
#else
strcpy(reply, "Not supported");
#endif
} else if (memcmp(command, "reboot", 6) == 0) {
strcpy(reply, "OK - rebooting");
scheduleReboot(REBOOT_NORMAL);
} else if (memcmp(command, "clkreboot", 9) == 0) {
getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm
_board->reboot();
} else if (memcmp(command, "advert.zerohop", 14) == 0) {
_callbacks->sendSelfAdvertisement(1500, false); // 0-hop (direct) advert
strcpy(reply, "OK - zerohop advert sent");
} else if (memcmp(command, "advert", 6) == 0) {
_callbacks->sendSelfAdvertisement(1500, true);
strcpy(reply, "OK - Advert sent");
} else if (memcmp(command, "clock sync", 10) == 0) {
uint32_t curr = getRTCClock()->getCurrentTime();
if (sender_timestamp > curr) {
getRTCClock()->setCurrentTime(sender_timestamp + 1);
uint32_t now = getRTCClock()->getCurrentTime();
time_t t = (time_t)now;
struct tm *tm = gmtime(&t);
snprintf(reply, CLI_REPLY_SIZE, "OK - clock set: %02d:%02d - %d/%d/%d UTC",
tm->tm_hour, tm->tm_min, tm->tm_mday, tm->tm_mon + 1, tm->tm_year + 1900);
} else {
strcpy(reply, "ERR: clock cannot go backwards");
}
} else if (memcmp(command, "clock", 5) == 0) {
uint32_t now = getRTCClock()->getCurrentTime();
time_t t = (time_t)now;
struct tm *tm = gmtime(&t);
snprintf(reply, CLI_REPLY_SIZE, "Clock: %02d:%02d - %d/%d/%d UTC",
tm->tm_hour, tm->tm_min, tm->tm_mday, tm->tm_mon + 1, tm->tm_year + 1900);
} else if (memcmp(command, "time ", 5) == 0) {
uint32_t secs = _atoi(&command[5]);
uint32_t curr = getRTCClock()->getCurrentTime();
if (secs > curr) {
getRTCClock()->setCurrentTime(secs);
time_t t = (time_t)secs;
struct tm *tm = gmtime(&t);
snprintf(reply, CLI_REPLY_SIZE, "OK - clock set: %02d:%02d - %d/%d/%d UTC",
tm->tm_hour, tm->tm_min, tm->tm_mday, tm->tm_mon + 1, tm->tm_year + 1900);
} else {
strcpy(reply, "(ERR: clock cannot go backwards)");
}
} else if (memcmp(command, "neighbors", 9) == 0) {
_callbacks->formatNeighborsReply(reply);
} else if (memcmp(command, "neighbor.remove ", 16) == 0) {
const char* hex = &command[16];
uint8_t pubkey[PUB_KEY_SIZE];
int hex_len = strlen(hex);
if (hex_len > PUB_KEY_SIZE * 2) hex_len = PUB_KEY_SIZE * 2;
int pubkey_len = hex_len / 2;
if (mesh::Utils::fromHex(pubkey, pubkey_len, hex)) {
_callbacks->removeNeighbor(pubkey, pubkey_len);
strcpy(reply, "OK");
} else {
strcpy(reply, "ERR: bad pubkey");
}
} else if (memcmp(command, "tempradio ", 10) == 0) {
strcpy(tmp, &command[10]);
const char* parts[5];
int num = mesh::Utils::parseTextParts(tmp, parts, 5);
float freq = num > 0 ? strtof(parts[0], nullptr) : 0.0f;
float bw = num > 1 ? strtof(parts[1], nullptr) : 0.0f;
uint8_t sf = num > 2 ? atoi(parts[2]) : 0;
uint8_t cr = num > 3 ? atoi(parts[3]) : 0;
int temp_timeout_mins = num > 4 ? atoi(parts[4]) : 0;
if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 &&
cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f && temp_timeout_mins > 0) {
_callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins);
snprintf(reply, CLI_REPLY_SIZE, "OK - temp params for %d mins", temp_timeout_mins);
} else {
strcpy(reply, "Error: freq 150-2500, bw 7-500, sf 5-12, cr 5-8, timeout>0");
}
} else if (memcmp(command, "password ", 9) == 0) {
StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password));
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "password now: %s", _prefs->password);
} else if (memcmp(command, "clear stats", 11) == 0) {
_callbacks->clearStats();
strcpy(reply, "(OK - stats reset)");
/*
* GET commands
*/
} else if (memcmp(command, "get ", 4) == 0) {
const char* config = &command[4];
if (memcmp(config, "dutycycle", 9) == 0) {
float dc = 100.0f / (_prefs->airtime_factor + 1.0f);
int dc_int = (int)dc;
int dc_frac = (int)((dc - dc_int) * 10.0f + 0.5f);
snprintf(reply, CLI_REPLY_SIZE, "> %d.%d%%", dc_int, dc_frac);
} else if (memcmp(config, "af", 2) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %.2f", (double)_prefs->airtime_factor);
} else if (memcmp(config, "int.thresh", 10) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->interference_threshold);
} else if (memcmp(config, "agc.reset.interval", 18) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %u", ((uint32_t)_prefs->agc_reset_interval) * 4);
} else if (memcmp(config, "multi.acks", 10) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->multi_acks);
} else if (memcmp(config, "allow.read.only", 15) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->allow_read_only ? "on" : "off");
} else if (memcmp(config, "flood.advert.interval", 21) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_advert_interval);
} else if (memcmp(config, "advert.interval", 15) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %u", ((uint32_t)_prefs->advert_interval) * 2);
} else if (memcmp(config, "guest.password", 14) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->guest_password);
} else if (sender_timestamp == 0 && memcmp(config, "prv.key", 7) == 0) {
uint8_t prv_key[PRV_KEY_SIZE];
int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE);
mesh::Utils::toHex(tmp, prv_key, len);
snprintf(reply, CLI_REPLY_SIZE, "> %s", tmp);
} else if (memcmp(config, "name", 4) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->node_name);
} else if (memcmp(config, "repeat", 6) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->disable_fwd ? "off" : "on");
} else if (memcmp(config, "lat", 3) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %.6f", _prefs->node_lat);
} else if (memcmp(config, "lon", 3) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %.6f", _prefs->node_lon);
} else if (memcmp(config, "radio", 5) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %.3f,%.1f,%u,%u",
(double)_prefs->freq, (double)_prefs->bw,
(uint32_t)_prefs->sf, (uint32_t)_prefs->cr);
} else if (memcmp(config, "rxdelay", 7) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> adaptive (rxdelay deprecated)");
} else if (memcmp(config, "txdelay", 7) == 0) {
float est = _callbacks->getContentionEstimate();
float ff = _callbacks->getFloodDelayFactor();
snprintf(reply, CLI_REPLY_SIZE, "> adaptive (est=%.1f flood=%.2f)",
(double)est, (double)ff);
} else if (memcmp(config, "apc.margin", 10) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %d dB", (int)_callbacks->getAPCTargetMargin());
} else if (memcmp(config, "flood.max", 9) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max);
} else if (memcmp(config, "direct.txdelay", 14) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> adaptive (direct.txdelay deprecated)");
} else if (memcmp(config, "backoff.multiplier", 18) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %.2f", (double)_prefs->backoff_multiplier);
} else if (memcmp(config, "owner.info", 10) == 0) {
*reply++ = '>';
*reply++ = ' ';
const char* sp = _prefs->owner_info;
while (*sp) {
*reply++ = (*sp == '\n') ? '|' : *sp;
sp++;
}
*reply = 0;
} else if (memcmp(config, "path.hash.mode", 14) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %d", (uint32_t)_prefs->path_hash_mode);
} else if (memcmp(config, "loop.detect", 11) == 0) {
if (_prefs->loop_detect == LOOP_DETECT_OFF) {
strcpy(reply, "> off");
} else if (_prefs->loop_detect == LOOP_DETECT_MINIMAL) {
strcpy(reply, "> minimal");
} else if (_prefs->loop_detect == LOOP_DETECT_MODERATE) {
strcpy(reply, "> moderate");
} else {
strcpy(reply, "> strict");
}
} else if (memcmp(config, "tx", 2) == 0 && (config[2] == 0 || config[2] == ' ')) {
if (_callbacks->isAPCEnabled()) {
int8_t apc = _callbacks->getAPCReduction();
float margin = _callbacks->getAPCMargin();
int effective = (int)_prefs->tx_power_dbm - (int)apc;
snprintf(reply, CLI_REPLY_SIZE, "> %ddBm (max=%d apc=-%d margin=%.1f target=%d)",
effective, (int)_prefs->tx_power_dbm, (int)apc, (double)margin,
(int)_callbacks->getAPCTargetMargin());
} else {
snprintf(reply, CLI_REPLY_SIZE, "> %ddBm (apc=off)",
(int)_prefs->tx_power_dbm);
}
} else if (memcmp(config, "freq", 4) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %.3f", (double)_prefs->freq);
} else if (memcmp(config, "public.key", 10) == 0) {
strcpy(reply, "> ");
mesh::Utils::toHex(&reply[2], _callbacks->getSelfId().pub_key, PUB_KEY_SIZE);
} else if (memcmp(config, "role", 4) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %s", _callbacks->getRole());
} else if (memcmp(config, "bootloader.ver", 14) == 0) {
char ver[32];
if (_board->getBootloaderVersion(ver, sizeof(ver))) {
snprintf(reply, CLI_REPLY_SIZE, "> %s", ver);
} else {
strcpy(reply, "> unknown");
}
} else if (memcmp(config, "adc.multiplier", 14) == 0) {
float adc_mult = _board->getAdcMultiplier();
if (adc_mult == 0.0f) {
strcpy(reply, "Error: unsupported by this board");
} else {
snprintf(reply, CLI_REPLY_SIZE, "> %.3f", (double)adc_mult);
}
} else if (memcmp(config, "radio.rxgain", 12) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_boost);
} else if (memcmp(config, "rxduty", 6) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_duty_cycle);
} else if (memcmp(config, "dc.restarts", 11) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "> %u",
(uint32_t)_callbacks->getDutyCycleTimeoutRestarts());
} else {
snprintf(reply, CLI_REPLY_SIZE, "??: %s", config);
}
/*
* SET commands
*/
} else if (memcmp(command, "set ", 4) == 0) {
const char* config = &command[4];
if (memcmp(config, "dutycycle ", 10) == 0) {
float dc = atof(&config[10]);
if (dc < 1 || dc > 100) {
strcpy(reply, "ERROR: dutycycle must be 1-100");
} else {
_prefs->airtime_factor = (100.0f / dc) - 1.0f;
savePrefs();
float actual = 100.0f / (_prefs->airtime_factor + 1.0f);
int a_int = (int)actual;
int a_frac = (int)((actual - a_int) * 10.0f + 0.5f);
snprintf(reply, CLI_REPLY_SIZE, "OK - %d.%d%%", a_int, a_frac);
}
} else if (memcmp(config, "af ", 3) == 0) {
_prefs->airtime_factor = atof(&config[3]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "int.thresh ", 11) == 0) {
_prefs->interference_threshold = atoi(&config[11]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "agc.reset.interval ", 19) == 0) {
_prefs->agc_reset_interval = atoi(&config[19]) / 4;
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "OK - interval rounded to %u", ((uint32_t)_prefs->agc_reset_interval) * 4);
} else if (memcmp(config, "multi.acks ", 11) == 0) {
int val = atoi(&config[11]);
if (val == 0 || val == 1) {
_prefs->multi_acks = (uint8_t)val;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: must be 0 or 1");
}
} else if (memcmp(config, "allow.read.only ", 16) == 0) {
if (memcmp(&config[16], "on", 2) == 0) {
_prefs->allow_read_only = 1;
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(&config[16], "off", 3) == 0) {
_prefs->allow_read_only = 0;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: must be on or off");
}
} else if (memcmp(config, "flood.advert.interval ", 22) == 0) {
int hours = _atoi(&config[22]);
if ((hours > 0 && hours < 3) || (hours > 168)) {
strcpy(reply, "Error: interval range is 3-168 hours");
} else {
_prefs->flood_advert_interval = (uint8_t)hours;
_callbacks->updateFloodAdvertTimer();
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "advert.interval ", 16) == 0) {
int mins = _atoi(&config[16]);
if ((mins > 0 && mins < MIN_LOCAL_ADVERT_INTERVAL) || (mins > 240)) {
snprintf(reply, CLI_REPLY_SIZE, "Error: interval range is %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL);
} else {
_prefs->advert_interval = (uint8_t)(mins / 2);
_callbacks->updateAdvertTimer();
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "guest.password ", 15) == 0) {
StrHelper::strncpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password));
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "prv.key ", 8) == 0) {
uint8_t prv_key[PRV_KEY_SIZE];
bool success = mesh::Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8]);
if (success && mesh::LocalIdentity::validatePrivateKey(prv_key)) {
mesh::LocalIdentity new_id;
new_id.readFrom(prv_key, PRV_KEY_SIZE);
_callbacks->saveIdentity(new_id);
strcpy(reply, "OK, reboot to apply! New pubkey: ");
mesh::Utils::toHex(&reply[33], new_id.pub_key, PUB_KEY_SIZE);
} else {
strcpy(reply, "Error, bad key");
}
} else if (memcmp(config, "name ", 5) == 0) {
if (isValidName(&config[5])) {
StrHelper::strncpy(_prefs->node_name, &config[5], sizeof(_prefs->node_name));
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: name cannot contain [ ] \\ : , ? *");
}
} else if (memcmp(config, "repeat ", 7) == 0) {
if (memcmp(&config[7], "on", 2) == 0) {
_prefs->disable_fwd = 0;
savePrefs();
strcpy(reply, "OK - repeat is now ON");
} else if (memcmp(&config[7], "off", 3) == 0) {
_prefs->disable_fwd = 1;
savePrefs();
strcpy(reply, "OK - repeat is now OFF");
} else {
strcpy(reply, "Error: must be on or off");
}
} else if (memcmp(config, "radio ", 6) == 0) {
strcpy(tmp, &config[6]);
const char* parts[4];
int num = mesh::Utils::parseTextParts(tmp, parts, 4);
float freq = num > 0 ? strtof(parts[0], nullptr) : 0.0f;
float bw = num > 1 ? strtof(parts[1], nullptr) : 0.0f;
uint8_t sf = num > 2 ? atoi(parts[2]) : 0;
uint8_t cr = num > 3 ? atoi(parts[3]) : 0;
if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 &&
cr >= 5 && cr <= 8 && bw >= 7.0f && bw <= 500.0f) {
_prefs->sf = sf;
_prefs->cr = cr;
_prefs->freq = freq;
_prefs->bw = bw;
_callbacks->savePrefs();
strcpy(reply, "OK - reboot to apply");
} else {
strcpy(reply, "Error: freq 150-2500, bw 7-500, sf 5-12, cr 5-8");
}
} else if (memcmp(config, "lat ", 4) == 0) {
_prefs->node_lat = atof(&config[4]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "lon ", 4) == 0) {
_prefs->node_lon = atof(&config[4]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "rxdelay ", 8) == 0) {
_prefs->rx_delay_base = atof(&config[8]);
savePrefs();
strcpy(reply, "OK (ignored: rxdelay is now adaptive)");
} else if (memcmp(config, "txdelay ", 8) == 0) {
_prefs->tx_delay_factor = atof(&config[8]);
savePrefs();
strcpy(reply, "OK (ignored: txdelay is now adaptive)");
} else if (memcmp(config, "flood.max ", 10) == 0) {
int m = atoi(&config[10]);
if (m >= 0 && m <= 64) {
_prefs->flood_max = (uint8_t)m;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: range 0-64");
}
} else if (memcmp(config, "direct.txdelay ", 15) == 0) {
_prefs->direct_tx_delay_factor = atof(&config[15]);
savePrefs();
strcpy(reply, "OK (ignored: direct.txdelay is now adaptive)");
} else if (memcmp(config, "backoff.multiplier ", 19) == 0) {
float f = atof(&config[19]);
if (f >= 0.0f && f <= 2.0f) {
_prefs->backoff_multiplier = f;
_callbacks->setBackoffMultiplier(f);
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, range 0.0-2.0");
}
} else if (memcmp(config, "owner.info ", 11) == 0) {
config += 11;
char* dp = _prefs->owner_info;
while (*config && dp - _prefs->owner_info < (int)sizeof(_prefs->owner_info) - 1) {
*dp++ = (*config == '|') ? '\n' : *config;
config++;
}
*dp = 0;
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "path.hash.mode ", 15) == 0) {
config += 15;
uint8_t mode = atoi(config);
if (mode < 3) {
_prefs->path_hash_mode = mode;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0,1, or 2");
}
} else if (memcmp(config, "loop.detect ", 12) == 0) {
config += 12;
uint8_t mode;
if (memcmp(config, "off", 3) == 0) {
mode = LOOP_DETECT_OFF;
} else if (memcmp(config, "minimal", 7) == 0) {
mode = LOOP_DETECT_MINIMAL;
} else if (memcmp(config, "moderate", 8) == 0) {
mode = LOOP_DETECT_MODERATE;
} else if (memcmp(config, "strict", 6) == 0) {
mode = LOOP_DETECT_STRICT;
} else {
mode = 0xFF;
strcpy(reply, "Error, must be: off, minimal, moderate, or strict");
}
if (mode != 0xFF) {
_prefs->loop_detect = mode;
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "apc.margin ", 11) == 0) {
int val = atoi(&config[11]);
if (val >= 6 && val <= 30) {
_prefs->apc_margin = (uint8_t)val;
_callbacks->setAPCTargetMargin((uint8_t)val);
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "OK - APC target margin=%d dB", val);
} else {
strcpy(reply, "Error: range 6-30 dB");
}
} else if (memcmp(config, "tx ", 3) == 0) {
if (memcmp(&config[3], "apc", 3) == 0) {
_prefs->apc_enabled = 1;
_callbacks->setAPCEnabled(true);
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm (apc=on)",
(int)_prefs->tx_power_dbm);
} else {
int val = atoi(&config[3]);
int max_tx = 30;
#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM
max_tx = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM;
#endif
if (val < -9 || val > max_tx) {
snprintf(reply, CLI_REPLY_SIZE, "Error: range -9 to %d dBm, or 'apc'", max_tx);
} else {
_prefs->apc_enabled = 0;
_prefs->tx_power_dbm = (int8_t)val;
savePrefs();
_callbacks->setAPCEnabled(false);
_callbacks->setTxPower(_prefs->tx_power_dbm);
snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm (apc=off)",
(int)_prefs->tx_power_dbm);
}
}
} else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) {
float f = atof(&config[5]);
if (f >= 150.0f && f <= 2500.0f) {
_prefs->freq = f;
savePrefs();
strcpy(reply, "OK - reboot to apply");
} else {
strcpy(reply, "Error: range 150-2500 MHz");
}
} else if (memcmp(config, "adc.multiplier ", 15) == 0) {
_prefs->adc_multiplier = atof(&config[15]);
if (_board->setAdcMultiplier(_prefs->adc_multiplier)) {
savePrefs();
if (_prefs->adc_multiplier == 0.0f) {
strcpy(reply, "OK - using default board multiplier");
} else {
snprintf(reply, CLI_REPLY_SIZE, "OK - multiplier set to %.3f", (double)_prefs->adc_multiplier);
}
} else {
_prefs->adc_multiplier = 0.0f;
strcpy(reply, "Error: unsupported by this board");
}
} else if (memcmp(config, "radio.rxgain ", 13) == 0) {
int val = atoi(&config[13]);
if (val == 0 || val == 1) {
_prefs->rx_boost = (uint8_t)val;
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "OK - radio.rxgain=%d (reboot to apply)", _prefs->rx_boost);
} else {
strcpy(reply, "Error: must be 0 or 1");
}
} else if (memcmp(config, "rxduty ", 7) == 0) {
int val = atoi(&config[7]);
if (val == 0 || val == 1) {
_prefs->rx_duty_cycle = (uint8_t)val;
savePrefs();
snprintf(reply, CLI_REPLY_SIZE, "OK - rxduty=%d (reboot to apply)", _prefs->rx_duty_cycle);
} else {
strcpy(reply, "Error: must be 0 or 1");
}
} else {
snprintf(reply, CLI_REPLY_SIZE, "unknown config: %s", config);
}
} else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) {
bool s = _callbacks->formatFileSystem();
snprintf(reply, CLI_REPLY_SIZE, "File system erase: %s", s ? "OK" : "Err");
} else if (memcmp(command, "ver", 3) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate());
} else if (memcmp(command, "board", 5) == 0) {
snprintf(reply, CLI_REPLY_SIZE, "%s", _board->getManufacturerName());
} else if (memcmp(command, "sensor get ", 11) == 0) {
const char* key = command + 11;
const char* val = _callbacks->getSensorSettingByKey(key);
if (val != nullptr) {
snprintf(reply, CLI_REPLY_SIZE, "> %s", val);
} else {
strcpy(reply, "null");
}
} else if (memcmp(command, "sensor set ", 11) == 0) {
strcpy(tmp, &command[11]);
const char* parts[2];
int num = mesh::Utils::parseTextParts(tmp, parts, 2, ' ');
const char* key = (num > 0) ? parts[0] : "";
const char* value = (num > 1) ? parts[1] : "null";
if (_callbacks->setSensorSettingValue(key, value)) {
strcpy(reply, "ok");
} else {
strcpy(reply, "can't find custom var");
}
} else if (memcmp(command, "sensor list", 11) == 0) {
char* dp = reply;
int start = 0;
int end = _callbacks->getNumSensorSettings();
if (strlen(command) > 11) {
start = _atoi(command + 12);
}
if (start >= end) {
strcpy(reply, "no custom var");
} else {
snprintf(dp, CLI_REPLY_SIZE - (dp - reply), "%d vars\n", end);
dp = strchr(dp, 0);
int i;
for (i = start; i < end && (dp - reply < 134); i++) {
snprintf(dp, CLI_REPLY_SIZE - (dp - reply), "%s=%s\n",
_callbacks->getSensorSettingName(i),
_callbacks->getSensorSettingValue(i));
dp = strchr(dp, 0);
}
if (i < end) {
snprintf(dp, CLI_REPLY_SIZE - (dp - reply), "... next:%d", i);
} else {
*(dp - 1) = 0; // remove last CR
}
}
} else if (memcmp(command, "gps on", 6) == 0) {
if (_callbacks->setGpsEnabled(true)) {
_prefs->gps_enabled = 1;
savePrefs();
strcpy(reply, "ok");
} else {
strcpy(reply, "gps toggle not found");
}
} else if (memcmp(command, "gps off", 7) == 0) {
if (_callbacks->setGpsEnabled(false)) {
_prefs->gps_enabled = 0;
savePrefs();
strcpy(reply, "ok");
} else {
strcpy(reply, "gps toggle not found");
}
} else if (memcmp(command, "gps setloc", 10) == 0) {
_prefs->node_lat = _callbacks->getNodeLat();
_prefs->node_lon = _callbacks->getNodeLon();
savePrefs();
strcpy(reply, "ok");
} else if (memcmp(command, "gps advert", 10) == 0) {
if (strlen(command) == 10) {
switch (_prefs->advert_loc_policy) {
case ADVERT_LOC_NONE: strcpy(reply, "> none"); break;
case ADVERT_LOC_PREFS: strcpy(reply, "> prefs"); break;
case ADVERT_LOC_SHARE: strcpy(reply, "> share"); break;
default: strcpy(reply, "error");
}
} else if (memcmp(command + 11, "none", 4) == 0) {
_prefs->advert_loc_policy = ADVERT_LOC_NONE;
savePrefs();
strcpy(reply, "ok");
} else if (memcmp(command + 11, "share", 5) == 0) {
_prefs->advert_loc_policy = ADVERT_LOC_SHARE;
savePrefs();
strcpy(reply, "ok");
} else if (memcmp(command + 11, "prefs", 5) == 0) {
_prefs->advert_loc_policy = ADVERT_LOC_PREFS;
savePrefs();
strcpy(reply, "ok");
} else {
strcpy(reply, "error");
}
} else if (memcmp(command, "gps", 3) == 0) {
_callbacks->formatGpsStatsReply(reply);
} else if (memcmp(command, "powersaving", 11) == 0) {
strcpy(reply, "Not implemented");
} else if (memcmp(command, "log start", 9) == 0) {
_callbacks->setLoggingOn(true);
strcpy(reply, " logging on");
} else if (memcmp(command, "log stop", 8) == 0) {
_callbacks->setLoggingOn(false);
strcpy(reply, " logging off");
} else if (memcmp(command, "log erase", 9) == 0) {
_callbacks->eraseLogFile();
strcpy(reply, " log erased");
} else if (sender_timestamp == 0 && memcmp(command, "log", 3) == 0) {
_callbacks->dumpLogFile();
strcpy(reply, " EOF");
} else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 &&
(command[13] == 0 || command[13] == ' ')) {
_callbacks->formatPacketStatsReply(reply);
} else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 &&
(command[11] == 0 || command[11] == ' ')) {
_callbacks->formatRadioStatsReply(reply);
} else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 &&
(command[10] == 0 || command[10] == ' ')) {
_callbacks->formatStatsReply(reply);
} else {
strcpy(reply, "Unknown command");
}
}