diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b41a38ec..de46b65f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -263,7 +263,7 @@ role-specific terminal on TCP port 5002. Credential writes reply before restarting the WiFi station, so a TCP client should expect to reconnect at the new address; USB password input is masked. Binary Companion clients can use command bytes -`0x46` and `0x47` over USB, BLE, or TCP port 5000 without the terminal-start +`0x7C` and `0x7D` over USB, BLE, or TCP port 5000 without the terminal-start token. WiFi-only Companions accept all three modes. Full Companion rejects `none` because its simultaneous BLE transport requires modem sleep. Companion device `powersaving` and LoRa `radio.rxps` remain independent. On an ESP32 Full diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index e0358be0..708344a0 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -234,20 +234,39 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused. | `0x3E` | `CMD_SEND_CHANNEL_DATA` | Send a channel binary datagram. | | `0x3F` / `0x40` | `CMD_SET_DEFAULT_FLOOD_SCOPE` / `CMD_GET_DEFAULT_FLOOD_SCOPE` | Write or read the default flood scope. | | `0x41` | `CMD_SEND_RAW_PACKET` | Queue a fully encoded raw mesh packet. | -| `0x42` / `0x43` | `CMD_GET_RADIO_FEM_RXGAIN` / `CMD_SET_RADIO_FEM_RXGAIN` | Read or set FEM receive gain. | -| `0x44` / `0x45` | `CMD_GET_RADIO_RXGAIN` / `CMD_SET_RADIO_RXGAIN` | Read or set the radio chip's boosted receive-gain mode. | -| `0x46` / `0x47` | `CMD_GET_WIFI_POWER_SAVE` / `CMD_SET_WIFI_POWER_SAVE` | Read or set ESP32 Companion WiFi modem sleep. | -| `0x48` / `0x49` | `CMD_GET_BLUETOOTH_NAME` / `CMD_SET_BLUETOOTH_NAME` | Read or set the independent Bluetooth device name. | +| `0x42` | `CMD_RUN_CLI_COMMAND` | Run a local CLI command (protocol v14+). | | `0x4A` | `CMD_EXEC_LOCAL_OTA_CONTROL` | Run one bounded local TempRadio or OTA command on a Full Companion. | | `0x4B` | `CMD_BLE_MOTA_SOURCE` | Query, start, or stop an nRF52 Full Companion's Bluetooth-backed LoRa mOTA source. | +| `0x78` / `0x79` | `CMD_GET_RADIO_FEM_RXGAIN` / `CMD_SET_RADIO_FEM_RXGAIN` | Fork extension: read or set FEM receive gain. | +| `0x7A` / `0x7B` | `CMD_GET_RADIO_RXGAIN` / `CMD_SET_RADIO_RXGAIN` | Fork extension: read or set the radio chip's boosted receive-gain mode. | +| `0x7C` / `0x7D` | `CMD_GET_WIFI_POWER_SAVE` / `CMD_SET_WIFI_POWER_SAVE` | Fork extension: read or set ESP32 Companion WiFi modem sleep. | +| `0x7E` / `0x7F` | `CMD_GET_BLUETOOTH_NAME` / `CMD_SET_BLUETOOTH_NAME` | Fork extension: read or set the independent Bluetooth device name. | The sections below detail the most common frames. Refer to the source named above for command bodies that are not expanded here. -The additive hardware-setting commands `0x42`-`0x47` do not change any existing -legacy frame layout. Clients should probe the command they need and treat +`CMD_RUN_CLI_COMMAND` is followed by the local CLI text without a terminating +NUL. The device returns `RESP_CODE_CLI_REPLY` (`0x1D`) followed by the reply +text. This is separate from sending a remote on-air CLI command with +`CMD_SEND_TXT_MSG` and `TXT_TYPE_CLI_COMMAND`. The body must contain at least +one byte and must not contain an embedded NUL. An unknown command is returned +as the normal CLI reply text `Unknown command`, not as an error frame. + +The fork-specific hardware-setting commands occupy the host-to-device range +`0x78`-`0x7F`, the highest currently unused block below the push-frame bit. +Command IDs must stay below `0x80`; IDs with bit 7 set identify asynchronous +device-to-host push frames. These commands do not change any existing frame +layout. Clients should probe the command they need and treat `ERR_CODE_UNSUPPORTED_CMD` as feature absence. +Firmware from this fork predating the upstream `0x42` allocation used +`0x42`-`0x49` for these eight settings. This firmware accepts those values as +deprecated aliases so existing clients continue to work. A one-byte `0x42` +frame is the legacy FEM-gain GET; `0x42` followed by text is the official +`CMD_RUN_CLI_COMMAND`. New clients should use `0x78`-`0x7F` (or send the +equivalent text through `0x42`) because future upstream commands may reuse the +remaining legacy IDs. + Both gain-command pairs can be used over the normal binary Companion connection; USB does not need to enter terminal mode. Inside the transport's normal Companion frame, send a GET command as its single command byte. Send a @@ -257,7 +276,9 @@ SET command followed by one byte (`0` for off, `1` for on). A GET reply is busy radio returns `ERR_CODE_BAD_STATE`; unsupported boosted gain returns `ERR_CODE_UNSUPPORTED_CMD`. The FEM pair also returns `ERR_CODE_UNSUPPORTED_CMD` when the board cannot control its external LNA. Raw -text such as `set radio.rxgain off` still requires the USB terminal start token. +unframed text such as `set radio.rxgain off` still requires the USB terminal +start token; the same text may instead be carried as a framed `0x42` command +over USB, BLE, or TCP. The WiFi power-save pair is available on ESP32 WiFi Companion builds over the normal binary USB, BLE, or TCP port 5000 transport. `CMD_GET_WIFI_POWER_SAVE` @@ -868,6 +889,7 @@ Byte values are authoritative; names are aliases. When reading firmware source, | `0x1A` | `RESP_ALLOWED_REPEAT_FREQ` | Allowed repeat-frequency ranges. | | `0x1B` | `RESP_CODE_CHANNEL_DATA_RECV` | Queued channel datagram. | | `0x1C` | `RESP_CODE_DEFAULT_FLOOD_SCOPE` | Default flood-scope data. | +| `0x1D` | `RESP_CODE_CLI_REPLY` | Text returned by `CMD_RUN_CLI_COMMAND`. | ### Asynchronous push types diff --git a/docs/payloads.md b/docs/payloads.md index 0a19bb02..23c72ae8 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -175,8 +175,9 @@ txt_type | Value | Description | Message content | |--------|---------------------------|--------------------------------------------------------------------------| | `0x00` | plain text message | the plain text of the message | -| `0x01` | CLI command | the command text of the message | +| `0x01` | CLI data | CLI command OR reply text | | `0x02` | signed plain text message | first four bytes is sender pubkey prefix, followed by plain text message | +| `0x03` | CLI command | (since v1.18+) CLI command text (explicit) | For a room post, companion firmware uses its own monotonic clock for the on-air timestamp and preserves that timestamp across application retries. Room diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 574b02d6..18d4f92b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -121,14 +121,31 @@ static const uint32_t COMMAND_RADIO_APPLY_TIMEOUT_MS = 5000UL; #define CMD_SET_DEFAULT_FLOOD_SCOPE 63 #define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_SEND_RAW_PACKET 65 -#define CMD_GET_RADIO_FEM_RXGAIN 66 -#define CMD_SET_RADIO_FEM_RXGAIN 67 -#define CMD_GET_RADIO_RXGAIN 68 -#define CMD_SET_RADIO_RXGAIN 69 -#define CMD_GET_WIFI_POWER_SAVE 70 -#define CMD_SET_WIFI_POWER_SAVE 71 -#define CMD_GET_BLUETOOTH_NAME 72 -#define CMD_SET_BLUETOOTH_NAME 73 +#define CMD_RUN_CLI_COMMAND 66 // v14+ + +// Fork-specific host-to-device commands use the highest currently unused +// block below 0x80. Bit 7 is reserved by the transport for device-to-host +// push frames, so command IDs must remain in the low half of the byte range. +#define CMD_GET_RADIO_FEM_RXGAIN 0x78 +#define CMD_SET_RADIO_FEM_RXGAIN 0x79 +#define CMD_GET_RADIO_RXGAIN 0x7A +#define CMD_SET_RADIO_RXGAIN 0x7B +#define CMD_GET_WIFI_POWER_SAVE 0x7C +#define CMD_SET_WIFI_POWER_SAVE 0x7D +#define CMD_GET_BLUETOOTH_NAME 0x7E +#define CMD_SET_BLUETOOTH_NAME 0x7F + +// Compatibility aliases used by this fork before upstream assigned 0x42 to +// CMD_RUN_CLI_COMMAND. A one-byte 0x42 frame remains distinguishable from the +// official command, whose frame always includes CLI text. +#define LEGACY_CMD_GET_RADIO_FEM_RXGAIN 0x42 +#define LEGACY_CMD_SET_RADIO_FEM_RXGAIN 0x43 +#define LEGACY_CMD_GET_RADIO_RXGAIN 0x44 +#define LEGACY_CMD_SET_RADIO_RXGAIN 0x45 +#define LEGACY_CMD_GET_WIFI_POWER_SAVE 0x46 +#define LEGACY_CMD_SET_WIFI_POWER_SAVE 0x47 +#define LEGACY_CMD_GET_BLUETOOTH_NAME 0x48 +#define LEGACY_CMD_SET_BLUETOOTH_NAME 0x49 #if defined(RADIO_FEM_RXGAIN) && (RADIO_FEM_RXGAIN == 0) static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 0; @@ -170,6 +187,7 @@ static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 1; #define RESP_ALLOWED_REPEAT_FREQ 26 #define RESP_CODE_CHANNEL_DATA_RECV 27 #define RESP_CODE_DEFAULT_FLOOD_SCOPE 28 +#define RESP_CODE_CLI_REPLY 29 // v14+, a reply to CMD_RUN_CLI_COMMAND #define MAX_CHANNEL_DATA_LENGTH (MAX_FRAME_SIZE - 9) @@ -943,8 +961,7 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); } -void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, - const char *text) { +void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection bool terminal_command_reply = false; uint32_t terminal_command_elapsed_millis = 0; @@ -957,6 +974,18 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3 terminal_command_reply, terminal_command_elapsed_millis); } +void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, + const char *text, char* reply) { + markConnectionActive(from); // in case this is from a server, and we have a connection + if (from.isRemoteCLIAllowed()) { + if (!handleCommand(text, sender_timestamp, reply)) { + strcat(reply, "Unknown command"); // reply may have cmd prefix from 'text' + } + } else { + queueMessage(from, TXT_TYPE_CLI_COMMAND, pkt, sender_timestamp, NULL, 0, text); + } +} + void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) { markConnectionActive(from); @@ -1739,6 +1768,8 @@ void MyMesh::begin(bool has_display, bool radio_available) { } void MyMesh::configureRadioFromPrefs() { + board.attachDynamicPrefs(_prefs.getCustom()); + if (!_radio_available) { saved_radio_apply_pending = false; board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); @@ -2039,6 +2070,81 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (handleCadCommand(command, reply, reply_size)) return true; + if (strcmp(command, "get radio.rxgain") == 0) { + if (!radio_driver.supportsRxBoostedGainMode()) { + snprintf(reply, reply_size, "Error: unsupported"); + } else { + snprintf(reply, reply_size, "> %s", + _prefs.rx_boosted_gain ? "on" : "off"); + } + return true; + } + + if (strncmp(command, "set radio.rxgain ", 17) == 0) { + const char* value = command + 17; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, reply_size, + "Error: use set radio.rxgain on|off"); + } else if (!radio_driver.supportsRxBoostedGainMode()) { + snprintf(reply, reply_size, "Error: unsupported"); + } else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) { + snprintf(reply, reply_size, "Error: radio busy or save failed"); + } else { + snprintf(reply, reply_size, "OK - radio.rxgain %s", value); + } + return true; + } + + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!board.canControlLoRaFemLna()) { + snprintf(reply, reply_size, "Error: unsupported"); + } else { + snprintf(reply, reply_size, "> %s", + board.isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + + if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) { + const char* value = command + 21; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, reply_size, + "Error: use set radio.fem.rxgain on|off"); + } else if (!board.canControlLoRaFemLna()) { + snprintf(reply, reply_size, "Error: unsupported"); + } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { + snprintf(reply, reply_size, "Error: failed to apply or save FEM RX gain"); + } else { + snprintf(reply, reply_size, "OK - radio.fem.rxgain %s", value); + } + return true; + } + + if (strcmp(command, "get radio.fem.txgain") == 0) { + if (!board.canControlLoRaFemPaGain()) { + snprintf(reply, reply_size, "Error: unsupported"); + } else { + snprintf(reply, reply_size, "> %s", + board.isLoRaFemPaGainEnabled() ? "on" : "off"); + } + return true; + } + + if (strncmp(command, "set radio.fem.txgain ", 21) == 0) { + const char* value = command + 21; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, reply_size, + "Error: use set radio.fem.txgain on|off"); + } else if (!board.canControlLoRaFemPaGain()) { + snprintf(reply, reply_size, "Error: unsupported"); + } else if (!applyAndSaveFemTxGain(strcmp(value, "on") == 0)) { + snprintf(reply, reply_size, "Error: failed to apply or save FEM TX gain"); + } else { + snprintf(reply, reply_size, "OK - radio.fem.txgain %s", value); + } + return true; + } + if (strcmp(command, "get display.rotation") == 0) { if (_ui == NULL || !_ui->supportsDisplayRotation()) { snprintf(reply, reply_size, "Error: display rotation is unsupported"); @@ -3099,6 +3205,24 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], _prefs.node_name, tlen); i += tlen; _serial->writeFrame(out_frame, i); + } else if (cmd_frame[0] == CMD_RUN_CLI_COMMAND && len >= 2) { // V14+ + int i = 1; + char *text = (char *)&cmd_frame[i]; + int tlen = len - i; + if (memchr(text, 0, tlen) != NULL) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + text[tlen] = 0; // ensure null + + reply_buf[0] = 0; + if (!handleCommand(text, 0, reply_buf)) { + strcat(reply_buf, "Unknown command"); // reply_buf may have cmd prefix from 'text' + } + out_frame[0] = RESP_CODE_CLI_REPLY; + int rlen = strlen(reply_buf); + memcpy(&out_frame[1], reply_buf, rlen); + _serial->writeFrame(out_frame, 1 + rlen); + } } else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) { int i = 1; uint8_t txt_type = cmd_frame[i++]; @@ -3109,7 +3233,7 @@ void MyMesh::handleCmdFrame(size_t len) { uint8_t *pub_key_prefix = &cmd_frame[i]; i += 6; ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6); - if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA)) { + if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) { char *text = (char *)&cmd_frame[i]; int tlen = len - i; uint32_t est_timeout; @@ -3126,11 +3250,11 @@ void MyMesh::handleCmdFrame(size_t len) { int result; uint32_t expected_ack; - if (txt_type == TXT_TYPE_CLI_DATA) { + if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { const uint32_t logical_request_id = msg_timestamp; msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection - result = sendCommandData(*recipient, msg_timestamp, attempt, text, - est_timeout, logical_request_id); + result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type, + text, est_timeout, logical_request_id); expected_ack = 0; // no Ack expected } else { const uint32_t app_timestamp = msg_timestamp; @@ -3989,8 +4113,11 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } - } else if (cmd_frame[0] == CMD_GET_RADIO_FEM_RXGAIN) { - if (!board.canControlLoRaFemLna()) { + } else if (cmd_frame[0] == CMD_GET_RADIO_FEM_RXGAIN + || cmd_frame[0] == LEGACY_CMD_GET_RADIO_FEM_RXGAIN) { + if (len != 1) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (!board.canControlLoRaFemLna()) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else { out_frame[0] = RESP_CODE_OK; @@ -3998,30 +4125,37 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[1], &value, 1); _serial->writeFrame(out_frame, 2); } - } else if (cmd_frame[0] == CMD_SET_RADIO_FEM_RXGAIN && len >= 2) { - uint8_t value = cmd_frame[1]; - if (!board.canControlLoRaFemLna()) { + } else if (cmd_frame[0] == CMD_SET_RADIO_FEM_RXGAIN + || cmd_frame[0] == LEGACY_CMD_SET_RADIO_FEM_RXGAIN) { + uint8_t value = len >= 2 ? cmd_frame[1] : 0; + if (len != 2) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (!board.canControlLoRaFemLna()) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else if (value <= 1) { if (applyAndSaveFemRxGain(value != 0)) { writeOKFrame(); } else { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + writeErrFrame(ERR_CODE_BAD_STATE); } } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } - } else if (cmd_frame[0] == CMD_GET_RADIO_RXGAIN) { - if (!radio_driver.supportsRxBoostedGainMode()) { + } else if (cmd_frame[0] == CMD_GET_RADIO_RXGAIN + || cmd_frame[0] == LEGACY_CMD_GET_RADIO_RXGAIN) { + if (len != 1) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (!radio_driver.supportsRxBoostedGainMode()) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else { out_frame[0] = RESP_CODE_OK; out_frame[1] = _prefs.rx_boosted_gain ? 1 : 0; _serial->writeFrame(out_frame, 2); } - } else if (cmd_frame[0] == CMD_SET_RADIO_RXGAIN && len >= 2) { - uint8_t value = cmd_frame[1]; - if (value > 1) { + } else if (cmd_frame[0] == CMD_SET_RADIO_RXGAIN + || cmd_frame[0] == LEGACY_CMD_SET_RADIO_RXGAIN) { + uint8_t value = len >= 2 ? cmd_frame[1] : 0; + if (len != 2 || value > 1) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (!radio_driver.supportsRxBoostedGainMode()) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); @@ -4030,41 +4164,57 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeOKFrame(); } - } else if (cmd_frame[0] == CMD_GET_WIFI_POWER_SAVE) { -#if defined(ESP32) && defined(WIFI_SSID) - out_frame[0] = RESP_CODE_OK; - out_frame[1] = getCompanionWiFiPowerSave(); - _serial->writeFrame(out_frame, 2); -#else - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); -#endif - } else if (cmd_frame[0] == CMD_SET_WIFI_POWER_SAVE && len >= 2) { -#if defined(ESP32) && defined(WIFI_SSID) - const uint8_t value = cmd_frame[1]; - if (value > mesh::wifi::kPowerSaveMax) { + } else if (cmd_frame[0] == CMD_GET_WIFI_POWER_SAVE + || cmd_frame[0] == LEGACY_CMD_GET_WIFI_POWER_SAVE) { + if (len != 1) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - char reply[160]; - if (applyAndSaveWiFiPowerSaving( - companionWiFiPowerSaveName(value), reply, sizeof(reply))) { - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_BAD_STATE); - } - } +#if defined(ESP32) && defined(WIFI_SSID) + out_frame[0] = RESP_CODE_OK; + out_frame[1] = getCompanionWiFiPowerSave(); + _serial->writeFrame(out_frame, 2); #else - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); #endif - } else if (cmd_frame[0] == CMD_GET_BLUETOOTH_NAME) { - out_frame[0] = RESP_CODE_OK; - out_frame[1] = mesh::companion::hasCustomBluetoothName( - _prefs.bluetooth_name) ? 1 : 0; - char* effective_name = reinterpret_cast(&out_frame[2]); - mesh::companion::formatBluetoothName( - effective_name, MAX_FRAME_SIZE - 1, _prefs.bluetooth_name, - BLE_NAME_PREFIX, _prefs.node_name); - _serial->writeFrame(out_frame, 2 + strlen(effective_name)); - } else if (cmd_frame[0] == CMD_SET_BLUETOOTH_NAME) { + } + } else if (cmd_frame[0] == CMD_SET_WIFI_POWER_SAVE + || cmd_frame[0] == LEGACY_CMD_SET_WIFI_POWER_SAVE) { + if (len != 2) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { +#if defined(ESP32) && defined(WIFI_SSID) + const uint8_t value = cmd_frame[1]; + if (value > mesh::wifi::kPowerSaveMax) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + char reply[160]; + if (applyAndSaveWiFiPowerSaving( + companionWiFiPowerSaveName(value), reply, sizeof(reply))) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_BAD_STATE); + } + } +#else + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); +#endif + } + } else if (cmd_frame[0] == CMD_GET_BLUETOOTH_NAME + || cmd_frame[0] == LEGACY_CMD_GET_BLUETOOTH_NAME) { + if (len != 1) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + out_frame[0] = RESP_CODE_OK; + out_frame[1] = mesh::companion::hasCustomBluetoothName( + _prefs.bluetooth_name) ? 1 : 0; + char* effective_name = reinterpret_cast(&out_frame[2]); + mesh::companion::formatBluetoothName( + effective_name, MAX_FRAME_SIZE - 1, _prefs.bluetooth_name, + BLE_NAME_PREFIX, _prefs.node_name); + _serial->writeFrame(out_frame, 2 + strlen(effective_name)); + } + } else if (cmd_frame[0] == CMD_SET_BLUETOOTH_NAME + || cmd_frame[0] == LEGACY_CMD_SET_BLUETOOTH_NAME) { const size_t name_len = len - 1; if (name_len > mesh::companion::BLUETOOTH_NAME_MAX_BYTES || memchr(&cmd_frame[1], 0, name_len) != NULL) { @@ -4362,22 +4512,37 @@ void MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact) { bool MyMesh::applyAndSaveFemRxGain(bool enabled) { if (!board.canControlLoRaFemLna()) return false; - const bool changed = board.isLoRaFemLnaEnabled() != enabled; + const bool previous_hardware = board.isLoRaFemLnaEnabled(); + const uint8_t previous_pref = _prefs.radio_fem_rxgain; + const uint8_t previous_override = _prefs.radio_fem_rxgain_override; + const bool changed = previous_hardware != enabled; if (!board.setLoRaFemLnaEnabled(enabled)) return false; if (changed && _radio_available) _radio->recalibrateNoiseFloor(); _prefs.radio_fem_rxgain = enabled ? 1 : 0; _prefs.radio_fem_rxgain_override = 1; - savePrefs(); + if (!savePrefs()) { + _prefs.radio_fem_rxgain = previous_pref; + _prefs.radio_fem_rxgain_override = previous_override; + board.setLoRaFemLnaEnabled(previous_hardware); + if (changed && _radio_available) _radio->recalibrateNoiseFloor(); + return false; + } return true; } bool MyMesh::applyAndSaveFemTxGain(bool enabled) { if (!board.canControlLoRaFemPaGain()) return false; + const bool previous_hardware = board.isLoRaFemPaGainEnabled(); + const uint8_t previous_pref = _prefs.radio_fem_txgain; if (!board.setLoRaFemPaGainEnabled(enabled)) return false; _prefs.radio_fem_txgain = enabled ? 1 : 0; - savePrefs(); + if (!savePrefs()) { + _prefs.radio_fem_txgain = previous_pref; + board.setLoRaFemPaGainEnabled(previous_hardware); + return false; + } return true; } @@ -4387,8 +4552,15 @@ bool MyMesh::applyAndSaveRxBoostedGain(bool enabled) { return false; } + const uint8_t previous_pref = _prefs.rx_boosted_gain; _prefs.rx_boosted_gain = enabled ? 1 : 0; - savePrefs(); + if (!savePrefs()) { + _prefs.rx_boosted_gain = previous_pref; + if (_radio_available) { + radio_driver.setRxBoostedGainMode(previous_pref != 0); + } + return false; + } return true; } @@ -5140,9 +5312,9 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, const size_t command_len = on_air_command == NULL ? 0 : strlen(on_air_command); - if (command_len == 0 || command_len > MAX_TEXT_LEN) { + if (command_len == 0 || command_len > MAX_CORRELATED_CLI_TEXT_LEN) { terminalOutput().printf(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n", - (unsigned)MAX_TEXT_LEN); + (unsigned)MAX_CORRELATED_CLI_TEXT_LEN); return; } @@ -5151,7 +5323,16 @@ void MyMesh::sendTerminalCommand(ContactInfo& recipient, const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); const uint32_t command_started_at = _ms->getMillis(); uint32_t est_timeout = 0; - const int result = sendCommandData(recipient, timestamp, 0, on_air_command, + // Legacy server roles used CLI_DATA for requests as well as replies. Keep + // that interoperable request form for deployed Repeaters, Rooms, and + // Sensors; Chat/Companion nodes need the explicit CLI_COMMAND direction. + const bool is_server_role = recipient.type == ADV_TYPE_REPEATER + || recipient.type == ADV_TYPE_ROOM + || recipient.type == ADV_TYPE_SENSOR; + const uint8_t txt_type = is_server_role + ? TXT_TYPE_CLI_DATA : TXT_TYPE_CLI_COMMAND; + const int result = sendCommandData(recipient, timestamp, 0, + txt_type, on_air_command, est_timeout, logical_request_id); if (result == MSG_SEND_FAILED) { terminalOutput().print(" ERROR: unable to send remote command\r\n"); @@ -5891,6 +6072,176 @@ void MyMesh::enterCLIRescue() { Serial.println("========= CLI Rescue ========="); } +static bool isCompanionRadioPrefsCommand(const char* command) { + if (command == NULL) return false; + + static const char* const exact_commands[] = { + "get radio", "get freq", "get af", "get dutycycle", "get tx", + "get rxdelay", "get path.hash.mode", "get multi.acks" + }; + for (const char* candidate : exact_commands) { + if (strcmp(command, candidate) == 0) return true; + } + + static const char* const set_prefixes[] = { + "set radio ", "set af ", "set dutycycle ", "set rxdelay ", + "set path.hash.mode ", "set multi.acks " + }; + for (const char* prefix : set_prefixes) { + if (strncmp(command, prefix, strlen(prefix)) == 0) return true; + } + return false; +} + +bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, + char* reply) { + if (command == NULL || reply == NULL) return false; + size_t reply_capacity = 160; + while (*command == ' ' || *command == '\t') command++; + + if (strlen(command) > 3 && command[2] == '|') { + // Optional two-character request prefix used by Companion CLI clients. + memcpy(reply, command, 3); + reply += 3; + reply_capacity -= 3; + *reply = 0; + command += 3; + while (*command == ' ' || *command == '\t') command++; + } + + if (handleLocalControlCommand(command, reply, reply_capacity)) return true; + + if (strncmp(command, "set tx ", 7) == 0) { + int32_t parsed = 0; + if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) { + strcpy(reply, "Error: invalid TX power"); + } else if (parsed < -9 || parsed > MAX_LORA_TX_POWER) { + snprintf(reply, reply_capacity, "Error: TX power must be -9 to %d", + MAX_LORA_TX_POWER); + } else { + const int8_t previous = _prefs.tx_power_dbm; + const int8_t requested = static_cast(parsed); + if (_radio_available && !radio_driver.setTxPower(requested)) { + strcpy(reply, "Error: radio busy or TX power rejected"); + } else { + _prefs.tx_power_dbm = requested; + if (!savePrefs()) { + _prefs.tx_power_dbm = previous; + if (_radio_available) radio_driver.setTxPower(previous); + strcpy(reply, "Error: TX power changed but save failed"); + } else { + strcpy(reply, "OK"); + } + } + } + return true; + } + + if (isCompanionRadioPrefsCommand(command)) { + const float previous_freq = _prefs.freq; + const float previous_bw = _prefs.bw; + const uint8_t previous_sf = _prefs.sf; + const uint8_t previous_cr = _prefs.cr; + const float previous_af = _prefs.airtime_factor; + const float previous_rxdelay = _prefs.rx_delay_base; + const uint8_t previous_hash_mode = _prefs.path_hash_mode; + const uint8_t previous_multi_acks = _prefs.multi_acks; + const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; + const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; + + if (!_prefs.getRadioPrefs()->handleCommand( + command, sender_timestamp, reply)) { + return false; + } + if (_prefs.getRadioPrefs()->isDirty()) { + if (strncmp(command, "set radio ", 10) == 0) { + recalcRxPowerSavingFromLevel( + _prefs.rx_ps_level, _prefs.sf, _prefs.bw, + _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, + &_prefs.rx_ps_sleep_us); + } + if (!savePrefs()) { + _prefs.freq = previous_freq; + _prefs.bw = previous_bw; + _prefs.sf = previous_sf; + _prefs.cr = previous_cr; + _prefs.airtime_factor = previous_af; + _prefs.rx_delay_base = previous_rxdelay; + _prefs.path_hash_mode = previous_hash_mode; + _prefs.multi_acks = previous_multi_acks; + _prefs.rx_ps_rx_us = previous_rx_us; + _prefs.rx_ps_sleep_us = previous_sleep_us; + _prefs.clearDirty(); + strcpy(reply, "Error: setting changed but save failed"); + } + } + return true; + } + + // Hook for future variant-specific commands not covered by the shared, + // runtime-aware FEM handlers above. + if (board.handleCommand(command, sender_timestamp, reply)) { + if (_prefs.isDirty() && !savePrefs()) { + strcpy(reply, "Error: board setting changed but save failed"); + } + return true; + } + + if (strncmp(command, "set name ", 9) == 0) { + const char* name = command + 9; + if (!AdvertDataParser::isValidName(name)) { + strcpy(reply, "Error, bad chars"); + } else { + char previous[sizeof(_prefs.node_name)]; + memcpy(previous, _prefs.node_name, sizeof(previous)); + StrHelper::strncpy(_prefs.node_name, name, sizeof(_prefs.node_name)); + if (!savePrefs()) { + memcpy(_prefs.node_name, previous, sizeof(_prefs.node_name)); + strcpy(reply, "Error: name changed but save failed"); + } else { + strcpy(reply, "OK"); + } + } + return true; + } + if (strcmp(command, "get name") == 0) { + snprintf(reply, reply_capacity, "> %s", _prefs.node_name); + return true; + } + + if (strncmp(command, "set pin ", 8) == 0) { + int32_t parsed = 0; + if (!mesh::cli::parseIntegerStrict(command + 8, parsed) + || parsed < 0 || parsed > 999999) { + strcpy(reply, "Error: pin must be 0-999999"); + } else { + const uint32_t previous = _prefs.ble_pin; + _prefs.ble_pin = static_cast(parsed); + if (!savePrefs()) { + _prefs.ble_pin = previous; + strcpy(reply, "Error: pin changed but save failed"); + } else { + snprintf(reply, reply_capacity, "> pin is now %06lu", + (unsigned long)_prefs.ble_pin); + } + } + return true; + } + + if (strcmp(command, "board") == 0) { + snprintf(reply, reply_capacity, "%s", board.getManufacturerName()); + return true; + } + + if (strcmp(command, "ver") == 0) { + snprintf(reply, reply_capacity, "%s (Build: %s)", FIRMWARE_VERSION, + FIRMWARE_BUILD_DATE); + return true; + } + + return false; +} + void MyMesh::checkCLIRescueCmd() { int len = strlen(cli_command); while (Serial.available() && len < sizeof(cli_command)-1) { @@ -5908,15 +6259,10 @@ void MyMesh::checkCLIRescueCmd() { if (len > 0 && cli_command[len - 1] == '\r') { // received complete line cli_command[len - 1] = 0; // replace newline with C string null terminator - if (memcmp(cli_command, "set ", 4) == 0) { - const char* config = &cli_command[4]; - if (memcmp(config, "pin ", 4) == 0) { - _prefs.ble_pin = atoi(&config[4]); - savePrefs(); - Serial.printf(" > pin is now %06d\n", _prefs.ble_pin); - } else { - Serial.printf(" Error: unknown config: %s\n", config); - } + reply_buf[0] = 0; + if (handleCommand(cli_command, 0, reply_buf)) { + // command was handled, print reply output + Serial.print(" "); Serial.print(reply_buf); Serial.println(); } else if (strcmp(cli_command, "rebuild") == 0) { bool success = _store->formatFileSystem(); if (success) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index d951ecfb..7290ed56 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -249,6 +249,8 @@ protected: const char *text) override; void onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) override; + void onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, + const char *text, char* reply) override; void onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) override; void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, @@ -281,7 +283,10 @@ protected: public: bool savePrefs() { - return _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); + const bool saved = + _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); + if (saved) _prefs.clearDirty(); + return saved; } #if ENV_INCLUDE_GPS == 1 @@ -313,6 +318,7 @@ private: } void checkCLIRescueCmd(); + bool handleCommand(const char* text, uint32_t sender_timestamp, char* reply); void checkSerialInterface(); bool applyAndSaveFemRxGain(bool enabled); bool applyAndSaveFemTxGain(bool enabled); @@ -466,6 +472,7 @@ private: #endif bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; + char reply_buf[166]; uint8_t app_target_ver; uint8_t *sign_data; uint32_t sign_data_len; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 91c706c0..44ae58c7 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -1,6 +1,11 @@ #pragma once + #include -#include // For uint8_t, uint32_t +#include + +#include +#include + #include "BluetoothName.h" #define TELEM_MODE_DENY 0 @@ -16,57 +21,181 @@ // an explicit user choice remains persistent. #define COMPANION_POWERSAVING_POLICY_VERSION 1 -struct CompanionNodePrefs { // persisted to file - float airtime_factor; - char node_name[32]; - float freq; - uint8_t sf; - uint8_t cr; - uint8_t multi_acks; - uint8_t manual_add_contacts; - float bw; - int8_t tx_power_dbm; - uint8_t telemetry_mode_base; - uint8_t telemetry_mode_loc; - uint8_t telemetry_mode_env; - float rx_delay_base; - uint32_t ble_pin; - uint8_t advert_loc_policy; - uint8_t buzzer_quiet; - uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled) - uint32_t gps_interval; // GPS read interval in seconds; 0 uses the 1-second default - uint8_t autoadd_config; // bitmask for auto-add contacts config - uint8_t rx_boosted_gain; // SX126x RX boosted gain mode (0=power saving, 1=boosted) - uint8_t radio_fem_rxgain; // LoRa FEM RX gain setting - uint8_t client_repeat; - uint8_t path_hash_mode; // which path mode to use when sending - uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) - char default_scope_name[31]; - uint8_t default_scope_key[16]; - uint8_t radio_fem_rxgain_override; // 1 once the user overrides the build default - uint8_t vibe_quiet; // haptic quiet mode; appended for prefs compatibility - uint8_t radio_fem_txgain; // LoRa FEM TX gain; appended for prefs compatibility - uint8_t rx_powersaving_enabled; // SX126x/LR11xx receive duty cycling - uint32_t rx_ps_rx_us; // receive window in microseconds - uint32_t rx_ps_sleep_us; // sleep window in microseconds - uint8_t rx_ps_level; // 0=manual timings, 1..10=level-derived - uint8_t rx_ps_preamble; // 0=auto from SF, otherwise 16 or 32 - uint8_t powersaving_enabled; // device CPU/GPS idle power saving - uint8_t wifi_enabled; // Companion WiFi radio and services - uint8_t powersaving_policy_version; // one-time default migration marker - uint8_t usb_logging_enabled; // live USB packet/debug output - char bluetooth_name[mesh::companion::BLUETOOTH_NAME_SIZE]; - // exact BLE name; empty uses BLE_NAME_PREFIX + node_name - uint16_t display_rotation_degrees; // 0=board default; otherwise 90/180/270 - uint8_t cad_enabled; // hardware channel activity detection - uint16_t cad_scan_timeout_ms; // 0=derive from SF/BW - uint16_t cad_retry_delay_ms; // 0=Mesh adaptive default - uint16_t cad_max_duration_ms; // 0=Dispatcher default +// Companion preferences remain stored by DataStore's explicit, append-only +// binary field list. The adapter objects are appended after every persisted +// field, and node latitude/longitude deliberately remain separate DataStore +// arguments, so the established field offsets and on-device format do not move. +class CompanionNodePrefs { +public: + float airtime_factor = 0; + char node_name[32] = {}; + float freq = 0; + uint8_t sf = 0; + uint8_t cr = 0; + uint8_t multi_acks = 0; + uint8_t manual_add_contacts = 0; + float bw = 0; + int8_t tx_power_dbm = 0; + uint8_t telemetry_mode_base = 0; + uint8_t telemetry_mode_loc = 0; + uint8_t telemetry_mode_env = 0; + float rx_delay_base = 0; + uint32_t ble_pin = 0; + uint8_t advert_loc_policy = 0; + uint8_t buzzer_quiet = 0; + uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) + uint32_t gps_interval = 0; // seconds; 0 uses the 1-second default + uint8_t autoadd_config = 0; // bitmask for auto-add contacts config + uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode + uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain + uint8_t client_repeat = 0; + uint8_t path_hash_mode = 0; + uint8_t autoadd_max_hops = 0; + char default_scope_name[31] = {}; + uint8_t default_scope_key[16] = {}; + + // Append-only binary preference tail. Do not insert persisted fields above + // or between these entries without updating DataStore's compatibility map. + uint8_t radio_fem_rxgain_override = 0; + uint8_t vibe_quiet = 0; + uint8_t radio_fem_txgain = 0; + uint8_t rx_powersaving_enabled = 0; + uint32_t rx_ps_rx_us = 0; + uint32_t rx_ps_sleep_us = 0; + uint8_t rx_ps_level = 0; + uint8_t rx_ps_preamble = 0; + uint8_t powersaving_enabled = 0; + uint8_t wifi_enabled = 0; + uint8_t powersaving_policy_version = 0; + uint8_t usb_logging_enabled = 0; + char bluetooth_name[mesh::companion::BLUETOOTH_NAME_SIZE] = {}; + uint16_t display_rotation_degrees = 0; + uint8_t cad_enabled = 0; + uint16_t cad_scan_timeout_ms = 0; + uint16_t cad_retry_delay_ms = 0; + uint16_t cad_max_duration_ms = 0; + +private: + class RadioPrefs : public CommonRadioPrefs { + CompanionNodePrefs* _parent; + + protected: + void structure() override { + def("freq", _parent->freq); + def("bw", _parent->bw); + def("sf", _parent->sf); + def("cr", _parent->cr); + def("cad", _parent->cad_enabled); + def("rxgain", _parent->rx_boosted_gain); + def("fem_rxgain", _parent->radio_fem_rxgain); + def("fem_txgain", _parent->radio_fem_txgain); + def("tx", _parent->tx_power_dbm); + def("af", _parent->airtime_factor); + def("rxdelay", _parent->rx_delay_base); + def("hash_mode", _parent->path_hash_mode); + def("multi_ack", _parent->multi_acks); + } + + public: + explicit RadioPrefs(CompanionNodePrefs* parent) : _parent(parent) { } + + float getFreq() const override { return _parent->freq; } + void setFreq(float value) override { + _parent->freq = value; + markDirty(); + } + float getBandwidth() const override { return _parent->bw; } + void setBandwidth(float value) override { + _parent->bw = value; + markDirty(); + } + uint8_t getSpreadFactor() const override { return _parent->sf; } + void setSpreadFactor(uint8_t value) override { + _parent->sf = value; + markDirty(); + } + uint8_t getCodingRate() const override { return _parent->cr; } + void setCodingRate(uint8_t value) override { + _parent->cr = value; + markDirty(); + } + float getAirtimeFactor() const override { return _parent->airtime_factor; } + void setAirtimeFactor(float value) override { + _parent->airtime_factor = value; + markDirty(); + } + bool isCadEnabled() const override { return _parent->cad_enabled != 0; } + void setCadEnabled(bool enabled) override { + _parent->cad_enabled = enabled ? 1 : 0; + markDirty(); + } + uint8_t getIntThresh() const override { return 0; } + void setIntThresh(uint8_t) override { } + uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } + void setRxGain(uint8_t value) override { + _parent->rx_boosted_gain = value; + markDirty(); + } + int8_t getTxPower() const override { return _parent->tx_power_dbm; } + void setTxPower(int8_t value) override { + _parent->tx_power_dbm = value; + markDirty(); + } + float getRxDelay() const override { return _parent->rx_delay_base; } + void setRxDelay(float value) override { + _parent->rx_delay_base = value; + markDirty(); + } + uint8_t getAgcResetInt() const override { return 0; } + void setAgcResetInt(uint8_t) override { } + uint8_t getHashMode() const override { return _parent->path_hash_mode; } + void setHashMode(uint8_t value) override { + _parent->path_hash_mode = value; + markDirty(); + } + uint8_t getMultiAcks() const override { return _parent->multi_acks; } + void setMultiAcks(uint8_t value) override { + _parent->multi_acks = value; + markDirty(); + } + float getFloodTxDelay() const override { return 0.5f; } + void setFloodTxDelay(float) override { } + float getDirectTxDelay() const override { return 0.2f; } + void setDirectTxDelay(float) override { } + uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } + void setFEMRxGain(uint8_t value) override { + _parent->radio_fem_rxgain = value; + _parent->radio_fem_rxgain_override = 1; + markDirty(); + } + uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } + void setFEMTxGain(uint8_t value) override { + _parent->radio_fem_txgain = value; + markDirty(); + } + }; + + // Runtime-only adapters must remain after the persisted field block above. + RadioPrefs radio; + DynamicConfigSerializer custom; + +public: + CompanionNodePrefs() : radio(this), custom(&radio) { } - // Keep the upstream repeat API while retaining the existing binary prefs - // layout used by this branch. bool isRepeatEn() const { return client_repeat != 0; } void setRepeatEn(bool enabled) { client_repeat = enabled ? 1 : 0; } + + CommonRadioPrefs* getRadioPrefs() { return &radio; } + KeyValueStore* getCustom() { return &custom; } + + bool isDirty() const { + return radio.isDirty() || custom.isDirty(); + } + + void clearDirty() { + radio.clearDirty(); + custom.clearDirty(); + } }; inline bool migrateCompanionPowerSavingDefault(CompanionNodePrefs& prefs) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 95655153..614f2cef 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2691,7 +2691,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) uint8_t flags = (data[4] >> 2); // message attempt number, and other flags - if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) { + if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || flags == TXT_TYPE_CLI_COMMAND)) { MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported text type received: flags=%02x", (uint32_t)flags); } else { // len can be > original length, but 'text' will be padded with zeroes @@ -3563,6 +3563,8 @@ void MyMesh::begin(FILESYSTEM *fs) { board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); + board.attachDynamicPrefs(_prefs.getCustom()); + updateAdvertTimer(); updateFloodAdvertTimer(); next_recent_repeater_sweep = futureMillis(RECENT_REPEATER_SWEEP_INTERVAL_MILLIS); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 590e7e73..9e0cedc4 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -800,7 +800,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) uint8_t flags = (data[4] >> 2); // message attempt number, and other flags - if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) { + if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || flags == TXT_TYPE_CLI_COMMAND)) { MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported command flags received: flags=%02x", (uint32_t)flags); return; } @@ -845,7 +845,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // advanced this client's room-post replay timestamp. send_ack = true; } - } else { // TXT_TYPE_CLI_DATA + } else { // TXT_TYPE_CLI_DATA or TXT_TYPE_CLI_COMMAND uint32_t request_id = sender_timestamp; mesh::RemoteCliRequest::parse(data, len, 5, request_id); const uint32_t command_fingerprint = @@ -1316,6 +1316,8 @@ void MyMesh::begin(FILESYSTEM *fs) { board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); + board.attachDynamicPrefs(_prefs.getCustom()); + updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index 3e8b33f9..9c26517a 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -257,6 +257,10 @@ protected: void onCommandDataRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) override { } + + void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) override { + } + void onSignedMessageRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) override { } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 98776f44..fe8013dc 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -774,7 +774,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i sendAckTo(*from, ack_hash, packet->getPathHashSize()); } } - } else if (flags == TXT_TYPE_CLI_DATA) { + } else if (flags == TXT_TYPE_CLI_DATA || flags == TXT_TYPE_CLI_COMMAND) { from->last_timestamp = sender_timestamp; from->last_activity = getRTCClock()->getCurrentTime(); @@ -1004,6 +1004,8 @@ void SensorMesh::begin(FILESYSTEM* fs) { board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); + board.attachDynamicPrefs(_prefs.getCustom()); + updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/platformio.ini b/platformio.ini index 34fa893d..cc7135ef 100644 --- a/platformio.ini +++ b/platformio.ini @@ -354,6 +354,8 @@ build_src_filter = +<../src/helpers/UserGpio.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/ArduinoSerialInterface.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> lib_deps = google/googletest @ 1.17.0 bblanchon/ArduinoJson @ 7.4.3 diff --git a/src/MeshCore.h b/src/MeshCore.h index e435ae36..394b2674 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -159,6 +159,13 @@ public: virtual bool isPowerManagementInitialized() const { return false; } virtual bool supportsVoltageWake() const { return false; } + virtual bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)command; + (void)sender_timestamp; + (void)reply; + return false; + } + inline static uint32_t n_cad_busy = 0; }; diff --git a/src/Utils.cpp b/src/Utils.cpp index 66e4da71..2b1fb88a 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -264,6 +264,15 @@ bool Utils::isHexChar(char c) { return c == '0' || hexVal(c) > 0; } +bool Utils::isZeroes(const uint8_t* buf, size_t len) { + while (len > 0) { + if (*buf != 0) return false; + buf++; + len--; + } + return true; +} + bool Utils::fromHex(uint8_t* dest, int dest_size, const char *src_hex) { if (dest == NULL || src_hex == NULL || dest_size < 0) return false; int len = strlen(src_hex); diff --git a/src/Utils.h b/src/Utils.h index 4563e21c..06368e82 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -82,6 +82,8 @@ public: static int parseTextParts(char* text, const char* parts[], int max_num, char separator=','); static bool isHexChar(char c); + + static bool isZeroes(const uint8_t* buf, size_t len); }; } diff --git a/src/helpers/AdvertDataHelpers.cpp b/src/helpers/AdvertDataHelpers.cpp index 998733ae..25e5cd3f 100644 --- a/src/helpers/AdvertDataHelpers.cpp +++ b/src/helpers/AdvertDataHelpers.cpp @@ -28,6 +28,14 @@ return i; } +bool AdvertDataParser::isValidName(const char *n) { + while (*n) { + if (*n == '[' || *n == ']' || *n == '\\' || *n == ':' || *n == ',' || *n == '?' || *n == '*') return false; + n++; + } + return true; +} + AdvertDataParser::AdvertDataParser(const uint8_t app_data[], uint8_t app_data_len) { _name[0] = 0; _lat = _lon = 0; diff --git a/src/helpers/AdvertDataHelpers.h b/src/helpers/AdvertDataHelpers.h index abe14cbd..f4e109e9 100644 --- a/src/helpers/AdvertDataHelpers.h +++ b/src/helpers/AdvertDataHelpers.h @@ -50,6 +50,8 @@ class AdvertDataParser { public: AdvertDataParser(const uint8_t app_data[], uint8_t app_data_len); + static bool isValidName(const char* name); + bool isValid() const { return _valid; } uint8_t getType() const { return _flags & 0x0F; } uint16_t getFeat1() const { return _extra1; } diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 899c4c37..8c17956c 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -15,6 +15,8 @@ #define TXT_ACK_DELAY 200 #endif +#define CLI_REPLY_DELAY_MILLIS 600 + bool BaseChatMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { return sendFlood(pkt, delay_millis); } @@ -274,8 +276,8 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender ContactInfo& from = contacts[i]; if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { - uint32_t timestamp; - memcpy(×tamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) + uint32_t sender_timestamp; + memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) uint8_t flags = data[4] >> 2; // message attempt number, and other flags // len can be > original length, but 'text' will be padded with zeroes @@ -283,7 +285,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender if (flags == TXT_TYPE_PLAIN) { from.lastmod = getRTCClock()->getCurrentTime(); // update last heard time - onMessageRecv(from, packet, timestamp, (const char *) &data[5]); // let UI know + onMessageRecv(from, packet, sender_timestamp, (const char *) &data[5]); // let UI know int text_len = strlen((char *)&data[5]); uint8_t ack_hash[6]; // calc truncated hash of the message timestamp + text + sender pub_key, to prove to sender that we got it @@ -301,20 +303,50 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender sendAckTo(from, ack_hash, 6); } } else if (flags == TXT_TYPE_CLI_DATA) { - onCommandDataRecv(from, packet, timestamp, (const char *) &data[5]); // let UI know + char *text = (char *)&data[5]; + onCommandDataRecv(from, packet, sender_timestamp, text); // let UI know // NOTE: no ack expected for CLI_DATA replies if (packet->isRouteFlood()) { - // let this sender know path TO here, so they can use sendDirect() (NOTE: no ACK as extra) - mesh::Packet* path = createPathReturn(from.id, secret, packet->path, packet->path_len, 0, NULL, 0); + // Let this sender learn a direct path without adding an ACK. + mesh::Packet* path = createPathReturn(from.id, secret, packet->path, + packet->path_len, 0, NULL, 0); if (path) sendFloodScoped(from, path); } + } else if (flags == TXT_TYPE_CLI_COMMAND) { + uint8_t temp[166]; + char *command = (char *)&data[5]; + char *reply = (char *)&temp[5]; + *reply = 0; + + onCLICommandRecv(from, packet, sender_timestamp, command, reply); // let UI know + // NOTE: no ack expected for CLI_COMMAND replies + + int text_len = strlen(reply); + if (text_len > 0) { + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + if (timestamp == sender_timestamp) { + // WORKAROUND: the two timestamps need to be different, in the CLI view + timestamp++; + } + memcpy(temp, ×tamp, 4); + temp[4] = (TXT_TYPE_CLI_DATA << 2); + + auto reply_pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, from.id, secret, temp, 5 + text_len); + if (reply_pkt) { + if (from.out_path_len == OUT_PATH_UNKNOWN) { + sendFloodScoped(from, reply_pkt, CLI_REPLY_DELAY_MILLIS); + } else { + sendDirect(reply_pkt, from.out_path, from.out_path_len, CLI_REPLY_DELAY_MILLIS); + } + } + } } else if (flags == TXT_TYPE_SIGNED_PLAIN) { - if (timestamp > from.sync_since) { // make sure 'sync_since' is up-to-date - from.sync_since = timestamp; + if (sender_timestamp > from.sync_since) { // make sure 'sync_since' is up-to-date + from.sync_since = sender_timestamp; } from.lastmod = getRTCClock()->getCurrentTime(); // update last heard time - onSignedMessageRecv(from, packet, timestamp, &data[5], (const char *) &data[9]); // let UI know + onSignedMessageRecv(from, packet, sender_timestamp, &data[5], (const char *) &data[9]); // let UI know uint32_t ack_hash; // calc truncated hash of the message timestamp + text + OUR pub_key, to prove to sender that we got it mesh::Utils::sha256((uint8_t *) &ack_hash, 4, data, 9 + strlen((char *)&data[9]), self_id.pub_key, PUB_KEY_SIZE); @@ -535,15 +567,18 @@ int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, - const char* text, uint32_t& est_timeout, + uint8_t txt_type, const char* text, + uint32_t& est_timeout, uint32_t logical_request_id) { est_timeout = 0; int text_len = strlen(text); - if (text_len > MAX_TEXT_LEN) return MSG_SEND_FAILED; + const size_t max_text_len = logical_request_id == 0 + ? MAX_TEXT_LEN : MAX_CORRELATED_CLI_TEXT_LEN; + if ((size_t)text_len > max_text_len) return MSG_SEND_FAILED; uint8_t temp[5 + MAX_TEXT_LEN + mesh::RemoteCliRequest::EXTENSION_SIZE]; memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique - temp[4] = (attempt & 3) | (TXT_TYPE_CLI_DATA << 2); + temp[4] = (attempt & 3) | (txt_type << 2); memcpy(&temp[5], text, text_len); size_t payload_len = 5 + text_len; diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 96489476..537e5611 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -3,10 +3,17 @@ #include // needed for PlatformIO #include #include +#include #include #define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // must be LESS than (MAX_PACKET_PAYLOAD - 4 - CIPHER_MAC_SIZE - 1) +static constexpr size_t MAX_CORRELATED_CLI_TEXT_LEN = + MAX_PACKET_PAYLOAD - CIPHER_MAC_SIZE - (CIPHER_BLOCK_SIZE - 1) + - 5 - mesh::RemoteCliRequest::EXTENSION_SIZE; +static_assert(MAX_CORRELATED_CLI_TEXT_LEN <= MAX_TEXT_LEN, + "correlated CLI text must fit the encrypted datagram"); + #include "ContactInfo.h" #define MAX_SEARCH_RESULTS 8 @@ -139,6 +146,7 @@ protected: virtual bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len); virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; virtual void onCommandDataRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; + virtual void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) = 0; virtual void onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0; virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0; virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0; @@ -185,7 +193,7 @@ public: const uint8_t* replace_retry_key = NULL, const uint8_t* message_retry_key = NULL); int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, - uint8_t attempt, const char* text, + uint8_t attempt, uint8_t txt_type, const char* text, uint32_t& est_timeout, uint32_t logical_request_id = 0); bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 0028fbf8..7244706d 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2352,6 +2352,7 @@ void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope) { _callbacks->updateAdvertTimer(); } _callbacks->savePrefs(scope); + _prefs->clearDirty(); } void CommonCLI::saveObserverPrefs() { @@ -2854,6 +2855,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->formatRadioStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { _callbacks->formatStatsReply(reply); + } else if (_board->handleCommand(command, sender_timestamp, reply)) { + if (_prefs->isDirty()) savePrefs(); } else { strcpy(reply, "Unknown command"); } @@ -4054,16 +4057,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep *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; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8af289e9..51626f7b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -10,6 +10,8 @@ #include #include #include +#include +#include #ifndef DEFAULT_CAD_ENABLED #define DEFAULT_CAD_ENABLED 0 @@ -240,7 +242,7 @@ public: uint8_t system_watchdog_enabled = 0; // nRF52 main-loop hardware watchdog (boolean; default on) private: - class RadioPrefs : public ConfigSerializer { + class RadioPrefs : public CommonRadioPrefs { NodePrefs* _parent; protected: @@ -266,6 +268,41 @@ private: public: explicit RadioPrefs(NodePrefs* parent) : _parent(parent) { } + // CommonRadioPrefs interface + float getFreq() const override { return _parent->freq; } + void setFreq(float f) override { _parent->freq = f; markDirty(); } + float getBandwidth() const override { return _parent->bw; } + void setBandwidth(float bw) override { _parent->bw = bw; markDirty(); } + uint8_t getSpreadFactor() const override { return _parent->sf; } + void setSpreadFactor(uint8_t sf) override { _parent->sf = sf; markDirty(); } + uint8_t getCodingRate() const override { return _parent->cr; } + void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); } + float getAirtimeFactor() const override { return _parent->airtime_factor; } + void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } + bool isCadEnabled() const override { return _parent->cad_enabled; } + void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } + uint8_t getIntThresh() const override { return _parent->interference_threshold; } + void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } + uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } + void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } + int8_t getTxPower() const override { return _parent->tx_power_dbm; } + void setTxPower(int8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } + float getRxDelay() const override { return _parent->rx_delay_base; } + void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } + uint8_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } + void setAgcResetInt(uint8_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } + uint8_t getHashMode() const override { return _parent->path_hash_mode; } + void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } + uint8_t getMultiAcks() const override { return _parent->multi_acks; } + void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } + float getFloodTxDelay() const override { return _parent->tx_delay_factor; } + void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } + float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } + void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } + uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } + void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } + uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } + void setFEMTxGain(uint8_t g) override { _parent->radio_fem_txgain = g; markDirty(); } }; RadioPrefs radio; @@ -349,6 +386,8 @@ private: }; RoomPrefs room; + DynamicConfigSerializer custom; + protected: void structure() override { def("name", node_name, sizeof(node_name)); @@ -366,12 +405,19 @@ protected: def("repeat", repeat); def("room", room); def("power", power); + def("custom", custom); } public: NodePrefs() : ConfigSerializer(), radio(this), bridge(this), gps(this), power(this), - repeat(this), room(this) { } + repeat(this), room(this), custom(&radio) { } + + CommonRadioPrefs* getRadioPrefs() { return &radio; } + KeyValueStore* getCustom() { return &custom; } + + bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } + void clearDirty() override { ConfigSerializer::clearDirty(); radio.clearDirty(); custom.clearDirty(); } }; #ifdef WITH_MQTT_BRIDGE diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp new file mode 100644 index 00000000..7240de05 --- /dev/null +++ b/src/helpers/CommonRadioPrefs.cpp @@ -0,0 +1,433 @@ +#include "CommonRadioPrefs.h" +#include "TxtDataHelpers.h" +#include "target.h" + +#include +#include +#include + +namespace { + +constexpr size_t RADIO_ARGS_CAPACITY = 132; + +size_t boundedLength(const char* text, size_t capacity) { + if (text == nullptr) return capacity; + size_t length = 0; + while (length < capacity && text[length] != 0) length++; + return length; +} + +bool startsWith(const char* text, const char* prefix) { + if (text == nullptr || prefix == nullptr) return false; + const size_t prefix_len = strlen(prefix); + return strncmp(text, prefix, prefix_len) == 0; +} + +bool parseIntStrict(const char* text, int32_t min_value, int32_t max_value, + int32_t& result) { + if (text == nullptr || *text == 0 || min_value > max_value) return false; + + bool negative = false; + if (*text == '-') { + negative = true; + text++; + } + if (*text == 0) return false; + + int64_t magnitude = 0; + while (*text != 0) { + if (*text < '0' || *text > '9') return false; + magnitude = magnitude * 10 + (*text++ - '0'); + if (magnitude > 2147483648LL) return false; + } + + const int64_t parsed = negative ? -magnitude : magnitude; + if (parsed < min_value || parsed > max_value) return false; + result = static_cast(parsed); + return true; +} + +// Accept only a plain fixed-point decimal. Exponents, NaN/Inf, whitespace, +// and trailing characters are rejected so malformed CLI values never become +// zero through atoi()/atof() fallback behavior. +bool parseDecimalStrict(const char* text, float& result) { + if (text == nullptr || *text == 0) return false; + + const char* cursor = text; + bool negative = false; + if (*cursor == '-') { + negative = true; + cursor++; + } + + bool saw_digit = false; + double value = 0.0; + while (*cursor >= '0' && *cursor <= '9') { + value = value * 10.0 + (*cursor++ - '0'); + if (!isfinite(value)) return false; + saw_digit = true; + } + + if (*cursor == '.') { + cursor++; + double place = 0.1; + while (*cursor >= '0' && *cursor <= '9') { + value += (*cursor++ - '0') * place; + place *= 0.1; + saw_digit = true; + } + } + + if (!saw_digit || *cursor != 0) return false; + value = negative ? -value : value; + if (!isfinite(value) || value > 3.402823466e+38 + || value < -3.402823466e+38) { + return false; + } + + result = static_cast(value); + return isfinite(result); +} + +bool splitRadioArgs(char* text, const char* parts[4]) { + if (text == nullptr || *text == 0) return false; + + size_t count = 0; + char* field = text; + while (true) { + if (*field == 0 || count >= 4) return false; + parts[count++] = field; + + char* comma = strchr(field, ','); + if (comma == nullptr) break; + *comma = 0; + field = comma + 1; + } + return count == 4; +} + +bool bwMatches(float bw, float allowed) { + return fabsf(bw - allowed) <= 0.001f; +} + +bool isValidLoRaBandwidth(float bw) { +#if defined(USE_LR1110) + return bwMatches(bw, 62.5f) + || bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#elif defined(USE_LLCC68) || defined(USE_SX1272) + return bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#else + return bwMatches(bw, 7.8f) + || bwMatches(bw, 10.4f) + || bwMatches(bw, 15.6f) + || bwMatches(bw, 20.8f) + || bwMatches(bw, 31.25f) + || bwMatches(bw, 41.7f) + || bwMatches(bw, 62.5f) + || bwMatches(bw, 125.0f) + || bwMatches(bw, 250.0f) + || bwMatches(bw, 500.0f); +#endif +} + +bool copyValue(char* destination, size_t capacity, const char* value) { + if (destination == nullptr || capacity == 0 || value == nullptr) return false; + const size_t value_len = strlen(value); + if (value_len >= capacity) { + destination[0] = 0; + return false; + } + memcpy(destination, value, value_len + 1); + return true; +} + +} // namespace + +bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { + if (key == nullptr || value == nullptr || max_len == 0) return false; + + if (strcmp(key, "fem_rxgain") == 0) { + return copyValue(value, max_len, getFEMRxGain() == 0 ? "0" : "1"); + } + if (strcmp(key, "fem_txgain") == 0) { + return copyValue(value, max_len, getFEMTxGain() == 0 ? "0" : "1"); + } + return false; +} + +bool CommonRadioPrefs::setByKey(const char* key, const char* value) { + if (key == nullptr || value == nullptr + || (strcmp(value, "0") != 0 && strcmp(value, "1") != 0)) { + return false; + } + + const uint8_t enabled = strcmp(value, "1") == 0 ? 1 : 0; + if (strcmp(key, "fem_rxgain") == 0) { + setFEMRxGain(enabled); + markDirty(); + return true; + } + if (strcmp(key, "fem_txgain") == 0) { + setFEMTxGain(enabled); + markDirty(); + return true; + } + return false; +} + +bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + if (command == nullptr || reply == nullptr) return false; + + if (strcmp(command, "get radio") == 0) { + char freq[16], bw[16]; + snprintf(freq, sizeof(freq), "%s", StrHelper::ftoa(getFreq())); + snprintf(bw, sizeof(bw), "%s", StrHelper::ftoa3(getBandwidth())); + sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)getSpreadFactor(), (uint32_t)getCodingRate()); + return true; + } + if (startsWith(command, "set radio ")) { + const char* args = command + strlen("set radio "); + const size_t args_len = boundedLength(args, RADIO_ARGS_CAPACITY); + if (args_len >= RADIO_ARGS_CAPACITY) { + strcpy(reply, "Error, invalid radio params"); + return true; + } + + char tmp[RADIO_ARGS_CAPACITY]; + memcpy(tmp, args, args_len + 1); + const char *parts[4]; + float freq = 0.0f; + float bw = 0.0f; + int32_t sf = 0; + int32_t cr = 0; + if (splitRadioArgs(tmp, parts) + && parseDecimalStrict(parts[0], freq) + && parseDecimalStrict(parts[1], bw) + && parseIntStrict(parts[2], 5, 12, sf) + && parseIntStrict(parts[3], 5, 8, cr) + && freq >= 150.0f && freq <= 2500.0f + && isValidLoRaBandwidth(bw)) { + setSpreadFactor(static_cast(sf)); + setCodingRate(static_cast(cr)); + setFreq(freq); + setBandwidth(bw); + strcpy(reply, "OK - reboot to apply"); + } else { + strcpy(reply, "Error, invalid radio params"); + } + return true; + } + + if (strcmp(command, "get freq") == 0) { + sprintf(reply, "> %s", StrHelper::ftoa(getFreq())); + return true; + } + + if (strcmp(command, "get af") == 0) { + sprintf(reply, "> %s", StrHelper::ftoa(getAirtimeFactor())); + return true; + } + if (startsWith(command, "set af ")) { + float factor = 0.0f; + if (parseDecimalStrict(command + strlen("set af "), factor) + && factor >= 0.0f && factor <= 9.0f) { + setAirtimeFactor(factor); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, invalid airtime factor"); + } + return true; + } + + if (strcmp(command, "get dutycycle") == 0) { + const float factor = getAirtimeFactor(); + if (!isfinite(factor) || factor < 0.0f) { + strcpy(reply, "Error: invalid airtime factor"); + } else { + const int tenths = static_cast(1000.0f / (factor + 1.0f) + 0.5f); + sprintf(reply, "> %d.%d%%", tenths / 10, tenths % 10); + } + return true; + } + if (startsWith(command, "set dutycycle ")) { + float dc = 0.0f; + if (!parseDecimalStrict(command + strlen("set dutycycle "), dc) + || dc < 1.0f || dc > 100.0f) { + strcpy(reply, "ERROR: dutycycle must be 1-100"); + } else { + setAirtimeFactor((100.0f / dc) - 1.0f); + const float actual = 100.0f / (getAirtimeFactor() + 1.0f); + const int tenths = static_cast(actual * 10.0f + 0.5f); + sprintf(reply, "OK - %d.%d%%", tenths / 10, tenths % 10); + } + return true; + } + + if (strcmp(command, "get int.thresh") == 0) { + sprintf(reply, "> %d", (uint32_t) getIntThresh()); + return true; + } + if (startsWith(command, "set int.thresh ")) { + int32_t threshold = 0; + if (parseIntStrict(command + strlen("set int.thresh "), 0, 255, threshold)) { + setIntThresh(static_cast(threshold)); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-255"); + } + return true; + } + + if (strcmp(command, "get cad") == 0) { + sprintf(reply, "> %s", isCadEnabled() ? "on" : "off"); + return true; + } + if (startsWith(command, "set cad ")) { + const char* value = command + strlen("set cad "); + if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) { + setCadEnabled(strcmp(value, "on") == 0); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: use set cad on|off"); + } + return true; + } + + if (strcmp(command, "get radio.rxgain") == 0) { + sprintf(reply, "> %s", getRxGain() != 0 ? "on" : "off"); + return true; + } + if (startsWith(command, "set radio.rxgain ")) { + const char* value = command + strlen("set radio.rxgain "); + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error: use set radio.rxgain on|off"); + return true; + } + + const bool enabled = strcmp(value, "on") == 0; + if (radio_driver.setRxBoostedGainMode(enabled)) { + setRxGain(enabled ? 1 : 0); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: unsupported"); + } + return true; + } + + if (strcmp(command, "get tx") == 0) { + sprintf(reply, "> %d", static_cast(getTxPower())); + return true; + } + if (startsWith(command, "set tx ")) { + int32_t dbm = 0; + if (!parseIntStrict(command + strlen("set tx "), -128, 127, dbm)) { + strcpy(reply, "Error: invalid TX power"); + } else if (!radio_driver.setTxPower(static_cast(dbm))) { + strcpy(reply, "Error: TX power rejected"); + } else { + setTxPower(static_cast(dbm)); + strcpy(reply, "OK"); + } + return true; + } + + if (strcmp(command, "get rxdelay") == 0) { + sprintf(reply, "> %s", StrHelper::ftoa(getRxDelay())); + return true; + } + if (startsWith(command, "set rxdelay ")) { + float db = 0.0f; + if (parseDecimalStrict(command + strlen("set rxdelay "), db) + && db >= 0.0f && db <= 20.0f) { + setRxDelay(db); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-20"); + } + return true; + } + + if (strcmp(command, "get agc.reset.interval") == 0) { + sprintf(reply, "> %d", (uint32_t) getAgcResetInt()); + return true; + } + if (startsWith(command, "set agc.reset.interval ")) { + int32_t seconds = 0; + if (parseIntStrict(command + strlen("set agc.reset.interval "), 0, 255, seconds)) { + setAgcResetInt(static_cast(seconds)); + sprintf(reply, "OK - interval rounded to %d", (uint32_t) getAgcResetInt()); + } else { + strcpy(reply, "Error, must be 0-255"); + } + return true; + } + + if (strcmp(command, "get path.hash.mode") == 0) { + sprintf(reply, "> %d", (uint32_t)getHashMode()); + return true; + } + if (startsWith(command, "set path.hash.mode ")) { + int32_t mode = 0; + if (parseIntStrict(command + strlen("set path.hash.mode "), 0, 2, mode)) { + setHashMode(static_cast(mode)); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0,1, or 2"); + } + return true; + } + + if (strcmp(command, "get multi.acks") == 0) { + sprintf(reply, "> %d", (uint32_t) getMultiAcks()); + return true; + } + if (startsWith(command, "set multi.acks ")) { + int32_t enabled = 0; + if (parseIntStrict(command + strlen("set multi.acks "), 0, 1, enabled)) { + setMultiAcks(static_cast(enabled)); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0 or 1"); + } + return true; + } + + if (strcmp(command, "get txdelay") == 0) { + sprintf(reply, "> %s", StrHelper::ftoa(getFloodTxDelay())); + return true; + } + if (startsWith(command, "set txdelay ")) { + float f = 0.0f; + if (parseDecimalStrict(command + strlen("set txdelay "), f) + && f >= 0.0f && f <= 2.0f) { + setFloodTxDelay(f); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-2"); + } + return true; + } + + if (strcmp(command, "get direct.txdelay") == 0) { + sprintf(reply, "> %s", StrHelper::ftoa(getDirectTxDelay())); + return true; + } + if (startsWith(command, "set direct.txdelay ")) { + float f = 0.0f; + if (parseDecimalStrict(command + strlen("set direct.txdelay "), f) + && f >= 0.0f && f <= 2.0f) { + setDirectTxDelay(f); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0-2"); + } + return true; + } + + return false; // not handled +} diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h new file mode 100644 index 00000000..417ac661 --- /dev/null +++ b/src/helpers/CommonRadioPrefs.h @@ -0,0 +1,69 @@ +#pragma once +#include "ConfigSerializer.h" +#include "KeyValueStore.h" + +class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { + bool _is_dirty = false; +protected: + CommonRadioPrefs() { } +public: + void markDirty() { _is_dirty = true; } + void clearDirty() { _is_dirty = false; } + bool isDirty() const { return _is_dirty; } + + virtual float getFreq() const = 0; + virtual void setFreq(float f) = 0; + + virtual float getBandwidth() const = 0; + virtual void setBandwidth(float bw) = 0; + + virtual uint8_t getSpreadFactor() const = 0; + virtual void setSpreadFactor(uint8_t sf) = 0; + + virtual uint8_t getCodingRate() const = 0; + virtual void setCodingRate(uint8_t cr) = 0; + + virtual float getAirtimeFactor() const = 0; + virtual void setAirtimeFactor(float af) = 0; + + virtual bool isCadEnabled() const = 0; + virtual void setCadEnabled(bool en) = 0; + + virtual uint8_t getIntThresh() const = 0; + virtual void setIntThresh(uint8_t t) = 0; + + virtual uint8_t getRxGain() const = 0; + virtual void setRxGain(uint8_t g) = 0; + + virtual int8_t getTxPower() const = 0; + virtual void setTxPower(int8_t dbm) = 0; + + virtual float getRxDelay() const = 0; + virtual void setRxDelay(float d) = 0; + + virtual uint8_t getAgcResetInt() const = 0; + virtual void setAgcResetInt(uint8_t secs) = 0; + + virtual uint8_t getHashMode() const = 0; + virtual void setHashMode(uint8_t m) = 0; + + virtual uint8_t getMultiAcks() const = 0; + virtual void setMultiAcks(uint8_t m) = 0; + + virtual float getFloodTxDelay() const = 0; + virtual void setFloodTxDelay(float d) = 0; + + virtual float getDirectTxDelay() const = 0; + virtual void setDirectTxDelay(float d) = 0; + + virtual uint8_t getFEMRxGain() const = 0; + virtual void setFEMRxGain(uint8_t g) = 0; + + virtual uint8_t getFEMTxGain() const = 0; + virtual void setFEMTxGain(uint8_t g) = 0; + + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); + + bool setByKey(const char* key, const char* value) override; // for dynamic key/value access + bool getByKey(const char* key, char* value, size_t max_len) override; +}; diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index 12673358..4f7130ca 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -90,6 +90,12 @@ int ConfigSerializer::Context::readNext() { case EXPECT_COMMA_OR_KEY: if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } case EXPECT_KEY: + // Empty objects are valid and are emitted by serializers such as the + // dynamic `custom` preferences object before it has any entries. + if (rd_len == 0 && c == '}') { + rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; + return TOK_END_OBJ; + } if (rd_len > 0 && c == ':') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_VAL_OR_OBJ; return TOK_KEY; } if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE; if (rd_len < CONFIG_MAX_KEYLEN-1 && is_key_char(c)) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h index 7e6d6f2a..e55b1b12 100644 --- a/src/helpers/ConfigSerializer.h +++ b/src/helpers/ConfigSerializer.h @@ -17,7 +17,9 @@ class ConfigSerializer { bool _first; int8_t _depth; + bool _dirty = false; +protected: enum OP { READ, WRITE }; class Context { @@ -38,13 +40,14 @@ class ConfigSerializer { const char* getToken() const { return rd_buf; } bool keyMatch(int8_t depth, const char* key) { return strcmp(key, _keys[depth]) == 0; } void setKey(uint8_t depth, const char* key) { strcpy(_keys[depth], key); } + const char* getKey(uint8_t depth) { return _keys[depth]; } }; Context* _context = NULL; + int8_t getDepth() const { return _depth; } void writeComma(); -protected: ConfigSerializer() { } void def(const char* key, char* value, size_t max_len); // max_len inclusive of null @@ -62,7 +65,12 @@ protected: virtual void structure() = 0; + void markDirty() { _dirty = true; } + public: bool loadSerial(Stream& s); bool saveSerial(Stream& s); + + virtual bool isDirty() const { return _dirty; } + virtual void clearDirty() { _dirty = false; } }; diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 2ab36ffb..54ff96d2 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -36,6 +36,12 @@ struct ContactInfo { return shared_secret; } + bool isFav() const { return flags & 0x01; } + bool isTelemBaseAllowed() const { return flags & 0x02; } + bool isTelemLocAllowed() const { return flags & 0x04; } + bool isTelemEnvAllowed() const { return flags & 0x08; } + bool isRemoteCLIAllowed() const { return flags & 0x10; } + private: mutable uint8_t shared_secret[PUB_KEY_SIZE]; }; diff --git a/src/helpers/DynamicConfigSerializer.cpp b/src/helpers/DynamicConfigSerializer.cpp new file mode 100644 index 00000000..d98e5fe3 --- /dev/null +++ b/src/helpers/DynamicConfigSerializer.cpp @@ -0,0 +1,228 @@ +#include "DynamicConfigSerializer.h" + +#include + +#define PROP_SEP_CHAR '|' +#define KEY_SEP_CHAR ':' + +namespace { + +size_t boundedLength(const char* text, size_t capacity) { + if (text == nullptr) return capacity; + size_t length = 0; + while (length < capacity && text[length] != 0) length++; + return length; +} + +bool isValidKey(const char* key, size_t& key_len) { + key_len = boundedLength(key, CONFIG_MAX_KEYLEN); + if (key_len == 0 || key_len >= CONFIG_MAX_KEYLEN) return false; + + for (size_t i = 0; i < key_len; i++) { + const char c = key[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_')) { + return false; + } + } + return true; +} + +bool isValidValue(const char* value, size_t& value_len) { + value_len = boundedLength(value, MAX_DYNAMIC_CONFG); + if (value_len >= MAX_DYNAMIC_CONFG) return false; + + for (size_t i = 0; i < value_len; i++) { + const uint8_t c = static_cast(value[i]); + // ':' and '|' are internal delimiters. Control characters would produce + // invalid serialized configuration because ConfigSerializer only escapes + // a subset of them. + if (c == static_cast(KEY_SEP_CHAR) + || c == static_cast(PROP_SEP_CHAR) + || c < 0x20 || c == 0x7F) { + return false; + } + } + return true; +} + +bool appendEntry(char* destination, size_t capacity, size_t& used, + const char* key, size_t key_len, + const char* value, size_t value_len) { + if (destination == nullptr || key == nullptr || value == nullptr + || used >= capacity) { + return false; + } + const size_t separator_len = used == 0 ? 0 : 1; + const size_t available = capacity - used; + if (separator_len + key_len + 1 + value_len + 1 > available) return false; + + if (separator_len != 0) destination[used++] = PROP_SEP_CHAR; + memcpy(destination + used, key, key_len); + used += key_len; + destination[used++] = KEY_SEP_CHAR; + memcpy(destination + used, value, value_len); + used += value_len; + destination[used] = 0; + return true; +} + +} // namespace + +bool DynamicConfigSerializer::setByKeyPrv(const char* key, const char* value) { + size_t key_len = 0; + size_t value_len = 0; + if (!isValidKey(key, key_len) || !isValidValue(value, value_len)) return false; + + if (_fallback && _fallback->setByKey(key, value)) return true; + + const size_t config_len = boundedLength(_config, sizeof(_config)); + if (config_len >= sizeof(_config)) return false; + + char tmp[MAX_DYNAMIC_CONFG]; + memcpy(tmp, _config, config_len + 1); + + char new_config[MAX_DYNAMIC_CONFG]; + new_config[0] = 0; + size_t used = 0; + + char* item = tmp; + while (*item != 0) { + char* next = strchr(item, PROP_SEP_CHAR); + if (next != nullptr) *next = 0; + + char* item_separator = strchr(item, KEY_SEP_CHAR); + if (item_separator == nullptr || item_separator == item) return false; + *item_separator = 0; + + size_t item_key_len = 0; + size_t item_value_len = 0; + const char* item_value = item_separator + 1; + if (!isValidKey(item, item_key_len) + || !isValidValue(item_value, item_value_len)) { + return false; + } + + const bool replacing = item_key_len == key_len + && memcmp(item, key, key_len) == 0; + if (!replacing + && !appendEntry(new_config, sizeof(new_config), used, + item, item_key_len, item_value, item_value_len)) { + return false; + } + + if (next == nullptr) break; + item = next + 1; + if (*item == 0) return false; // reject a trailing/consecutive separator + } + + if (!appendEntry(new_config, sizeof(new_config), used, + key, key_len, value, value_len)) { + return false; + } + + memcpy(_config, new_config, used + 1); + return true; +} + +bool DynamicConfigSerializer::setByKey(const char* key, const char* value) { + if (setByKeyPrv(key, value)) { + markDirty(); + return true; + } + return false; +} + +bool DynamicConfigSerializer::getByKey(const char* key, char* value, size_t max_len) { + if (value == nullptr || max_len == 0) return false; + + size_t key_len = 0; + if (!isValidKey(key, key_len)) { + value[0] = 0; + return false; + } + + if (_fallback && _fallback->getByKey(key, value, max_len)) return true; + + value[0] = 0; + const size_t config_len = boundedLength(_config, sizeof(_config)); + if (config_len >= sizeof(_config)) return false; + + char tmp[MAX_DYNAMIC_CONFG]; + memcpy(tmp, _config, config_len + 1); + + char* item = tmp; + while (*item != 0) { + char* next = strchr(item, PROP_SEP_CHAR); + if (next != nullptr) *next = 0; + + char* item_separator = strchr(item, KEY_SEP_CHAR); + if (item_separator == nullptr || item_separator == item) return false; + *item_separator = 0; + + size_t item_key_len = 0; + size_t item_value_len = 0; + const char* item_value = item_separator + 1; + if (!isValidKey(item, item_key_len) + || !isValidValue(item_value, item_value_len)) { + return false; + } + + if (item_key_len == key_len && memcmp(item, key, key_len) == 0) { + if (item_value_len >= max_len) return false; + memcpy(value, item_value, item_value_len + 1); + return true; + } + + if (next == nullptr) break; + item = next + 1; + if (*item == 0) return false; + } + return false; +} + +void DynamicConfigSerializer::structure() { + if (_context->op() == OP::WRITE) { + const size_t config_len = boundedLength(_config, sizeof(_config)); + if (config_len >= sizeof(_config)) { + _context->success = false; + return; + } + + char tmp[MAX_DYNAMIC_CONFG]; + memcpy(tmp, _config, config_len + 1); + + char* item = tmp; + while (*item != 0) { + char* next = strchr(item, PROP_SEP_CHAR); + if (next != nullptr) *next = 0; + + char* item_separator = strchr(item, KEY_SEP_CHAR); + if (item_separator == nullptr || item_separator == item) { + _context->success = false; + return; + } + *item_separator = 0; + + size_t item_key_len = 0; + size_t item_value_len = 0; + char* item_value = item_separator + 1; + if (!isValidKey(item, item_key_len) + || !isValidValue(item_value, item_value_len)) { + _context->success = false; + return; + } + def(item, item_value, item_value_len + 1); + + if (next == nullptr) break; + item = next + 1; + if (*item == 0) { + _context->success = false; + return; + } + } + } else { + if (!setByKeyPrv(_context->getKey(getDepth()), _context->getToken())) { + _context->success = false; + } + } +} diff --git a/src/helpers/DynamicConfigSerializer.h b/src/helpers/DynamicConfigSerializer.h new file mode 100644 index 00000000..e5dc4c7c --- /dev/null +++ b/src/helpers/DynamicConfigSerializer.h @@ -0,0 +1,23 @@ +#pragma once +#include "ConfigSerializer.h" +#include "KeyValueStore.h" + +#ifndef MAX_DYNAMIC_CONFG + #define MAX_DYNAMIC_CONFG 128 +#endif + +class DynamicConfigSerializer : public ConfigSerializer, public KeyValueStore { + char _config[MAX_DYNAMIC_CONFG]; + KeyValueStore* _fallback; + + bool setByKeyPrv(const char* key, const char* value); + +protected: + void structure() override; + +public: + DynamicConfigSerializer(KeyValueStore* fallback = NULL) : _fallback(fallback) { _config[0] = 0; } + + bool setByKey(const char* key, const char* value) override; + bool getByKey(const char* key, char* value, size_t max_len) override; +}; diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 1d52c849..7f297b2a 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -25,6 +25,7 @@ #if !defined(LIGHTWEIGHT_WIFI_OTA) class AsyncWebServer; #endif +#include class ESP32Board : public mesh::MainBoard { protected: @@ -72,6 +73,8 @@ public: #endif } + void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + // Temperature from ESP32 MCU float getMCUTemperature() override { uint32_t raw = 0; diff --git a/src/helpers/KeyValueStore.h b/src/helpers/KeyValueStore.h new file mode 100644 index 00000000..cc4ac455 --- /dev/null +++ b/src/helpers/KeyValueStore.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include + +class KeyValueStore { +protected: + KeyValueStore() { } +public: + virtual bool setByKey(const char* key, const char* value) { return false; } + virtual bool getByKey(const char* key, char* value, size_t max_len) { return false; } +}; diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index bc7e66bc..00dcdd38 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -2,6 +2,7 @@ #include #include +#include #if defined(NRF52_PLATFORM) @@ -73,6 +74,8 @@ public: bool isUsbDataConnected() override; bool isUsbHostConnected() override; + void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + #ifdef NRF52_POWER_MANAGEMENT uint16_t getBootVoltage() override { return boot_voltage_mv; } virtual uint32_t getResetReason() const override { return reset_reason; } diff --git a/src/helpers/TxtDataHelpers.h b/src/helpers/TxtDataHelpers.h index 0a8f8369..48da1248 100644 --- a/src/helpers/TxtDataHelpers.h +++ b/src/helpers/TxtDataHelpers.h @@ -4,8 +4,10 @@ #include #define TXT_TYPE_PLAIN 0 // a plain text message -#define TXT_TYPE_CLI_DATA 1 // a CLI command +#define TXT_TYPE_CLI_DATA 1 // a CLI command -or- reply #define TXT_TYPE_SIGNED_PLAIN 2 // plain text, signed by sender +#define TXT_TYPE_CLI_COMMAND 3 // a CLI command (explictly) + #define DATA_TYPE_RESERVED 0x0000 // reserved for future use #define DATA_TYPE_DEV 0xFFFF // developer namespace for experimenting with group/channel datagrams and building apps diff --git a/src/helpers/stm32/STM32Board.h b/src/helpers/stm32/STM32Board.h index cc33dfe5..f6dfe2e8 100644 --- a/src/helpers/stm32/STM32Board.h +++ b/src/helpers/stm32/STM32Board.h @@ -2,6 +2,7 @@ #include #include +#include class STM32Board : public mesh::MainBoard { protected: @@ -12,6 +13,8 @@ public: startup_reason = BD_STARTUP_NORMAL; } + void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + uint8_t getStartupReason() const override { return startup_reason; } uint16_t getBattMilliVolts() override { @@ -42,4 +45,4 @@ public: #endif bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override { return false; }; -}; \ No newline at end of file +}; diff --git a/test/mocks/target.h b/test/mocks/target.h new file mode 100644 index 00000000..67dda37d --- /dev/null +++ b/test/mocks/target.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +// CommonRadioPrefs applies live settings through the target's global radio +// driver. Native serializer tests do not build a hardware variant, so provide +// the narrow interface that helper needs. +class NativeRadioDriverMock { +public: + bool setRxBoostedGainMode(bool) { return true; } + bool setTxPower(int8_t) { return true; } +}; + +inline NativeRadioDriverMock radio_driver; diff --git a/test/test_companion_frame_queue/test_companion_frame_queue.cpp b/test/test_companion_frame_queue/test_companion_frame_queue.cpp index 6e074a5e..352ddbb9 100644 --- a/test/test_companion_frame_queue/test_companion_frame_queue.cpp +++ b/test/test_companion_frame_queue/test_companion_frame_queue.cpp @@ -14,6 +14,16 @@ static bool enqueue(TestFrame queue[], size_t& count, size_t capacity, return mesh::enqueueCompanionFrame(queue, count, capacity, &code, 1); } +TEST(CompanionFrameQueue, HighBitSeparatesResponsesFromPushes) { + const uint8_t highest_command_or_response = 0x7F; + const uint8_t first_push = 0x80; + const uint8_t unknown_high_code = 0x98; + + EXPECT_FALSE(mesh::isCompanionPushFrame(&highest_command_or_response, 1)); + EXPECT_TRUE(mesh::isCompanionPushFrame(&first_push, 1)); + EXPECT_TRUE(mesh::isCompanionPushFrame(&unknown_high_code, 1)); +} + TEST(CompanionFrameQueue, PushTrafficLeavesOneResponseSlot) { TestFrame queue[4] = {}; size_t count = 0; diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index 16ec43e1..a830775f 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -1,5 +1,6 @@ #include #include "helpers/ConfigSerializer.h" +#include "helpers/DynamicConfigSerializer.h" class NativeFileSystem { public: @@ -314,6 +315,90 @@ TEST(NodePrefs, ExplicitWrappedBridgeFormatReplacesRawInMemoryValue) { EXPECT_EQ(mesh::bridge::ESPNOW_FORMAT_WRAPPED, loaded.bridge_format); } +TEST(DynamicConfigSerializer, GetSet_Basic) { + DynamicConfigSerializer data; + + bool s1 = data.setByKey("age", "11"); + bool s2 = data.setByKey("name", "Scott"); + EXPECT_TRUE(s1 && s2); + + char tmp[32]; + bool g1 = data.getByKey("age", tmp, 31); + EXPECT_TRUE(g1); + EXPECT_STREQ("11", tmp); + + bool g2 = data.getByKey("name", tmp, 31); + EXPECT_TRUE(g2); + EXPECT_STREQ("Scott", tmp); +} + +TEST(DynamicConfigSerializer, Set_Replaces) { + DynamicConfigSerializer data; + + bool s1 = data.setByKey("age", "11"); + bool s2 = data.setByKey("name", "Scott"); + EXPECT_TRUE(s1 && s2); + + bool s3 = data.setByKey("age", "333"); + EXPECT_TRUE(s3); + + char tmp[32]; + bool g1 = data.getByKey("age", tmp, 31); + EXPECT_TRUE(g1); + EXPECT_STREQ("333", tmp); + + bool g2 = data.getByKey("name", tmp, 31); + EXPECT_TRUE(g2); + EXPECT_STREQ("Scott", tmp); +} + +TEST(DynamicConfigSerializer, GetUnknown_Fail) { + DynamicConfigSerializer data; + + bool s1 = data.setByKey("age", "11"); + EXPECT_TRUE(s1); + + char tmp[32]; + bool g2 = data.getByKey("name", tmp, 31); + EXPECT_FALSE(g2); +} + +TEST(DynamicConfigSerializer, SaveCustom_Basic) { + MockPrintStream s; + DynamicConfigSerializer data; + + bool s1 = data.setByKey("age", "11"); + bool s2 = data.setByKey("name", "Scott"); + EXPECT_TRUE(s1 && s2); + + bool success = data.saveSerial(s); + EXPECT_TRUE(success); + + auto l = s.getLength(); + char tmp[128]; + memcpy(tmp, s.getBytes(), l); + tmp[l] = 0; + + const char* expect = "{age:\"11\",name:\"Scott\"}"; + EXPECT_STREQ(expect, tmp); +} + +TEST(DynamicConfigSerializer, LoadCustom_Basic) { + MockInputStream s("{age:\"" TEST_INT_S "\",name:\"Scott\"}"); + DynamicConfigSerializer data; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + char tmp[32]; + bool g1 = data.getByKey("age", tmp, 31); + EXPECT_TRUE(g1); + EXPECT_STREQ(TEST_INT_S, tmp); + + bool g2 = data.getByKey("name", tmp, 31); + EXPECT_TRUE(g2); + EXPECT_STREQ("Scott", tmp); +} // ── main ─────────────────────────────────────────────────────── diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index 78af529d..4aa2bbc4 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -138,3 +138,57 @@ bool T096Board::canControlLoRaFemLna() const { bool T096Board::isLoRaFemLnaEnabled() const { return loRaFEMControl.isLNAEnabled(); } + +void T096Board::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + if (_prefs == nullptr) { + return; + } + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); +} + +bool T096Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + if (command == nullptr || reply == nullptr) { + return false; + } + + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (_prefs == nullptr) { + strcpy(reply, "Error: preferences unavailable"); + } else if (strcmp(&command[21], "on") == 0) { + if (setLoRaFemLnaEnabled(true)) { + _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (strcmp(&command[21], "off") == 0) { + if (setLoRaFemLnaEnabled(false)) { + _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} diff --git a/variants/heltec_t096/T096Board.h b/variants/heltec_t096/T096Board.h index 15c7e68b..31f8c957 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -4,9 +4,12 @@ #include #include #include +#include #include "LoRaFEMControl.h" class T096Board : public NRF52BoardDCDC { + KeyValueStore* _prefs = NULL; + protected: #ifdef NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; @@ -19,6 +22,8 @@ public: T096Board() :periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE), NRF52Board("T096_OTA") {} void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; void onBeforeTransmit(void) override; void onAfterTransmit(void) override; diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 31db3221..4fe6d6a4 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -91,3 +91,57 @@ void HeltecTrackerV2Board::begin() { bool HeltecTrackerV2Board::isLoRaFemLnaEnabled() const { return loRaFEMControl.isLNAEnabled(); } + +void HeltecTrackerV2Board::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + if (_prefs == nullptr) { + return; + } + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); +} + +bool HeltecTrackerV2Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + if (command == nullptr || reply == nullptr) { + return false; + } + + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (_prefs == nullptr) { + strcpy(reply, "Error: preferences unavailable"); + } else if (strcmp(&command[21], "on") == 0) { + if (setLoRaFemLnaEnabled(true)) { + _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (strcmp(&command[21], "off") == 0) { + if (setLoRaFemLnaEnabled(false)) { + _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h index 2bd6a025..31f4cf04 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -6,6 +6,7 @@ #include "LoRaFEMControl.h" class HeltecTrackerV2Board : public ESP32Board { + KeyValueStore* _prefs = NULL; public: RefCountedDigitalPin periph_power; @@ -14,6 +15,9 @@ public: HeltecTrackerV2Board() : periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE) { } void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; + void onBeforeTransmit(void) override; void onAfterTransmit(void) override; void powerOff() override; diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 3f13f41f..853569d3 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -80,3 +80,57 @@ void HeltecV4Board::begin() { bool HeltecV4Board::isLoRaFemLnaEnabled() const { return loRaFEMControl.isLNAEnabled(); } + +void HeltecV4Board::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + if (_prefs == nullptr) { + return; + } + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); +} + +bool HeltecV4Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + if (command == nullptr || reply == nullptr) { + return false; + } + + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (_prefs == nullptr) { + strcpy(reply, "Error: preferences unavailable"); + } else if (strcmp(&command[21], "on") == 0) { + if (setLoRaFemLnaEnabled(true)) { + _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (strcmp(&command[21], "off") == 0) { + if (setLoRaFemLnaEnabled(false)) { + _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index 55166bb3..c1468d28 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -10,6 +10,7 @@ #endif class HeltecV4Board : public ESP32Board { + KeyValueStore* _prefs = NULL; protected: float adc_mult = ADC_MULTIPLIER; @@ -20,6 +21,9 @@ public: HeltecV4Board() : periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE) { } void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; + void onBeforeTransmit(void) override; void onAfterTransmit(void) override; void powerOff() override; diff --git a/variants/rpi_picow/PicoWBoard.h b/variants/rpi_picow/PicoWBoard.h index c8ca24ff..d0037558 100644 --- a/variants/rpi_picow/PicoWBoard.h +++ b/variants/rpi_picow/PicoWBoard.h @@ -2,6 +2,7 @@ #include #include +#include // built-ins #define PIN_VBAT_READ 26 @@ -16,6 +17,8 @@ public: void begin(); uint8_t getStartupReason() const override { return startup_reason; } + void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + void onBeforeTransmit() override { digitalWrite(LED_BUILTIN, HIGH); // turn TX LED on } diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp index dd863aca..7daabf18 100644 --- a/variants/station_g3_esp32/StationG3Board.cpp +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -44,3 +44,95 @@ bool StationG3Board::canControlLoRaFemPaGain() const { bool StationG3Board::isLoRaFemPaGainEnabled() const { return loRaFEMControl.isPAGainEnabled(); } + +void StationG3Board::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + if (_prefs == nullptr) { + return; + } + + char gain[8]; + + gain[0] = 0; + _prefs->getByKey("fem_rxgain", gain, 7); // get initial values + setLoRaFemLnaEnabled(strcmp(gain, "1") == 0); + + gain[0] = 0; + _prefs->getByKey("fem_txgain", gain, 7); // get initial values + setLoRaFemPaGainEnabled(strcmp(gain, "1") == 0); +} + +bool StationG3Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + if (command == nullptr || reply == nullptr) { + return false; + } + + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!loRaFEMControl.canControlLNA()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!loRaFEMControl.canControlLNA()) { + strcpy(reply, "Error: unsupported"); + } else if (_prefs == nullptr) { + strcpy(reply, "Error: preferences unavailable"); + } else if (strcmp(&command[21], "on") == 0) { + if (setLoRaFemLnaEnabled(true)) { + _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (strcmp(&command[21], "off") == 0) { + if (setLoRaFemLnaEnabled(false)) { + _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + if (strcmp(command, "get radio.fem.txgain") == 0) { + if (!loRaFEMControl.canControlPAGain()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemPaGainEnabled() ? "on" : "off"); + } + return true; + } + if (strncmp(command, "set radio.fem.txgain ", 21) == 0) { + if (!loRaFEMControl.canControlPAGain()) { + strcpy(reply, "Error: unsupported"); + } else if (_prefs == nullptr) { + strcpy(reply, "Error: preferences unavailable"); + } else if (strcmp(&command[21], "on") == 0) { + if (setLoRaFemPaGainEnabled(true)) { + _prefs->setByKey("fem_txgain", "1"); + strcpy(reply, "OK - LoRa FEM TX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM TX gain"); + } + } else if (strcmp(&command[21], "off") == 0) { + if (setLoRaFemPaGainEnabled(false)) { + _prefs->setByKey("fem_txgain", "0"); + strcpy(reply, "OK - LoRa FEM TX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM TX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} diff --git a/variants/station_g3_esp32/StationG3Board.h b/variants/station_g3_esp32/StationG3Board.h index 52628eb6..82ac18d5 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -6,6 +6,8 @@ #include "LoRaFEMControl.h" class StationG3Board : public ESP32Board { + KeyValueStore* _prefs = NULL; + public: LoRaFEMControl loRaFEMControl; @@ -25,6 +27,10 @@ public: } } + void attachDynamicPrefs(KeyValueStore* prefs); + + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; + void setPrimaryLNAEnable(bool enabled) { loRaFEMControl.setLNAEnable(enabled); } diff --git a/variants/waveshare_rp2040_lora/WaveshareBoard.h b/variants/waveshare_rp2040_lora/WaveshareBoard.h index 37d06c7a..ecf3bdd2 100644 --- a/variants/waveshare_rp2040_lora/WaveshareBoard.h +++ b/variants/waveshare_rp2040_lora/WaveshareBoard.h @@ -2,6 +2,7 @@ #include #include +#include // LoRa radio module pins for Waveshare RP2040-LoRa-HF/LF // https://files.waveshare.com/wiki/RP2040-LoRa/Rp2040-lora-sch.pdf @@ -32,6 +33,8 @@ public: void begin(); uint8_t getStartupReason() const override { return startup_reason; } + void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + #ifdef P_LORA_TX_LED void onBeforeTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); } diff --git a/variants/xiao_rp2040/XiaoRP2040Board.h b/variants/xiao_rp2040/XiaoRP2040Board.h index e146c364..bcc2d5cb 100644 --- a/variants/xiao_rp2040/XiaoRP2040Board.h +++ b/variants/xiao_rp2040/XiaoRP2040Board.h @@ -2,6 +2,7 @@ #include #include +#include /* * This board has no built-in way to read battery voltage. @@ -30,6 +31,8 @@ public: void begin(); uint8_t getStartupReason() const override { return startup_reason; } + void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op + #ifdef P_LORA_TX_LED void onBeforeTransmit() override { digitalWrite(P_LORA_TX_LED, HIGH); } void onAfterTransmit() override { digitalWrite(P_LORA_TX_LED, LOW); }