/* * SPDX-License-Identifier: MIT * CommonCLI - Common CLI command handlers for repeaters */ #include "CommonCLI.h" #include "battery_curve.h" #include "led_gate.h" #include "buzzer_gate.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #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; } #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); } } /* How long past the initial delay we keep waiting for the transport to drain, * and how often we look. The poll MUST re-schedule rather than sleep: this * handler runs on the system work queue, which is the same queue that drains * the BLE TX ring — blocking here would stall the very thing being waited on * and guarantee the timeout. */ #define REBOOT_TX_DRAIN_GRACE_MS 3000 #define REBOOT_TX_POLL_MS 20 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); /* Hold the reset until the companion app has actually been told what * happened — the CLI reply and, more importantly, the delivery-ack for the * command that asked for this reboot. Losing that ack is what makes the * app resend the command, and a resend of "reboot" arrives after RAM has * been zeroed, so the v-contact dedup ring cannot recognise it and the * command runs a second time. */ if (!self->_callbacks->transportTxIdle() && k_uptime_get() < self->_reboot_deadline_ms) { k_work_reschedule(&self->_reboot_work, K_MSEC(REBOOT_TX_POLL_MS)); return; } switch (self->_pending_reboot) { case REBOOT_DFU: static_cast(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. On a * companion the handler then keeps deferring in REBOOT_TX_POLL_MS steps * until the BLE/USB transport has drained, up to the grace below. */ _reboot_deadline_ms = k_uptime_get() + 2000 + REBOOT_TX_DRAIN_GRACE_MS; k_work_schedule(&_reboot_work, K_SECONDS(2)); } /* CLI commands are case-sensitive, matching upstream Arduino MeshCore. * * A case-insensitive normalizer lived here from 2026-07-12 until 2026-07-19. * It lowercased the first two whitespace-delimited tokens before matching, on * the assumption that a value never appears before the third token. That is * false for "password ", whose value IS token 1 -- so any admin * password containing uppercase was silently stored folded to lowercase and * could never be used to log in again. ("set guest.password " was * unaffected: three tokens.) * * Do not reintroduce input folding here. Any scheme that rewrites the buffer * before dispatch has to guess where keywords end and arguments begin, and * that guess is what broke. If case-insensitivity is wanted again, do it at * the comparison sites so argument bytes are never touched. */ 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 /* Deferred like every other reboot path: called inline this reset the * board before the reply — and before any delivery-ack — could leave * the TX queue. */ strcpy(reply, "OK - clock reset, rebooting"); scheduleReboot(REBOOT_NORMAL); } 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); time_sync_report(TIME_SYNC_CLI); zephcore_rtc_save(sender_timestamp + 1); /* persist to hardware RTC */ MeshTimeSync* ts = _callbacks->getMeshTimeSync(); if (ts) ts->noteManualSync((uint32_t)(k_uptime_get() / 1000)); 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_sync_report(TIME_SYNC_CLI); zephcore_rtc_save(secs); /* persist to hardware RTC */ MeshTimeSync* ts = _callbacks->getMeshTimeSync(); if (ts) ts->noteManualSync((uint32_t)(k_uptime_get() / 1000)); 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) { snprintf(tmp, sizeof(tmp), "%.*s", (int)(sizeof(tmp) - 1), &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::strzcpy(_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, "leds", 4) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer", 6) == 0) { uint8_t mode = zephcore_buzzer_mode_from_prefs(_prefs->buzzer_quiet); snprintf(reply, CLI_REPLY_SIZE, "> %u (%s)", mode, zephcore_buzzer_mode_name(mode)); #endif } else if (memcmp(config, "agc.reset.interval", 18) == 0) { strcpy(reply, "Removed - Automatic AGC reset is on"); } else if (memcmp(config, "multi.acks", 10) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->multi_acks); #ifdef CONFIG_ZEPHCORE_ROLE_ROOM_SERVER /* Room server only. RoomServerMesh.cpp is the sole consumer of * allow_read_only; RepeaterMesh.cpp never reads it, so on a repeater * this advertised a setting that silently did nothing. Note this is a * deliberate divergence from Arduino MeshCore, whose shared CommonCLI * exposes the knob on every role for the same reason ours used to. * * The pref itself stays unconditional -- it is byte 114 of the on-flash * prefs layout, identical to Arduino's, so dropping it would shift every * field after it and invalidate existing prefs files. */ } else if (memcmp(config, "allow.read.only", 15) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->allow_read_only ? "on" : "off"); #endif } 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.fem.rxgain", 16) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->fem_rxgain); } else if (memcmp(config, "radio.rxgain", 12) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_boost); } 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, "flood.max.advert", 16) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max_advert); } else if (memcmp(config, "flood.max.unscoped", 18) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max_unscoped); } 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 (strcmp(config, "tx") == 0) { /* Plain number, matching upstream Arduino MeshCore's "> %d". */ snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->tx_power_dbm); } else if (memcmp(config, "freqerr", 7) == 0) { /* MUST stay above "freq" — that is a 4-char prefix match and * would swallow this one. * * Carrier frequency error measured on received packets, LR2021 * only. Diagnostic: nothing acts on it. The mean approximates * THIS node's reference error only once averaged over many * different peers (theirs cancel, ours does not), which is why * the spread and packet count are shown alongside it. */ int n = snprintf(reply, CLI_REPLY_SIZE, "> "); if (_callbacks->formatFreqErrorStatus(reply + n, CLI_REPLY_SIZE - n) == 0) { strcpy(reply, "not available"); } } 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 { uint16_t mv = _board->getBattMilliVolts(); uint16_t target_mv = battery_curve_default.ocv_mv[0]; if (mv > 0) { snprintf(reply, CLI_REPLY_SIZE, "> %.3f (%u mV, target >= %u mV for 100%%)", (double)adc_mult, mv, target_mv); } else { snprintf(reply, CLI_REPLY_SIZE, "> %.3f (no ADC reading)", (double)adc_mult); } } } else if (memcmp(config, "rxduty", 6) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_duty_cycle); } else if (memcmp(config, "gps diag", 8) == 0) { // What the last module-configuration attempt actually did. reply[0] = '>'; reply[1] = ' '; gps_get_diag_report(reply + 2, CLI_REPLY_SIZE - 2); } else if (memcmp(config, "gps duty", 8) == 0) { uint32_t s = gps_get_poll_interval_sec(); // now-effective value if (s == 0) strcpy(reply, "> always on (0)"); else snprintf(reply, CLI_REPLY_SIZE, "> %u", (unsigned)s); } else if (memcmp(config, "dc.restarts", 11) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_callbacks->getDutyCycleTimeoutRestarts()); } else if (memcmp(config, "probe.interval", 14) == 0) { /* Seconds between periodic radio measurements — the noise-floor * sample and the CAD probe that consumes it. 0 = probing off. */ snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->probe_interval); } else if (memcmp(config, "cad", 3) == 0) { /* Runtime state + per-level probe stats live in the radio. * Remote replies get the truncated buffer like meshtimesync. */ size_t cap = (sender_timestamp == 0) ? CLI_REPLY_SIZE : CLI_REMOTE_REPLY_SIZE; int n = snprintf(reply, cap, "> "); if (_callbacks->formatCadStatus(reply + n, (int)cap - n) == 0) { strcpy(reply, "not available"); } } else if (memcmp(config, "extra.sf", 8) == 0) { char* dp = reply; dp += sprintf(dp, "> "); int shown = 0; for (int i = 0; i < EXTRA_SF_MAX && _prefs->extra_sf[i] != 0; i++) { dp += sprintf(dp, "%s%u", shown++ ? "," : "", (unsigned)_prefs->extra_sf[i]); } if (shown == 0) { strcpy(reply, "> none"); } } else if (memcmp(config, "meshtimesync", 12) == 0) { MeshTimeSync* ts = _callbacks->getMeshTimeSync(); if (ts == nullptr) { strcpy(reply, "not available"); } else { /* Only the local USB CLI (sender_timestamp == 0) gets the * full evidence table; remote replies are truncated to the * packet buffer. */ size_t cap = (sender_timestamp == 0) ? CLI_REPLY_SIZE : CLI_REMOTE_REPLY_SIZE; ts->formatStatus(reply, cap, getRTCClock()->getCurrentTime(), (uint32_t)(k_uptime_get() / 1000), _prefs->meshtimesync != 0); } } 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) { /* Companion runtime never reads this (getInterferenceThreshold is * only overridden in Repeater/RoomServer) — reject instead of a * false OK. */ if (strcmp(_callbacks->getRole(), "companion") == 0) { strcpy(reply, "Error: not supported on companion"); } else { _prefs->interference_threshold = atoi(&config[11]); savePrefs(); strcpy(reply, "OK"); } } else if (memcmp(config, "leds ", 5) == 0) { /* Master switch for every LED on the node: heartbeat, unread-message * and LoRa TX activity, plus the message and shutdown flashes. Not * the display backlight — that has its own UI brightness setting. */ const char* val = &config[5]; int on; if (memcmp(val, "on", 2) == 0 || val[0] == '1') { on = 1; } else if (memcmp(val, "off", 3) == 0 || val[0] == '0') { on = 0; } else { on = -1; } if (on < 0) { strcpy(reply, "Error: must be on or off"); } else { _prefs->leds_disabled = on ? 0 : 1; zephcore_leds_set_disabled(_prefs->leds_disabled != 0); savePrefs(); strcpy(reply, "OK"); } #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer ", 7) == 0) { /* 0 = silent, 1 = sound + vibration, 2 = vibration only, * 3 = sound only. Modes 2 and 3 only mean something on a board * with a vibration motor, which most boards don't have. */ const char* val = &config[7]; int mode; if (memcmp(val, "vibrate", 7) == 0 || val[0] == '2') { mode = ZEPHCORE_BUZZER_VIBRATE; } else if (memcmp(val, "sound", 5) == 0 || val[0] == '3') { mode = ZEPHCORE_BUZZER_SOUND; } else if (memcmp(val, "on", 2) == 0 || val[0] == '1') { mode = ZEPHCORE_BUZZER_ON; } else if (memcmp(val, "off", 3) == 0 || val[0] == '0') { mode = ZEPHCORE_BUZZER_OFF; } else { mode = -1; } if (mode < 0) { strcpy(reply, "Error: 0 (silent), 1 (sound+vib), 2 (vibrate) or 3 (sound)"); } else if ((mode == ZEPHCORE_BUZZER_VIBRATE || mode == ZEPHCORE_BUZZER_SOUND) && !zephcore_buzzer_has_vibrate()) { strcpy(reply, "Error: no vibration motor on this board - use 0 or 1"); } else { _prefs->buzzer_quiet = zephcore_buzzer_prefs_from_mode((uint8_t)mode); zephcore_buzzer_set_mode((uint8_t)mode, false); ui_set_buzzer_mode((uint8_t)mode); savePrefs(); strcpy(reply, "OK"); } #endif } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { /* Periodic AGC recalibration was removed: it reset the noise floor * to its unseeded sentinel on every fire, forcing a fresh seed and * a full EMA warmup, and it was already forced off under RX duty * cycle. RX duty cycle is the supported way to cut RX current. * The prefs BYTE is retained (read/written, never acted on) — the * on-disk layout is byte-exact and shifting it would corrupt every * existing node's prefs. */ strcpy(reply, "Removed - Automatic AGC reset is on"); } else if (memcmp(config, "cad.auto ", 9) == 0) { if (memcmp(&config[9], "on", 2) == 0 || memcmp(&config[9], "off", 3) == 0) { _prefs->cad_auto = (config[9] == 'o' && config[10] == 'n') ? 1 : 0; _callbacks->applyCadPrefs(); savePrefs(); strcpy(reply, "OK"); } else { strcpy(reply, "Error: must be on or off"); } } else if (memcmp(config, "cad.offset ", 11) == 0) { int val = atoi(&config[11]); if (val < CAD_OFFSET_MIN || val > CAD_OFFSET_MAX) { snprintf(reply, CLI_REPLY_SIZE, "Error: offset range is %d..%d", CAD_OFFSET_MIN, CAD_OFFSET_MAX); } else { _prefs->cad_offset = (int8_t)val; _callbacks->applyCadPrefs(); savePrefs(); strcpy(reply, "OK"); } /* Governs every periodic radio measurement, not just CAD — the * noise-floor sampler and the CAD probe share one reading. */ } else if (memcmp(config, "probe.interval ", 15) == 0) { int val = atoi(&config[15]); if (val != 0 && (val < 10 || val > 255)) { strcpy(reply, "Error: interval is 0 (probing off) or 10-255 seconds"); } else { _prefs->probe_interval = (uint8_t)val; _callbacks->applyCadPrefs(); savePrefs(); strcpy(reply, "OK"); } } else if (memcmp(config, "cad.busycap ", 12) == 0) { int val = atoi(&config[12]); if (val != 0 && (val < 10 || val > 90)) { strcpy(reply, "Error: busycap is 0 (off) or 10-90 percent"); } else { _prefs->cad_busycap = (uint8_t)val; _callbacks->applyCadPrefs(); savePrefs(); strcpy(reply, "OK"); } } else if (memcmp(config, "cad.reset", 9) == 0) { _callbacks->resetCadStats(); strcpy(reply, "OK - CAD probe stats cleared"); } else if (memcmp(config, "extra.sf ", 9) == 0) { /* LR2021 side detectors: up to 3 extra SFs received alongside * `sf`. "0" / "off" clears the set. The chip-side constraints * (each > sf, distinct, spread <= 4, BW>=500 caps the count) are * enforced in the driver, so an accepted set is a valid one. */ char tmp[32]; const char* parts[EXTRA_SF_MAX + 1]; uint8_t sfs[EXTRA_SF_MAX] = {0}; StrHelper::strncpy(tmp, &config[9], sizeof(tmp)); int num = mesh::Utils::parseTextParts(tmp, parts, EXTRA_SF_MAX + 1, ' '); if (num == 1 && (strcmp(parts[0], "0") == 0 || strcmp(parts[0], "off") == 0)) { num = 0; } if (num > EXTRA_SF_MAX) { sprintf(reply, "Error: at most %d extra SFs", EXTRA_SF_MAX); } else { for (int i = 0; i < num; i++) sfs[i] = (uint8_t)atoi(parts[i]); if (_callbacks->configSideDetectors(sfs, (uint8_t)num)) { memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf)); for (int i = 0; i < num; i++) _prefs->extra_sf[i] = sfs[i]; savePrefs(); strcpy(reply, num ? "OK - extra SFs set" : "OK - extra SFs cleared"); } else { strcpy(reply, "Error: unsupported or invalid extra SF config"); } } } 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"); } #ifdef CONFIG_ZEPHCORE_ROLE_ROOM_SERVER /* Room server only -- see the matching guard on the `get` side. */ } 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"); } #endif } 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::strzcpy(_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) { snprintf(tmp, sizeof(tmp), "%.*s", (int)(sizeof(tmp) - 1), &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) { /* Snapshot old params, then mutate _prefs and save so later * savePrefs() calls (set af, set name, ...) don't clobber * the new values with stale RAM. Freeze the running radio on * the old params via override so the on-air config doesn't * change until reboot. */ float old_freq = _prefs->freq; float old_bw = _prefs->bw; uint8_t old_sf = _prefs->sf; uint8_t old_cr = _prefs->cr; _prefs->freq = freq; _prefs->bw = bw; _prefs->sf = sf; _prefs->cr = cr; _callbacks->savePrefs(); _callbacks->freezeRadioParams(old_freq, old_bw, old_sf, old_cr); 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.advert ", 17) == 0) { int m = atoi(&config[17]); if (m >= 0 && m <= 64) { _prefs->flood_max_advert = (uint8_t)m; savePrefs(); strcpy(reply, "OK"); } else { strcpy(reply, "Error: range 0-64"); } } else if (memcmp(config, "flood.max.unscoped ", 19) == 0) { int m = atoi(&config[19]); if (m >= 0 && m <= 64) { _prefs->flood_max_unscoped = (uint8_t)m; savePrefs(); strcpy(reply, "OK"); } else { strcpy(reply, "Error: range 0-64"); } } 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) { /* Companion's setBackoffMultiplier callback is the base-class * no-op and the value isn't restored at boot — reject instead of * a false OK. */ if (strcmp(_callbacks->getRole(), "companion") == 0) { strcpy(reply, "Error: not supported on companion"); } else { 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) { /* Loop detection runs only in the Repeater/RoomServer forward * path — companions never consult loop_detect. */ if (strcmp(_callbacks->getRole(), "companion") == 0) { strcpy(reply, "Error: not supported on companion"); return; } 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, "tx ", 3) == 0) { char *end = nullptr; long parsed = strtol(&config[3], &end, 10); int max_tx = 30; #ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM max_tx = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; #endif if (end == &config[3] || *end != '\0' || parsed < -9 || parsed > max_tx) { snprintf(reply, CLI_REPLY_SIZE, "Error: range -9 to %d dBm", max_tx); } else { _prefs->tx_power_dbm = (int8_t)parsed; savePrefs(); _callbacks->setTxPower(_prefs->tx_power_dbm); snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm", (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) { float old_freq = _prefs->freq; _prefs->freq = f; savePrefs(); /* Keep _prefs->freq = f in RAM so a later savePrefs() (from any * other "set" command before reboot) can't rewrite the old freq * back; freeze the running radio on the old freq until reboot, * mirroring the "set radio" handler above. */ _callbacks->freezeRadioParams(old_freq, _prefs->bw, _prefs->sf, _prefs->cr); strcpy(reply, "OK - reboot to apply"); } else { strcpy(reply, "Error: range 150-2500 MHz"); } } else if (strcmp(config, "adc.multiplier target") == 0) { strcpy(reply, "Error: need mV target (e.g. set adc.multiplier target 4173)"); } else if (memcmp(config, "adc.multiplier target ", 22) == 0) { /* Calibrate against a known voltage measured with a multimeter. */ uint16_t target_mv = (uint16_t)atoi(&config[22]); uint16_t current_mv = _board->getBattMilliVolts(); if (current_mv == 0) { strcpy(reply, "Error: no ADC reading on this board"); } else if (target_mv < 3000 || target_mv > 4400) { strcpy(reply, "Error: target out of range (3000-4400 mV)"); } else { float current_mult = _board->getAdcMultiplier(); float new_mult = current_mult * (float)target_mv / (float)current_mv; _prefs->adc_multiplier = new_mult; if (_board->setAdcMultiplier(new_mult)) { savePrefs(); snprintf(reply, CLI_REPLY_SIZE, "OK - multiplier %.3f -> %.3f (%u -> %u mV)", (double)current_mult, (double)new_mult, current_mv, target_mv); } else { _prefs->adc_multiplier = 0.0f; strcpy(reply, "Error: unsupported by this board"); } } } else if (memcmp(config, "adc.multiplier full", 19) == 0) { /* Calibrate: board must be on a full charge. Scales the current * multiplier so the ADC reads the board's curve 100% point. */ uint16_t current_mv = _board->getBattMilliVolts(); if (current_mv == 0) { strcpy(reply, "Error: no ADC reading on this board"); } else { uint16_t target_mv = battery_curve_default.ocv_mv[0]; float current_mult = _board->getAdcMultiplier(); float new_mult = current_mult * (float)target_mv / (float)current_mv; _prefs->adc_multiplier = new_mult; if (_board->setAdcMultiplier(new_mult)) { savePrefs(); snprintf(reply, CLI_REPLY_SIZE, "OK - multiplier %.3f -> %.3f (%u -> %u mV)", (double)current_mult, (double)new_mult, current_mv, target_mv); } else { _prefs->adc_multiplier = 0.0f; strcpy(reply, "Error: unsupported by this board"); } } } else if (memcmp(config, "adc.multiplier ", 15) == 0) { const char *arg = &config[15]; float val = atof(arg); /* Reject non-numeric, NaN, inf, negative, and out-of-range values. * 0 is valid (resets to DTS default). Upper bound covers all real * divider/reference combinations with margin. */ bool bad = (val != 0.0f && val < 100.0f) || val > 30000.0f || val < 0.0f; /* atof returns 0 for non-numeric strings — distinguish from literal "0" */ if (val == 0.0f && arg[0] != '0') bad = true; if (bad) { strcpy(reply, "Error: invalid multiplier (0 to reset, or 100-30000)"); } else if (_board->setAdcMultiplier(val)) { _prefs->adc_multiplier = val; savePrefs(); if (val == 0.0f) { strcpy(reply, "OK - using default board multiplier"); } else { snprintf(reply, CLI_REPLY_SIZE, "OK - multiplier set to %.3f", (double)val); } } else { strcpy(reply, "Error: unsupported by this board"); } } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { const char* arg = &config[17]; int val = -1; if (memcmp(arg, "on", 2) == 0) val = 1; else if (memcmp(arg, "off", 3) == 0) val = 0; else if (arg[0] == '0' || arg[0] == '1') val = atoi(arg); if (val == 0 || val == 1) { /* Same shape as radio.rxgain: always save, then apply live and * report when the radio driver has no FEM gate. */ _prefs->fem_rxgain = (uint8_t)val; savePrefs(); if (_callbacks->setFemRxGain(val == 1)) { snprintf(reply, CLI_REPLY_SIZE, "OK - radio.fem.rxgain=%d", _prefs->fem_rxgain); } else { strcpy(reply, "Error: unsupported"); } } else { strcpy(reply, "Error: must be 0, 1, on, or off"); } } else if (memcmp(config, "radio.rxgain ", 13) == 0) { const char* arg = &config[13]; int val = -1; if (memcmp(arg, "on", 2) == 0) val = 1; else if (memcmp(arg, "off", 3) == 0) val = 0; else if (arg[0] == '0' || arg[0] == '1') val = atoi(arg); if (val == 0 || val == 1) { /* Always save (upstream f3d4d8cd), then apply live and * report when the radio has no RX boost feature. */ _prefs->rx_boost = (uint8_t)val; savePrefs(); if (_callbacks->setRxBoostedGain(val == 1)) { snprintf(reply, CLI_REPLY_SIZE, "OK - radio.rxgain=%d", _prefs->rx_boost); } else { strcpy(reply, "Error: unsupported"); } } else { strcpy(reply, "Error: must be 0, 1, on, or off"); } } else if (memcmp(config, "rxduty ", 7) == 0) { const char* arg = &config[7]; int val = -1; if (memcmp(arg, "on", 2) == 0) val = 1; else if (memcmp(arg, "off", 3) == 0) val = 0; else if (arg[0] == '0' || arg[0] == '1') val = atoi(arg); 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, 1, on, or off"); } } else if (memcmp(config, "gps diag", 8) == 0) { // set gps diag <0|1|on|off> — arm module-configuration reporting. // Not persisted: clears on reboot, by design. const char* arg = config + 8; while (*arg == ' ') arg++; int val = -1; if (memcmp(arg, "on", 2) == 0) val = 1; else if (memcmp(arg, "off", 3) == 0) val = 0; else if (arg[0] == '0' || arg[0] == '1') val = atoi(arg); if (val == 0 || val == 1) { gps_set_diag(val == 1); if (val == 1) { strcpy(reply, "OK - gps diag on; run 'gps off' then 'gps on', " "then 'get gps diag'"); } else { strcpy(reply, "OK - gps diag off"); } } else { strcpy(reply, "usage: set gps diag <0|1|on|off>"); } } else if (memcmp(config, "gps duty", 8) == 0) { // set gps duty | default (0 = always on) const char* arg = config + 8; while (*arg == ' ') arg++; uint32_t val = 0; bool ok = true; if (*arg == '\0') { ok = false; } else if (strcmp(arg, "default") == 0) { val = _callbacks->getDefaultGpsIntervalSec(); } else { char* end = NULL; unsigned long parsed = strtoul(arg, &end, 10); // reject non-numeric, fractions, or trailing garbage if (end == arg || *end != '\0') ok = false; else val = (uint32_t)parsed; } if (!ok) { strcpy(reply, "usage: set gps duty | default (0 = always on)"); } else { if (val > 604800UL) val = 604800UL; // cap at 1 week else if (val != 0 && val < 10) val = 10; // floor 10s (0 = always on) _prefs->gps_interval = val; gps_set_poll_interval_sec(val); // apply live savePrefs(); if (val == 0) strcpy(reply, "OK - gps duty=0 (always on)"); else snprintf(reply, CLI_REPLY_SIZE, "OK - gps duty=%u s", (unsigned)val); } } else if (memcmp(config, "meshtimesync ", 13) == 0) { const char* arg = &config[13]; if (_callbacks->getMeshTimeSync() == nullptr) { strcpy(reply, "not available"); } else if (memcmp(arg, "on", 2) == 0) { _prefs->meshtimesync = 1; savePrefs(); strcpy(reply, "OK - meshtimesync on"); } else if (memcmp(arg, "off", 3) == 0) { _prefs->meshtimesync = 0; savePrefs(); strcpy(reply, "OK - meshtimesync off"); } else { strcpy(reply, "Error: must be on or off"); } } else { snprintf(reply, CLI_REPLY_SIZE, "unknown config: %.230s", config); } } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { bool s = _callbacks->formatFileSystem(); if (s) { /* formatFileSystem() flattens the mounted NVS bonds partition, * leaving stale in-RAM bond state. Reboot (deferred so this * reply transmits first) so NVS + the BT stack re-init cleanly. */ snprintf(reply, CLI_REPLY_SIZE, "File system erase: OK - rebooting"); scheduleReboot(REBOOT_NORMAL); } else { snprintf(reply, CLI_REPLY_SIZE, "File system erase: 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) { snprintf(tmp, sizeof(tmp), "%.*s", (int)(sizeof(tmp) - 1), &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"); } }