diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 84fbc19598..cebf0cfe5c 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -31,8 +31,11 @@ jobs: - Heltec_v3_room_server # nRF52 - RAK_4631_companion_radio_ble + - RAK_4631_companion_radio_ethernet - RAK_4631_repeater + - RAK_4631_repeater_ethernet - RAK_4631_room_server + - RAK_4631_room_server_ethernet # RP2040 - PicoW_repeater # STM32 diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e3af3aafbf..5d48f4c67b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -vv + run: pio test -e native -e native_kiss_modem -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 8057bc70a7..0000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "recommendations": [ - "pioarduino.pioarduino-ide", - "platformio.platformio-ide" - ], - "unwantedRecommendations": [ - "ms-vscode.cpptools-extension-pack" - ] -} diff --git a/boards/heltec-rc32.json b/boards/heltec-rc32.json new file mode 100644 index 0000000000..b9bafa2656 --- /dev/null +++ b/boards/heltec-rc32.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_rc32" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "Heltec RC32 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "Heltec" +} diff --git a/boards/heltec_tower_v2.json b/boards/heltec_tower_v2.json new file mode 100644 index 0000000000..9f9b6ab591 --- /dev/null +++ b/boards/heltec_tower_v2.json @@ -0,0 +1,61 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A","0x4405"], + ["0x239A","0x0029"], + ["0x239A","0x002A"], + ["0x239A","0x0071"] + ], + "usb_product": "HT-n5262", + "mcu": "nrf52840", + "variant": "heltec_tower_v2", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": [ + "bluetooth" + ], + "debug": { + "jlink_device": "nRF52840_xxAA", + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52.cfg" + }, + "frameworks": [ + "arduino" + ], + "name": "Heltec Tower V2 Board", + "upload": { + "maximum_ram_size": 235520, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://heltec.org/", + "vendor": "Heltec" +} diff --git a/boards/heltec_v4.json b/boards/heltec_v4.json index 36cdfc043d..0d3a52cd07 100644 --- a/boards/heltec_v4.json +++ b/boards/heltec_v4.json @@ -9,7 +9,7 @@ "extra_flags": [ "-DBOARD_HAS_PSRAM", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DARDUINO_USB_MODE=0", + "-DARDUINO_USB_MODE=1", "-DARDUINO_RUNNING_CORE=1", "-DARDUINO_EVENT_RUNNING_CORE=1" ], @@ -40,4 +40,4 @@ }, "url": "https://heltec.org/", "vendor": "heltec" -} \ No newline at end of file +} diff --git a/boards/heltec_v4_r8.json b/boards/heltec_v4_r8.json new file mode 100644 index 0000000000..6dd97c84b2 --- /dev/null +++ b/boards/heltec_v4_r8.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_v4_r8" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "heltec" +} diff --git a/boards/inhero_mr2.json b/boards/inhero_mr2.json new file mode 100644 index 0000000000..c048f68459 --- /dev/null +++ b/boards/inhero_mr2.json @@ -0,0 +1,74 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DARDUINO_NRF52840_FEATHER -DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + [ + "0x239A", + "0x8029" + ], + [ + "0x239A", + "0x0029" + ], + [ + "0x239A", + "0x002A" + ], + [ + "0x239A", + "0x802A" + ] + ], + "usb_product": "Inhero MR2", + "mcu": "nrf52840", + "variant": "Inhero_MR2_Board", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": [ + "bluetooth" + ], + "debug": { + "jlink_device": "nRF52840_xxAA", + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52.cfg" + }, + "frameworks": [ + "arduino" + ], + "name": "Inhero MR-2", + "upload": { + "maximum_ram_size": 235520, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink", + "cmsis-dap" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://inhero.de", + "vendor": "Inhero GmbH" +} diff --git a/boards/meshnology_w12.json b/boards/meshnology_w12.json new file mode 100644 index 0000000000..2e74af9b32 --- /dev/null +++ b/boards/meshnology_w12.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x0002"], ["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "meshnology_w12" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "heltec_wifi_lora_32 v5 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "heltec" +} \ No newline at end of file diff --git a/boards/seeed-mesh-tracker-x1.json b/boards/seeed-mesh-tracker-x1.json new file mode 100644 index 0000000000..347aca53ff --- /dev/null +++ b/boards/seeed-mesh-tracker-x1.json @@ -0,0 +1,60 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v7.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DARDUINO_WIO_WM1110 -DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A", "0x8029"], + ["0x239A", "0x0029"], + ["0x239A", "0x002A"], + ["0x239A", "0x802A"], + ["0x2886", "0x0057"] + ], + "usb_product": "X1-BOOT", + "mcu": "nrf52840", + "variant": "Seeed_Mesh-Tracker-X1", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "7.3.0", + "sd_fwid": "0x0123" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": ["bluetooth"], + "debug": { + "jlink_device": "nRF52840_xxAA", + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52.cfg" + }, + "frameworks": ["arduino"], + "name": "Seeed SenseCAP MeshTracker X1", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink", + "cmsis-dap", + "blackmagic" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://www.seeedstudio.com/SenseCAP-MeshTracker-X1-for-Meshtastic-p-6793.html", + "vendor": "Seeed Studio" +} diff --git a/boards/thinknode_m7.json b/boards/thinknode_m7.json new file mode 100644 index 0000000000..2a0c5e5838 --- /dev/null +++ b/boards/thinknode_m7.json @@ -0,0 +1,42 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-D BOARD_HAS_PSRAM", + "-D ARDUINO_USB_CDC_ON_BOOT=0", + "-D ARDUINO_USB_MODE=0", + "-D ARDUINO_RUNNING_CORE=1", + "-D ARDUINO_EVENT_RUNNING_CORE=0" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "qio_opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "ELECROW-ThinkNode-M7" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "ELECROW ThinkNode M7", + "upload": { + "flash_size": "8MB", + "maximum_ram_size": 524288, + "maximum_size": 8388608, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.elecrow.com", + "vendor": "ELECROW" +} diff --git a/boards/thinknode_m9.json b/boards/thinknode_m9.json new file mode 100755 index 0000000000..31ebdbb91a --- /dev/null +++ b/boards/thinknode_m9.json @@ -0,0 +1,57 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi", + "partitions": "default_16MB.csv" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=0", + "-DARDUINO_USB_MODE=0", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=0" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "qio_opi", + "hwids": [ + [ + "0x303A", + "0x1001" + ] + ], + "mcu": "esp32s3", + "variant": "ELECROW-ThinkNode-M9" + }, + "connectivity": [ + "wifi", + "bluetooth", + "lora" + ], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": [ + "esp-builtin" + ], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": [ + "arduino", + "espidf" + ], + "name": "elecrow-thinknode-m9", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 524288, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html", + "vendor": "ELECROW" +} \ No newline at end of file diff --git a/default.nix b/default.nix index 4f9e1c59b8..13d410de3e 100644 --- a/default.nix +++ b/default.nix @@ -1,11 +1,12 @@ -{ pkgs ? import {} }: -let +{pkgs ? import {}}: let in pkgs.mkShell { buildInputs = [ pkgs.platformio pkgs.python3 + pkgs.gcc + pkgs.gtest # optional: needed as a programmer i.e. for esp32 pkgs.avrdude ]; -} + } diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 66a9b77afe..b618ae2bfe 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -19,6 +19,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore - [GPS](#gps-when-gps-support-is-compiled-in) - [Sensors](#sensors-when-sensor-support-is-compiled-in) - [Bridge](#bridge-when-bridge-support-is-compiled-in) + - [Ethernet](#ethernet-when-ethernet-support-is-compiled-in) --- @@ -276,6 +277,20 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change the LoRa FEM receive-path gain state on supported boards +**Usage:** +- `get radio.fem.rxgain` +- `set radio.fem.rxgain ` + +**Parameters:** +- `state`: `on`|`off` + +**Notes:** +- This controls the external LoRa FEM receive-path LNA where the board supports it. +- This is separate from `radio.rxgain`, which controls the radio chip receive gain mode. + +--- + ### System #### View or change this node's name @@ -290,7 +305,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Default:** Varies by board -**Note:** Max length varies. If a location is set, the max length is 24 bytes; 32 otherwise. Emoji and unicode characters may take more than one byte. +**Note:** Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary. --- @@ -577,6 +592,20 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### Enable or disable hardware Channel Activity Detection (CAD) +**Usage:** +- `get cad` +- `set cad ` + +**Description:** When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of `int.thresh` — either, both, or none may be active. + +**Parameters:** +- `on|off`: Enable or disable hardware CAD + +**Default:** `off` + +--- + #### View or change the AGC Reset Interval **Usage:** - `get agc.reset.interval` @@ -1124,3 +1153,25 @@ region save **Note:** Returns an error on boards without power management support. --- + +### Ethernet (when Ethernet support is compiled in) + +Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Ethernet module. Use the `_ethernet` firmware variants (e.g. `RAK_4631_repeater_ethernet`) to enable this feature. + +--- + +#### View Ethernet connection status +**Usage:** +- `eth.status` + +**Output:** +- `ETH: :` when connected (e.g. `ETH: 192.168.1.50:23`) +- `ETH: not connected` when Ethernet is not active + +**Notes:** +- Available on repeater and room server firmware only. Companion radio ethernet firmware does not expose a CLI. +- The Ethernet interface obtains an IP address via DHCP automatically on boot. +- A TCP server listens on port 23 (default) for CLI connections. +- Connect with any TCP client (e.g. `nc`, PuTTY) to access the same CLI available over serial. + +--- diff --git a/docs/faq.md b/docs/faq.md index 5ebdb9b614..c7d7a118e3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -83,6 +83,7 @@ A list of frequently-asked questions and answers for MeshCore - [7.5. Q: What is the format of a contact or channel QR code?](#75-q-what-is-the-format-of-a-contact-or-channel-qr-code) - [7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?](#76-q-how-do-i-connect-to-the-companion-via-wi-fi-eg-using-a-heltec-v3) - [7.7. Q: I have a Station G2, or a Heltec V4, or an Ikoka Stick, or a radio with an EByte E22-900M30S or an EByte E22-900M33S module, what should their transmit power be set to?](#77-q-i-have-a-station-g2-or-a-heltec-v4-or-an-ikoka-stick-or-a-radio-with-an-ebyte-e22-900m30s-or-an-ebyte-e22-900m33s-module-what-should-their-transmit-power-be-set-to) + - [7.8. Q: How do I use Ethernet with a RAK4631?](#78-q-how-do-i-use-ethernet-with-a-rak4631) ## 1. Introduction @@ -820,3 +821,29 @@ For companion radios, you can set these radios' transmit power in the smartphone | **Ikoka Stick E22-900M33S** | 2W Model | 9 dBm | 2W | **DO NOT EXCEED** (Risk of burn out) [data sheet](https://www.cdebyte.com/pdf-down.aspx?id=4216) Refer to your local government's requirements | | **Heltec V4** | Standard Output | 10 dBm | 22 dBm (~0.15W) | | | | High Output | 22 dBm | 28 dBm (~0.5W to 0.6W) | | + +--- + +### 7.8. Q: How do I use Ethernet with a RAK4631? + **A:** +MeshCore supports Ethernet on RAK4631 boards using the [RAK13800](https://docs.rakwireless.com/product-categories/wisblock/rak13800/datasheet/) WisBlock Ethernet module (based on the W5100S chip). + +**Hardware required:** +- RAK4631 WisBlock Core +- RAK19007 or RAK19018 WisBlock Base Board (with an available IO slot) +- RAK13800 WisBlock Ethernet module +- Ethernet cable connected to a network with a DHCP server + +**Firmware:** +Flash one of the Ethernet-enabled firmware variants: +- `RAK_4631_repeater_ethernet` - Repeater with Ethernet CLI access +- `RAK_4631_room_server_ethernet` - Room server with Ethernet CLI access +- `RAK_4631_companion_radio_ethernet` - Companion radio over Ethernet (replaces BLE) + +**Connecting:** +- The device obtains an IP address via DHCP automatically on boot. +- For repeaters and room servers, connect to the device on TCP port 23 using any TCP client (e.g. `nc 23` or PuTTY in raw mode). This gives you the same CLI available over serial/USB. +- For companion radio firmware, the Ethernet interface replaces BLE as the transport to companion apps. Connect on TCP port 5000 (same as the WiFi companion radio). +- Use the `eth.status` CLI command to check connection status and see the assigned IP address. + +--- diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index 9a9962248b..3f4dbf9c4b 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -41,7 +41,7 @@ Maximum unescaped frame size: 512 bytes. | Command | Value | Data | Description | |-------------|--------|--------------------|-------------------------------------------------------------| -| Data | `0x00` | Raw packet | Queue packet for transmission | +| Data | `0x00` | Raw packet | Queue packet for transmission (one pending at a time) | | TXDELAY | `0x01` | Delay (1 byte) | Transmitter keyup delay in 10ms units (default: 50 = 500ms) | | Persistence | `0x02` | P (1 byte) | CSMA persistence parameter 0-255 (default: 63) | | SlotTime | `0x03` | Interval (1 byte) | CSMA slot interval in 10ms units (default: 10 = 100ms) | @@ -58,6 +58,12 @@ Maximum unescaped frame size: 512 bytes. Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes. +Only one packet may be pending for radio transmission at a time. If the host sends a second Data frame before the first has completed, the modem responds with Error (0xF1) and TxBusy (0x07). + +### Host Output Backpressure + +Outbound frames are encoded into a 2-slot queue and flushed when serial output space is available; `loop()` never blocks on writes. Radio TX state advances independently of host read speed. TxDone is retained until it can be queued. If the outbound queue is full, the modem responds with Error (0xF1) and TxBusy (0x07). Hosts should read serial promptly to avoid delayed responses. + ### CSMA Behavior The TNC implements p-persistent CSMA for half-duplex operation: @@ -156,15 +162,15 @@ Response codes use the high-bit convention: `response = command | 0x80`. Generic | MacFailed | `0x04` | MAC verification failed | | UnknownCmd | `0x05` | Unknown sub-command | | EncryptFailed | `0x06` | Encryption failed | -| TxBusy | `0x07` | Transmit busy | +| TxBusy | `0x07` | Radio TX busy, or host output queue full | ### Unsolicited Events The TNC sends these SetHardware frames without a preceding request: -**TxDone (0xF8)**: Sent after a packet has been transmitted. Contains a single byte: 0x01 for success, 0x00 for failure. +**TxDone (0xF8)**: Sent after radio transmission completes. Contains a single byte: 0x01 for success, 0x00 for failure. Delivery to the host may be delayed under serial backpressure but is not dropped. -**RxMeta (0xF9)**: Sent immediately after each standard data frame (type 0x00) with metadata for the received packet. Contains SNR (1 byte, signed, value x4 for 0.25 dB precision) followed by RSSI (1 byte, signed, dBm). Enabled by default; can be toggled with SetSignalReport. Standard KISS clients ignore this frame. +**RxMeta (0xF9)**: Sent after each standard data frame (type 0x00) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame. ## Data Formats diff --git a/docs/number_allocations.md b/docs/number_allocations.md index 857525d468..b8cc22a212 100644 --- a/docs/number_allocations.md +++ b/docs/number_allocations.md @@ -17,6 +17,8 @@ Once you have a working app/project, you need to be able to demonstrate it exist | 0000 - 00FF | -reserved for internal use- | | | 0100 | MeshCore Open | zsylvester@monitormx.com — https://github.com/zjs81/meshcore-open | | 0110 - 011F | Ripple | ripple_biz@protonmail.com — https://buymeacoffee.com/ripplebiz | +| 0120 | MCO Advanced | most.original.address@gmail.com — https://hdden.ru/MCOa/ | +| 0130 - 013F | StreamSensor | william@housedillon.com - https://housedillon.com/blog/lora-e5-with-seeed-fusion | | FF00 - FFFF | -reserved for testing/dev- | | (add rows, inside the range 0100 - FEFF for custom apps) diff --git a/docs/payloads.md b/docs/payloads.md index 9d2a8a113b..a2945e27f0 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -23,7 +23,7 @@ NOTE: all 16 and 32-bit integer fields are Little Endian. * Node hash: the first byte of the node's public key -# Node advertisement +## Node advertisement This kind of payload notifies receivers that a node exists, and gives information about the node | Field | Size (bytes) | Description | @@ -57,7 +57,7 @@ Appdata Flags | `0x40` | has feature 2 | Reserved for future use. | | `0x80` | has name | appdata contains a node name | -# Acknowledgement +## Acknowledgement An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the "extra" payload (see [Returned Path](#returned-path)) instead of as a separate acknowledgement packet. CLI commands do not cause acknowledgement responses, neither discrete nor extra. @@ -66,7 +66,7 @@ An acknowledgement that a message was received. Note that for returned path mess | checksum | 4 | CRC checksum of message timestamp, text, and sender pubkey | -# Returned path, request, response, and plain text message +## Returned path, request, response, and plain text message Returned path, request, response, and plain text messages are all formatted in the same way. See the subsection for more details about the ciphertext's associated plaintext representation. @@ -77,7 +77,7 @@ Returned path, request, response, and plain text messages are all formatted in t | cipher MAC | 2 | MAC for encrypted data in next field | | ciphertext | rest of payload | encrypted message, see subsections below for details | -## Returned path +### Returned path Returned path messages provide a description of the route a packet took from the original author. Receivers will send returned path messages to the author of the original message. @@ -88,7 +88,7 @@ Returned path messages provide a description of the route a packet took from the | extra type | 1 | extra, bundled payload type, eg., acknowledgement or response. Same values as in [Packet Format](./packet_format.md) | | extra | rest of data | extra, bundled payload content, follows same format as main content defined by this document | -## Request +### Request | Field | Size (bytes) | Description | |--------------|-----------------|------------------------------------------| @@ -102,7 +102,7 @@ For the common chat/server helpers in `BaseChatMesh`, the current request type v | `0x01` | get stats | get stats of repeater or room server | | `0x02` | keepalive | keep-alive request used for maintained connections | -### Get stats +#### Get stats Gets information about the node, possibly including the following: @@ -125,32 +125,32 @@ Gets information about the node, possibly including the following: * Number posted (?) * Number of post pushes (?) -### Get telemetry data +#### Get telemetry data Not defined in `BaseChatMesh`. Sensor- and application-specific request payloads may be implemented by higher-level firmware. -### Get Telemetry +#### Get Telemetry Not defined in `BaseChatMesh`. -### Get Min/Max/Ave (Sensor nodes) +#### Get Min/Max/Ave (Sensor nodes) Not defined in `BaseChatMesh`. -### Get Access List +#### Get Access List Not defined in `BaseChatMesh`. -### Get Neighbors +#### Get Neighbors Not defined in `BaseChatMesh`. -### Get Owner Info +#### Get Owner Info Not defined in `BaseChatMesh`. -## Response +### Response | Field | Size (bytes) | Description | |---------|-----------------|-----------------------------------| @@ -158,7 +158,7 @@ Not defined in `BaseChatMesh`. Response contents are opaque application data. There is no single generic response envelope beyond the encrypted payload wrapper shown above. -## Plain text message +### Plain text message | Field | Size (bytes) | Description | |--------------------|-----------------|-----------------------------------------------------------------------------------| @@ -174,7 +174,7 @@ txt_type | `0x01` | CLI command | the command text of the message | | `0x02` | signed plain text message | first four bytes is sender pubkey prefix, followed by plain text message | -# Anonymous request +## Anonymous request | Field | Size (bytes) | Description | |------------------|-----------------|-------------------------------------------| @@ -183,7 +183,7 @@ txt_type | cipher MAC | 2 | MAC for encrypted data in next field | | ciphertext | rest of payload | encrypted message, see below for details | -## Room server login +### Room server login | Field | Size (bytes) | Description | |----------------|-----------------|-------------------------------------------------------------------------------| @@ -191,14 +191,14 @@ txt_type | sync timestamp | 4 | sender's "sync messages SINCE x" timestamp | | password | rest of message | password for room | -## Repeater/Sensor login +### Repeater/Sensor login | Field | Size (bytes) | Description | |----------------|-----------------|-------------------------------------------------------------------------------| | timestamp | 4 | sender time (unix timestamp) | | password | rest of message | password for repeater/sensor | -## Repeater - Regions request +### Repeater - Regions request | Field | Size (bytes) | Description | |----------------|--------------|------------------------------| @@ -207,7 +207,7 @@ txt_type | reply path len | 1 | path len for reply | | reply path | (variable) | reply path | -## Repeater - Owner info request +### Repeater - Owner info request | Field | Size (bytes) | Description | |----------------|--------------|------------------------------| @@ -216,7 +216,7 @@ txt_type | reply path len | 1 | path len for reply | | reply path | (variable) | reply path | -## Repeater - Clock and status request +### Repeater - Clock and status request | Field | Size (bytes) | Description | |----------------|--------------|------------------------------| @@ -226,7 +226,7 @@ txt_type | reply path | (variable) | reply path | -# Group text message +## Group text message | Field | Size (bytes) | Description | |--------------|-----------------|----------------------------------------------| @@ -239,7 +239,7 @@ The plaintext contained in the ciphertext matches the format described in [plain The sender name is unverified message text. Group messages contain no sender signature, so any channel-key holder can choose any sender name. -# Group datagram +## Group datagram | Field | Size (bytes) | Description | |--------------|-----------------|----------------------------------------------| @@ -256,14 +256,14 @@ The data contained in the ciphertext uses the format below: | data | rest of payload | (depends on data type) | -# Control data +## Control data | Field | Size (bytes) | Description | |--------------|-----------------|--------------------------------------------| | flags | 1 | upper 4 bits is sub_type | | data | rest of payload | typically unencrypted data | -## DISCOVER_REQ (sub_type) +### DISCOVER_REQ (sub_type) | Field | Size (bytes) | Description | |--------------|-----------------|----------------------------------------------| @@ -272,7 +272,7 @@ The data contained in the ciphertext uses the format below: | tag | 4 | randomly generate by sender | | since | 4 | (optional) epoch timestamp (0 by default) | -## DISCOVER_RESP (sub_type) +### DISCOVER_RESP (sub_type) | Field | Size (bytes) | Description | |--------------|-----------------|--------------------------------------------| @@ -282,6 +282,6 @@ The data contained in the ciphertext uses the format below: | pubkey | 8 or 32 | node's ID (or prefix) | -# Custom packet +## Custom packet Custom packets have no defined format. diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 0eee45aef3..b25b1442fc 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -4,7 +4,7 @@ #include #include #include -#include +#include #include #ifdef PIN_BUZZER @@ -25,10 +25,10 @@ enum class UIEventType { class AbstractUITask { protected: mesh::MainBoard* _board; - BaseSerialInterface* _serial; + MultiSerialInterface* _interfaceManager; bool _connected; - AbstractUITask(mesh::MainBoard* board, BaseSerialInterface* serial) : _board(board), _serial(serial) { + AbstractUITask(mesh::MainBoard* board, MultiSerialInterface* interfaceManager) : _board(board), _interfaceManager(interfaceManager) { _connected = false; } @@ -36,9 +36,9 @@ class AbstractUITask { void setHasConnection(bool connected) { _connected = connected; } bool hasConnection() const { return _connected; } uint16_t getBattMilliVolts() const { return _board->getBattMilliVolts(); } - bool isSerialEnabled() const { return _serial->isEnabled(); } - void enableSerial() { _serial->enable(); } - void disableSerial() { _serial->disable(); } + bool isBluetoothEnabled() const { return _interfaceManager->isBluetoothEnabled(); } + void enableBluetooth() { _interfaceManager->enableBluetooth(); } + void disableBluetooth() { _interfaceManager->disableBluetooth(); } virtual void msgRead(int msgcount) = 0; virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index bf2f36c3d9..06c56a7a44 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -189,17 +189,22 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) { return identity_store.save("_main", identity); } -void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) { - if (_fs->exists("/new_prefs")) { - loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename - } else if (_fs->exists("/node_prefs")) { - loadPrefsInt("/node_prefs", prefs, node_lat, node_lon); - savePrefs(prefs, node_lat, node_lon); // save to new filename - _fs->remove("/node_prefs"); // remove old +void DataStore::loadPrefs(NodePrefs& prefs) { + if (_fs->exists("/prefs.json")) { + File file = openRead(_fs, "/prefs.json"); + if (file) { + prefs.loadSerial(file); // new Serial prefs + file.close(); + } + } else if (_fs->exists("/new_prefs")) { + loadPrefsInt("/new_prefs", prefs); + if (savePrefs(prefs) ) { // save to new format + //_fs->remove("/new_prefs"); // remove old + } } } -void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) { +void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs) { File file = openRead(_fs, filename); if (file) { uint8_t pad[8]; @@ -207,12 +212,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0 file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4 file.read(pad, 4); // 36 - file.read((uint8_t *)&node_lat, sizeof(node_lat)); // 40 - file.read((uint8_t *)&node_lon, sizeof(node_lon)); // 48 + file.read((uint8_t *)&_prefs.node_lat, sizeof(_prefs.node_lat)); // 40 + file.read((uint8_t *)&_prefs.node_lon, sizeof(_prefs.node_lon)); // 48 file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56 file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60 file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61 - file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62 + file.read((uint8_t *)&_prefs._client_repeat, sizeof(_prefs._client_repeat)); // 62 file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63 file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64 file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68 @@ -234,48 +239,21 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + // migrate old fields + _prefs.setRepeatEn(_prefs._client_repeat != 0); + file.close(); } } -void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { - File file = openWrite(_fs, "/new_prefs"); +bool DataStore::savePrefs(NodePrefs& _prefs) { + File file = openWrite(_fs, "/prefs.json"); if (file) { - uint8_t pad[8]; - memset(pad, 0, sizeof(pad)); - - file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0 - file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4 - file.write(pad, 4); // 36 - file.write((uint8_t *)&node_lat, sizeof(node_lat)); // 40 - file.write((uint8_t *)&node_lon, sizeof(node_lon)); // 48 - file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56 - file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60 - file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61 - file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62 - file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63 - file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64 - file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68 - file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69 - file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70 - file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71 - file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72 - file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76 - file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77 - file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78 - file.write(pad, 1); // 79 - file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 - file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 - file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87 - file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88 - file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 - file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 - file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 - + bool success = _prefs.saveSerial(file); file.close(); + return success; } + return false; } void DataStore::loadContacts(DataStoreHost* host) { @@ -545,7 +523,7 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src uint32_t pos = 0, found_pos = 0; uint32_t min_timestamp = 0xFFFFFFFF; - // search for matching key OR evict by oldest timestmap + // search for matching key OR evict by oldest timestamp BlobRec tmp; file.seek(0); while (file.read((uint8_t *) &tmp, sizeof(tmp)) == sizeof(tmp)) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index af5ee7af86..e0a145caf1 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -19,7 +19,7 @@ class DataStore { mesh::RTCClock* _clock; IdentityStore identity_store; - void loadPrefsInt(const char *filename, NodePrefs& prefs, double& node_lat, double& node_lon); + void loadPrefsInt(const char *filename, NodePrefs& prefs); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) void checkAdvBlobFile(); #endif @@ -33,8 +33,8 @@ class DataStore { FILESYSTEM* getSecondaryFS() const { return _fsExtra; } bool loadMainIdentity(mesh::LocalIdentity &identity); bool saveMainIdentity(const mesh::LocalIdentity &identity); - void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); - void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); + void loadPrefs(NodePrefs& prefs); + bool savePrefs(NodePrefs& prefs); void loadContacts(DataStoreHost* host); void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); void loadChannels(DataStoreHost* host); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6fbb0f7428..b03334c1c9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -261,6 +261,9 @@ float MyMesh::getAirtimeBudgetFactor() const { int MyMesh::getInterferenceThreshold() const { return 0; // disabled for now, until currentRSSI() problem is resolved } +bool MyMesh::getCADEnabled() const { + return false; // hardware CAD before TX (disabled by default, until configurable) +} int MyMesh::calcRxDelay(float score, uint32_t air_time) const { if (_prefs.rx_delay_base <= 0.0f) return 0; @@ -480,7 +483,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { } bool MyMesh::allowPacketForward(const mesh::Packet* packet) { - return _prefs.client_repeat != 0; + return _prefs.isRepeatEn(); } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) { @@ -654,6 +657,11 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim // query other sensors -- target specific sensors.querySensors(permissions, telemetry); + float temperature = board.getMCUTemperature(); + if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN + telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature + } + memcpy(reply, &sender_timestamp, 4); // reflect sender_timestamp back in response packet (kind of like a 'tag') @@ -854,7 +862,7 @@ void MyMesh::onSendTimeout() {} MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui) : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables), - _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui) { + _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) { _iter_started = false; _cli_rescue = false; offline_queue_len = 0; @@ -868,7 +876,6 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe send_unscoped = false; // defaults - memset(&_prefs, 0, sizeof(_prefs)); _prefs.airtime_factor = 1.0; strcpy(_prefs.node_name, "NONAME"); _prefs.freq = LORA_FREQ; @@ -879,6 +886,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default //_prefs.rx_delay_base = 10.0f; enable once new algo fixed + _prefs.setRepeatEn(false); #if defined(USE_SX1262) || defined(USE_SX1268) #ifdef SX126X_RX_BOOSTED_GAIN _prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN; @@ -923,7 +931,9 @@ void MyMesh::begin(bool has_display) { #endif // load persisted prefs - _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); + _store->loadPrefs(_prefs); + sensors.node_lat = _prefs.node_lat; + sensors.node_lon = _prefs.node_lon; // sanitise bad pref values _prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f); @@ -1023,7 +1033,7 @@ void MyMesh::handleCmdFrame(size_t len) { i += 40; StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20); i += 20; - out_frame[i++] = _prefs.client_repeat; // v9+ + out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+ out_frame[i++] = _prefs.path_hash_mode; // v10+ _serial->writeFrame(out_frame, i); } else if (cmd_frame[0] == CMD_APP_START && @@ -1385,7 +1395,7 @@ void MyMesh::handleCmdFrame(size_t len) { _prefs.cr = cr; _prefs.freq = (float)freq / 1000.0; _prefs.bw = (float)bw / 1000.0; - _prefs.client_repeat = repeat; + _prefs.setRepeatEn(repeat != 0); savePrefs(); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); @@ -1542,6 +1552,7 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE); anon.out_path_len = 0; // default to zero-hop direct anon.type = ADV_TYPE_NONE; // unknown + anon.lastmod = getRTCClock()->getCurrentTime(); if (addContact(anon)) recipient = &anon; } @@ -1636,6 +1647,11 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len == 4) { // 'self' telemetry request telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + float temperature = board.getMCUTemperature(); + if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN + telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature + } + // query other sensors -- target specific sensors.querySensors(0xFF, telemetry); @@ -1981,6 +1997,7 @@ void MyMesh::handleCmdFrame(size_t len) { sendPacket(pkt, priority, 0); writeOKFrame(); } else { + releasePacket(pkt); writeErrFrame(ERR_CODE_ILLEGAL_ARG); } } else { @@ -2186,15 +2203,7 @@ void MyMesh::checkSerialInterface() { && !_serial->isWriteBusy() // don't spam the Serial Interface too quickly! ) { ContactInfo contact; - bool found = false; - while (_iter.hasNext(this, contact)) { - if (contact.type != ADV_TYPE_NONE) { - found = true; - break; - } - } - - if (found) { + if (_iter.hasNext(this, contact)) { if (contact.lastmod > _iter_filter_since) { // apply the 'since' filter writeContactRespFrame(RESP_CODE_CONTACT, contact); if (contact.lastmod > _most_recent_lastmod) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 43d3950beb..f73fe0e063 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 13 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "6 Jun 2026" +#define FIRMWARE_BUILD_DATE "9 Aug 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.16.0" +#define FIRMWARE_VERSION "v1.17.0" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -105,6 +105,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; + bool getCADEnabled() const override; int calcRxDelay(float score, uint32_t air_time) const override; uint32_t getRetransmitDelay(const mesh::Packet *packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; @@ -164,7 +165,11 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { } public: - void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } + void savePrefs() { + _prefs.node_lat = sensors.node_lat; + _prefs.node_lon = sensors.node_lon; + _store->savePrefs(_prefs); + } #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 48c381ceaf..4ec1803e1c 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -1,5 +1,6 @@ #pragma once #include // For uint8_t, uint32_t +#include #define TELEM_MODE_DENY 0 #define TELEM_MODE_ALLOW_FLAGS 1 // use contact.flags @@ -8,30 +9,130 @@ #define ADVERT_LOC_NONE 0 #define ADVERT_LOC_SHARE 1 -struct NodePrefs { // persisted to file - float airtime_factor; +class NodePrefs : public ConfigSerializer { // persisted to file +public: + float airtime_factor = 0; 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 - 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 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) + double node_lat = 0, node_lon = 0; + 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 vibe_quiet = 0; + uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) + uint32_t gps_interval = 0; // GPS read interval in seconds + uint8_t autoadd_config = 0; // bitmask for auto-add contacts config + uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) + uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd + uint8_t path_hash_mode = 0; // which path mode to use when sending + uint8_t autoadd_max_hops = 0; // 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]; + +private: + class RadioPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) + NodePrefs* _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("int_thr", _parent->interference_threshold); + def("rxgain", _parent->rx_boosted_gain); + def("fem_rxgain", _parent->rx_boosted_gain); + def("tx", _parent->tx_power_dbm); + def("af", _parent->airtime_factor); + def("rxdelay", _parent->rx_delay_base); + //def("f_txdelay", _parent->tx_delay_factor); currently hard-coded + //def("d_txdelay", _parent->direct_tx_delay_factor); currently hard-coded + //def("agc_int", _parent->agc_reset_interval); + def("hash_mode", _parent->path_hash_mode); + def("multi_ack", _parent->multi_acks); + } + public: + RadioPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RadioPrefs radio; + + class GPSPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) + NodePrefs* _parent; + protected: + void structure() override { + def("en", _parent->gps_enabled); // boolean + def("int", _parent->gps_interval); // interval in seconds + def("adv_loc", _parent->advert_loc_policy); + } + public: + GPSPrefs(NodePrefs* parent) : _parent(parent) { } + }; + GPSPrefs gps; + + class RepeatPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) + public: + uint8_t disable_fwd = 1; + protected: + void structure() override { + def("disable", disable_fwd); + //def("f_max", flood_max); + //def("f_max_uns", flood_max_unscoped); + //def("f_max_adv", flood_max_advert); + //def("loop", loop_detect); + } + }; + RepeatPrefs repeat; + + class CompanionPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("auto_max", _parent->autoadd_max_hops); // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) + def("defs_nm", _parent->default_scope_name, sizeof(_parent->default_scope_name)); + def("defs_key", (void *) _parent->default_scope_key, sizeof(_parent->default_scope_key)); + def("pin", _parent->ble_pin); + def("buzz_q", _parent->buzzer_quiet); + def("vibe_q", _parent->vibe_quiet); + def("auto_add", _parent->autoadd_config); // bitmask for auto-add contacts config + def("man_add", _parent->manual_add_contacts); + def("tel_base", _parent->telemetry_mode_base); + def("tel_loc", _parent->telemetry_mode_loc); + def("tel_env", _parent->telemetry_mode_env); + } + public: + CompanionPrefs(NodePrefs* parent) : _parent(parent) { } + }; + CompanionPrefs companion; + +protected: + void structure() override { + def("name", node_name, sizeof(node_name)); + //def("adv_int", advert_interval); + //def("f_adv_int", flood_advert_interval); + def("lat", node_lat); + def("lon", node_lon); + def("radio", radio); + def("gps", gps); + def("repeat", repeat); + def("comp", companion); + } +public: + NodePrefs() : radio(this), gps(this), companion(this) { + node_name[0] = 0; + default_scope_name[0] = 0; + memset(default_scope_key, 0, sizeof(default_scope_key)); + } + // new accessor methods + bool isRepeatEn() const { return repeat.disable_fwd == 0; } + void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; } }; \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index ef9b6bfca4..89f0e6cb9f 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -12,19 +12,72 @@ static uint32_t _atoi(const char* sp) { return n; } +// interface manager +#include +MultiSerialInterface interface_manager; + +// include bluetooth interface +#if defined(BLE_PIN_CODE) + #ifdef ESP32 + // include esp32 bluetooth interface + #include + SerialBLEInterface bluetooth_interface; + #elif defined(NRF52_PLATFORM) + // include nrf52 bluetooth interface + #include + SerialBLEInterface bluetooth_interface; + #else + #error "SerialBLEInterface is not defined for this platform" + #endif +#endif + +// include wifi interface +#ifdef WIFI_SSID + #ifndef TCP_PORT + #define TCP_PORT 5000 + #endif + #ifdef ESP32 + // include esp32 wifi interface + #include + SerialWifiInterface wifi_interface; + #else + #error "SerialWifiInterface is not defined for this platform" + #endif +#endif + +// include usb interface +#if defined(ENABLE_USB_INTERFACE) + #include + ArduinoSerialInterface usb_serial_interface; +#endif + +// include ethernet interface +#if defined(ETHERNET_ENABLED) + #include + ETHERNET_CLASS ethernet_interface; +#endif + +// include hardware serial interface +#if defined(SERIAL_RX) + #include + ArduinoSerialInterface hardware_serial_interface; + HardwareSerial companion_serial(1); +#endif + +// platform file system #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include #if defined(QSPIFLASH) #include DataStore store(InternalFS, QSPIFlash, rtc_clock); #else - #if defined(EXTRAFS) - #include - CustomLFS ExtraFS(0xD4000, 0x19000, 128); - DataStore store(InternalFS, ExtraFS, rtc_clock); - #else - DataStore store(InternalFS, rtc_clock); - #endif + #if defined(EXTRAFS) + #include + CustomLFS ExtraFS(0xD4000, 0x19000, 128); + DataStore store(InternalFS, ExtraFS, rtc_clock); + #else + DataStore store(InternalFS, rtc_clock); + #endif #endif #elif defined(RP2040_PLATFORM) #include @@ -34,61 +87,10 @@ static uint32_t _atoi(const char* sp) { DataStore store(SPIFFS, rtc_clock); #endif -#ifdef ESP32 - #ifdef WIFI_SSID - #include - SerialWifiInterface serial_interface; - #ifndef TCP_PORT - #define TCP_PORT 5000 - #endif - #elif defined(BLE_PIN_CODE) - #include - SerialBLEInterface serial_interface; - #elif defined(SERIAL_RX) - #include - ArduinoSerialInterface serial_interface; - HardwareSerial companion_serial(1); - #else - #include - ArduinoSerialInterface serial_interface; - #endif -#elif defined(RP2040_PLATFORM) - //#ifdef WIFI_SSID - // #include - // SerialWifiInterface serial_interface; - // #ifndef TCP_PORT - // #define TCP_PORT 5000 - // #endif - // #elif defined(BLE_PIN_CODE) - // #include - // SerialBLEInterface serial_interface; - #if defined(SERIAL_RX) - #include - ArduinoSerialInterface serial_interface; - HardwareSerial companion_serial(1); - #else - #include - ArduinoSerialInterface serial_interface; - #endif -#elif defined(NRF52_PLATFORM) - #ifdef BLE_PIN_CODE - #include - SerialBLEInterface serial_interface; - #else - #include - ArduinoSerialInterface serial_interface; - #endif -#elif defined(STM32_PLATFORM) - #include - ArduinoSerialInterface serial_interface; -#else - #error "need to define a serial interface" -#endif - /* GLOBAL OBJECTS */ #ifdef DISPLAY_CLASS #include "UITask.h" - UITask ui_task(&board, &serial_interface); + UITask ui_task(&board, &interface_manager); #endif StdRNG fast_rng; @@ -113,9 +115,12 @@ void halt() { void setup() { Serial.begin(115200); - board.begin(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.begin(); +#endif + #ifdef DISPLAY_CLASS DisplayDriver* disp = NULL; if (display.begin()) { @@ -155,13 +160,6 @@ void setup() { false #endif ); - -#ifdef BLE_PIN_CODE - serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); -#else - serial_interface.begin(Serial); -#endif - the_mesh.startInterface(serial_interface); #elif defined(RP2040_PLATFORM) LittleFS.begin(); store.begin(); @@ -172,22 +170,6 @@ void setup() { false #endif ); - - //#ifdef WIFI_SSID - // WiFi.begin(WIFI_SSID, WIFI_PWD); - // serial_interface.begin(TCP_PORT); - // #elif defined(BLE_PIN_CODE) - // char dev_name[32+16]; - // sprintf(dev_name, "%s%s", BLE_NAME_PREFIX, the_mesh.getNodeName()); - // serial_interface.begin(dev_name, the_mesh.getBLEPin()); - #if defined(SERIAL_RX) - companion_serial.setPins(SERIAL_RX, SERIAL_TX); - companion_serial.begin(115200); - serial_interface.begin(companion_serial); - #else - serial_interface.begin(Serial); - #endif - the_mesh.startInterface(serial_interface); #elif defined(ESP32) SPIFFS.begin(true); store.begin(); @@ -198,7 +180,17 @@ void setup() { false #endif ); +#else + #error "need to define filesystem" +#endif + +// add bluetooth interface +#if defined(BLE_PIN_CODE) + bluetooth_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); + interface_manager.addInterface(InterfaceType::Bluetooth, &bluetooth_interface); +#endif +// add wifi interface #ifdef WIFI_SSID board.setInhibitSleep(true); // prevent sleep when WiFi is active WiFi.setAutoReconnect(true); @@ -214,21 +206,31 @@ void setup() { }); WiFi.begin(WIFI_SSID, WIFI_PWD); - serial_interface.begin(TCP_PORT); -#elif defined(BLE_PIN_CODE) - serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); -#elif defined(SERIAL_RX) + wifi_interface.begin(TCP_PORT); + interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); +#endif + +// add usb interface +#if defined(ENABLE_USB_INTERFACE) + usb_serial_interface.begin(Serial); + interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface); +#endif + +// add ethernet interface +#if defined(ETHERNET_ENABLED) + ethernet_interface.begin(); + interface_manager.addInterface(InterfaceType::Ethernet, ðernet_interface); +#endif + +// add hardware serial interface +#if defined(SERIAL_RX) companion_serial.setPins(SERIAL_RX, SERIAL_TX); companion_serial.begin(115200); - serial_interface.begin(companion_serial); -#else - serial_interface.begin(Serial); -#endif - the_mesh.startInterface(serial_interface); -#else - #error "need to define filesystem" + hardware_serial_interface.begin(companion_serial); + interface_manager.addInterface(InterfaceType::HardwareSerial, &hardware_serial_interface); #endif + the_mesh.startInterface(interface_manager); sensors.begin(); #if ENV_INCLUDE_GPS == 1 @@ -244,11 +246,15 @@ void setup() { void loop() { the_mesh.loop(); + interface_manager.loop(); sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); #endif rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif if (!the_mesh.hasPendingWork()) { #if defined(NRF52_PLATFORM) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7c84201941..051f3b31ea 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -53,23 +53,24 @@ class SplashScreen : public UIScreen { int render(DisplayDriver& display) override { // meshcore logo - display.setColor(DisplayDriver::BLUE); + display.setColor(UIColor::corp_blue); int logoWidth = 128; display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // meshcore website const char* website = "https://meshcore.io"; - display.setColor(DisplayDriver::LIGHT); + display.setColor(UIColor::primary_txt); display.setTextSize(1); uint16_t websiteWidth = display.getTextWidth(website); display.setCursor((display.width() - websiteWidth) / 2, 22); display.print(website); // version info - display.setColor(DisplayDriver::LIGHT); + display.setColor(UIColor::primary_txt); display.setTextSize(1); display.drawTextCentered(display.width()/2, 35, _version_info); + display.setColor(UIColor::secondary_txt); display.setTextSize(1); display.drawTextCentered(display.width()/2, 48, FIRMWARE_BUILD_DATE); @@ -128,7 +129,7 @@ class HomeScreen : public UIScreen { int iconHeight = 10; int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner int iconY = 0; - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::title_txt); // battery outline display.drawRect(iconX, iconY, iconWidth, iconHeight); @@ -143,7 +144,7 @@ class HomeScreen : public UIScreen { // show muted icon if buzzer is muted #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { - display.setColor(DisplayDriver::RED); + display.setColor(UIColor::warning_txt); display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8); } #endif @@ -188,34 +189,41 @@ class HomeScreen : public UIScreen { } int render(DisplayDriver& display) override { + display.setColor(UIColor::title_bkg); + display.fillRect(0, 0, display.width(), 12); char tmp[80]; // node name display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::title_txt); char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - display.setCursor(0, 0); + display.setCursor(0, 2); display.print(filtered_name); // battery voltage renderBatteryIndicator(display, _task->getBattMilliVolts()); // curr page indicator + if (UIColor::title_bkg == UIColor::window_bkg) { + display.setColor(UIColor::title_txt); + } else { + display.setColor(UIColor::title_bkg); + } int y = 14; int x = display.width() / 2 - 5 * (HomePage::Count-1); for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { if (i == _page) { - display.fillRect(x-1, y-1, 3, 3); + display.fillRect(x-1, y-1, 4, 4); } else { - display.fillRect(x, y, 1, 1); + display.fillRect(x, y, 2, 2); } } if (_page == HomePage::FIRST) { - display.setColor(DisplayDriver::YELLOW); + display.setColor(UIColor::primary_txt); display.setTextSize(2); sprintf(tmp, "MSG: %d", _task->getMsgCount()); - display.drawTextCentered(display.width() / 2, 20, tmp); + display.drawTextCentered(display.width() / 2, 22, tmp); #ifdef WIFI_SSID IPAddress ip = WiFi.localIP(); @@ -224,19 +232,19 @@ class HomeScreen : public UIScreen { display.drawTextCentered(display.width() / 2, 54, tmp); #endif if (_task->hasConnection()) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::warning_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 43, "< Connected >"); } else if (the_mesh.getBLEPin() != 0) { // BT pin - display.setColor(DisplayDriver::RED); + display.setColor(UIColor::warning_txt); display.setTextSize(2); sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); display.drawTextCentered(display.width() / 2, 43, tmp); } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::primary_txt); int y = 20; for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { auto a = &recent[i]; @@ -260,7 +268,7 @@ class HomeScreen : public UIScreen { display.print(tmp); } } else if (_page == HomePage::RADIO) { - display.setColor(DisplayDriver::YELLOW); + display.setColor(UIColor::primary_txt); display.setTextSize(1); // freq / sf display.setCursor(0, 20); @@ -279,15 +287,17 @@ class HomeScreen : public UIScreen { sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); display.print(tmp); } else if (_page == HomePage::BLUETOOTH) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, - _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, + _task->isBluetoothEnabled() ? bluetooth_on : bluetooth_off, 32, 32); + display.setColor(UIColor::secondary_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); + display.setColor(UIColor::secondary_txt); display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { @@ -305,24 +315,33 @@ class HomeScreen : public UIScreen { #else strcpy(buf, gps_state ? "gps on" : "gps off"); #endif + display.setColor(UIColor::primary_txt); display.drawTextLeftAlign(0, y, buf); if (nmea == NULL) { y = y + 12; + display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "Can't access GPS"); } else { + display.setColor(UIColor::primary_txt); strcpy(buf, nmea->isValid()?"fix":"no fix"); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; + display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "sat"); + display.setColor(UIColor::primary_txt); sprintf(buf, "%d", nmea->satellitesCount()); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; + display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "pos"); + display.setColor(UIColor::primary_txt); sprintf(buf, "%.4f %.4f", nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; + display.setColor(UIColor::secondary_txt); display.drawTextLeftAlign(0, y, "alt"); + display.setColor(UIColor::primary_txt); sprintf(buf, "%.2f", nmea->getAltitude()/1000.); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 12; @@ -390,7 +409,9 @@ class HomeScreen : public UIScreen { strcpy(name, "unk"); sprintf(buf, ""); } display.setCursor(0, y); + display.setColor(UIColor::secondary_txt); display.print(name); + display.setColor(UIColor::primary_txt); display.setCursor( display.width()-display.getTextWidth(buf)-1, y ); @@ -401,11 +422,13 @@ class HomeScreen : public UIScreen { else sensors_scroll_offset = 0; #endif } else if (_page == HomePage::SHUTDOWN) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::corp_blue); display.setTextSize(1); if (_shutdown_init) { + display.setColor(UIColor::warning_txt); display.drawTextCentered(display.width() / 2, 34, "hibernating..."); } else { + display.setColor(UIColor::secondary_txt); display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); } @@ -426,10 +449,10 @@ class HomeScreen : public UIScreen { return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { - if (_task->isSerialEnabled()) { // toggle Bluetooth on/off - _task->disableSerial(); + if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off + _task->disableBluetooth(); } else { - _task->enableSerial(); + _task->enableBluetooth(); } return true; } @@ -498,7 +521,7 @@ class MsgPreviewScreen : public UIScreen { char tmp[16]; display.setCursor(0, 0); display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::corp_blue); sprintf(tmp, "Unread: %d", num_unread); display.print(tmp); @@ -518,13 +541,13 @@ class MsgPreviewScreen : public UIScreen { display.drawRect(0, 11, display.width(), 1); // horiz line display.setCursor(0, 14); - display.setColor(DisplayDriver::YELLOW); + display.setColor(UIColor::secondary_txt); char filtered_origin[sizeof(p->origin)]; display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); display.print(filtered_origin); display.setCursor(0, 25); - display.setColor(DisplayDriver::LIGHT); + display.setColor(UIColor::primary_txt); char filtered_msg[sizeof(p->msg)]; display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); display.printWordWrap(filtered_msg, display.width()); @@ -697,8 +720,7 @@ void UITask::shutdown(bool restart){ if (restart) { _board->reboot(); } else { - _display->turnOff(); - radio_driver.powerOff(); + // Power off board including radio, display, GPS and components _board->powerOff(); } } @@ -748,6 +770,16 @@ void UITask::loop() { c = handleTripleClick(KEY_SELECT); } #endif +#if defined(UI_HAS_ROTARY_INPUT) + RotaryInputEvent rotaryEv = rotary_input.poll(); + if (c == 0 && _display != NULL && _display->isOn()) { + if (rotaryEv == RotaryInputEvent::Next) { + c = KEY_NEXT; + } else if (rotaryEv == RotaryInputEvent::Prev) { + c = KEY_PREV; + } + } +#endif #if defined(PIN_USER_BTN_ANA) if (abs(millis() - _analogue_pin_read_millis) > 10) { int ev = analog_btn.check(); @@ -797,9 +829,9 @@ void UITask::loop() { _display->setTextSize(1); int y = _display->height() / 3; int p = _display->height() / 32; - _display->setColor(DisplayDriver::DARK); + _display->setColor(UIColor::popup_bkg); _display->fillRect(p, y, _display->width() - p*2, y); - _display->setColor(DisplayDriver::LIGHT); // draw box border + _display->setColor(UIColor::popup_txt); // draw box border _display->drawRect(p, y, _display->width() - p*2, y); _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); _next_refresh = _alert_expiry; // will need refresh when alert is dismissed @@ -836,7 +868,7 @@ void UITask::loop() { if (_display != NULL) { _display->startFrame(); _display->setTextSize(2); - _display->setColor(DisplayDriver::RED); + _display->setColor(UIColor::warning_txt); _display->drawTextCentered(_display->width() / 2, 20, "Low Battery."); _display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!"); _display->endFrame(); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a77ad6e7ec..52d3ffa13c 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -4,7 +4,7 @@ #include #include #include -#include +#include #include #include @@ -65,7 +65,7 @@ class UITask : public AbstractUITask { public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { + UITask(mesh::MainBoard* board, MultiSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { next_batt_chck = _next_refresh = 0; ui_started_at = 0; curr = NULL; diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 5529046775..b49b05dd19 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -6,12 +6,19 @@ #define AUTO_OFF_MILLIS 15000 // 15 seconds #define BOOT_SCREEN_MILLIS 3000 // 3 seconds -#ifdef PIN_STATUS_LED +#if defined(PIN_STATUS_LED) || defined(PIN_STATUS_LED_R) #define LED_ON_MILLIS 20 #define LED_ON_MSG_MILLIS 200 #define LED_CYCLE_MILLIS 4000 #endif +#if defined(PIN_STATUS_LED_R) && defined(PIN_STATUS_LED_G) && defined(PIN_STATUS_LED_B) +#define STATUS_LED_RGB 1 +#ifndef LOW_BATT_MILLIVOLTS +#define LOW_BATT_MILLIVOLTS 3500 +#endif +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif @@ -60,6 +67,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no buzzer.startup(); #endif +#ifdef HAS_DRV2605 + vibration.begin(); + vibration.quiet(_node_prefs->vibe_quiet); +#endif + // Initialize digital button if available #ifdef PIN_USER_BTN _userButton = new Button(PIN_USER_BTN, USER_BTN_PRESSED); @@ -130,6 +142,10 @@ void UITask::clearMsgPreview() { void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { _msgcount = msgcount; +#ifdef HAS_DRV2605 + vibration.trigger(); // vibrate even while the app is connected (honors quiet + cooldown) +#endif + if (path_len == 0xFF) { sprintf(_origin, "(F) %s", from_name); } else { @@ -167,7 +183,7 @@ void UITask::renderBatteryIndicator(uint16_t batteryMilliVolts) { int iconHeight = 12; int iconX = _display->width() - iconWidth - 5; // Position the icon near the top-right corner int iconY = 0; - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::primary_txt); // battery outline _display->drawRect(iconX, iconY, iconWidth, iconHeight); @@ -188,7 +204,7 @@ void UITask::renderCurrScreen() { _display->setTextSize(1.4); uint16_t textWidth = _display->getTextWidth(_alert); _display->setCursor((_display->width() - textWidth) / 2, 22); - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::warning_txt); _display->print(_alert); _alert[0] = 0; _need_refresh = true; @@ -197,30 +213,29 @@ void UITask::renderCurrScreen() { // render message preview _display->setCursor(0, 0); _display->setTextSize(1); - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::primary_txt); _display->print(_node_prefs->node_name); _display->setCursor(0, 12); - _display->setColor(DisplayDriver::YELLOW); + _display->setColor(UIColor::secondary_txt); _display->print(_origin); _display->setCursor(0, 24); - _display->setColor(DisplayDriver::LIGHT); _display->print(_msg); _display->setCursor(_display->width() - 28, 9); _display->setTextSize(2); - _display->setColor(DisplayDriver::ORANGE); + _display->setColor(UIColor::primary_txt); sprintf(tmp, "%d", _msgcount); _display->print(tmp); - _display->setColor(DisplayDriver::YELLOW); // last color will be kept on T114 + _display->setColor(UIColor::secondary_txt); // last color will be kept on T114 } else if ((millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo - _display->setColor(DisplayDriver::BLUE); + _display->setColor(UIColor::corp_blue); int logoWidth = 128; _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // version info - _display->setColor(DisplayDriver::LIGHT); + _display->setColor(UIColor::primary_txt); _display->setTextSize(1); uint16_t textWidth = _display->getTextWidth(_version_info); _display->setCursor((_display->width() - textWidth) / 2, 22); @@ -229,7 +244,7 @@ void UITask::renderCurrScreen() { // node name _display->setCursor(0, 0); _display->setTextSize(1); - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::primary_txt); _display->print(_node_prefs->node_name); // battery voltage @@ -237,7 +252,7 @@ void UITask::renderCurrScreen() { // freq / sf _display->setCursor(0, 20); - _display->setColor(DisplayDriver::YELLOW); + _display->setColor(UIColor::secondary_txt); sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); _display->print(tmp); @@ -248,21 +263,127 @@ void UITask::renderCurrScreen() { // BT pin if (!_connected && the_mesh.getBLEPin() != 0) { - _display->setColor(DisplayDriver::RED); + _display->setColor(UIColor::warning_txt); _display->setTextSize(2); _display->setCursor(0, 43); sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); _display->print(tmp); - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::primary_txt); } else { - _display->setColor(DisplayDriver::LIGHT); + _display->setColor(UIColor::primary_txt); } } _need_refresh = false; } +#ifdef STATUS_LED_RGB +static void statusLedWrite(uint8_t r, uint8_t g, uint8_t b) { + analogWrite(PIN_STATUS_LED_R, r); + analogWrite(PIN_STATUS_LED_G, g); + analogWrite(PIN_STATUS_LED_B, b); +} + +static void statusLedWheel(uint8_t pos) { // smooth hue sweep, 0..255 + if (pos < 85) { + statusLedWrite(255 - pos * 3, pos * 3, 0); + } else if (pos < 170) { + pos -= 85; + statusLedWrite(0, 255 - pos * 3, pos * 3); + } else { + pos -= 170; + statusLedWrite(pos * 3, 0, 255 - pos * 3); + } +} +#endif + void UITask::userLedHandler() { -#ifdef PIN_STATUS_LED +#ifdef STATUS_LED_RGB + static bool booted = false; + static bool flourish_active = false; + static unsigned long flourish_until = 0; + static int prev_msgcount = 0; + static int state = 0; + static unsigned long next_change = 0; + static int last_increment = 0; + static unsigned long next_batt_check = 0; + static bool low_batt = false; + + unsigned long cur_time = millis(); + + if (!booted) { // color sweep on boot + booted = true; + flourish_until = cur_time + 1200; + flourish_active = true; + } + if (_msgcount > prev_msgcount) { // color sweep when a new message arrives + flourish_until = cur_time + 800; + flourish_active = true; + } + prev_msgcount = _msgcount; + + if (flourish_active) { + if (cur_time < flourish_until) { + statusLedWheel((cur_time % 1200) * 255 / 1200); + return; + } + statusLedWrite(0, 0, 0); + flourish_active = false; + state = 0; + next_change = cur_time; + } + + if (cur_time > next_batt_check) { // battery reads are not free, keep them rare + low_batt = _board->getBattMilliVolts() < LOW_BATT_MILLIVOLTS; + next_batt_check = cur_time + 60000; + } + +#ifdef EXT_CHRG_DETECT + // charge status display while docked (skipped when messages are waiting, + // so the unread indication is never masked) + static unsigned long next_pwr_check = 0; + static bool ext_powered = false, ext_charging = false; + if (cur_time > next_pwr_check) { + ext_powered = _board->isExternalPowered(); + ext_charging = ext_powered && digitalRead(EXT_CHRG_DETECT) == LOW; + next_pwr_check = cur_time + 1000; + } + if (ext_powered && _msgcount == 0) { + if (ext_charging) { + // amber breathing while charging + int ph = cur_time % 2000; + int v = ph < 1000 ? ph : 2000 - ph; + uint8_t lvl = (uint8_t)(v * 255 / 1000); + statusLedWrite(lvl, (uint8_t)(lvl * 35 / 100), 0); + } else { + statusLedWrite(0, 40, 0); // dim solid green: charge complete + } + state = 0; + next_change = cur_time; + return; + } +#endif + + if (cur_time > next_change) { + if (state == 0) { + state = 1; + last_increment = (_msgcount > 0) ? LED_ON_MSG_MILLIS : LED_ON_MILLIS; + next_change = cur_time + last_increment; + if (low_batt) { + statusLedWrite(255, 0, 0); // red: battery low + } else if (_msgcount > 0) { + statusLedWrite(255, 90, 0); // amber: unread messages + } else if (_connected) { + statusLedWrite(0, 0, 255); // blue: app connected + } else { + statusLedWrite(0, 255, 0); // green: heartbeat + } + } else { + state = 0; + next_change = cur_time + LED_CYCLE_MILLIS - last_increment; + statusLedWrite(0, 0, 0); + } + } +#elif defined(PIN_STATUS_LED) static int state = 0; static int next_change = 0; static int last_increment = 0; @@ -307,7 +428,7 @@ void UITask::shutdown(bool restart){ if (restart) { _board->reboot(); } else { - radio_driver.powerOff(); + // Power off board including radio, display, GPS and components _board->powerOff(); } } @@ -407,8 +528,26 @@ void UITask::handleButtonDoublePress() { void UITask::handleButtonTriplePress() { MESH_DEBUG_PRINTLN("UITask: triple press triggered"); - // Toggle buzzer quiet mode - #ifdef PIN_BUZZER +#if defined(PIN_BUZZER) && defined(HAS_DRV2605) + // cycle alert modes: Buzz+Vibe -> Buzz only -> Vibe only -> Silent + int mode = (_node_prefs->buzzer_quiet ? 2 : 0) | (_node_prefs->vibe_quiet ? 1 : 0); + mode = (mode + 1) & 3; + _node_prefs->buzzer_quiet = (mode & 2) ? 1 : 0; + _node_prefs->vibe_quiet = (mode & 1) ? 1 : 0; + buzzer.quiet(_node_prefs->buzzer_quiet); + vibration.quiet(_node_prefs->vibe_quiet); + // audible/tactile confirmation of the new mode (no screen on some boards) + if (!_node_prefs->buzzer_quiet) notify(UIEventType::ack); + if (!_node_prefs->vibe_quiet) vibration.trigger(true); + switch (mode) { + case 0: sprintf(_alert, "Alerts: Buzz+Vibe"); break; + case 1: sprintf(_alert, "Alerts: Buzz only"); break; + case 2: sprintf(_alert, "Alerts: Vibe only"); break; + default: sprintf(_alert, "Alerts: Silent"); break; + } + the_mesh.savePrefs(); + _need_refresh = true; +#elif defined(PIN_BUZZER) if (buzzer.isQuiet()) { buzzer.quiet(false); notify(UIEventType::ack); @@ -420,7 +559,7 @@ void UITask::handleButtonTriplePress() { _node_prefs->buzzer_quiet = buzzer.isQuiet(); the_mesh.savePrefs(); _need_refresh = true; - #endif +#endif } void UITask::handleButtonQuadruplePress() { diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 60cd0d042c..02d126e8fe 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -14,11 +14,18 @@ #include "Button.h" +#ifdef HAS_DRV2605 + #include +#endif + class UITask : public AbstractUITask { DisplayDriver* _display; SensorManager* _sensors; #ifdef PIN_BUZZER genericBuzzer buzzer; +#endif +#ifdef HAS_DRV2605 + DRV2605Vibration vibration; #endif unsigned long _next_refresh, _auto_off; NodePrefs* _node_prefs; @@ -54,7 +61,7 @@ class UITask : public AbstractUITask { public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { + UITask(mesh::MainBoard* board, MultiSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { _next_refresh = 0; ui_started_at = 0; } diff --git a/examples/companion_radio/ui-tiny/ScrollingStatusBar.h b/examples/companion_radio/ui-tiny/ScrollingStatusBar.h index f5943c9b6f..9967489fd2 100644 --- a/examples/companion_radio/ui-tiny/ScrollingStatusBar.h +++ b/examples/companion_radio/ui-tiny/ScrollingStatusBar.h @@ -104,7 +104,7 @@ class ScrollingStatusBar { if (_status[0] == 0) return; display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::primary_txt); // if (_needs_redraw) { // _text_width = display.getTextWidth(_status); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 0119475e77..b6bdbcf4bd 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -56,24 +56,24 @@ class SplashScreen : public UIScreen { int render(DisplayDriver& display) override { if (millis() < version_after) { // meshcore logo - display.setColor(DisplayDriver::BLUE); + display.setColor(UIColor::corp_blue); int logoWidth = 72; display.drawXbm(0, 0, meshcore_logo, 72, 36); } else { // meshcore website const char* website = "meshcore.io"; - display.setColor(DisplayDriver::LIGHT); + display.setColor(UIColor::primary_txt); display.setTextSize(1); uint16_t websiteWidth = display.getTextWidth(website); display.setCursor((display.width() - websiteWidth) / 2, 9); display.print(website); // version info - display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width()/2, 18, _version_info); + display.setColor(UIColor::secondary_txt); display.setTextSize(1); display.drawTextCentered(display.width()/2, 27, FIRMWARE_BUILD_DATE); } @@ -163,7 +163,7 @@ class HomeScreen : public UIScreen { // display.print(filtered_name); - display.setColor(DisplayDriver::YELLOW); + display.setColor(UIColor::primary_txt); display.setTextSize(2); sprintf(tmp, "MSG: %d", _task->getMsgCount()); display.setCursor(0, 10); @@ -180,19 +180,19 @@ class HomeScreen : public UIScreen { display.drawTextCentered(display.width() / 2, 54, tmp); #endif if (_task->hasConnection()) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::warning_txt); display.setTextSize(1); display.drawTextCentered(display.width() / 2, display.height()-8, "< Connected >"); } else if (the_mesh.getBLEPin() != 0) { // BT pin - display.setColor(DisplayDriver::RED); + display.setColor(UIColor::warning_txt); display.setTextSize(2); sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); display.drawTextCentered(display.width() / 2, display.height()-8, tmp); } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::primary_txt); int y = 8; for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { auto a = &recent[i]; @@ -216,7 +216,7 @@ class HomeScreen : public UIScreen { display.print(tmp); } } else if (_page == HomePage::RADIO) { - display.setColor(DisplayDriver::YELLOW); + display.setColor(UIColor::primary_txt); display.setTextSize(1); // frequency and spreading factor display.setCursor(0, 8); @@ -238,14 +238,14 @@ class HomeScreen : public UIScreen { display.drawTextRightAlign(display.width(), 26, tmp); } else if (_page == HomePage::BLUETOOTH) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 8, - _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, + _task->isBluetoothEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setTextSize(1); // display.drawTextCentered(display.width() / 2, 40 - 11, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, 8, advert_icon, 32, 32); // display.drawTextCentered(display.width() / 2, 40 - 11, "advert: " PRESS_LABEL); #if ENV_INCLUDE_GPS == 1 @@ -264,9 +264,11 @@ class HomeScreen : public UIScreen { #else strcpy(buf, gps_state ? "gps on" : "gps off"); #endif + display.setColor(UIColor::primary_txt); display.drawTextLeftAlign(0, y, buf); if (nmea == NULL) { // y = y + 8; + display.setColor(UIColor::warning_txt); display.drawTextLeftAlign(0, y, "Can't access GPS"); } else { if (!gps_state || !nmea->isValid()) { @@ -274,6 +276,7 @@ class HomeScreen : public UIScreen { } else { sprintf(buf, "%d sat", nmea->satellitesCount()); } + display.setColor(UIColor::primary_txt); display.drawTextRightAlign(display.width()-1, y, buf); y = y + 8; sprintf(buf, "lat %.4f", @@ -349,8 +352,10 @@ class HomeScreen : public UIScreen { r.skipData(type); strcpy(name, "unk"); sprintf(buf, ""); } + display.setColor(UIColor::secondary_txt); display.setCursor(0, y); display.print(name); + display.setColor(UIColor::primary_txt); display.setCursor( display.width()-display.getTextWidth(buf)-1, y ); @@ -361,7 +366,7 @@ class HomeScreen : public UIScreen { else sensors_scroll_offset = 0; #endif } else if (_page == HomePage::SHUTDOWN) { - display.setColor(DisplayDriver::GREEN); + display.setColor(UIColor::secondary_txt); display.setTextSize(1); if (_shutdown_init) { display.drawTextCentered(display.width() / 2, 20, "hibernating..."); @@ -386,10 +391,10 @@ class HomeScreen : public UIScreen { return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { - if (_task->isSerialEnabled()) { // toggle Bluetooth on/off - _task->disableSerial(); + if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off + _task->disableBluetooth(); } else { - _task->enableSerial(); + _task->enableBluetooth(); } return true; } @@ -566,8 +571,7 @@ void UITask::shutdown(bool restart){ if (restart) { _board->reboot(); } else { - _display->turnOff(); - radio_driver.powerOff(); + // Power off board including radio, display, GPS and components _board->powerOff(); } } @@ -673,7 +677,7 @@ void UITask::loop() { _cached_batt_mv, isBuzzerQuiet(), getGPSState(), - isSerialEnabled()); + isBluetoothEnabled()); bool status_dirty = _statusBar.needsRedraw(); bool content_dirty = (millis() >= _next_refresh && curr); @@ -693,9 +697,9 @@ void UITask::loop() { _display->setTextSize(1); int y = _display->height() / 3; int p = _display->height() / 32; - _display->setColor(DisplayDriver::DARK); + _display->setColor(UIColor::popup_bkg); _display->fillRect(p, y, _display->width() - p*2, y); - _display->setColor(DisplayDriver::LIGHT); // draw box border + _display->setColor(UIColor::popup_txt); // draw box border _display->drawRect(p, y, _display->width() - p*2, y); _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); _next_refresh = _alert_expiry; // will need refresh when alert is dismissed diff --git a/examples/companion_radio/ui-tiny/UITask.h b/examples/companion_radio/ui-tiny/UITask.h index 344e48b98f..dc689478ec 100644 --- a/examples/companion_radio/ui-tiny/UITask.h +++ b/examples/companion_radio/ui-tiny/UITask.h @@ -4,7 +4,7 @@ #include #include #include -#include +#include #include #include @@ -71,7 +71,7 @@ class UITask : public AbstractUITask { public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { + UITask(mesh::MainBoard* board, MultiSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { next_batt_chck = _next_refresh = 0; _cached_batt_mv = 0; ui_started_at = 0; diff --git a/examples/kiss_modem/KissModem.cpp b/examples/kiss_modem/KissModem.cpp index eeab1501d5..7dcfc8d7d9 100644 --- a/examples/kiss_modem/KissModem.cpp +++ b/examples/kiss_modem/KissModem.cpp @@ -22,6 +22,7 @@ KissModem::KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& r _getStatsCallback = nullptr; _config = {0, 0, 0, 0, 0}; _signal_report_enabled = true; + resetOutputQueue(); } void KissModem::begin() { @@ -30,37 +31,168 @@ void KissModem::begin() { _rx_active = false; _has_pending_tx = false; _tx_state = TX_IDLE; + resetOutputQueue(); } -void KissModem::writeByte(uint8_t b) { - if (b == KISS_FEND) { - _serial.write(KISS_FESC); - _serial.write(KISS_TFEND); - } else if (b == KISS_FESC) { - _serial.write(KISS_FESC); - _serial.write(KISS_TFESC); - } else { - _serial.write(b); +void KissModem::resetOutputQueue() { + _tx_frame_head = 0; + _tx_frame_tail = 0; + _tx_frame_count = 0; + _tx_busy_error_pending = false; + _tx_done_pending = false; + _tx_done_result = 0; +} + +void KissModem::popTxFrame() { + _tx_frame_head = (uint8_t)((_tx_frame_head + 1) % KISS_TX_FRAME_QUEUE_DEPTH); + _tx_frame_count--; +} + +uint16_t KissModem::appendEscapedByte(uint8_t* dest, uint16_t idx, uint16_t max_len, uint8_t b) { + if (b == KISS_FEND || b == KISS_FESC) { + if (idx + 2 > max_len) { + return 0; + } + dest[idx++] = KISS_FESC; + dest[idx++] = (b == KISS_FEND) ? KISS_TFEND : KISS_TFESC; + return idx; } + if (idx + 1 > max_len) { + return 0; + } + dest[idx++] = b; + return idx; } -void KissModem::writeFrame(uint8_t type, const uint8_t* data, uint16_t len) { - _serial.write(KISS_FEND); - writeByte(type); +uint16_t KissModem::encodeFrame(uint8_t type, const uint8_t* data, uint16_t len, uint8_t* dest, uint16_t max_len) { + if (max_len < KISS_FRAME_BOUNDARY_BYTES) { + return 0; + } + + uint16_t idx = 0; + dest[idx++] = KISS_FEND; + + idx = appendEscapedByte(dest, idx, max_len, type); + if (idx == 0) { + return 0; + } + for (uint16_t i = 0; i < len; i++) { - writeByte(data[i]); + idx = appendEscapedByte(dest, idx, max_len, data[i]); + if (idx == 0) { + return 0; + } + } + + if (idx + 1 > max_len) { + return 0; } - _serial.write(KISS_FEND); + dest[idx++] = KISS_FEND; + return idx; } -void KissModem::writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len) { - _serial.write(KISS_FEND); - writeByte(KISS_CMD_SETHARDWARE); - writeByte(sub_cmd); - for (uint16_t i = 0; i < len; i++) { - writeByte(data[i]); +bool KissModem::tryFlushFrames() { + while (_tx_frame_count > 0) { + const uint8_t idx = _tx_frame_head; + const uint16_t frame_len = _tx_frame_len[idx]; + uint16_t written_len = _tx_frame_written[idx]; + + if (written_len >= frame_len) { + popTxFrame(); + continue; + } + + const int available = _serial.availableForWrite(); + if (available <= 0) { + return false; + } + + const uint16_t remaining = frame_len - written_len; + const uint16_t chunk_len = (available < (int)remaining) ? (uint16_t)available : remaining; + if (chunk_len == 0) { + return false; + } + + size_t chunk_written = _serial.write(_tx_frame_buf[idx] + written_len, chunk_len); + if (chunk_written == 0) { + return false; + } + + written_len += (uint16_t)chunk_written; + _tx_frame_written[idx] = written_len; + + if (written_len < frame_len) { + return false; + } + + popTxFrame(); } - _serial.write(KISS_FEND); + return true; +} + +bool KissModem::queueFrame(uint8_t type, const uint8_t* data, uint16_t len, bool mark_busy_error) { + if (_tx_frame_count >= KISS_TX_FRAME_QUEUE_DEPTH && !tryFlushFrames()) { + if (mark_busy_error) { + _tx_busy_error_pending = true; + } + return false; + } + const uint8_t idx = _tx_frame_tail; + uint16_t frame_len = encodeFrame(type, data, len, _tx_frame_buf[idx], sizeof(_tx_frame_buf[idx])); + if (frame_len == 0) { + return false; + } + + _tx_frame_len[idx] = frame_len; + _tx_frame_written[idx] = 0; + _tx_frame_tail = (uint8_t)((_tx_frame_tail + 1) % KISS_TX_FRAME_QUEUE_DEPTH); + _tx_frame_count++; + tryFlushFrames(); + return true; +} + +bool KissModem::queuePendingBusyError() { + if (!_tx_busy_error_pending) { + return true; + } + const uint8_t err = HW_ERR_TX_BUSY; + if (!queueHardwareFrame(HW_RESP_ERROR, &err, 1, false)) { + return false; + } + _tx_busy_error_pending = false; + return true; +} + +bool KissModem::queueHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len, bool mark_busy_error) { + if (len > KISS_MAX_FRAME_SIZE) { + return false; + } + _tx_hw_payload[0] = sub_cmd; + if (len > 0) { + memcpy(_tx_hw_payload + 1, data, len); + } + return queueFrame(KISS_CMD_SETHARDWARE, _tx_hw_payload, len + 1, mark_busy_error); +} + +bool KissModem::queuePendingTxDone() { + if (!_tx_done_pending) { + return true; + } + if (!queueHardwareFrame(HW_RESP_TX_DONE, &_tx_done_result, 1, false)) { + return false; + } + _tx_done_pending = false; + return true; +} + +void KissModem::setTxDonePending(uint8_t result) { + _tx_done_result = result; + _tx_done_pending = true; + _tx_state = TX_DONE_PENDING; +} + +bool KissModem::writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len) { + return queueHardwareFrame(sub_cmd, data, len, true); } void KissModem::writeHardwareError(uint8_t error_code) { @@ -68,6 +200,8 @@ void KissModem::writeHardwareError(uint8_t error_code) { } void KissModem::loop() { + tryFlushFrames(); + while (_serial.available()) { uint8_t b = _serial.read(); @@ -106,6 +240,8 @@ void KissModem::loop() { } processTx(); + tryFlushFrames(); + queuePendingBusyError(); } void KissModem::processFrame() { @@ -295,10 +431,7 @@ void KissModem::processTx() { _tx_timer = millis(); _tx_state = TX_SENDING; } else { - uint8_t result = 0x00; - writeHardwareFrame(HW_RESP_TX_DONE, &result, 1); - _has_pending_tx = false; - _tx_state = TX_IDLE; + setTxDonePending(0x00); } } break; @@ -306,14 +439,15 @@ void KissModem::processTx() { case TX_SENDING: if (_radio.isSendComplete()) { _radio.onSendFinished(); - uint8_t result = 0x01; - writeHardwareFrame(HW_RESP_TX_DONE, &result, 1); - _has_pending_tx = false; - _tx_state = TX_IDLE; + setTxDonePending(0x01); } else if (millis() - _tx_timer >= _radio.getEstAirtimeFor(_pending_tx_len) * KISS_TX_TIMEOUT_FACTOR) { _radio.onSendFinished(); - uint8_t result = 0x00; - writeHardwareFrame(HW_RESP_TX_DONE, &result, 1); + setTxDonePending(0x00); + } + break; + + case TX_DONE_PENDING: + if (queuePendingTxDone()) { _has_pending_tx = false; _tx_state = TX_IDLE; } @@ -322,8 +456,7 @@ void KissModem::processTx() { } void KissModem::onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len) { - writeFrame(KISS_CMD_DATA, packet, len); - if (_signal_report_enabled) { + if (queueFrame(KISS_CMD_DATA, packet, len) && _signal_report_enabled) { uint8_t meta[2] = { (uint8_t)snr, (uint8_t)rssi }; writeHardwareFrame(HW_RESP_RX_META, meta, 2); } diff --git a/examples/kiss_modem/KissModem.h b/examples/kiss_modem/KissModem.h index bbe99d6de4..a23e459bea 100644 --- a/examples/kiss_modem/KissModem.h +++ b/examples/kiss_modem/KissModem.h @@ -13,6 +13,14 @@ #define KISS_MAX_FRAME_SIZE 512 #define KISS_MAX_PACKET_SIZE 255 +#define KISS_FRAME_BOUNDARY_BYTES 2 +#define KISS_TYPE_BYTES 1 +#define KISS_HW_SUBCMD_BYTES 1 +#define KISS_MAX_ESCAPABLE_BYTES (KISS_MAX_FRAME_SIZE + KISS_TYPE_BYTES + KISS_HW_SUBCMD_BYTES) +#define KISS_MAX_ESCAPED_PAYLOAD_SIZE (2 * KISS_MAX_ESCAPABLE_BYTES) +#define KISS_MAX_ENCODED_FRAME_SIZE (KISS_FRAME_BOUNDARY_BYTES + KISS_MAX_ESCAPED_PAYLOAD_SIZE) +#define KISS_TX_FRAME_QUEUE_DEPTH 2 +#define KISS_HW_MAX_PAYLOAD_SIZE (KISS_MAX_FRAME_SIZE + KISS_HW_SUBCMD_BYTES) #define KISS_CMD_DATA 0x00 #define KISS_CMD_TXDELAY 0x01 @@ -94,7 +102,8 @@ enum TxState { TX_WAIT_CLEAR, TX_SLOT_WAIT, TX_DELAY, - TX_SENDING + TX_SENDING, + TX_DONE_PENDING }; class KissModem { @@ -130,10 +139,28 @@ class KissModem { RadioConfig _config; bool _signal_report_enabled; - - void writeByte(uint8_t b); - void writeFrame(uint8_t type, const uint8_t* data, uint16_t len); - void writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len); + uint8_t _tx_frame_buf[KISS_TX_FRAME_QUEUE_DEPTH][KISS_MAX_ENCODED_FRAME_SIZE]; + uint16_t _tx_frame_len[KISS_TX_FRAME_QUEUE_DEPTH]; + uint16_t _tx_frame_written[KISS_TX_FRAME_QUEUE_DEPTH]; + uint8_t _tx_frame_head; + uint8_t _tx_frame_tail; + uint8_t _tx_frame_count; + bool _tx_busy_error_pending; + bool _tx_done_pending; + uint8_t _tx_done_result; + uint8_t _tx_hw_payload[KISS_HW_MAX_PAYLOAD_SIZE]; + + static uint16_t appendEscapedByte(uint8_t* dest, uint16_t idx, uint16_t max_len, uint8_t b); + static uint16_t encodeFrame(uint8_t type, const uint8_t* data, uint16_t len, uint8_t* dest, uint16_t max_len); + void resetOutputQueue(); + void popTxFrame(); + bool tryFlushFrames(); + bool queueFrame(uint8_t type, const uint8_t* data, uint16_t len, bool mark_busy_error = true); + bool queuePendingBusyError(); + bool queueHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len, bool mark_busy_error); + bool queuePendingTxDone(); + void setTxDonePending(uint8_t result); + bool writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len); void writeHardwareError(uint8_t error_code); void processFrame(); void handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint16_t len); @@ -182,4 +209,5 @@ class KissModem { bool isTxBusy() const { return _tx_state != TX_IDLE; } /** True only when radio is actually transmitting; use to skip recvRaw in main loop. */ bool isActuallyTransmitting() const { return _tx_state == TX_SENDING; } + bool isHostOutputBackedUp() const { return _tx_frame_count > 0 || _tx_busy_error_pending || _tx_done_pending; } }; diff --git a/examples/kiss_modem/main.cpp b/examples/kiss_modem/main.cpp index 7fbcaed127..5836a69416 100644 --- a/examples/kiss_modem/main.cpp +++ b/examples/kiss_modem/main.cpp @@ -20,6 +20,8 @@ #define NOISE_FLOOR_CALIB_INTERVAL_MS 2000 #define AGC_RESET_INTERVAL_MS 30000 +#define USB_TX_TIMEOUT_MS 50 +#define USB_TX_BUFFER_SIZE 1024 StdRNG rng; mesh::LocalIdentity identity; @@ -111,6 +113,10 @@ void setup() { uint32_t start = millis(); while (!Serial && millis() - start < 3000) delay(10); delay(100); +#if defined(ESP32) && ARDUINO_USB_MODE + Serial.setTxTimeoutMs(USB_TX_TIMEOUT_MS); + Serial.setTxBufferSize(USB_TX_BUFFER_SIZE); +#endif modem = new KissModem(Serial, identity, rng, radio_driver, board, sensors); #endif @@ -126,7 +132,7 @@ void setup() { void loop() { modem->loop(); - if (!modem->isActuallyTransmitting()) { + if (!modem->isActuallyTransmitting() && !modem->isHostOutputBackedUp()) { if (!modem->isTxBusy()) { if ((uint32_t)(millis() - next_agc_reset_ms) >= AGC_RESET_INTERVAL_MS) { radio_driver.resetAGC(); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 096907494b..907967a1a8 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -147,11 +147,10 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} - reply_path_len = *data & 63; - reply_path_hash_size = (*data >> 6) + 1; - data++; + reply_path_len = *data++; + if (!mesh::Packet::isValidPathLen(reply_path_len)) return 0; // reject - bad encoding - memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); + mesh::Packet::writePath(reply_path, data, reply_path_len); // data += (uint8_t)reply_path_len * reply_path_hash_size; memcpy(reply_data, &sender_timestamp, 4); // prefix with sender_timestamp, like a tag @@ -166,11 +165,10 @@ uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t send uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} - reply_path_len = *data & 63; - reply_path_hash_size = (*data >> 6) + 1; - data++; + reply_path_len = *data++; + if (!mesh::Packet::isValidPathLen(reply_path_len)) return 0; // reject - bad encoding - memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); + mesh::Packet::writePath(reply_path, data, reply_path_len); // data += (uint8_t)reply_path_len * reply_path_hash_size; memcpy(reply_data, &sender_timestamp, 4); // prefix with sender_timestamp, like a tag @@ -186,11 +184,10 @@ uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} - reply_path_len = *data & 63; - reply_path_hash_size = (*data >> 6) + 1; - data++; + reply_path_len = *data++; + if (!mesh::Packet::isValidPathLen(reply_path_len)) return 0; // reject - bad encoding - memcpy(reply_path, data, ((uint8_t)reply_path_len) * reply_path_hash_size); + mesh::Packet::writePath(reply_path, data, reply_path_len); // data += (uint8_t)reply_path_len * reply_path_hash_size; memcpy(reply_data, &sender_timestamp, 4); // prefix with sender_timestamp, like a tag @@ -252,6 +249,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t } sensors.querySensors(perm_mask, telemetry); + // Board-specific telemetry (boards can override queryBoardTelemetry in their Board class) + if (perm_mask & TELEM_PERM_ENVIRONMENT) { + board.queryBoardTelemetry(telemetry); + } + // This default temperature will be overridden by external sensors (if any) float temperature = board.getMCUTemperature(); if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN @@ -549,8 +551,7 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { return getRNG()->nextInt(0, 5*t + 1); } -bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { - // just try to determine region for packet (apply later in allowPacketForward()) +mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { @@ -562,8 +563,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } - // do normal processing - return false; + return Mesh::onRecvPacket(pkt); } void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, @@ -576,7 +576,7 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m data[len] = 0; // ensure null terminator uint8_t reply_len; - reply_path_len = -1; + reply_path_len = 0xFF; if (data[4] == 0 || data[4] >= ' ') { // is password, ie. a login request reply_len = handleLoginReq(sender, secret, timestamp, &data[4], packet->isRouteFlood()); } else if (data[4] == ANON_REQ_TYPE_REGIONS && packet->isRouteDirect()) { @@ -596,13 +596,12 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m mesh::Packet* path = createPathReturn(sender, secret, packet->path, packet->path_len, PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else if (reply_path_len < 0) { + } else if (reply_path_len == 0xFF) { mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); if (reply) sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - uint8_t path_len = ((reply_path_hash_size - 1) << 6) | (reply_path_len & 63); - if (reply) sendDirect(reply, reply_path, path_len, SERVER_RESPONSE_DELAY); + if (reply) sendDirect(reply, reply_path, reply_path_len, SERVER_RESPONSE_DELAY); } } } @@ -867,13 +866,13 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc set_radio_at = revert_radio_at = 0; _logging = false; region_load_active = false; + recv_pkt_region = NULL; #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); #endif // defaults - memset(&_prefs, 0, sizeof(_prefs)); _prefs.airtime_factor = 1.0; _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f @@ -893,6 +892,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_unscoped = 64; _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled + _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') // bridge defaults _prefs.bridge_enabled = 1; // enabled @@ -917,6 +917,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_boosted_gain = 1; // enabled by default; #endif #endif + _prefs.radio_fem_rxgain = 1; pending_discover_tag = 0; pending_discover_until = 0; @@ -965,6 +966,7 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); + board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -1059,9 +1061,13 @@ void MyMesh::setTxPower(int8_t power_dbm) { radio_driver.setTxPower(power_dbm); } -#if defined(USE_SX1262) || defined(USE_SX1268) -void MyMesh::setRxBoostedGain(bool enable) { - radio_driver.setRxBoostedGainMode(enable); +bool MyMesh::setRxBoostedGain(bool enable) { + return radio_driver.setRxBoostedGainMode(enable); +} + +#if defined(USE_LR2021) +bool MyMesh::configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { + return radio_driver.configSideDetectors(sideDetSFs, num, bw); } #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 7597c6c6f6..2f923c871a 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -11,6 +11,7 @@ #include #elif defined(ESP32) #include + using File = fs::File; #endif #ifdef WITH_RS232_BRIDGE @@ -69,11 +70,11 @@ struct NeighbourInfo { }; #ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE "6 Jun 2026" + #define FIRMWARE_BUILD_DATE "9 Aug 2026" #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.16.0" + #define FIRMWARE_VERSION "v1.17.0" #endif #define FIRMWARE_ROLE "repeater" @@ -91,8 +92,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; uint8_t reply_path[MAX_PATH_SIZE]; - int8_t reply_path_len; - uint8_t reply_path_hash_size; + uint8_t reply_path_len; TransportKeyStore key_store; RegionMap region_map, temp_map; RegionEntry* load_stack[8]; @@ -150,6 +150,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int getInterferenceThreshold() const override { return _prefs.interference_threshold; } + bool getCADEnabled() const override { + return _prefs.cad_enabled; + } int getAGCResetInterval() const override { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } @@ -163,7 +166,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { } #endif - bool filterRecvFloodPacket(mesh::Packet* pkt) override; + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; @@ -249,7 +252,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { // To check if there is pending work bool hasPendingWork() const; -#if defined(USE_SX1262) || defined(USE_SX1268) - void setRxBoostedGain(bool enable) override; -#endif + bool setRxBoostedGain(bool enable) override; + + #if defined(USE_LR2021) + virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) override; + #endif + }; diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 6a85143887..e7225557dd 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -1,4 +1,5 @@ #include "UITask.h" +#include "target.h" #include #include @@ -9,6 +10,8 @@ #define AUTO_OFF_MILLIS 20000 // 20 seconds #define BOOT_SCREEN_MILLIS 4000 // 4 seconds +#define POWEROFF_DELAY 3000 + // 'meshcore', 128x13px static const uint8_t meshcore_logo [] PROGMEM = { 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, @@ -29,9 +32,14 @@ static const uint8_t meshcore_logo [] PROGMEM = { void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; _auto_off = millis() + AUTO_OFF_MILLIS; + _started_at = millis(); _node_prefs = node_prefs; _display->turnOn(); +#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) + user_btn.begin(); +#endif + // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 char *version = strdup(firmware_version); @@ -41,47 +49,56 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi } // v1.2.3 (1 Jan 2025) - sprintf(_version_info, "%s (%s)", version, build_date); + snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date); + free(version); } void UITask::renderCurrScreen() { char tmp[80]; - if (millis() < BOOT_SCREEN_MILLIS) { // boot screen + if (millis() < _started_at + BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo - _display->setColor(DisplayDriver::BLUE); + _display->setColor(UIColor::corp_blue); int logoWidth = 128; _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // meshcore website const char* website = "https://meshcore.io"; - _display->setColor(DisplayDriver::LIGHT); + _display->setColor(UIColor::primary_txt); _display->setTextSize(1); - uint16_t websiteWidth = _display->getTextWidth(website); - _display->setCursor((_display->width() - websiteWidth) / 2, 22); - _display->print(website); + _display->drawTextCentered(_display->width() / 2, 22, website); // version info - _display->setColor(DisplayDriver::LIGHT); _display->setTextSize(1); - uint16_t versionWidth = _display->getTextWidth(_version_info); - _display->setCursor((_display->width() - versionWidth) / 2, 35); - _display->print(_version_info); + _display->drawTextCentered(_display->width() / 2, 35, _version_info); // node type const char* node_type = "< Repeater >"; - uint16_t typeWidth = _display->getTextWidth(node_type); - _display->setCursor((_display->width() - typeWidth) / 2, 48); - _display->print(node_type); - } else { // home screen - // node name + _display->drawTextCentered(_display->width() / 2, 48, node_type); + } else if (_powering_off_at > 0) { + // meshcore logo + _display->setColor(UIColor::corp_blue); + int logoWidth = 128; + _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); + + // meshcore website + const char* website = "https://meshcore.io"; + _display->setColor(UIColor::primary_txt); + _display->setTextSize(1); + _display->drawTextCentered(_display->width()/ 2, 22, website); + + // Powering off + const char* poweroff_string = "Turning OFF"; + uint16_t poffWidth = _display->getTextWidth(poweroff_string); + _display->setCursor((_display->width() - poffWidth) / 2, 48); + _display->drawTextCentered(_display->width()/2, 48, poweroff_string); + } else { _display->setCursor(0, 0); _display->setTextSize(1); - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::primary_txt); _display->print(_node_prefs->node_name); // freq / sf _display->setCursor(0, 20); - _display->setColor(DisplayDriver::YELLOW); sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); _display->print(tmp); @@ -93,21 +110,19 @@ void UITask::renderCurrScreen() { } void UITask::loop() { -#ifdef PIN_USER_BTN - if (millis() >= _next_read) { - int btnState = digitalRead(PIN_USER_BTN); - if (btnState != _prevBtnState) { - if (btnState == USER_BTN_PRESSED) { // pressed? - if (_display->isOn()) { - // TODO: any action ? - } else { - _display->turnOn(); - } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer - } - _prevBtnState = btnState; +#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) + int ev = user_btn.check(); + if (ev == BUTTON_EVENT_CLICK) { + if (_display->isOn()) { + // TODO: any action ? + } else { + _display->turnOn(); } - _next_read = millis() + 200; // 5 reads per second + _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + _display->turnOn(); + Serial.println("Powering Off"); + _powering_off_at = millis() + POWEROFF_DELAY; } #endif @@ -123,4 +138,13 @@ void UITask::loop() { _display->turnOff(); } } + + if (_powering_off_at > 0) { // power off timer armed +#ifdef LED_PIN + digitalWrite(LED_PIN, LED_STATE_ON); // switch on the led until poweroff +#endif + if (millis() > _powering_off_at) { + _board->powerOff(); // should not return + } + } } diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index a27259f117..d8e3ce1d2f 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -4,15 +4,18 @@ #include class UITask { + mesh::MainBoard* _board; DisplayDriver* _display; unsigned long _next_read, _next_refresh, _auto_off; int _prevBtnState; NodePrefs* _node_prefs; char _version_info[32]; + unsigned long _powering_off_at = 0; + unsigned long _started_at = 0; void renderCurrScreen(); public: - UITask(DisplayDriver& display) : _display(&display) { _next_read = _next_refresh = 0; } + UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; } void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); void loop(); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 2ce056f521..06e53171ea 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -5,7 +5,12 @@ #ifdef DISPLAY_CLASS #include "UITask.h" - static UITask ui_task(display); + static UITask ui_task(board, display); +#endif + +#ifdef ETHERNET_ENABLED + #define ETHERNET_CLI_BANNER "MeshCore Repeater CLI" + #include #endif StdRNG fast_rng; @@ -18,6 +23,9 @@ void halt() { } static char command[160]; +#ifdef ETHERNET_ENABLED +static char ethernet_command[160]; +#endif // For power saving unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot @@ -33,6 +41,10 @@ void setup() { board.begin(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.begin(); +#endif + #if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) // give some extra time for serial to settle so // boot debug messages can be seen on terminal @@ -86,6 +98,9 @@ void setup() { mesh::Utils::printHex(Serial, the_mesh.self_id.pub_key, PUB_KEY_SIZE); Serial.println(); command[0] = 0; +#ifdef ETHERNET_ENABLED + ethernet_command[0] = 0; +#endif sensors.begin(); @@ -95,6 +110,10 @@ void setup() { ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); #endif +#ifdef ETHERNET_ENABLED + ethernet_start_task(); +#endif + // send out initial zero hop Advertisement to the mesh #if ENABLE_ADVERT_ON_BOOT == 1 the_mesh.sendSelfAdvertisement(16000, false); @@ -104,6 +123,7 @@ void setup() { } void loop() { + // Handle Serial CLI int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); @@ -122,7 +142,14 @@ void loop() { Serial.print('\n'); command[len - 1] = 0; // replace newline with C string null terminator char reply[160]; + reply[0] = 0; +#ifdef ETHERNET_ENABLED + if (!ethernet_handle_command(command, reply)) { + the_mesh.handleCommand(0, command, reply); + } +#else the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial! +#endif if (reply[0]) { Serial.print(" -> "); Serial.println(reply); } @@ -130,7 +157,22 @@ void loop() { command[0] = 0; // reset command buffer } -#if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) + board.tick(); // Feed watchdog and perform board-specific tasks + +#ifdef ETHERNET_ENABLED + ethernet_loop_maintain(); + if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) { + char reply[160]; + reply[0] = 0; + if (!ethernet_handle_command(ethernet_command, reply)) { + the_mesh.handleCommand(0, ethernet_command, reply); + } + ethernet_send_reply(reply); + ethernet_command[0] = 0; + } +#endif + +#if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) && !defined(DISPLAY_CLASS) // Hold the user button to power off the SenseCAP Solar repeater. int btnState = digitalRead(PIN_USER_BTN); if (btnState == LOW) { @@ -152,6 +194,9 @@ void loop() { #endif rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { #if defined(NRF52_PLATFORM) board.sleep(0); // nrf ignores seconds param, sleeps whenever possible diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 98b22fdb72..0aff39cc1a 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -39,18 +39,35 @@ struct ServerStats { }; void MyMesh::addPost(ClientInfo *client, const char *postData) { + storePost(client->id, postData); +} + +void MyMesh::addSystemPost(const char *postData) { + if (!postData || postData[0] == 0) return; + + MESH_DEBUG_PRINTLN("room.post: addSystemPost: %s", postData); + + storePost(self_id, postData); +} + +void MyMesh::storePost(const mesh::Identity &author, const char *postData) { + int idx = next_post_idx; // TODO: suggested postData format: /<descrption> - posts[next_post_idx].author = client->id; // add to cyclic queue - StrHelper::strncpy(posts[next_post_idx].text, postData, MAX_POST_TEXT_LEN); + posts[idx].author = author; // add to cyclic queue + StrHelper::strncpy(posts[idx].text, postData, MAX_POST_TEXT_LEN); - posts[next_post_idx].post_timestamp = getRTCClock()->getCurrentTimeUnique(); + posts[idx].post_timestamp = getRTCClock()->getCurrentTimeUnique(); + MESH_DEBUG_PRINTLN("room.post: storePost idx=%d text=%s", idx, posts[idx].text); + MESH_DEBUG_PRINTLN("room.post: timestamp=%u", posts[idx].post_timestamp); next_post_idx = (next_post_idx + 1) % MAX_UNSYNCED_POSTS; next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS); _num_posted++; // stats + MESH_DEBUG_PRINTLN("room.post: next_post_idx=%d num_posted=%d push scheduled", next_post_idx, _num_posted); } void MyMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { + MESH_DEBUG_PRINTLN("room.post: pushPostToClient text=%s", post.text); int len = 0; memcpy(&reply_data[len], &post.post_timestamp, 4); len += 4; // this is a PAST timestamp... but should be accepted by client @@ -290,8 +307,7 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { return true; } -bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { - // just try to determine region for packet (apply later in allowPacketForward()) +mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { @@ -303,8 +319,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } - // do normal processing - return false; + return Mesh::onRecvPacket(pkt); } void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, @@ -627,9 +642,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _logging = false; region_load_active = false; set_radio_at = revert_radio_at = 0; + recv_pkt_region = NULL; // defaults - memset(&_prefs, 0, sizeof(_prefs)); _prefs.airtime_factor = 1.0; _prefs.rx_delay_base = 0.0f; // off by default, was 10.0 _prefs.tx_delay_factor = 0.5f; // was 0.25f; @@ -650,6 +665,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.flood_max_unscoped = 64; _prefs.flood_max_advert = 8; _prefs.interference_threshold = 0; // disabled + _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') #ifdef ROOM_PASSWORD StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password)); #endif @@ -659,6 +675,15 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; +#if defined(USE_SX1262) || defined(USE_SX1268) +#ifdef SX126X_RX_BOOSTED_GAIN + _prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN; +#else + _prefs.rx_boosted_gain = 1; // enabled by default; +#endif +#endif + _prefs.radio_fem_rxgain = 1; + next_post_idx = 0; next_client_idx = 0; next_push = 0; @@ -699,6 +724,8 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); updateAdvertTimer(); updateFloodAdvertTimer(); @@ -805,6 +832,10 @@ void MyMesh::setTxPower(int8_t power_dbm) { radio_driver.setTxPower(power_dbm); } +bool MyMesh::setRxBoostedGain(bool enable) { + return radio_driver.setRxBoostedGainMode(enable); +} + void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); @@ -934,6 +965,15 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply Serial.printf("\n"); } reply[0] = 0; + } else if (strncmp(command, "room.post", 9) == 0) { + char* msg = command + 9; + while (*msg == ' ') msg++; + if (*msg == 0) { + snprintf(reply, MAX_POST_TEXT_LEN, "ERR empty message"); + } else { + addSystemPost(msg); + snprintf(reply, MAX_POST_TEXT_LEN, "OK"); + } } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5277ddad61..878c2e9593 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -27,11 +27,11 @@ /* ------------------------------ Config -------------------------------- */ #ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE "6 Jun 2026" + #define FIRMWARE_BUILD_DATE "9 Aug 2026" #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.16.0" + #define FIRMWARE_VERSION "v1.17.0" #endif #ifndef LORA_FREQ @@ -119,6 +119,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int matching_peer_indexes[MAX_CLIENTS]; void addPost(ClientInfo* client, const char* postData); + void storePost(const mesh::Identity& author, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); @@ -144,6 +145,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int getInterferenceThreshold() const override { return _prefs.interference_threshold; } + bool getCADEnabled() const override { + return _prefs.cad_enabled; + } int getAGCResetInterval() const override { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } @@ -151,7 +155,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { return _prefs.multi_acks; } - bool filterRecvFloodPacket(mesh::Packet* pkt) override; + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; bool allowPacketForward(const mesh::Packet* packet) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; @@ -173,6 +177,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); void begin(FILESYSTEM* fs); + void addSystemPost(const char* postData); const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } @@ -203,6 +208,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void dumpLogFile() override; void setTxPower(int8_t power_dbm) override; + bool setRxBoostedGain(bool enable) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 640a1d2d10..8e4c2fcf64 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -41,27 +41,27 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi } // v1.2.3 (1 Jan 2025) - sprintf(_version_info, "%s (%s)", version, build_date); + snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date); + free(version); } void UITask::renderCurrScreen() { char tmp[80]; if (millis() < BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo - _display->setColor(DisplayDriver::BLUE); + _display->setColor(UIColor::corp_blue); int logoWidth = 128; _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // meshcore website const char* website = "https://meshcore.io"; - _display->setColor(DisplayDriver::LIGHT); + _display->setColor(UIColor::primary_txt); _display->setTextSize(1); uint16_t websiteWidth = _display->getTextWidth(website); _display->setCursor((_display->width() - websiteWidth) / 2, 22); _display->print(website); // version info - _display->setColor(DisplayDriver::LIGHT); _display->setTextSize(1); uint16_t versionWidth = _display->getTextWidth(_version_info); _display->setCursor((_display->width() - versionWidth) / 2, 35); @@ -76,12 +76,11 @@ void UITask::renderCurrScreen() { // node name _display->setCursor(0, 0); _display->setTextSize(1); - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::primary_txt); _display->print(_node_prefs->node_name); // freq / sf _display->setCursor(0, 20); - _display->setColor(DisplayDriver::YELLOW); sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); _display->print(tmp); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index a3798b2175..d833fff39e 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -3,6 +3,11 @@ #include "MyMesh.h" +#ifdef ETHERNET_ENABLED + #define ETHERNET_CLI_BANNER "MeshCore Room Server CLI" + #include <helpers/nrf52/EthernetCLI.h> +#endif + #ifdef DISPLAY_CLASS #include "UITask.h" static UITask ui_task(display); @@ -17,6 +22,9 @@ void halt() { } static char command[MAX_POST_TEXT_LEN+1]; +#ifdef ETHERNET_ENABLED +static char ethernet_command[MAX_POST_TEXT_LEN+1]; +#endif void setup() { Serial.begin(115200); @@ -24,6 +32,10 @@ void setup() { board.begin(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.begin(); +#endif + #ifdef DISPLAY_CLASS if (display.begin()) { display.startFrame(); @@ -67,6 +79,9 @@ void setup() { mesh::Utils::printHex(Serial, the_mesh.self_id.pub_key, PUB_KEY_SIZE); Serial.println(); command[0] = 0; +#ifdef ETHERNET_ENABLED + ethernet_command[0] = 0; +#endif sensors.begin(); @@ -76,6 +91,10 @@ void setup() { ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); #endif +#ifdef ETHERNET_ENABLED + ethernet_start_task(); +#endif + // send out initial zero hop Advertisement to the mesh #if ENABLE_ADVERT_ON_BOOT == 1 the_mesh.sendSelfAdvertisement(16000, false); @@ -101,7 +120,14 @@ void loop() { if (len > 0 && command[len - 1] == '\r') { // received complete line command[len - 1] = 0; // replace newline with C string null terminator char reply[160]; + reply[0] = 0; +#ifdef ETHERNET_ENABLED + if (!ethernet_handle_command(command, reply)) { + the_mesh.handleCommand(0, command, reply); + } +#else the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial! +#endif if (reply[0]) { Serial.print(" -> "); Serial.println(reply); } @@ -109,10 +135,26 @@ void loop() { command[0] = 0; // reset command buffer } +#ifdef ETHERNET_ENABLED + ethernet_loop_maintain(); + if (ethernet_read_line(ethernet_command, sizeof(ethernet_command))) { + char reply[160]; + reply[0] = 0; + if (!ethernet_handle_command(ethernet_command, reply)) { + the_mesh.handleCommand(0, ethernet_command, reply); + } + ethernet_send_reply(reply); + ethernet_command[0] = 0; + } +#endif + the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); #endif rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif } diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index d50667368c..da42ddcbbd 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -135,7 +135,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { File file = _fs->open("/contacts", "w", true); #endif if (file) { - ContactsIterator iter; + ContactsIterator iter = startContactsIterator(); ContactInfo c; uint8_t unused = 0; uint32_t reserved = 0; @@ -560,6 +560,10 @@ void setup() { board.begin(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.begin(); +#endif + if (!radio_init()) { halt(); } fast_rng.begin(radio_driver.getRngSeed()); @@ -591,4 +595,7 @@ void setup() { void loop() { the_mesh.loop(); rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 879fcbf026..17f8e323e5 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -323,6 +323,9 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { int SensorMesh::getInterferenceThreshold() const { return _prefs.interference_threshold; } +bool SensorMesh::getCADEnabled() const { + return _prefs.cad_enabled; +} int SensorMesh::getAGCResetInterval() const { return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds } @@ -707,7 +710,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise set_radio_at = revert_radio_at = 0; // defaults - memset(&_prefs, 0, sizeof(_prefs)); _prefs.airtime_factor = 1.0; _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; _prefs.tx_delay_factor = 0.5f; // was 0.25f @@ -726,11 +728,13 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.disable_fwd = true; _prefs.flood_max = 64; _prefs.interference_threshold = 0; // disabled + _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') // GPS defaults _prefs.gps_enabled = 0; _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + _prefs.radio_fem_rxgain = 1; memset(default_scope.key, 0, sizeof(default_scope.key)); } @@ -766,6 +770,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); + board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index c9f135f65e..3a33b638c9 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -34,11 +34,11 @@ #define PERM_RECV_ALERTS_HI (1 << 7) // high priority alerts #ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE "6 Jun 2026" + #define FIRMWARE_BUILD_DATE "9 Aug 2026" #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.16.0" + #define FIRMWARE_VERSION "v1.17.0" #endif #define FIRMWARE_ROLE "sensor" @@ -120,6 +120,7 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; int getInterferenceThreshold() const override; + bool getCADEnabled() const override; int getAGCResetInterval() const override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index 757ea1dc60..75a0b46509 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -41,27 +41,27 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi } // v1.2.3 (1 Jan 2025) - sprintf(_version_info, "%s (%s)", version, build_date); + snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date); + free(version); } void UITask::renderCurrScreen() { char tmp[80]; if (millis() < BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo - _display->setColor(DisplayDriver::BLUE); + _display->setColor(UIColor::corp_blue); int logoWidth = 128; _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // meshcore website const char* website = "https://meshcore.io"; - _display->setColor(DisplayDriver::LIGHT); + _display->setColor(UIColor::primary_txt); _display->setTextSize(1); uint16_t websiteWidth = _display->getTextWidth(website); _display->setCursor((_display->width() - websiteWidth) / 2, 22); _display->print(website); // version info - _display->setColor(DisplayDriver::LIGHT); _display->setTextSize(1); uint16_t versionWidth = _display->getTextWidth(_version_info); _display->setCursor((_display->width() - versionWidth) / 2, 35); @@ -76,12 +76,11 @@ void UITask::renderCurrScreen() { // node name _display->setCursor(0, 0); _display->setTextSize(1); - _display->setColor(DisplayDriver::GREEN); + _display->setColor(UIColor::primary_txt); _display->print(_node_prefs->node_name); // freq / sf _display->setCursor(0, 20); - _display->setColor(DisplayDriver::YELLOW); sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); _display->print(tmp); diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index cace67a08c..67ad2d2ed0 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -58,6 +58,10 @@ void setup() { board.begin(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.begin(); +#endif + #ifdef DISPLAY_CLASS if (display.begin()) { display.startFrame(); @@ -141,10 +145,15 @@ void loop() { command[0] = 0; // reset command buffer } + board.tick(); // Feed watchdog and perform board-specific tasks + the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS ui_task.loop(); #endif rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif } diff --git a/platformio.ini b/platformio.ini index e16f7b8304..e78124a40b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,7 +19,8 @@ monitor_speed = 115200 lib_deps = SPI Wire - jgromes/RadioLib @ ^7.6.0 + ;jgromes/RadioLib @ ^7.7.1 + https://github.com/jgromes/RadioLib.git#6d8934836678d8894e3d556550475b37dce3e2b6 rweather/Crypto @ ^0.4.0 adafruit/RTClib @ ^2.1.3 melopero/Melopero RV3028 @ ^1.1.0 @@ -90,16 +91,17 @@ build_flags = ${arduino_base.build_flags} -D NRF52_PLATFORM -D LFS_NO_ASSERT=1 -D EXTRAFS=1 + -D USE_CC310_HW_CRYPTO=1 lib_deps = ${arduino_base.lib_deps} - https://github.com/oltaco/CustomLFS#0.2.2 + https://github.com/oltaco/CustomLFS#0.2.3 ; ----------------- RP2040 --------------------- [rp2040_base] extends = arduino_base upload_protocol = picotool board_build.core = earlephilhower -platform = https://github.com/maxgerhardt/platform-raspberrypi.git#4e22a0d ; framework-arduinopico @ 1.50600.0+sha.6a1d13e9 +platform = https://github.com/maxgerhardt/platform-raspberrypi.git ; framework-arduinopico @ 1.50600.0+sha.6a1d13e9 build_flags = ${arduino_base.build_flags} -D RP2040_PLATFORM @@ -157,12 +159,31 @@ lib_deps = [env:native] platform = native +test_framework = googletest build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes +test_ignore = test_kiss_modem build_src_filter = -<*> +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/helpers/ConfigSerializer.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_kiss_modem] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -I examples/kiss_modem +test_build_src = yes +test_filter = test_kiss_modem +build_src_filter = + -<*> + +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a11131d..c0610b7f8a 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -66,6 +66,7 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { void Dispatcher::loop() { if (millisHasNowPassed(next_floor_calib_time)) { _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); + _radio->setCADEnabled(getCADEnabled()); next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); } _radio->loop(); diff --git a/src/Dispatcher.h b/src/Dispatcher.h index dd032f130d..aad6cba3ec 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -65,6 +65,8 @@ class Radio { virtual void triggerNoiseFloorCalibrate(int threshold) { } + virtual void setCADEnabled(bool enable) { } + virtual void resetAGC() { } virtual bool isInRecvMode() const = 0; @@ -166,6 +168,7 @@ class Dispatcher { virtual uint32_t getCADFailRetryDelay() const; virtual uint32_t getCADFailMaxDuration() const; virtual int getInterferenceThreshold() const { return 0; } // disabled by default + virtual bool getCADEnabled() const { return false; } // hardware CAD disabled by default virtual int getAGCResetInterval() const { return 0; } // disabled by default virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } diff --git a/src/Identity.cpp b/src/Identity.cpp index ea546274da..25419fd4fe 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -4,6 +4,11 @@ #include <ed_25519.h> #include <Ed25519.h> +#ifdef USE_CC310_HW_CRYPTO +#include <Adafruit_nRFCrypto.h> +#include "nrf_cc310/include/crys_ec_edw_api.h" +#endif + namespace mesh { Identity::Identity() { @@ -15,7 +20,20 @@ Identity::Identity(const char* pub_hex) { } bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { -#if 0 +#ifdef USE_CC310_HW_CRYPTO + // nRF52840 CryptoCell CC310 hardware Ed25519 verification. The software + // implementations need ~3KB of stack (which can overflow the Adafruit core's + // 4KB loop task stack from the advert receive path); the hardware path + // needs much less, around 600-700bytes. The CC310 workspace is static, faster, + // should save power at scale as well. + static CRYS_ECEDW_TempBuff_t cc310_tmp; + nRFCrypto.begin(); + CRYSError_t rc = CRYS_ECEDW_Verify((uint8_t*)sig, CRYS_ECEDW_SIGNATURE_BYTES, + (uint8_t*)pub_key, CRYS_ECEDW_MOD_SIZE_IN_BYTES, + (uint8_t*)message, (size_t)msg_len, &cc310_tmp); + nRFCrypto.end(); + return rc == CRYS_OK; +#elif 0 // NOTE: memory corruption bug was found in this function!! return ed25519_verify(sig, message, msg_len, pub_key); #else diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 87ad61af2e..c11f37cacf 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -55,7 +55,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint16_t offset = (uint16_t)pkt->path_len << path_sz; if (offset >= len) { // TRACE has reached end of given path onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); - } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { + } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); // append SNR (Not hash!) pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); @@ -89,14 +90,16 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) { return forwardMultipartDirect(pkt); } else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) { - if (!_tables->hasSeen(pkt)) { // don't retransmit! + if (!_tables->wasSeen(pkt)) { // don't retransmit! + _tables->markSeen(pkt); removeSelfFromPath(pkt); routeDirectRecvAcks(pkt, 0); } return ACTION_RELEASE; } - if (!_tables->hasSeen(pkt)) { + if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); removeSelfFromPath(pkt); uint32_t d = getDirectRetransmitDelay(pkt); @@ -117,7 +120,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { memcpy(&ack_crc, &pkt->payload[i], 4); i += 4; if (i > pkt->payload_len) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete ACK packet", getLogDateTime()); - } else if (!_tables->hasSeen(pkt)) { + } else if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); onAckRecv(pkt, ack_crc); action = routeRecvPacket(pkt); } @@ -134,7 +138,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t* macAndData = &pkt->payload[i]; // MAC + encrypted data if (i + CIPHER_MAC_SIZE >= pkt->payload_len) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete data packet", getLogDateTime()); - } else if (!_tables->hasSeen(pkt)) { + } else if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); // NOTE: this is a 'first packet wins' impl. When receiving from multiple paths, the first to arrive wins. // For flood mode, the path may not be the 'best' in terms of hops. // FUTURE: could send back multiple paths, using createPathReturn(), and let sender choose which to use(?) @@ -155,6 +160,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH) { int k = 0; uint8_t path_len = data[k++]; + if (!Packet::isValidPathLen(path_len)) { + MESH_DEBUG_PRINTLN("%s PAYLOAD_TYPE_PATH, bad path_len: %u", getLogDateTime(), (uint32_t)path_len); + break; // reject bad encoding + } uint8_t hash_size = (path_len >> 6) + 1; uint8_t hash_count = path_len & 63; uint8_t* path = &data[k]; k += hash_size*hash_count; @@ -193,7 +202,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t* macAndData = &pkt->payload[i]; // MAC + encrypted data if (i + 2 >= pkt->payload_len) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete data packet", getLogDateTime()); - } else if (!_tables->hasSeen(pkt)) { + } else if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); if (self_id.isHashMatch(&dest_hash)) { Identity sender(sender_pub_key); @@ -220,7 +230,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t* macAndData = &pkt->payload[i]; // MAC + encrypted data if (i + 2 >= pkt->payload_len) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete data packet", getLogDateTime()); - } else if (!_tables->hasSeen(pkt)) { + } else if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); // scan channels DB, for all matching hashes of 'channel_hash' (max 4 matches supported ATM) GroupChannel channels[4]; int num = searchChannelsByHash(&channel_hash, channels, 4); @@ -251,7 +262,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): incomplete advertisement packet", getLogDateTime()); } else if (self_id.matches(id.pub_key)) { MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): receiving SELF advert packet", getLogDateTime()); - } else if (!_tables->hasSeen(pkt)) { + } else if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); uint8_t* app_data = &pkt->payload[i]; int app_data_len = pkt->payload_len - i; if (app_data_len > MAX_ADVERT_DATA_SIZE) { app_data_len = MAX_ADVERT_DATA_SIZE; } @@ -278,7 +290,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { break; } case PAYLOAD_TYPE_RAW_CUSTOM: { - if (pkt->isRouteDirect() && !_tables->hasSeen(pkt)) { + if (pkt->isRouteDirect() && !_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); onRawDataRecv(pkt); //action = routeRecvPacket(pkt); don't flood route these (yet) } @@ -296,7 +309,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { tmp.payload_len = pkt->payload_len - 1; memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); - if (!_tables->hasSeen(&tmp)) { + if (!_tables->wasSeen(&tmp)) { + _tables->markSeen(&tmp); uint32_t ack_crc; memcpy(&ack_crc, tmp.payload, 4); @@ -353,7 +367,8 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) { tmp.payload_len = pkt->payload_len - 1; memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); - if (!_tables->hasSeen(&tmp)) { // don't retransmit! + if (!_tables->wasSeen(&tmp)) { // don't retransmit! + _tables->markSeen(&tmp); removeSelfFromPath(&tmp); routeDirectRecvAcks(&tmp, ((uint32_t)remaining + 1) * 300); // expect multipart ACKs 300ms apart (x2) } @@ -633,7 +648,7 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_si packet->header |= ROUTE_TYPE_FLOOD; packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -662,7 +677,7 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m packet->transport_codes[1] = transport_codes[1]; packet->setPathHashSizeAndCount(path_hash_size, 0); - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us uint8_t pri; if (packet->getPayloadType() == PAYLOAD_TYPE_PATH) { @@ -695,7 +710,7 @@ void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uin pri = 0; } } - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us sendPacket(packet, pri, delay_millis); } @@ -705,7 +720,7 @@ void Mesh::sendZeroHop(Packet* packet, uint32_t delay_millis) { packet->path_len = 0; // path_len of zero means Zero Hop - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us sendPacket(packet, 0, delay_millis); } @@ -718,7 +733,7 @@ void Mesh::sendZeroHop(Packet* packet, uint16_t* transport_codes, uint32_t delay packet->path_len = 0; // path_len of zero means Zero Hop - _tables->hasSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us + _tables->markSeen(packet); // mark this packet as already sent in case it is rebroadcast back to us sendPacket(packet, 0, delay_millis); } diff --git a/src/Mesh.h b/src/Mesh.h index 2302d6b5b7..49a299a6a4 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -15,8 +15,9 @@ class GroupChannel { */ class MeshTables { public: - virtual bool hasSeen(const Packet* packet) = 0; - virtual void clear(const Packet* packet) = 0; // remove this packet hash from table + virtual bool wasSeen(const Packet* packet) = 0; + virtual void markSeen(const Packet* packet) = 0; + virtual void clear(const Packet* packet) = 0; // remove this packet hash from table }; /** @@ -100,7 +101,7 @@ class Mesh : public Dispatcher { * \param auth_code a code to authenticate the packet * \param flags zero for now * \param path_snrs single byte SNR*4 for each hop in the path - * \param path_hashes hashes if each repeater in the path + * \param path_hashes hashes of each repeater in the path * \param path_len length of the path_snrs[] and path_hashes[] arrays */ virtual void onTraceRecv(Packet* packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t path_len) { } diff --git a/src/MeshCore.h b/src/MeshCore.h index cfa33cf90b..c48be366ba 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -37,6 +37,8 @@ #define BRIDGE_DEBUG_PRINTLN(...) {} #endif +class CayenneLPP; + namespace mesh { #define BD_STARTUP_NORMAL 0 // getStartupReason() codes @@ -64,6 +66,9 @@ class MainBoard { virtual uint8_t getStartupReason() const = 0; virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; } virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported + virtual bool setLoRaFemLnaEnabled(bool enable) { return false; } + virtual bool canControlLoRaFemLna() const { return false; } + virtual bool isLoRaFemLnaEnabled() const { return false; } // Power management interface (boards with power management override these) virtual bool isExternalPowered() { return false; } @@ -72,6 +77,12 @@ class MainBoard { virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; } virtual uint8_t getShutdownReason() const { return 0; } virtual const char* getShutdownReasonString(uint8_t reason) { return "Not available"; } + + // Custom board commands and telemetry (boards can override these) + virtual void tick() {} + virtual bool getCustomGetter(const char* getCommand, char* reply, uint32_t maxlen) { return false; } + virtual const char* setCustomSetter(const char* setCommand) { return nullptr; } + virtual bool queryBoardTelemetry(CayenneLPP& telemetry) { return false; } }; /** diff --git a/src/Packet.h b/src/Packet.h index 0886a06c4e..c19d9e9d8f 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -25,7 +25,7 @@ namespace mesh { #define PAYLOAD_TYPE_GRP_DATA 0x06 // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: data_type(uint16), data_len, blob) #define PAYLOAD_TYPE_ANON_REQ 0x07 // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...) #define PAYLOAD_TYPE_PATH 0x08 // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra) -#define PAYLOAD_TYPE_TRACE 0x09 // trace a path, collecting SNI for each hop +#define PAYLOAD_TYPE_TRACE 0x09 // trace a path, collecting SNR for each hop #define PAYLOAD_TYPE_MULTIPART 0x0A // packet is one of a set of packets #define PAYLOAD_TYPE_CONTROL 0x0B // a control/discovery packet //... diff --git a/src/Utils.cpp b/src/Utils.cpp index 186c8720a2..d4bc8c4502 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -2,6 +2,13 @@ #include <AES.h> #include <SHA256.h> +#ifdef USE_CC310_HW_CRYPTO +#include <Adafruit_nRFCrypto.h> +#include "nrf_cc310/include/crys_hash.h" +#include "nrf_cc310/include/crys_hmac.h" +#include "nrf_cc310/include/ssi_aes.h" +#endif + #ifdef ARDUINO #include <Arduino.h> #endif @@ -15,19 +22,58 @@ uint32_t RNG::nextInt(uint32_t _min, uint32_t _max) { } void Utils::sha256(uint8_t *hash, size_t hash_len, const uint8_t* msg, int msg_len) { +#ifdef USE_CC310_HW_CRYPTO + static CRYS_HASH_Result_t result; + nRFCrypto.begin(); + CRYS_HASH(CRYS_HASH_SHA256_mode, (uint8_t*)msg, (size_t)msg_len, result); + nRFCrypto.end(); + memcpy(hash, result, hash_len); +#else SHA256 sha; sha.update(msg, msg_len); sha.finalize(hash, hash_len); +#endif } void Utils::sha256(uint8_t *hash, size_t hash_len, const uint8_t* frag1, int frag1_len, const uint8_t* frag2, int frag2_len) { +#ifdef USE_CC310_HW_CRYPTO + static CRYS_HASHUserContext_t ctx; + static CRYS_HASH_Result_t result; + nRFCrypto.begin(); + CRYS_HASH_Init(&ctx, CRYS_HASH_SHA256_mode); + CRYS_HASH_Update(&ctx, (uint8_t*)frag1, (size_t)frag1_len); + CRYS_HASH_Update(&ctx, (uint8_t*)frag2, (size_t)frag2_len); + CRYS_HASH_Finish(&ctx, result); + nRFCrypto.end(); + memcpy(hash, result, hash_len); +#else SHA256 sha; sha.update(frag1, frag1_len); sha.update(frag2, frag2_len); sha.finalize(hash, hash_len); +#endif } int Utils::decrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len) { +#ifdef USE_CC310_HW_CRYPTO + static SaSiAesUserContext_t ctx; + SaSiAesUserKeyData_t keyData = { (uint8_t*)shared_secret, CIPHER_KEY_SIZE }; + uint8_t* dp = dest; + const uint8_t* sp = src; + size_t dummy_out = 0; + + nRFCrypto.begin(); + SaSi_AesInit(&ctx, SASI_AES_DECRYPT, SASI_AES_MODE_ECB, SASI_AES_PADDING_NONE); + SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData)); + while (sp - src < src_len) { + SaSi_AesBlock(&ctx, (uint8_t*)sp, 16, dp); + dp += 16; sp += 16; + } + SaSi_AesFinish(&ctx, 0, NULL, 0, NULL, &dummy_out); + SaSi_AesFree(&ctx); + nRFCrypto.end(); + return sp - src; +#else AES128 aes; uint8_t* dp = dest; const uint8_t* sp = src; @@ -39,9 +85,34 @@ int Utils::decrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s } return sp - src; // will always be multiple of 16 +#endif } int Utils::encrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len) { +#ifdef USE_CC310_HW_CRYPTO + static SaSiAesUserContext_t ctx; + SaSiAesUserKeyData_t keyData = { (uint8_t*)shared_secret, CIPHER_KEY_SIZE }; + uint8_t* dp = dest; + size_t dummy_out = 0; + + nRFCrypto.begin(); + SaSi_AesInit(&ctx, SASI_AES_ENCRYPT, SASI_AES_MODE_ECB, SASI_AES_PADDING_NONE); + SaSi_AesSetKey(&ctx, SASI_AES_USER_KEY, &keyData, sizeof(keyData)); + while (src_len >= 16) { + SaSi_AesBlock(&ctx, (uint8_t*)src, 16, dp); + dp += 16; src += 16; src_len -= 16; + } + if (src_len > 0) { // remaining partial block — zero-pad to 16 bytes + uint8_t tmp[16] = {}; + memcpy(tmp, src, src_len); + SaSi_AesBlock(&ctx, tmp, 16, dp); + dp += 16; + } + SaSi_AesFinish(&ctx, 0, NULL, 0, NULL, &dummy_out); + SaSi_AesFree(&ctx); + nRFCrypto.end(); + return dp - dest; +#else AES128 aes; uint8_t* dp = dest; @@ -58,15 +129,27 @@ int Utils::encrypt(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* s dp += 16; } return dp - dest; // will always be multiple of 16 +#endif } int Utils::encryptThenMAC(const uint8_t* shared_secret, uint8_t* dest, const uint8_t* src, int src_len) { int enc_len = encrypt(shared_secret, dest + CIPHER_MAC_SIZE, src, src_len); +#ifdef USE_CC310_HW_CRYPTO + static CRYS_HMACUserContext_t hmac_ctx; + static CRYS_HASH_Result_t hmac_result; + nRFCrypto.begin(); + CRYS_HMAC_Init(&hmac_ctx, CRYS_HASH_SHA256_mode, (uint8_t*)shared_secret, PUB_KEY_SIZE); + CRYS_HMAC_Update(&hmac_ctx, dest + CIPHER_MAC_SIZE, enc_len); + CRYS_HMAC_Finish(&hmac_ctx, hmac_result); + nRFCrypto.end(); + memcpy(dest, hmac_result, CIPHER_MAC_SIZE); +#else SHA256 sha; sha.resetHMAC(shared_secret, PUB_KEY_SIZE); sha.update(dest + CIPHER_MAC_SIZE, enc_len); sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, dest, CIPHER_MAC_SIZE); +#endif return CIPHER_MAC_SIZE + enc_len; } @@ -75,12 +158,25 @@ int Utils::MACThenDecrypt(const uint8_t* shared_secret, uint8_t* dest, const uin if (src_len <= CIPHER_MAC_SIZE) return 0; // invalid src bytes uint8_t hmac[CIPHER_MAC_SIZE]; +#ifdef USE_CC310_HW_CRYPTO + { + static CRYS_HMACUserContext_t hmac_ctx; + static CRYS_HASH_Result_t hmac_result; + nRFCrypto.begin(); + CRYS_HMAC_Init(&hmac_ctx, CRYS_HASH_SHA256_mode, (uint8_t*)shared_secret, PUB_KEY_SIZE); + CRYS_HMAC_Update(&hmac_ctx, (uint8_t*)(src + CIPHER_MAC_SIZE), src_len - CIPHER_MAC_SIZE); + CRYS_HMAC_Finish(&hmac_ctx, hmac_result); + nRFCrypto.end(); + memcpy(hmac, hmac_result, CIPHER_MAC_SIZE); + } +#else { SHA256 sha; sha.resetHMAC(shared_secret, PUB_KEY_SIZE); sha.update(src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); sha.finalizeHMAC(shared_secret, PUB_KEY_SIZE, hmac, CIPHER_MAC_SIZE); } +#endif if (memcmp(hmac, src, CIPHER_MAC_SIZE) == 0) { return decrypt(shared_secret, dest, src + CIPHER_MAC_SIZE, src_len - CIPHER_MAC_SIZE); } @@ -150,4 +246,4 @@ int Utils::parseTextParts(char* text, const char* parts[], int max_num, char sep return num; } -} \ No newline at end of file +} diff --git a/src/helpers/AdvertDataHelpers.cpp b/src/helpers/AdvertDataHelpers.cpp index 0e05620ec2..998733ae04 100644 --- a/src/helpers/AdvertDataHelpers.cpp +++ b/src/helpers/AdvertDataHelpers.cpp @@ -1,4 +1,5 @@ #include <helpers/AdvertDataHelpers.h> +#include <helpers/UTF8Helpers.h> uint8_t AdvertDataBuilder::encodeTo(uint8_t app_data[]) { app_data[0] = _type; @@ -16,11 +17,12 @@ app_data[0] |= ADV_FEAT2_MASK; memcpy(&app_data[i], &_extra2, 2); i += 2; } - if (_name && *_name != 0) { - app_data[0] |= ADV_NAME_MASK; - const char* sp = _name; - while (*sp && i < MAX_ADVERT_DATA_SIZE) { - app_data[i++] = *sp++; + if (_name && *_name != 0) { + size_t name_len = mesh::validUtf8PrefixLength(_name, MAX_ADVERT_DATA_SIZE - i); + if (name_len > 0) { + app_data[0] |= ADV_NAME_MASK; + memcpy(&app_data[i], _name, name_len); + i += name_len; } } return i; @@ -84,4 +86,4 @@ void AdvertTimeHelper::formatRelativeTimeDiff(char dest[], int32_t seconds_from_ } } } -} \ No newline at end of file +} diff --git a/src/helpers/AutoDiscoverRTCClock.cpp b/src/helpers/AutoDiscoverRTCClock.cpp index a310539bc7..af08fddb03 100644 --- a/src/helpers/AutoDiscoverRTCClock.cpp +++ b/src/helpers/AutoDiscoverRTCClock.cpp @@ -42,6 +42,7 @@ void AutoDiscoverRTCClock::begin(TwoWire& wire) { } if (i2c_probe(wire, PCF8563_ADDRESS)) { + MESH_DEBUG_PRINTLN("PCF8563: Found"); rtc_8563_success = rtc_8563.begin(&wire); } diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index d3ef034e67..972a97e9e6 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -68,29 +68,36 @@ void BaseChatMesh::bootstrapRTCfromContacts() { } ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) { - if (num_contacts < MAX_CONTACTS) { - return &contacts[num_contacts++]; - } else if (transient_only || shouldOverwriteWhenFull()) { - // Find oldest non-favourite contact by oldest lastmod timestamp - int oldest_idx = -1; - uint32_t oldest_lastmod = 0xFFFFFFFF; - for (int i = 0; i < num_contacts; i++) { - if (transient_only) { - if (contacts[i].type == ADV_TYPE_NONE && contacts[i].lastmod < oldest_lastmod) { - oldest_lastmod = contacts[i].lastmod; - oldest_idx = i; - } - } else { + int oldest_idx = -1; + uint32_t oldest_lastmod = 0xFFFFFFFF; + if (transient_only) { + // only allocate from first N + for (int i = 0; i < MAX_ANON_CONTACTS; i++) { + if (contacts[i].type == ADV_TYPE_NONE && contacts[i].lastmod < oldest_lastmod) { + oldest_lastmod = contacts[i].lastmod; + oldest_idx = i; + } + } + if (oldest_idx >= 0) { + // NOTE: do NOT call onContactOverwrite() + return &contacts[oldest_idx]; + } + } else { + if (num_contacts < MAX_ANON_CONTACTS+MAX_CONTACTS) { + return &contacts[num_contacts++]; + } else if (shouldOverwriteWhenFull()) { + // Find oldest non-favourite contact by oldest lastmod timestamp + for (int i = MAX_ANON_CONTACTS; i < num_contacts; i++) { bool is_favourite = (contacts[i].flags & 0x01) != 0; - if (!is_favourite && contacts[i].lastmod < oldest_lastmod && contacts[i].type != ADV_TYPE_NONE) { + if (!is_favourite && contacts[i].lastmod < oldest_lastmod) { oldest_lastmod = contacts[i].lastmod; oldest_idx = i; } } - } - if (oldest_idx >= 0) { - onContactOverwrite(contacts[oldest_idx].id.pub_key); - return &contacts[oldest_idx]; + if (oldest_idx >= 0) { + onContactOverwrite(contacts[oldest_idx].id.pub_key); + return &contacts[oldest_idx]; + } } } return NULL; // no space, no overwrite or all contacts are all favourites @@ -139,6 +146,11 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, packet->header = save; } + if (from && from->type == ADV_TYPE_NONE) { // already in contacts, but from a temporary ANON_REQ ? + memset(from, 0, sizeof(*from)); // clear the anon/temp slot + from = NULL; // do normal 'add' flow + } + bool is_new = false; // true = not in contacts[], false = exists in contacts[] if (from == NULL) { if (!shouldAutoAddContactType(parser.getType())) { @@ -930,11 +942,11 @@ bool BaseChatMesh::getContactByIdx(uint32_t idx, ContactInfo& contact) { } ContactsIterator BaseChatMesh::startContactsIterator() { - return ContactsIterator(); + return ContactsIterator(MAX_ANON_CONTACTS); // start at offset, skip the anon entries } bool ContactsIterator::hasNext(const BaseChatMesh* mesh, ContactInfo& dest) { - if (next_idx >= mesh->getNumContacts()) return false; + if (next_idx >= mesh->getTotalContactSlots()) return false; dest = mesh->contacts[next_idx++]; return true; diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index c04bfda38c..d987854709 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -28,8 +28,9 @@ class ContactVisitor { class BaseChatMesh; class ContactsIterator { - int next_idx = 0; + int next_idx; public: + ContactsIterator(int start) { next_idx = start; } bool hasNext(const BaseChatMesh* mesh, ContactInfo& dest); }; @@ -79,8 +80,9 @@ class BaseChatMesh : public mesh::Mesh { protected: BaseChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) - { - num_contacts = 0; + { + resetContacts(); + #ifdef MAX_GROUP_CHANNELS memset(channels, 0, sizeof(channels)); num_channels = 0; @@ -91,7 +93,11 @@ class BaseChatMesh : public mesh::Mesh { } void bootstrapRTCfromContacts(); - void resetContacts() { num_contacts = 0; } + + void resetContacts() { + memset(contacts, 0, sizeof(contacts[0])*MAX_ANON_CONTACTS); // set all to have type = ADV_TYPE_NONE(0) + num_contacts = MAX_ANON_CONTACTS; // seed the first contacts for anon requests + } void populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp); ContactInfo* allocateContactSlot(bool transient_only=false); // helper to find slot for new contact @@ -166,7 +172,8 @@ class BaseChatMesh : public mesh::Mesh { ContactInfo* lookupContactByPubKey(const uint8_t* pub_key, int prefix_len); bool removeContact(ContactInfo& contact); bool addContact(const ContactInfo& contact); - int getNumContacts() const { return num_contacts; } + int getTotalContactSlots() const { return num_contacts; } + int getNumContacts() const { return num_contacts - MAX_ANON_CONTACTS; } // don't include the reserved slots at start bool getContactByIdx(uint32_t idx, ContactInfo& contact); ContactsIterator startContactsIterator(); ChannelDetails* addChannel(const char* name, const char* psk_base64); diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index e9a3f2ab46..23933fcb4b 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -14,6 +14,7 @@ class BaseSerialInterface { virtual bool isEnabled() const = 0; virtual bool isConnected() const = 0; + virtual void loop() {}; virtual bool isWriteBusy() const = 0; virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b78ad6ebd6..cfe5d3a7a3 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -28,16 +28,25 @@ static bool isValidName(const char *n) { } void CommonCLI::loadPrefs(FILESYSTEM* fs) { - if (fs->exists("/com_prefs")) { - loadPrefsInt(fs, "/com_prefs"); // new filename - } else if (fs->exists("/node_prefs")) { - loadPrefsInt(fs, "/node_prefs"); - savePrefs(fs); // save to new filename - fs->remove("/node_prefs"); // remove old + if (fs->exists("/prefs.json")) { +#if defined(RP2040_PLATFORM) + File file = fs->open("/prefs.json", "r"); +#else + File file = fs->open("/prefs.json"); +#endif + if (file) { + _prefs->loadSerial(file); // new Serial prefs + file.close(); + } + } else if (fs->exists("/com_prefs")) { + loadPrefsInt(fs, "/com_prefs"); + if (savePrefs(fs)) { // save to new Serial prefs + // fs->remove("/com_prefs"); // remove old + } } } -void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { +void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy prefs loader #if defined(RP2040_PLATFORM) File file = fs->open(filename, "r"); #else @@ -88,10 +97,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 - file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - file.read((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 - file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 - // next: 293 + file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 + file.read((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 + file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 + file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293 + file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 + // next: 295 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -121,73 +132,28 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // sanitise settings _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean + _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean file.close(); } } -void CommonCLI::savePrefs(FILESYSTEM* fs) { +bool CommonCLI::savePrefs(FILESYSTEM* fs) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - fs->remove("/com_prefs"); - File file = fs->open("/com_prefs", FILE_O_WRITE); + fs->remove("/prefs.json"); + File file = fs->open("/prefs.json", FILE_O_WRITE); #elif defined(RP2040_PLATFORM) - File file = fs->open("/com_prefs", "w"); + File file = fs->open("/prefs.json", "w"); #else - File file = fs->open("/com_prefs", "w", true); + File file = fs->open("/prefs.json", "w", true); #endif if (file) { - uint8_t pad[8]; - memset(pad, 0, sizeof(pad)); - - file.write((uint8_t *)&_prefs->airtime_factor, sizeof(_prefs->airtime_factor)); // 0 - file.write((uint8_t *)&_prefs->node_name, sizeof(_prefs->node_name)); // 4 - file.write(pad, 4); // 36 - file.write((uint8_t *)&_prefs->node_lat, sizeof(_prefs->node_lat)); // 40 - file.write((uint8_t *)&_prefs->node_lon, sizeof(_prefs->node_lon)); // 48 - file.write((uint8_t *)&_prefs->password[0], sizeof(_prefs->password)); // 56 - file.write((uint8_t *)&_prefs->freq, sizeof(_prefs->freq)); // 72 - file.write((uint8_t *)&_prefs->tx_power_dbm, sizeof(_prefs->tx_power_dbm)); // 76 - file.write((uint8_t *)&_prefs->disable_fwd, sizeof(_prefs->disable_fwd)); // 77 - file.write((uint8_t *)&_prefs->advert_interval, sizeof(_prefs->advert_interval)); // 78 - file.write(pad, 1); // 79 : 1 byte unused (rx_boosted_gain moved to end) - file.write((uint8_t *)&_prefs->rx_delay_base, sizeof(_prefs->rx_delay_base)); // 80 - file.write((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84 - file.write((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88 - file.write((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104 - file.write(pad, 4); // 108 : 4 byte unused - file.write((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112 - file.write((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113 - file.write((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 - file.write((uint8_t *)&_prefs->multi_acks, sizeof(_prefs->multi_acks)); // 115 - file.write((uint8_t *)&_prefs->bw, sizeof(_prefs->bw)); // 116 - file.write((uint8_t *)&_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval)); // 120 - file.write((uint8_t *)&_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 121 - file.write((uint8_t *)&_prefs->loop_detect, sizeof(_prefs->loop_detect)); // 122 - file.write(pad, 1); // 123 - file.write((uint8_t *)&_prefs->flood_max, sizeof(_prefs->flood_max)); // 124 - file.write((uint8_t *)&_prefs->flood_advert_interval, sizeof(_prefs->flood_advert_interval)); // 125 - file.write((uint8_t *)&_prefs->interference_threshold, sizeof(_prefs->interference_threshold)); // 126 - file.write((uint8_t *)&_prefs->bridge_enabled, sizeof(_prefs->bridge_enabled)); // 127 - file.write((uint8_t *)&_prefs->bridge_delay, sizeof(_prefs->bridge_delay)); // 128 - file.write((uint8_t *)&_prefs->bridge_pkt_src, sizeof(_prefs->bridge_pkt_src)); // 130 - file.write((uint8_t *)&_prefs->bridge_baud, sizeof(_prefs->bridge_baud)); // 131 - file.write((uint8_t *)&_prefs->bridge_channel, sizeof(_prefs->bridge_channel)); // 135 - file.write((uint8_t *)&_prefs->bridge_secret, sizeof(_prefs->bridge_secret)); // 136 - file.write((uint8_t *)&_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled)); // 152 - file.write(pad, 3); // 153 - file.write((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156 - file.write((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157 - file.write((uint8_t *)&_prefs->advert_loc_policy, sizeof(_prefs->advert_loc_policy)); // 161 - file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 - file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 - file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 - file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290 - file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291 - file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292 - // next: 293 - + bool success = _prefs->saveSerial(file); file.close(); + return success; } + return false; } #define MIN_LOCAL_ADVERT_INTERVAL 60 @@ -500,6 +466,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->interference_threshold = atoi(&config[11]); savePrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "cad ", 4) == 0) { + _prefs->cad_enabled = memcmp(&config[4], "on", 2) == 0; + savePrefs(); + strcpy(reply, "OK"); } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { _prefs->agc_reset_interval = atoi(&config[19]) / 4; savePrefs(); @@ -561,13 +531,37 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->disable_fwd = memcmp(&config[7], "off", 3) == 0; savePrefs(); strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON"); -#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1110) } else if (memcmp(config, "radio.rxgain ", 13) == 0) { - _prefs->rx_boosted_gain = memcmp(&config[13], "on", 2) == 0; - strcpy(reply, "OK"); + bool enabled = memcmp(&config[13], "on", 2) == 0; + _prefs->rx_boosted_gain = enabled; savePrefs(); - _callbacks->setRxBoostedGain(_prefs->rx_boosted_gain); -#endif + if (_callbacks->setRxBoostedGain(enabled)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: unsupported"); + } + } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { + if (!_board->canControlLoRaFemLna()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&config[17], "on", 2) == 0) { + if (_board->setLoRaFemLnaEnabled(true)) { + _prefs->radio_fem_rxgain = 1; + savePrefs(); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (memcmp(&config[17], "off", 3) == 0) { + if (_board->setLoRaFemLnaEnabled(false)) { + _prefs->radio_fem_rxgain = 0; + savePrefs(); + 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"); + } } else if (memcmp(config, "radio ", 6) == 0) { strcpy(tmp, &config[6]); const char *parts[4]; @@ -746,6 +740,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep savePrefs(); strcpy(reply, "OK"); #endif + } else if (memcmp(config, "board.", 6) == 0) { + const char* result = _board->setCustomSetter(&config[6]); + if (result != nullptr) { + strcpy(reply, result); + } else { + strcpy(reply, "Error: unknown board command"); + } } else if (memcmp(config, "adc.multiplier ", 15) == 0) { _prefs->adc_multiplier = atof(&config[15]); if (_board->setAdcMultiplier(_prefs->adc_multiplier)) { @@ -757,8 +758,30 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } else { _prefs->adc_multiplier = 0.0f; - strcpy(reply, "Error: unsupported by this board"); + strcpy(reply, "Error: unsupported"); }; + #if defined(USE_LR2021) + } else if (memcmp(config, "extra.sf ", 9) == 0) { + strcpy(tmp, &config[9]); + const char *parts[4]; + uint8_t sideDetSFs[4]; + int num = mesh::Utils::parseTextParts(tmp, parts, 4); + if (num > 3) { + sprintf(reply, "Invalid extra SF config"); + } else { + for (int i = 0; i < num; i++) { + sideDetSFs[i] = atoi(parts[i]); + } + sideDetSFs[num] = 0; + if (_callbacks->configSideDetectors(sideDetSFs, num, _prefs->bw)) { + for (int i = 0; i <= num; i++) _prefs->extra_sf[i] = sideDetSFs[i]; + savePrefs(); + sprintf(reply, "OK - extra SFs set"); + } else { + sprintf(reply, "Invalid extra SF config"); + } + } + #endif } else { strcpy(reply, "unknown config: "); StrHelper::strncpy(&reply[16], config, 160-17); @@ -776,6 +799,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor)); } else if (memcmp(config, "int.thresh", 10) == 0) { sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); + } else if (memcmp(config, "cad", 3) == 0) { + sprintf(reply, "> %s", _prefs->cad_enabled ? "on" : "off"); } else if (memcmp(config, "agc.reset.interval", 18) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4); } else if (memcmp(config, "multi.acks", 10) == 0) { @@ -801,10 +826,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lat)); } else if (memcmp(config, "lon", 3) == 0) { sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lon)); -#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1110) } else if (memcmp(config, "radio.rxgain", 12) == 0) { sprintf(reply, "> %s", _prefs->rx_boosted_gain ? "on" : "off"); -#endif + } else if (memcmp(config, "radio.fem.rxgain", 16) == 0) { + if (!_board->canControlLoRaFemLna()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", _board->isLoRaFemLnaEnabled() ? "on" : "off"); + } } else if (memcmp(config, "radio", 5) == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(_prefs->freq)); @@ -890,12 +919,20 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "> unknown"); } #else - strcpy(reply, "ERROR: unsupported"); + strcpy(reply, "Error: unsupported"); #endif + } else if (memcmp(config, "board.", 6) == 0) { + char res[100]; + memset(res, 0, sizeof(res)); + if (_board->getCustomGetter(&config[6], res, sizeof(res))) { + strcpy(reply, res); + } else { + strcpy(reply, "Error: unknown board command"); + } } else if (memcmp(config, "adc.multiplier", 14) == 0) { float adc_mult = _board->getAdcMultiplier(); if (adc_mult == 0.0f) { - strcpy(reply, "Error: unsupported by this board"); + strcpy(reply, "Error: unsupported"); } else { sprintf(reply, "> %.3f", adc_mult); } @@ -913,19 +950,23 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "ERROR: Power management not supported"); #endif } else if (memcmp(config, "pwrmgt.bootreason", 17) == 0) { -#ifdef NRF52_POWER_MANAGEMENT sprintf(reply, "> Reset: %s; Shutdown: %s", _board->getResetReasonString(_board->getResetReason()), _board->getShutdownReasonString(_board->getShutdownReason())); -#else - strcpy(reply, "ERROR: Power management not supported"); -#endif } else if (memcmp(config, "pwrmgt.bootmv", 13) == 0) { #ifdef NRF52_POWER_MANAGEMENT sprintf(reply, "> %u mV", _board->getBootVoltage()); #else strcpy(reply, "ERROR: Power management not supported"); #endif + } else if (memcmp(config, "extra.sf", 8) == 0) { + char* tmp = reply; + for (int i = 0; i < 3 && _prefs->extra_sf[i] != 0; i++) { + tmp += sprintf(tmp, "%s%d", (i == 0) ? "" : ",", _prefs->extra_sf[i]); + } + if (tmp == reply) { + sprintf(reply, "No extra SF configured"); + } } else { sprintf(reply, "??: %s", config); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index b509c2b31a..2a9ec43bcb 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -5,6 +5,7 @@ #include <helpers/SensorManager.h> #include <helpers/ClientACL.h> #include <helpers/RegionMap.h> +#include <helpers/ConfigSerializer.h> #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) #define WITH_BRIDGE @@ -19,50 +20,176 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 -struct NodePrefs { // persisted to file - float airtime_factor; +class NodePrefs : public ConfigSerializer { +public: + // in-memory backing data + float airtime_factor = 0; char node_name[32]; - double node_lat, node_lon; + double node_lat = 0, node_lon = 0; char password[16]; - float freq; - int8_t tx_power_dbm; - uint8_t disable_fwd; - uint8_t advert_interval; // minutes / 2 - uint8_t flood_advert_interval; // hours - float rx_delay_base; - float tx_delay_factor; + float freq = 0; + int8_t tx_power_dbm = 0; + uint8_t disable_fwd = 0; + uint8_t advert_interval = 0; // minutes / 2 + uint8_t flood_advert_interval = 0; // hours + float rx_delay_base = 0; + float tx_delay_factor = 0; char guest_password[16]; - float direct_tx_delay_factor; + float direct_tx_delay_factor = 0; uint32_t guard; - uint8_t sf; - uint8_t cr; - uint8_t allow_read_only; - uint8_t multi_acks; - float bw; - uint8_t flood_max; - uint8_t flood_max_unscoped; - uint8_t flood_max_advert; - uint8_t interference_threshold; - uint8_t agc_reset_interval; // secs / 4 + uint8_t sf = 0; + uint8_t cr = 0; + uint8_t allow_read_only = 0; + uint8_t multi_acks = 0; + float bw = 0; + uint8_t flood_max = 0; + uint8_t flood_max_unscoped = 0; + uint8_t flood_max_advert = 0; + uint8_t interference_threshold = 0; + uint8_t agc_reset_interval = 0; // secs / 4 // Bridge settings - uint8_t bridge_enabled; // boolean - uint16_t bridge_delay; // milliseconds (default 500 ms) - uint8_t bridge_pkt_src; // 0 = logTx, 1 = logRx (default logTx) - uint32_t bridge_baud; // 9600, 19200, 38400, 57600, 115200 (default 115200) - uint8_t bridge_channel; // 1-14 (ESP-NOW only) + uint8_t bridge_enabled = 0; // boolean + uint16_t bridge_delay = 0; // milliseconds (default 500 ms) + uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logTx) + uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200) + uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) // Power setting - uint8_t powersaving_enabled; // boolean + uint8_t powersaving_enabled = 0; // boolean // Gps settings - uint8_t gps_enabled; - uint32_t gps_interval; // in seconds - uint8_t advert_loc_policy; - uint32_t discovery_mod_timestamp; - float adc_multiplier; + uint8_t gps_enabled = 0; + uint32_t gps_interval = 0; // in seconds + uint8_t advert_loc_policy = 0; + uint32_t discovery_mod_timestamp = 0; + float adc_multiplier = 0; char owner_info[120]; - uint8_t rx_boosted_gain; // power settings - uint8_t path_hash_mode; // which path mode to use when sending - uint8_t loop_detect; + uint8_t rx_boosted_gain = 0; // power settings + uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting + uint8_t path_hash_mode = 0; // which path mode to use when sending + uint8_t loop_detect = 0; + uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) + uint8_t extra_sf[4]; + +private: + class RadioPrefs : public ConfigSerializer { + NodePrefs* _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("int_thr", _parent->interference_threshold); + def("rxgain", _parent->rx_boosted_gain); + def("fem_rxgain", _parent->rx_boosted_gain); + def("tx", _parent->tx_power_dbm); + def("af", _parent->airtime_factor); + def("rxdelay", _parent->rx_delay_base); + def("f_txdelay", _parent->tx_delay_factor); + def("d_txdelay", _parent->direct_tx_delay_factor); + def("agc_int", _parent->agc_reset_interval); + def("hash_mode", _parent->path_hash_mode); + def("multi_ack", _parent->multi_acks); + } + public: + RadioPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RadioPrefs radio; + + class BridgePrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("en", _parent->bridge_enabled); // boolean + def("delay", _parent->bridge_delay); // milliseconds (default 500 ms) + def("src", _parent->bridge_pkt_src); // 0 = logTx, 1 = logRx (default logTx) + def("baud", _parent->bridge_baud); // 9600, 19200, 38400, 57600, 115200 (default 115200) + def("ch", _parent->bridge_channel); // 1-14 (ESP-NOW only) + def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); // for XOR encryption of bridge packets (ESP-NOW only) + } + public: + BridgePrefs(NodePrefs* parent) : _parent(parent) { } + }; + BridgePrefs bridge; + + class GPSPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("en", _parent->gps_enabled); // boolean + def("int", _parent->gps_interval); // interval in seconds + def("adv_loc", _parent->advert_loc_policy); + } + public: + GPSPrefs(NodePrefs* parent) : _parent(parent) { } + }; + GPSPrefs gps; + + class PowerPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("adc_mult", _parent->adc_multiplier); + def("pwr_sav_en", _parent->powersaving_enabled); + } + public: + PowerPrefs(NodePrefs* parent) : _parent(parent) { } + }; + PowerPrefs power; + + class RepeatPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("disable", _parent->disable_fwd); + def("f_max", _parent->flood_max); + def("f_max_uns", _parent->flood_max_unscoped); + def("f_max_adv", _parent->flood_max_advert); + def("loop", _parent->loop_detect); + } + public: + RepeatPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RepeatPrefs repeat; + + class RoomPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("rd_only", _parent->allow_read_only); + } + public: + RoomPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RoomPrefs room; + +protected: + void structure() override { + def("name", node_name, sizeof(node_name)); + def("pass", password, sizeof(password)); + def("guest", guest_password, sizeof(guest_password)); + def("owner", owner_info, sizeof(owner_info)); + def("adv_int", advert_interval); + def("f_adv_int", flood_advert_interval); + def("lat", node_lat); + def("lon", node_lon); + def("radio", radio); + def("bridge", bridge); + def("gps", gps); + def("repeat", repeat); + def("room", room); + def("power", power); + } + +public: + NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this) { + node_name[0] = 0; + password[0] = 0; + guest_password[0] = 0; + bridge_secret[0] = 0; + owner_info[0] = 0; + } }; class CommonCLICallbacks { @@ -109,9 +236,15 @@ class CommonCLICallbacks { // no op by default }; - virtual void setRxBoostedGain(bool enable) { - // no op by default + virtual bool setRxBoostedGain(bool enable) { + return false; // CommonCLI reports unsupported if not overridden by wrapper }; + + #if defined(USE_LR2021) + virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { + return false; // Override in wrapper + } + #endif }; class CommonCLI { @@ -137,7 +270,7 @@ class CommonCLI { : _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(®ion_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { } void loadPrefs(FILESYSTEM* _fs); - void savePrefs(FILESYSTEM* _fs); + bool savePrefs(FILESYSTEM* _fs); void handleCommand(uint32_t sender_timestamp, char* command, char* reply); uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); }; diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp new file mode 100644 index 0000000000..adff147f47 --- /dev/null +++ b/src/helpers/ConfigSerializer.cpp @@ -0,0 +1,331 @@ +#include "ConfigSerializer.h" + +bool ConfigSerializer::saveSerial(Stream& s) { + Context context(&s, OP::WRITE); + _context = &context; // set the context for structure() call + s.print("{"); // root object + _first = true; + structure(); + if (s.print("}") != 1) context.success = false; // failure detect + _context = NULL; + return context.success; +} + +#define TOK_ERROR -1 +#define TOK_EOF 0 +#define TOK_KEY 1 +#define TOK_VALUE 2 +#define TOK_START_OBJ 3 +#define TOK_END_OBJ 4 +#define TOK_WHITESPACE 5 + +static bool is_whitespace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} +static bool is_key_char(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; +} +static bool is_value_char(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || c == '-' || c == '.'; +} + +#define EXPECT_OPEN_BRACE 0 +#define EXPECT_KEY 1 +#define EXPECT_VAL_OR_OBJ 2 +#define EXPECT_STRING_VAL 3 +#define EXPECT_STRING_ESCAPE 4 +#define EXPECT_COMMA_OR_CLOSE 5 +#define EXPECT_COMMA_OR_KEY 6 +#define EXPECT_COMMA_OR_KEY_OR_CLOSE 7 + +int ConfigSerializer::Context::readNext() { + char c; + if (pending) { + c = pending; + pending = 0; + } else { + if (_f->available() == 0) return TOK_EOF; + + int n = _f->read(); + if (n < 0) return TOK_EOF; + c = (char)n; + } + + switch (rd_mode) { + case EXPECT_OPEN_BRACE: + if (c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; } + if (is_whitespace(c)) return TOK_WHITESPACE; + return TOK_ERROR; + + case EXPECT_COMMA_OR_KEY_OR_CLOSE: + if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } + case EXPECT_COMMA_OR_KEY: + if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } + case EXPECT_KEY: + 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; } + return TOK_ERROR; + + case EXPECT_VAL_OR_OBJ: + if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE; + if (rd_len == 0 && c == '"') { rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + if (rd_len == 0 && c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; } + if (is_value_char(c) && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } + if (rd_len > 0 && (c == ',' || c == '}' || is_whitespace(c))) { pending = c; rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; } + return TOK_ERROR; + + case EXPECT_STRING_ESCAPE: + if ((c == 'n') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = '\n'; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + if ((c == 'r') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = '\r'; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + if ((c == '"' || c == '\\' || c == '/') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + return TOK_ERROR; // unsupport escape + + case EXPECT_STRING_VAL: + if (c == '"') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; } + if (c == '\\') { rd_mode = EXPECT_STRING_ESCAPE; return TOK_WHITESPACE; } + if (rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } + return TOK_ERROR; + + case EXPECT_COMMA_OR_CLOSE: + if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } + if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } + if (is_whitespace(c)) return TOK_WHITESPACE; + return TOK_ERROR; + } + return TOK_ERROR; // unknown mode +} + +bool ConfigSerializer::loadSerial(Stream& s) { + Context context(&s, OP::READ); + _context = &context; // set the context for structure() call + uint8_t sp = 0; // object nesting stack pointer + int next_tok; + + // parse the Json file + while ((next_tok = context.readNext()) > TOK_EOF) { + if (next_tok == TOK_KEY) { + context.setKey(sp, context.getToken()); + } else if (next_tok == TOK_VALUE) { + _depth = 1; // re-run the structure() hierarchy again (looking for specific key, at specific depth) + structure(); + } else if (next_tok == TOK_START_OBJ) { + if (sp < CONFIG_MAX_DEPTH - 1) { + sp++; + } else { + //Serial.printf("Error: max nesting reached"); // TODO: debug logging + context.success = false; + break; + } + } else if (next_tok == TOK_END_OBJ) { + if (sp > 0) { + sp--; + } else { + //Serial.printf("Error: too many closing '}'"); // TODO: debug logging + context.success = false; + break; + } + } + } + if (sp != 0 || next_tok == TOK_ERROR) { + context.success = false; // unmatched { }, or other parse error + } + _context = NULL; + return context.success; +} + +void ConfigSerializer::writeComma() { + if (_first) { + _first = false; + } else { + _context->file()->print(","); // comma separated properties + } +} + +#include <Utils.h> + +void ConfigSerializer::def(const char* key, void* value, size_t len) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":\""); + mesh::Utils::printHex(*_context->file(), (uint8_t*) value, len); + _context->file()->print("\""); + } else { + if (_context->keyMatch(_depth, key)) { + memset(value, 0, len); + mesh::Utils::fromHex((uint8_t *)value, len, _context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, char* value, size_t max_len) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":\""); + char c; + while ((c = *value++) != 0) { // TODO: handle UTF-8 encoding + if (c == '"') { + _context->file()->print("\\\""); + } else if (c == '\\') { + _context->file()->print("\\\\"); + } else if (c == '\n') { + _context->file()->print("\\n"); + } else if (c == '\r') { + _context->file()->print("\\r"); + } else { + _context->file()->print(c); + } + } + _context->file()->print("\""); + } else { + if (_context->keyMatch(_depth, key)) { + strncpy(value, _context->getToken(), max_len - 1); + value[max_len - 1] = 0; + } + } +} + +void ConfigSerializer::def(const char* key, int32_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print(value); + } else { + if (_context->keyMatch(_depth, key)) { + value = atol(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, uint32_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print(value); + } else { + if (_context->keyMatch(_depth, key)) { + value = atol(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, int16_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((int32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atol(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, uint16_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((uint32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atoi(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, uint8_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((uint32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atoi(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, int8_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((int32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atoi(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, bool& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print(value ? "true" : "false"); + } else { + if (_context->keyMatch(_depth, key)) { + value = strcmp(_context->getToken(), "true") == 0 || atoi(_context->getToken()) != 0; // 'true' or a non-zero number + } + } +} + +void ConfigSerializer::def(const char* key, double& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + if (value == 0.0) { + _context->file()->print("0"); // shorter encoding + } else { + _context->file()->print(value, 6); // REVISIT: how many dec places? + } + } else { + if (_context->keyMatch(_depth, key)) { + value = atof(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, float& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + if (value == 0.0f) { + _context->file()->print("0"); // shorter encoding + } else { + _context->file()->print(value, 4); // REVISIT: how many dec places? + } + } else { + if (_context->keyMatch(_depth, key)) { + value = (float) atof(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, ConfigSerializer& sub_obj) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":{"); + sub_obj._context = _context; // inherit the Context + sub_obj._first = true; + sub_obj.structure(); // recurse into sub object + if (_context->file()->print("}") != 1) _context->success = false; // failure detect + } else { + if (_context->keyMatch(_depth, key)) { + sub_obj._context = _context; // inherit the Context + sub_obj._depth = _depth + 1; + sub_obj.structure(); // recurse into sub object + } + } +} diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h new file mode 100644 index 0000000000..7e6d6f2a69 --- /dev/null +++ b/src/helpers/ConfigSerializer.h @@ -0,0 +1,68 @@ +#pragma once + +#include <Arduino.h> + +#ifndef CONFIG_MAX_DEPTH + #define CONFIG_MAX_DEPTH 8 +#endif + +#ifndef CONFIG_MAX_KEYLEN + #define CONFIG_MAX_KEYLEN 16 +#endif + +#ifndef CONFIG_MAX_TOKEN_LEN + #define CONFIG_MAX_TOKEN_LEN 128 +#endif + +class ConfigSerializer { + bool _first; + int8_t _depth; + + enum OP { READ, WRITE }; + + class Context { + Stream* _f; + OP _op; + uint8_t rd_len; + uint8_t rd_mode; + char pending; + char rd_buf[CONFIG_MAX_TOKEN_LEN]; + char _keys[CONFIG_MAX_DEPTH][CONFIG_MAX_KEYLEN]; + + public: + bool success = true; + Context(Stream* f, OP op) : _f(f), _op(op) { rd_buf[rd_len = 0] = 0; rd_mode = 0; pending = 0; } + OP op() const { return _op; } + Stream* file() const { return _f; } + int readNext(); + 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); } + }; + + Context* _context = NULL; + + void writeComma(); + +protected: + ConfigSerializer() { } + + void def(const char* key, char* value, size_t max_len); // max_len inclusive of null + void def(const char* key, void* value, size_t len); // binary blob (encoded in hex) + void def(const char* key, int32_t& value); + void def(const char* key, int16_t& value); + void def(const char* key, int8_t& value); + void def(const char* key, uint32_t& value); + void def(const char* key, uint16_t& value); + void def(const char* key, uint8_t& value); + void def(const char* key, float& value); + void def(const char* key, double& value); + void def(const char* key, bool& value); + void def(const char* key, ConfigSerializer& sub_obj); + + virtual void structure() = 0; + +public: + bool loadSerial(Stream& s); + bool saveSerial(Stream& s); +}; diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index e0ca1d0eeb..a55abb264e 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,6 +1,7 @@ #ifdef ESP_PLATFORM #include "ESP32Board.h" +#include <target.h> #if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only #include <WiFi.h> @@ -44,4 +45,45 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { } #endif +void ESP32Board::powerOff() { + enterDeepSleep(0); // Do not wakeup +} + +void ESP32Board::enterDeepSleep(uint32_t secs) { + // Power off the display if any +#ifdef DISPLAY_CLASS + display.turnOff(); +#endif + + // Power off LoRa + radio_driver.powerOff(); + + // Keep LoRa inactive during deepsleep + digitalWrite(P_LORA_NSS, HIGH); +#if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6) + gpio_hold_en((gpio_num_t)P_LORA_NSS); +#else + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); +#endif + + // Power off GPS if any + if (sensors.getLocationProvider() != NULL) { + sensors.getLocationProvider()->stop(); + } + + // Flush serial buffers + Serial.flush(); + delay(100); + + // Clear stale wakeup sources to avoid ghost wakeup + // This is required when Power Management and automatic lightsleep are enabled + esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL); + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000ULL); + } + + // Finally set ESP32 into deepsleep + esp_deep_sleep_start(); // CPU halts here and never returns! +} #endif diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index a4cbf2a980..d7eb5fee26 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -14,6 +14,7 @@ #include <Wire.h> #include "soc/rtc.h" #include "esp_system.h" +#include <driver/rtc_io.h> class ESP32Board : public mesh::MainBoard { protected: @@ -62,6 +63,9 @@ class ESP32Board : public mesh::MainBoard { return raw / 4; } + virtual void powerOff() override; + void enterDeepSleep(uint32_t secs); + uint32_t getIRQGpio() override { return P_LORA_DIO_1; // default for SX1262 } @@ -155,6 +159,42 @@ class ESP32Board : public mesh::MainBoard { void setInhibitSleep(bool inhibit) { inhibit_sleep = inhibit; } + + uint32_t getResetReason() const override { + return esp_reset_reason(); + } + + // https://docs.espressif.com/projects/esp-idf/en/v4.4.7/esp32/api-reference/system/system.html + const char* getResetReasonString(uint32_t reason) { + switch (reason) { + case ESP_RST_UNKNOWN: + return "Unknown or first boot"; + case ESP_RST_POWERON: + return "Power-on reset"; + case ESP_RST_EXT: + return "External reset"; + case ESP_RST_SW: + return "Software reset"; + case ESP_RST_PANIC: + return "Panic / exception reset"; + case ESP_RST_INT_WDT: + return "Interrupt watchdog reset"; + case ESP_RST_TASK_WDT: + return "Task watchdog reset"; + case ESP_RST_WDT: + return "Other watchdog reset"; + case ESP_RST_DEEPSLEEP: + return "Wake from deep sleep"; + case ESP_RST_BROWNOUT: + return "Brownout (low voltage)"; + case ESP_RST_SDIO: + return "SDIO reset"; + default: + static char buf[40]; + snprintf(buf, sizeof(buf), "Unknown reset reason (%d)", reason); + return buf; + } + } }; class ESP32RTCClock : public mesh::RTCClock { diff --git a/src/helpers/ExternalWatchdogManager.h b/src/helpers/ExternalWatchdogManager.h new file mode 100644 index 0000000000..cb4f492360 --- /dev/null +++ b/src/helpers/ExternalWatchdogManager.h @@ -0,0 +1,12 @@ +#pragma once + +class ExternalWatchdogManager { +protected: + unsigned long last_feed_watchdog; +public: + ExternalWatchdogManager() { last_feed_watchdog = 0; } + virtual bool begin() { return false; } + virtual void loop() { } + virtual unsigned long getIntervalMs() const { return 0; } + virtual void feed() { } +}; diff --git a/src/helpers/MeshadventurerBoard.h b/src/helpers/MeshadventurerBoard.h index 65e1110294..0325161d5d 100644 --- a/src/helpers/MeshadventurerBoard.h +++ b/src/helpers/MeshadventurerBoard.h @@ -15,8 +15,6 @@ #include "ESP32Board.h" -#include <driver/rtc_io.h> - class MeshadventurerBoard : public ESP32Board { public: @@ -35,34 +33,6 @@ class MeshadventurerBoard : public ESP32Board { } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are held on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void powerOff() override { - // TODO: re-enable this when there is a definite wake-up source pin: - // enterDeepSleep(0); - } - uint16_t getBattMilliVolts() override { analogReadResolution(12); diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h new file mode 100644 index 0000000000..f7742b24c3 --- /dev/null +++ b/src/helpers/MultiSerialInterface.h @@ -0,0 +1,200 @@ +#pragma once + +#include "BaseSerialInterface.h" + +#ifndef MAX_INTERFACES + // ble, usb, wifi, ethernet + #define MAX_INTERFACES 4 +#endif + +enum class InterfaceType : uint8_t { + NONE, + Bluetooth, + USB, + WiFi, + Ethernet, + HardwareSerial +}; + +class MultiSerialInterface : public BaseSerialInterface { +private: + + struct RegisteredInterface { + InterfaceType type = InterfaceType::NONE; + BaseSerialInterface* instance = nullptr; + }; + + bool _enabled = false; + RegisteredInterface _interfaces[MAX_INTERFACES] = {}; + +public: + bool addInterface(InterfaceType type, BaseSerialInterface* iface) { + // make sure an interface was provided + if(iface == nullptr){ + return false; + } + + // put it in the first free slot + for(int i = 0; i < MAX_INTERFACES; i++){ + if(_interfaces[i].instance == nullptr){ + _interfaces[i].instance = iface; + _interfaces[i].type = type; + return true; + } + } + + // no free slots available + return false; + } + + bool removeInterface(BaseSerialInterface* iface) { + // make sure an interface was provided + if(iface == nullptr){ + return false; + } + + // find and remove interface + for(int i = 0; i < MAX_INTERFACES; i++){ + if(_interfaces[i].instance == iface){ + _interfaces[i] = {}; + return true; + } + } + + // interface not found + return false; + } + + void enableBluetooth() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + iface.instance->enable(); + } + } + } + + void disableBluetooth() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + iface.instance->disable(); + } + } + } + + bool isBluetoothEnabled() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + return iface.instance->isEnabled(); + } + } + return false; + } + + // enable all interfaces + void enable() override { + _enabled = true; + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->enable(); + } + } + } + + // disable all interfaces + void disable() override { + _enabled = false; + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->disable(); + } + } + } + + bool isEnabled() const override { + return _enabled; + } + + bool isConnected() const override { + // not connected when disabled + if(!_enabled){ + return false; + } + + // check if any interface is connected + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isConnected()) { + return true; + } + } + + // nothing connected + return false; + } + + // loop all interfaces + void loop() override { + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->loop(); + } + } + } + + bool isWriteBusy() const override { + // not busy when disabled + if(!_enabled){ + return false; + } + + // check if any interface is busy + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled() && iface.instance->isWriteBusy()){ + return true; + } + } + + // nothing busy + return false; + } + + size_t writeFrame(const uint8_t src[], size_t len) override { + // don't write when disabled or nothing provided + if(!_enabled || len == 0){ + return 0; + } + + // write frame to all enabled interfaces + bool allSuccessful = true; + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled()){ + if(iface.instance->writeFrame(src, len) != len){ + allSuccessful = false; + } + } + } + + // report success if all writes completed successfully + return allSuccessful ? len : 0; + } + + size_t checkRecvFrame(uint8_t dest[]) override { + // don't read when disabled + if(!_enabled){ + return 0; + } + + // try to read a frame from any enabled interface + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled()){ + size_t frameSize = iface.instance->checkRecvFrame(dest); + if(frameSize > 0){ + return frameSize; + } + } + } + + // no frame received + return 0; + } + +}; diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 17265f0455..b6c8fec56c 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -1,5 +1,6 @@ #if defined(NRF52_PLATFORM) #include "NRF52Board.h" +#include <target.h> #include <bluefruit.h> #include <nrf_soc.h> @@ -279,16 +280,29 @@ void NRF52Board::sleep(uint32_t secs) { // Temperature from NRF52 MCU float NRF52Board::getMCUTemperature() { - NRF_TEMP->TASKS_START = 1; // Start temperature measurement - - long startTime = millis(); - while (NRF_TEMP->EVENTS_DATARDY == 0) { // Wait for completion. Should complete in 50us - if(millis() - startTime > 5) { // To wait 5ms just in case - NRF_TEMP->TASKS_STOP = 1; + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + if (sd_enabled) { + uint32_t err_code; + int32_t temp; + err_code = sd_temp_get(&temp); + if (err_code == NRF_SUCCESS) { + return (float)temp * 0.25f; + } else { return NAN; } + } else { + NRF_TEMP->TASKS_START = 1; // Start temperature measurement + + long startTime = millis(); + while (NRF_TEMP->EVENTS_DATARDY == 0) { // Wait for completion. Should complete in 50us + if(millis() - startTime > 5) { // To wait 5ms just in case + NRF_TEMP->TASKS_STOP = 1; + return NAN; + } + } } - + NRF_TEMP->EVENTS_DATARDY = 0; // Clear event flag int32_t temp = NRF_TEMP->TEMP; // In 0.25 *C units @@ -297,6 +311,65 @@ float NRF52Board::getMCUTemperature() { return temp * 0.25f; // Convert to *C } +void NRF52Board::shutdownPeripherals() { + // Power off the display if any +#ifdef DISPLAY_CLASS + if (display.isOn()) { + display.turnOff(); + } +#endif + // Prep LoRa radio for power down + #ifdef P_LORA_RESET + digitalWrite(P_LORA_RESET, HIGH); // preload OUT latch so pinMode can't glitch NRESET low + pinMode(P_LORA_RESET, OUTPUT); + digitalWrite(P_LORA_RESET, LOW); // deliberate hardware reset (datasheet: >=100us) + delayMicroseconds(200); + digitalWrite(P_LORA_RESET, HIGH); + #endif + #if defined(P_LORA_SCLK) && defined(P_LORA_MISO) && defined(P_LORA_MOSI) + SPI.setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); + SPI.begin(); // SPI may not be started on some shutdown paths, need it to shut down radio + #endif + #ifdef P_LORA_BUSY + pinMode(P_LORA_BUSY, INPUT); + uint32_t started_at = millis(); + while (digitalRead(P_LORA_BUSY) && millis() - started_at < 10) {} //wait for radio to be ready + #endif + #ifdef P_LORA_NSS + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(P_LORA_NSS, HIGH); + #endif + // Power off LoRa + radio_driver.powerOff(); + + // Keep LoRa inactive during deepsleep + #ifdef P_LORA_NSS + digitalWrite(P_LORA_NSS, HIGH); + #endif + + // Power off GPS if any + if(sensors.getLocationProvider() != NULL) { + sensors.getLocationProvider()->stop(); + } + + // Flush serial buffers + Serial.flush(); + delay(100); +} + +void NRF52Board::powerOff() { + shutdownPeripherals(); + + // Enter SYSTEMOFF + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + if (sd_enabled) { // SoftDevice is enabled + sd_power_system_off(); + } else { // SoftDevice is not enable + NRF_POWER->SYSTEMOFF = POWER_SYSTEMOFF_SYSTEMOFF_Enter; + } +} + bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { static const char BOOTLOADER_MARKER[] = "UF2 Bootloader "; const uint8_t* flash = (const uint8_t*)0x000FB000; // earliest known info.txt location is 0xFB90B, latest is 0xFCC4B @@ -323,7 +396,8 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); - Bluefruit.begin(1, 0); + if (!Bluefruit.begin(1, 0)) return false; + // Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4 Bluefruit.setTxPower(4); // Set the BLE device name diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 17065cf443..dba15f974e 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -50,6 +50,8 @@ class NRF52Board : public mesh::MainBoard { virtual uint8_t getStartupReason() const override { return startup_reason; } virtual float getMCUTemperature() override; virtual void reboot() override { NVIC_SystemReset(); } + virtual void shutdownPeripherals(); + virtual void powerOff() override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[]) override; virtual void sleep(uint32_t secs) override; diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 7b8399e260..4667e0038e 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -93,13 +93,15 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { while (num_regions < MAX_REGION_ENTRIES) { auto r = ®ions[num_regions]; - success = file.read((uint8_t *) &r->id, sizeof(r->id)) == sizeof(r->id); + int n = file.read((uint8_t *) &r->id, sizeof(r->id)); + if (n == 0) break; // clean EOF + success = (n == sizeof(r->id)); success = success && file.read((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent); success = success && file.read((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name); success = success && file.read((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags); success = success && file.read(pad, sizeof(pad)) == sizeof(pad); - if (!success) break; // EOF + if (!success) break; // partial read or corruption if (r->id >= next_id) { // make sure next_id is valid next_id = r->id + 1; @@ -108,7 +110,7 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { } } file.close(); - return true; + return success; } } return false; // failed @@ -139,7 +141,7 @@ bool RegionMap::save(FILESYSTEM* _fs, const char* path) { } } file.close(); - return true; + return success; } return false; // failed } diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 0b79cfb422..956f36faa6 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -31,27 +31,31 @@ class SimpleMeshTables : public mesh::MeshTables { } #endif - bool hasSeen(const mesh::Packet* packet) override { + bool wasSeen(const mesh::Packet* packet) override { uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); const uint8_t* sp = _hashes; for (int i = 0; i < MAX_PACKET_HASHES; i++, sp += MAX_HASH_SIZE) { - if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { + if (memcmp(hash, sp, MAX_HASH_SIZE) == 0) { if (packet->isRouteDirect()) { - _direct_dups++; // keep some stats + _direct_dups++; } else { _flood_dups++; } return true; } } - - memcpy(&_hashes[_next_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); - _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; // cyclic table return false; } + void markSeen(const mesh::Packet* packet) override { + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + memcpy(&_hashes[_next_idx * MAX_HASH_SIZE], hash, MAX_HASH_SIZE); + _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; + } + void clear(const mesh::Packet* packet) override { uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); diff --git a/src/helpers/UTF8Helpers.h b/src/helpers/UTF8Helpers.h new file mode 100644 index 0000000000..e06cf4a62c --- /dev/null +++ b/src/helpers/UTF8Helpers.h @@ -0,0 +1,56 @@ +#pragma once + +#include <stddef.h> +#include <stdint.h> + +namespace mesh { + +inline bool isUtf8Continuation(uint8_t byte) { + return (byte & 0xC0) == 0x80; +} + +inline size_t validUtf8PrefixLength(const char* text, size_t max_bytes) { + if (text == nullptr) return 0; + + size_t offset = 0; + while (text[offset] != '\0') { + const uint8_t first = static_cast<uint8_t>(text[offset]); + size_t sequence_length = 0; + + if (first <= 0x7F) { + sequence_length = 1; + } else if (first >= 0xC2 && first <= 0xDF) { + sequence_length = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + sequence_length = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + sequence_length = 4; + } else { + break; + } + + if (offset + sequence_length > max_bytes) break; + + bool complete = true; + for (size_t i = 1; i < sequence_length; i++) { + if (text[offset + i] == '\0' || !isUtf8Continuation(static_cast<uint8_t>(text[offset + i]))) { + complete = false; + break; + } + } + if (!complete) break; + + if (sequence_length == 3) { + const uint8_t second = static_cast<uint8_t>(text[offset + 1]); + if ((first == 0xE0 && second < 0xA0) || (first == 0xED && second > 0x9F)) break; + } else if (sequence_length == 4) { + const uint8_t second = static_cast<uint8_t>(text[offset + 1]); + if ((first == 0xF0 && second < 0x90) || (first == 0xF4 && second > 0x8F)) break; + } + + offset += sequence_length; + } + return offset; +} + +} // namespace mesh diff --git a/src/helpers/bridges/BridgeBase.cpp b/src/helpers/bridges/BridgeBase.cpp index d2e2e5e007..8093d3cb5b 100644 --- a/src/helpers/bridges/BridgeBase.cpp +++ b/src/helpers/bridges/BridgeBase.cpp @@ -39,7 +39,8 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) { return; } - if (!_seen_packets.hasSeen(packet)) { + if (!_seen_packets.wasSeen(packet)) { + _seen_packets.markSeen(packet); // bridge_delay provides a buffer to prevent immediate processing conflicts in the mesh network. _mgr->queueInbound(packet, millis() + _prefs->bridge_delay); } else { diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h index 04c1564bd4..8bbe646678 100644 --- a/src/helpers/bridges/BridgeBase.h +++ b/src/helpers/bridges/BridgeBase.h @@ -110,7 +110,7 @@ class BridgeBase : public AbstractBridge { * @brief Common packet handling for received packets * * Implements the standard pattern used by all bridges: - * - Check if packet was seen before using _seen_packets.hasSeen() + * - Check if packet was seen before using _seen_packets.wasSeen() * - Queue packet for mesh processing if not seen before * - Free packet if already seen to prevent duplicates * diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index b9eb1c105c..3c094e7c90 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -32,7 +32,7 @@ void ESPNowBridge::begin() { // Initialize WiFi in station mode WiFi.mode(WIFI_STA); - // Set wifi channel + // Set Wi-Fi channel if (esp_wifi_set_channel(_prefs->bridge_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error setting WIFI channel to %d\n", _prefs->bridge_channel); return; @@ -167,7 +167,8 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) { return; } - if (!_seen_packets.hasSeen(packet)) { + if (!_seen_packets.wasSeen(packet)) { + _seen_packets.markSeen(packet); // Create a temporary buffer just for size calculation and reuse for actual writing uint8_t sizingBuffer[MAX_PAYLOAD_SIZE]; uint16_t meshPacketLen = packet->writeTo(sizingBuffer); diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index 0024f6f2f2..f719d342e4 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -115,7 +115,8 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) { return; } - if (!_seen_packets.hasSeen(packet)) { + if (!_seen_packets.wasSeen(packet)) { + _seen_packets.markSeen(packet); uint8_t buffer[MAX_SERIAL_PACKET_SIZE]; uint16_t len = packet->writeTo(buffer + 4); diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index 67b1448e89..f474215a15 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -38,7 +38,7 @@ class ESPNOWRadio : public mesh::Radio { * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual void setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { } virtual bool getRxBoostedGainMode() const { return false; } uint32_t intID(); diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index dcfa0e1e34..6371cf3353 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -103,10 +103,9 @@ void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param void SerialBLEInterface::onDisconnect(BLEServer* pServer) { BLE_DEBUG_PRINTLN("onDisconnect()"); + deviceConnected = false; if (_isEnabled) { adv_restart_time = millis() + ADVERT_RESTART_DELAY; - - // loop() will detect this on next loop, and set deviceConnected to false } } @@ -118,17 +117,24 @@ void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gat if (len > MAX_FRAME_SIZE) { BLE_DEBUG_PRINTLN("ERROR: onWrite(), frame too big, len=%d", len); - } else if (recv_queue_len >= FRAME_QUEUE_SIZE) { - BLE_DEBUG_PRINTLN("ERROR: onWrite(), recv_queue is full!"); } else { - recv_queue[recv_queue_len].len = len; - memcpy(recv_queue[recv_queue_len].buf, rxValue, len); - recv_queue_len++; + Frame frame = {}; + frame.len = len; + memcpy(frame.buf, rxValue, len); + + if (xQueueSend(recv_queue, &frame, 0) != pdTRUE) { + BLE_DEBUG_PRINTLN("ERROR: onWrite(), recv_queue is full!"); + } } } // ---------- public methods +void SerialBLEInterface::clearBuffers() { + xQueueReset(recv_queue); + send_queue_len = 0; +} + void SerialBLEInterface::enable() { if (_isEnabled) return; @@ -202,21 +208,13 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { } } - if (recv_queue_len > 0) { // check recv queue - size_t len = recv_queue[0].len; // take from top of queue - memcpy(dest, recv_queue[0].buf, len); - - BLE_DEBUG_PRINTLN("readBytes: sz=%d, hdr=%d", len, (uint32_t) dest[0]); - - recv_queue_len--; - for (int i = 0; i < recv_queue_len; i++) { // delete top item from queue - recv_queue[i] = recv_queue[i + 1]; - } - return len; + Frame frame; + if (xQueueReceive(recv_queue, &frame, 0) == pdTRUE) { + memcpy(dest, frame.buf, frame.len); + BLE_DEBUG_PRINTLN("readBytes: sz=%d, hdr=%d", (uint32_t) frame.len, (uint32_t) dest[0]); + return frame.len; } - if (pServer->getConnectedCount() == 0) deviceConnected = false; - if (deviceConnected != oldDeviceConnected) { if (!deviceConnected) { // disconnecting clearBuffers(); diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 965e90fd19..9fb6adba94 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -5,6 +5,8 @@ #include <BLEServer.h> #include <BLEUtils.h> #include <BLE2902.h> +#include <freertos/FreeRTOS.h> +#include <freertos/queue.h> class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLEServerCallbacks, BLECharacteristicCallbacks { BLEServer *pServer; @@ -24,12 +26,13 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE }; #define FRAME_QUEUE_SIZE 4 - int recv_queue_len; - Frame recv_queue[FRAME_QUEUE_SIZE]; + StaticQueue_t recv_queue_state; + uint8_t recv_queue_storage[FRAME_QUEUE_SIZE * sizeof(Frame)]; + QueueHandle_t recv_queue; int send_queue_len; Frame send_queue[FRAME_QUEUE_SIZE]; - void clearBuffers() { recv_queue_len = 0; send_queue_len = 0; } + void clearBuffers(); protected: // BLESecurityCallbacks methods @@ -58,7 +61,10 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE _isEnabled = false; _last_write = 0; last_conn_id = 0; - send_queue_len = recv_queue_len = 0; + recv_queue = xQueueCreateStatic( + FRAME_QUEUE_SIZE, sizeof(Frame), recv_queue_storage, &recv_queue_state + ); + send_queue_len = 0; } /** diff --git a/src/helpers/esp32/TBeamBoard.cpp b/src/helpers/esp32/TBeamBoard.cpp index 5f708d71d9..d9f6e02275 100644 --- a/src/helpers/esp32/TBeamBoard.cpp +++ b/src/helpers/esp32/TBeamBoard.cpp @@ -16,6 +16,16 @@ void TBeamBoard::begin() { ESP32Board::begin(); +#ifdef TBEAM_SUPREME_SX1262 + // On the T-Beam S3 Supreme the PMU + RTC sit on Wire1 (GPIO 42/41, brought + // up by XPowersLib), while the SH1106 OLED and the BME280/QMC6310 sensors + // sit on the primary bus, Wire (GPIO PIN_BOARD_SDA/PIN_BOARD_SCL). Nothing + // else initialises Wire on this board, so the display driver would + // otherwise talk on the wrong (default) pins and display.begin() fails, + // leaving the screen blank. Bring the OLED bus up here. + Wire.begin(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + power_init(); //Configure user button diff --git a/src/helpers/ethernet/EthernetInterface.h b/src/helpers/ethernet/EthernetInterface.h new file mode 100644 index 0000000000..4e1f9176a6 --- /dev/null +++ b/src/helpers/ethernet/EthernetInterface.h @@ -0,0 +1,11 @@ +#pragma once + +#if defined(ETHERNET_ENABLED) + #if defined(ETHERNET_USE_CH390) + #include "helpers/ethernet/ch390/CH390EthernetInterface.h" + #elif defined(ETHERNET_USE_RAK13800) + #include "helpers/ethernet/RAK13800/RAK13800EthernetInterface.h" + #else + #error "ETHERNET_ENABLED is defined, but no specific driver flag (e.g. ETHERNET_USE_CH390) was provided!" + #endif +#endif diff --git a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp new file mode 100644 index 0000000000..027581e303 --- /dev/null +++ b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp @@ -0,0 +1,109 @@ +#include "RAK13800EthernetInterface.h" +#include "../../nrf52/EthernetMac.h" +#include <SPI.h> +#include <EthernetUdp.h> + +#define PIN_SPI1_MISO (29) // (0 + 29) +#define PIN_SPI1_MOSI (30) // (0 + 30) +#define PIN_SPI1_SCK (3) // (0 + 3) + +SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SPI1_MOSI); + +#define PIN_ETHERNET_POWER_EN WB_IO2 // output, high to enable +#define PIN_ETHERNET_RESET 21 +#define PIN_ETHERNET_SS 26 + +bool RAK13800EthernetInterface::begin() { + + // WB_IO2 (power enable) is already driven HIGH by early constructor + // in RAK4631Board.cpp to support POE boot. + // Skip hardware reset — the W5100S comes out of power-on reset cleanly, + // and toggling reset kills the PHY link which breaks POE power. +#ifdef PIN_ETHERNET_RESET + pinMode(PIN_ETHERNET_RESET, OUTPUT); + digitalWrite(PIN_ETHERNET_RESET, HIGH); +#endif + + // generate mac address + uint8_t mac[6]; + generateEthernetMac(mac); + ETHERNET_DEBUG_PRINTLN( + "Ethernet MAC: %02X:%02X:%02X:%02X:%02X:%02X", + mac[0], + mac[1], + mac[2], + mac[3], + mac[4], + mac[5]); + ETHERNET_SPI_PORT.begin(); + Ethernet.init(ETHERNET_SPI_PORT, PIN_ETHERNET_SS); + + // Use static IP if build flags are defined, otherwise DHCP + #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) && defined(ETHERNET_STATIC_DNS) + IPAddress ip(ETHERNET_STATIC_IP); + IPAddress gateway(ETHERNET_STATIC_GATEWAY); + IPAddress subnet(ETHERNET_STATIC_SUBNET); + IPAddress dns(ETHERNET_STATIC_DNS); + Ethernet.begin(mac, ip, dns, gateway, subnet); + #else + if (Ethernet.begin(mac) == 0) { + ETHERNET_DEBUG_PRINTLN("Failed to initialize RAK13800 hardware."); + if (Ethernet.hardwareStatus() == EthernetNoHardware) { + ETHERNET_DEBUG_PRINTLN("Ethernet hardware not found."); + } else if (Ethernet.linkStatus() == LinkOFF) { + ETHERNET_DEBUG_PRINTLN("Ethernet cable not connected."); + } else { + ETHERNET_DEBUG_PRINTLN("DHCP failed for unknown reason."); + } + return false; + } + #endif + + ETHERNET_DEBUG_PRINTLN("Ethernet begin complete"); + ETHERNET_DEBUG_PRINT_IP("IP Address", Ethernet.localIP()); + ETHERNET_DEBUG_PRINT_IP("Subnet Mask", Ethernet.subnetMask()); + ETHERNET_DEBUG_PRINT_IP("Gateway", Ethernet.gatewayIP()); + ETHERNET_DEBUG_PRINT_IP("DNS", Ethernet.dnsServerIP()); + + server.begin(); + ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT); + + return true; +} + +int RAK13800EthernetInterface::available() { + return client.available(); +} + +int RAK13800EthernetInterface::read() { + return client.read(); +} + +size_t RAK13800EthernetInterface::write(const uint8_t *buf, size_t size) { + return client.write(buf, size); +} + +bool RAK13800EthernetInterface::isConnected() const { + return _isConnected; +} + +void RAK13800EthernetInterface::loop() { + + Ethernet.maintain(); + + auto newClient = server.accept(); + if (newClient) { + IPAddress remoteIp = newClient.remoteIP(); + uint16_t remotePort = newClient.remotePort(); + ETHERNET_DEBUG_PRINTLN("New client accepted %u.%u.%u.%u:%u", remoteIp[0], remoteIp[1], remoteIp[2], remoteIp[3], remotePort); + if (client) { + ETHERNET_DEBUG_PRINTLN("Closing previous client"); + client.stop(); + } + client = newClient; + onClientConnected(); + } + + _isConnected = client.connected(); + +} diff --git a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h new file mode 100644 index 0000000000..34bb758777 --- /dev/null +++ b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../SerialEthernetInterface.h" +#include <SPI.h> +#include <RAK13800_W5100S.h> + +class RAK13800EthernetInterface : public SerialEthernetInterface { + + bool _isConnected; + EthernetServer server; + EthernetClient client; + + public: + RAK13800EthernetInterface() : server(EthernetServer(ETHERNET_TCP_PORT)) { + _isConnected = false; + } + + bool begin(); + void loop() override; + + // BaseSerialInterface methods + bool isConnected() const override; + + int available() override; + int read() override; + size_t write(const uint8_t *buf, size_t size) override; +}; diff --git a/src/helpers/ethernet/SerialEthernetInterface.cpp b/src/helpers/ethernet/SerialEthernetInterface.cpp new file mode 100644 index 0000000000..22bcabc10b --- /dev/null +++ b/src/helpers/ethernet/SerialEthernetInterface.cpp @@ -0,0 +1,140 @@ +#include "SerialEthernetInterface.h" + +#define RECV_STATE_IDLE 0 +#define RECV_STATE_HDR_FOUND 1 +#define RECV_STATE_LEN1_FOUND 2 +#define RECV_STATE_LEN2_FOUND 3 + +bool SerialEthernetInterface::begin() { + return true; +} + +void SerialEthernetInterface::enable() { + if (_isEnabled) return; + _isEnabled = true; + clearBuffers(); +} + +void SerialEthernetInterface::disable() { + _isEnabled = false; +} + +size_t SerialEthernetInterface::writeFrame(const uint8_t src[], size_t len) { + if (len > MAX_FRAME_SIZE) { + ETHERNET_DEBUG_PRINTLN("writeFrame(), frame too big, len=%d\n", len); + return 0; + } + + if (isConnected() && len > 0) { + if (send_queue_len >= FRAME_QUEUE_SIZE) { + ETHERNET_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); + return 0; + } + + send_queue[send_queue_len].len = len; // add to send queue + memcpy(send_queue[send_queue_len].buf, src, len); + send_queue_len++; + + return len; + } + return 0; +} + +bool SerialEthernetInterface::isWriteBusy() const { + return false; +} + +void SerialEthernetInterface::onClientConnected() { + _state = RECV_STATE_IDLE; + _frame_len = 0; + _rx_len = 0; +} + +size_t SerialEthernetInterface::checkRecvFrame(uint8_t dest[]) { + + if (isConnected()) { + if (send_queue_len > 0) { // first, check send queue + + _last_write = millis(); + int len = send_queue[0].len; + +#if ETHERNET_RAW_LINE + ETHERNET_DEBUG_PRINTLN("TX line len=%d", len); + client.write(send_queue[0].buf, len); + client.write("\r\n", 2); +#else + uint8_t pkt[3+len]; // use same header as serial interface so client can delimit frames + pkt[0] = '>'; + pkt[1] = (len & 0xFF); // LSB + pkt[2] = (len >> 8); // MSB + memcpy(&pkt[3], send_queue[0].buf, send_queue[0].len); + ETHERNET_DEBUG_PRINTLN("Sending frame len=%d", len); + #if ETHERNET_DEBUG_LOGGING && ARDUINO + ETHERNET_DEBUG_PRINTLN("TX frame len=%d", len); + #endif + write(pkt, 3 + len); +#endif + send_queue_len--; + for (int i = 0; i < send_queue_len; i++) { // delete top item from queue + send_queue[i] = send_queue[i + 1]; + } + } else { + while (available()) { + int c = read(); + if (c < 0) break; + +#if ETHERNET_RAW_LINE + if (c == '\r' || c == '\n') { + if (_rx_len == 0) { + continue; + } + uint16_t out_len = _rx_len; + if (out_len > MAX_FRAME_SIZE) out_len = MAX_FRAME_SIZE; + memcpy(dest, _rx_buf, out_len); + _rx_len = 0; + return out_len; + } + if (_rx_len < MAX_FRAME_SIZE) { + _rx_buf[_rx_len] = (uint8_t)c; + _rx_len++; + } +#else + switch (_state) { + case RECV_STATE_IDLE: + if (c == '<') { + _state = RECV_STATE_HDR_FOUND; + } + break; + case RECV_STATE_HDR_FOUND: + _frame_len = (uint8_t)c; + _state = RECV_STATE_LEN1_FOUND; + break; + case RECV_STATE_LEN1_FOUND: + _frame_len |= ((uint16_t)c) << 8; + _rx_len = 0; + _state = _frame_len > 0 ? RECV_STATE_LEN2_FOUND : RECV_STATE_IDLE; + break; + default: + if (_rx_len < MAX_FRAME_SIZE) { + _rx_buf[_rx_len] = (uint8_t)c; + } + _rx_len++; + if (_rx_len >= _frame_len) { + if (_frame_len > MAX_FRAME_SIZE) { + _frame_len = MAX_FRAME_SIZE; + } + #if ETHERNET_DEBUG_LOGGING && ARDUINO + ETHERNET_DEBUG_PRINTLN("RX frame len=%d", _frame_len); + #endif + memcpy(dest, _rx_buf, _frame_len); + _state = RECV_STATE_IDLE; + return _frame_len; + } + } +#endif + } + } + } + + return 0; +} diff --git a/src/helpers/ethernet/SerialEthernetInterface.h b/src/helpers/ethernet/SerialEthernetInterface.h new file mode 100644 index 0000000000..46789f7106 --- /dev/null +++ b/src/helpers/ethernet/SerialEthernetInterface.h @@ -0,0 +1,75 @@ +#pragma once + +#include "../BaseSerialInterface.h" + +#ifndef ETHERNET_TCP_PORT + #define ETHERNET_TCP_PORT 5000 +#endif +// define ETHERNET_RAW_LINE=1 to use raw line-based CLI instead of framed packets + +class SerialEthernetInterface : public BaseSerialInterface { + bool _isEnabled; + unsigned long _last_write; + uint8_t _state; + uint16_t _frame_len; + uint16_t _rx_len; + uint8_t _rx_buf[MAX_FRAME_SIZE]; + + struct Frame { + uint8_t len; + uint8_t buf[MAX_FRAME_SIZE]; + }; + + #define FRAME_QUEUE_SIZE 4 + int send_queue_len; + Frame send_queue[FRAME_QUEUE_SIZE]; + + void clearBuffers() { + send_queue_len = 0; + _state = 0; + _frame_len = 0; + _rx_len = 0; + } + + protected: + + public: + SerialEthernetInterface() { + _isEnabled = false; + _last_write = 0; + send_queue_len = 0; + _state = 0; + _frame_len = 0; + _rx_len = 0; + } + bool begin(); + + void onClientConnected(); + + // BaseSerialInterface methods + void enable() override; + void disable() override; + bool isEnabled() const override { return _isEnabled; } + + bool isConnected() const override; + bool isWriteBusy() const override; + + size_t writeFrame(const uint8_t src[], size_t len) override; + size_t checkRecvFrame(uint8_t dest[]) override; + + virtual int available() = 0; + virtual int read() = 0; + virtual size_t write(const uint8_t *buf, size_t size) = 0; +}; + + +#if ETHERNET_DEBUG_LOGGING && ARDUINO + #include <Arduino.h> + #define ETHERNET_DEBUG_PRINT(F, ...) Serial.printf("ETH: " F, ##__VA_ARGS__) + #define ETHERNET_DEBUG_PRINTLN(F, ...) Serial.printf("ETH: " F "\n", ##__VA_ARGS__) + #define ETHERNET_DEBUG_PRINT_IP(name, ip) Serial.printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]) +#else + #define ETHERNET_DEBUG_PRINT(...) {} + #define ETHERNET_DEBUG_PRINTLN(...) {} + #define ETHERNET_DEBUG_PRINT_IP(...) {} +#endif diff --git a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp new file mode 100644 index 0000000000..7f6962453b --- /dev/null +++ b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp @@ -0,0 +1,94 @@ +#include "CH390EthernetInterface.h" + +void onWiFiEvent(WiFiEvent_t event) { + switch(event){ + case ARDUINO_EVENT_ETH_START: + ETHERNET_DEBUG_PRINTLN("Ethernet Started"); + break; + case ARDUINO_EVENT_ETH_CONNECTED: + ETHERNET_DEBUG_PRINTLN("Ethernet Connected"); + break; + case ARDUINO_EVENT_ETH_DISCONNECTED: + ETHERNET_DEBUG_PRINTLN("Ethernet Disconnected"); + break; + case ARDUINO_EVENT_ETH_GOT_IP: + ETHERNET_DEBUG_PRINTLN("Ethernet Got IP"); + ETHERNET_DEBUG_PRINT_IP("IP Address", CH390.localIP()); + ETHERNET_DEBUG_PRINT_IP("Subnet Mask", CH390.subnetMask()); + ETHERNET_DEBUG_PRINT_IP("Gateway", CH390.gatewayIP()); + ETHERNET_DEBUG_PRINT_IP("DNS", CH390.dnsIP()); + ETHERNET_DEBUG_PRINTLN("MAC Address: %s", CH390.macAddress().c_str()); + break; + default: + break; + } +} + +bool CH390EthernetInterface::begin() { + + // listen to ethernet events + WiFi.onEvent(onWiFiEvent); + + // Init CH390 + ch390_config_t config = CH390_DEFAULT_CONFIG(); + config.spi_miso_gpio = ETH_MISO_PIN; + config.spi_mosi_gpio = ETH_MOSI_PIN; + config.spi_sck_gpio = ETH_SCLK_PIN; + config.spi_cs_gpio = ETH_CS_PIN; + config.int_gpio = ETH_INT_PIN; + if (!CH390.begin(config)) { + ETHERNET_DEBUG_PRINTLN("Failed to initialize CH390 hardware."); + return false; + } + + // Setup Static IP if build flags are present + #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) + IPAddress ip(ETHERNET_STATIC_IP); + IPAddress gw(ETHERNET_STATIC_GATEWAY); + IPAddress sn(ETHERNET_STATIC_SUBNET); + CH390.config(ip, gw, sn); + #endif + + // Start Server + server.begin(ETHERNET_TCP_PORT); + ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT); + + return true; +} + +int CH390EthernetInterface::available() { + return client.available(); +} + +int CH390EthernetInterface::read() { + return client.read(); +} + +size_t CH390EthernetInterface::write(const uint8_t *buf, size_t size) { + return client.write(buf, size); +} + +bool CH390EthernetInterface::isConnected() const { + return _isConnected; +} + +void CH390EthernetInterface::loop() { + + if (server.hasClient()) { + auto newClient = server.available(); + if (newClient) { + IPAddress remoteIp = newClient.remoteIP(); + uint16_t remotePort = newClient.remotePort(); + ETHERNET_DEBUG_PRINTLN("New client accepted %u.%u.%u.%u:%u", remoteIp[0], remoteIp[1], remoteIp[2], remoteIp[3], remotePort); + if (client) { + ETHERNET_DEBUG_PRINTLN("Closing previous client"); + client.stop(); + } + client = newClient; + onClientConnected(); + } + } + + _isConnected = client.connected(); + +} diff --git a/src/helpers/ethernet/ch390/CH390EthernetInterface.h b/src/helpers/ethernet/ch390/CH390EthernetInterface.h new file mode 100644 index 0000000000..09c3fc23d4 --- /dev/null +++ b/src/helpers/ethernet/ch390/CH390EthernetInterface.h @@ -0,0 +1,30 @@ +#pragma once + +#include "../SerialEthernetInterface.h" +#include <SPI.h> +#include <WiFi.h> +#include <WiFiServer.h> +#include <WiFiClient.h> +#include <ESP32_CH390.h> + +class CH390EthernetInterface : public SerialEthernetInterface { + + bool _isConnected; + WiFiServer server; + WiFiClient client; + + public: + CH390EthernetInterface(){ + _isConnected = false; + } + + bool begin(); + void loop() override; + + // BaseSerialInterface methods + bool isConnected() const override; + + int available() override; + int read() override; + size_t write(const uint8_t *buf, size_t size) override; +}; diff --git a/src/helpers/nrf52/EthernetCLI.h b/src/helpers/nrf52/EthernetCLI.h new file mode 100644 index 0000000000..34802e567a --- /dev/null +++ b/src/helpers/nrf52/EthernetCLI.h @@ -0,0 +1,157 @@ +#pragma once + +#ifdef ETHERNET_ENABLED + +#include <Arduino.h> +#include <SPI.h> +#include <RAK13800_W5100S.h> +#include <helpers/nrf52/EthernetMac.h> + +#define PIN_SPI1_MISO (29) +#define PIN_SPI1_MOSI (30) +#define PIN_SPI1_SCK (3) + +static SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SPI1_MOSI); + +#define PIN_ETHERNET_POWER_EN WB_IO2 +#define PIN_ETHERNET_RESET 21 +#define PIN_ETHERNET_SS 26 + +#ifndef ETHERNET_TCP_PORT + #define ETHERNET_TCP_PORT 23 // telnet port for CLI access +#endif + +#ifndef ETHERNET_CLI_BANNER + #define ETHERNET_CLI_BANNER "MeshCore CLI" +#endif + +#define ETHERNET_RETRY_INTERVAL_MS 30000 + +static EthernetServer ethernet_server(ETHERNET_TCP_PORT); +static EthernetClient ethernet_client; +static volatile bool ethernet_running = false; + +// FreeRTOS task: handles hw init, DHCP, and retries in the background +static void ethernet_task(void* param) { + (void)param; + + Serial.println("ETH: Initializing hardware"); + // WB_IO2 (power enable) is already driven HIGH by early constructor + // in RAK4631Board.cpp to support POE boot. + // Skip hardware reset — the W5100S comes out of power-on reset cleanly, + // and toggling reset kills the PHY link which breaks POE power. + pinMode(PIN_ETHERNET_RESET, OUTPUT); + digitalWrite(PIN_ETHERNET_RESET, HIGH); + + ETHERNET_SPI_PORT.begin(); + Ethernet.init(ETHERNET_SPI_PORT, PIN_ETHERNET_SS); + + uint8_t mac[6]; + generateEthernetMac(mac); + Serial.printf("ETH: MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + // Retry loop: keep trying until we get an IP + while (!ethernet_running) { + Serial.println("ETH: Attempting DHCP..."); + if (Ethernet.begin(mac, 10000, 2000) == 0) { + if (Ethernet.hardwareStatus() == EthernetNoHardware) { + Serial.println("ETH: Hardware not found, giving up"); + vTaskDelete(NULL); + return; + } + if (Ethernet.linkStatus() == LinkOFF) { + Serial.println("ETH: Cable not connected, will retry"); + } else { + Serial.println("ETH: DHCP failed, will retry"); + } + vTaskDelay(pdMS_TO_TICKS(ETHERNET_RETRY_INTERVAL_MS)); + continue; + } + + IPAddress ip = Ethernet.localIP(); + Serial.printf("ETH: IP: %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]); + Serial.printf("ETH: Listening on TCP port %d\n", ETHERNET_TCP_PORT); + ethernet_server.begin(); + ethernet_running = true; + } + + // DHCP succeeded, task is done + vTaskDelete(NULL); +} + +static void ethernet_start_task() { + xTaskCreate(ethernet_task, "eth_init", 1024, NULL, 1, NULL); +} + +// Format ethernet status into reply buffer. Returns true if command was handled. +static bool ethernet_handle_command(const char* command, char* reply) { + if (strcmp(command, "eth.status") == 0) { + if (!ethernet_running) { + strcpy(reply, "ETH: not connected"); + } else { + IPAddress ip = Ethernet.localIP(); + sprintf(reply, "ETH: %u.%u.%u.%u:%d", ip[0], ip[1], ip[2], ip[3], ETHERNET_TCP_PORT); + } + return true; + } + return false; +} + +// Check for new TCP client connections, replacing any existing connection. +// Use accept() (not available()) so we only see newly-accepted sockets; +// available() also returns existing connected sockets that have data, which +// would force us to disambiguate every inbound packet from a real new client. +static void ethernet_check_client() { + auto newClient = ethernet_server.accept(); + if (newClient) { + if (ethernet_client) ethernet_client.stop(); + ethernet_client = newClient; + IPAddress ip = ethernet_client.remoteIP(); + Serial.printf("ETH: Client connected from %u.%u.%u.%u\n", ip[0], ip[1], ip[2], ip[3]); + ethernet_client.println(ETHERNET_CLI_BANNER); + } +} + +// Call from loop() to maintain DHCP and check for new clients +static void ethernet_loop_maintain() { + if (ethernet_running) { + ethernet_check_client(); + Ethernet.maintain(); + } +} + +// Read a line from the Ethernet client into the command buffer. +// Returns true when a complete line is ready to process (command is null-terminated). +// The caller should process the command and then reset ethernet_command[0] = 0. +static bool ethernet_read_line(char* ethernet_command, size_t buf_size) { + if (!ethernet_running || !ethernet_client || !ethernet_client.connected()) return false; + + int elen = strlen(ethernet_command); + while (ethernet_client.available() && elen < (int)buf_size - 1) { + char c = ethernet_client.read(); + if (c == '\n' && elen == 0) continue; // ignore leading LF (from CR+LF) + if (c == '\r' || c == '\n') { ethernet_command[elen++] = '\r'; break; } + ethernet_command[elen++] = c; + ethernet_command[elen] = 0; + } + if (elen == (int)buf_size - 1) { + ethernet_command[buf_size - 1] = '\r'; + } + + if (elen > 0 && ethernet_command[elen - 1] == '\r') { + ethernet_command[elen - 1] = 0; + ethernet_client.println(); + return true; + } + return false; +} + +// Send a reply to the Ethernet client +static void ethernet_send_reply(const char* reply) { + if (reply[0]) { + ethernet_client.print(" -> "); ethernet_client.println(reply); + } +} + +#endif // ETHERNET_ENABLED diff --git a/src/helpers/nrf52/EthernetMac.h b/src/helpers/nrf52/EthernetMac.h new file mode 100644 index 0000000000..0ee2ac06f5 --- /dev/null +++ b/src/helpers/nrf52/EthernetMac.h @@ -0,0 +1,13 @@ +#pragma once + +#include <Arduino.h> + +static inline void generateEthernetMac(uint8_t mac[6]) { + uint32_t device_id = NRF_FICR->DEVICEID[0]; + mac[0] = 0x02; + mac[1] = 0x92; + mac[2] = 0x1F; + mac[3] = (device_id >> 16) & 0xFF; + mac[4] = (device_id >> 8) & 0xFF; + mac[5] = device_id & 0xFF; +} diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index 82f886c324..1dcd916fa1 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -2,10 +2,12 @@ #include <RadioLib.h> -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 - class CustomLLCC68 : public LLCC68 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomLLCC68(Module *mod) : LLCC68(mod) { } @@ -66,11 +68,11 @@ class CustomLLCC68 : public LLCC68 { setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); #endif #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX1262X_RXEN - #define SX1262X_RXEN RADIOLIB_NC + #ifndef SX126X_RXEN + #define SX126X_RXEN RADIOLIB_NC #endif - #ifndef SX1262X_TXEN - #define SX1262X_TXEN RADIOLIB_NC + #ifndef SX126X_TXEN + #define SX126X_TXEN RADIOLIB_NC #endif setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); #endif @@ -78,10 +80,61 @@ class CustomLLCC68 : public LLCC68 { return true; // success } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return LLCC68::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); } bool getRxBoostedGainMode() { diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 8861f76d24..ae0fe0a253 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -14,6 +14,10 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { ((CustomLLCC68 *)_radio)->setBandwidth(bw); ((CustomLLCC68 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomLLCC68 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomLLCC68 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + } bool isReceivingPacket() override { @@ -33,8 +37,8 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - void setRxBoostedGainMode(bool en) override { - ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomLLCC68 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 4061c6b1a6..75674a1dfd 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -4,6 +4,10 @@ #include "MeshCore.h" class CustomLR1110 : public LR1110 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; bool _rx_boosted = false; public: @@ -31,11 +35,61 @@ class CustomLR1110 : public LR1110 { bool getRxBoostedGainMode() const { return _rx_boosted; } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags. + return LR1110::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqStatus(); - bool detected = ((irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) || (irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED)); - return detected; + uint32_t irq = getIrqStatus(); + bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 + bool header = irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; // bit 5 + bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6 + uint32_t now = millis(); + if (hdrErr) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 13efd25b57..44230c61c3 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -14,6 +14,9 @@ class CustomLR1110Wrapper : public RadioLibWrapper { ((CustomLR1110 *)_radio)->setBandwidth(bw); ((CustomLR1110 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomLR1110 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomLR1110 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } void doResetAGC() override { lr11x0ResetAGC((LR11x0 *)_radio, ((CustomLR1110 *)_radio)->getFreqMHz()); } @@ -26,6 +29,11 @@ class CustomLR1110Wrapper : public RadioLibWrapper { return rssi; } + uint32_t getEstAirtimeFor(int len_bytes) override { + auto airtime = RadioLibWrapper::getEstAirtimeFor(len_bytes); + return airtime < 200 ? 200 : airtime; // at least 200 millis + } + void onSendFinished() override { RadioLibWrapper::onSendFinished(); _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts @@ -36,8 +44,8 @@ class CustomLR1110Wrapper : public RadioLibWrapper { uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } - void setRxBoostedGainMode(bool en) override { - ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomLR1110 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h new file mode 100644 index 0000000000..17944ef006 --- /dev/null +++ b/src/helpers/radiolib/CustomLR2021.h @@ -0,0 +1,76 @@ +#pragma once + +#include <RadioLib.h> +#include "MeshCore.h" + +class CustomLR2021 : public LR2021 { + bool _rx_boosted = false; + + public: + CustomLR2021(Module *mod) : LR2021(mod) { irqDioNum = LR2021_IRQ_DIO; } + + bool std_init(SPIClass* spi = NULL) + { + + #ifdef LR2021_TCXO_VOLTAGE + float tcxo = LR2021_TCXO_VOLTAGE; + #else + float tcxo = 1.6f; + #endif + + #ifdef LORA_CR + uint8_t cr = LORA_CR; + #else + uint8_t cr = 5; + #endif + + #if defined(P_LORA_SCLK) + #ifdef NRF52_PLATFORM + if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } + #elif defined(RP2040_PLATFORM) + if (spi) { + spi->setMISO(P_LORA_MISO); + //spi->setCS(P_LORA_NSS); // Setting CS results in freeze + spi->setSCK(P_LORA_SCLK); + spi->setMOSI(P_LORA_MOSI); + spi->begin(); + } + #else + if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + #endif + #endif + int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f + if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { + tcxo = 0.0f; + status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + } + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + setCRC(2); + explicitHeader(); + + + #ifdef LR2021_RX_BOOSTED_GAIN + setRxBoostedGainMode(LR2021_RX_BOOSTED_GAIN); + #endif + + return true; // success + } + + float getFreqMHz() const { return freqMHz; } + + bool getRxBoostedGainMode() const { return _rx_boosted; } + + bool isReceiving() { + uint32_t irq = getIrqStatus(); + bool detected = ((irq & RADIOLIB_LR2021_IRQ_SYNCWORD_VALID) || (irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED)); + return detected; + } + + uint8_t getSpreadingFactor() const { return spreadingFactor; } +}; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h new file mode 100644 index 0000000000..879898315e --- /dev/null +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -0,0 +1,107 @@ +#pragma once + +#include "CustomLR2021.h" +#include "RadioLibWrappers.h" + +#ifndef USE_LR2021 +#define USE_LR2021 +#endif + +#ifndef LR2021_RX_BOOST_LEVEL +#define LR2021_RX_BOOST_LEVEL 7 +#endif + +class CustomLR2021Wrapper : public RadioLibWrapper { +public: + CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + ((CustomLR2021 *)_radio)->setFrequency(freq); + ((CustomLR2021 *)_radio)->setSpreadingFactor(sf); + ((CustomLR2021 *)_radio)->setBandwidth(bw); + ((CustomLR2021 *)_radio)->setCodingRate(cr); + updatePreamble(sf); + applySideDetectorConfig(); + } + + bool configSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override { + LR2021LoRaSideDetector_t tmp[3]; + uint8_t sf = getSpreadingFactor(); + + if (sf >= 10 && num > 1) { return false; } // only 1 side detector allowed when primary SF >= 10 + for (int i = 0; i < num; i++) { + if (sideDetSFs[i] > 12 || sideDetSFs[i] < 5) { return false; } // must be valid SF + if (sideDetSFs[i] <= sf) { return false; } // must be < primary SF + if (sideDetSFs[i] > sf + 4) { return false; } // span must not be > 4 + + tmp[i].sf = sideDetSFs[i]; + float tSym = calcTsym(tmp[i].sf, bw); + if (tSym >= 16.0f) { + tmp[i].ldro = true; + } else { + tmp[i].ldro = false; + } + tmp[i].invertIQ = false; + tmp[i].syncWord = 0x12; + } + int16_t status = ((CustomLR2021 *)_radio)->setSideDetector(tmp, num); + RadioLibWrapper::idle(); // trigger startReceive() + MESH_DEBUG_PRINTLN("setSideDetector() returned %d", status); + + if (status == RADIOLIB_ERR_NONE) { + for (int i = 0; i < num; i++) { _sideDet[i] = tmp[i]; } + _numSideDet = num; + } else { + return false; + } + + return true; + } + + int16_t applySideDetectorConfig() { + int16_t status = ((CustomLR2021 *)_radio)->setSideDetector(_sideDet, _numSideDet); + RadioLibWrapper::idle(); // trigger startReceive() + return status; + } + + float calcTsym(uint8_t sf, float bw) { + float tSym = (float)(uint32_t(1) << sf) / (float)bw; + return tSym; + } + + bool isReceivingPacket() override { + return ((CustomLR2021 *)_radio)->isReceiving(); + } + + float getCurrentRSSI() override { + float rssi = -110; + ((CustomLR2021 *)_radio)->getRssiInst(&rssi); + return rssi; + } + + void onSendFinished() override { + RadioLibWrapper::onSendFinished(); + _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + } + + float getLastRSSI() const override { return ((CustomLR2021 *)_radio)->getRSSI(); } + float getLastSNR() const override { return ((CustomLR2021 *)_radio)->getSNR(); } + + uint8_t getSpreadingFactor() const override { return ((CustomLR2021 *)_radio)->getSpreadingFactor(); } + + bool setRxBoostedGainMode(bool en) override { + ((CustomLR2021 *)_radio)->standby(); // LR2021 must be in standby to accept setRxBoostedGainMode + int16_t status = ((CustomLR2021 *)_radio)->setRxBoostedGainMode(en ? LR2021_RX_BOOST_LEVEL: 0); + RadioLibWrapper::idle(); // trigger startReceive() + return status == RADIOLIB_ERR_NONE; + } + + bool getRxBoostedGainMode() const override { + return ((CustomLR2021 *)_radio)->getRxBoostedGainMode(); + } + + protected: + LR2021LoRaSideDetector_t _sideDet[3]; + size_t _numSideDet = 0; + +}; diff --git a/src/helpers/radiolib/CustomSTM32WLx.h b/src/helpers/radiolib/CustomSTM32WLx.h index cbdd5c8c4e..e312a5ee47 100644 --- a/src/helpers/radiolib/CustomSTM32WLx.h +++ b/src/helpers/radiolib/CustomSTM32WLx.h @@ -2,16 +2,70 @@ #include <RadioLib.h> -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 - class CustomSTM32WLx : public STM32WLx { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomSTM32WLx(STM32WLx_Module *mod) : STM32WLx(mod) { } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return STM32WLx::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); + } + }; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 97bf6820d6..a792a87750 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -15,6 +15,9 @@ class CustomSTM32WLxWrapper : public RadioLibWrapper { ((CustomSTM32WLx *)_radio)->setBandwidth(bw); ((CustomSTM32WLx *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomSTM32WLx *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomSTM32WLx *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } bool isReceivingPacket() override { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index ad20122902..b4ee6c97aa 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -1,11 +1,14 @@ #pragma once #include <RadioLib.h> - -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 +#include "MeshCore.h" class CustomSX1262 : public SX1262 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomSX1262(Module *mod) : SX1262(mod) { } @@ -27,6 +30,14 @@ class CustomSX1262 : public SX1262 { uint8_t cr = 5; #endif + #ifdef SX126X_USE_REGULATOR_LDO + constexpr bool useRegulatorLDO = SX126X_USE_REGULATOR_LDO; + #else + constexpr bool useRegulatorLDO = false; + #endif + + MESH_DEBUG_PRINTLN("SX1262 regulator requested: %s", useRegulatorLDO ? "LDO" : "DC-DC"); + #if defined(P_LORA_SCLK) #ifdef NRF52_PLATFORM if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } @@ -42,11 +53,12 @@ class CustomSX1262 : public SX1262 { if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); #endif #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { + MESH_DEBUG_PRINTLN("SX1262 init failed with error %d, retrying with TCXO at 0.0V", status); tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); } if (status != RADIOLIB_ERR_NONE) { Serial.print("ERROR: radio init failed: "); @@ -83,13 +95,66 @@ class CustomSX1262 : public SX1262 { writeRegister(0x8B5, &r_data, 1); #endif + MESH_DEBUG_PRINTLN("SX1262 status=0x%02X device_errors=0x%04X", getStatus(), getDeviceErrors()); + return true; // success } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); } bool getRxBoostedGainMode() { diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index cc7bb2238b..bfea50ec9c 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -18,6 +18,9 @@ class CustomSX1262Wrapper : public RadioLibWrapper { ((CustomSX1262 *)_radio)->setBandwidth(bw); ((CustomSX1262 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomSX1262 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } bool isReceivingPacket() override { @@ -40,8 +43,8 @@ class CustomSX1262Wrapper : public RadioLibWrapper { void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - void setRxBoostedGainMode(bool en) override { - ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomSX1262 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index cc541e49f3..f915332dce 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -2,10 +2,12 @@ #include <RadioLib.h> -#define SX126X_IRQ_HEADER_VALID 0b0000010000 // 4 4 valid LoRa header received -#define SX126X_IRQ_PREAMBLE_DETECTED 0x04 - class CustomSX1268 : public SX1268 { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; + bool _headerSeen = false; + public: CustomSX1268(Module *mod) : SX1268(mod) { } @@ -66,11 +68,11 @@ class CustomSX1268 : public SX1268 { setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); #endif #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX1262X_RXEN - #define SX1262X_RXEN RADIOLIB_NC + #ifndef SX126X_RXEN + #define SX126X_RXEN RADIOLIB_NC #endif - #ifndef SX1262X_TXEN - #define SX1262X_TXEN RADIOLIB_NC + #ifndef SX126X_TXEN + #define SX126X_TXEN RADIOLIB_NC #endif setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); #endif @@ -78,11 +80,63 @@ class CustomSX1268 : public SX1268 { return true; // success } + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return SX1268::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + bool isReceiving() { - uint16_t irq = getIrqFlags(); - bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED); - return detected; + uint32_t irq = getIrqFlags(); + bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 + bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 + bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 + uint32_t now = millis(); + if (hdrErr) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; + _headerSeen = false; + return false; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return false; + } + + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + _activityAt = 0; _headerSeen = false; + return false; + } + return true; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + + return false; + } + return true; + } + _activityAt = 0; _headerSeen = false; + return false; } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); + } + bool getRxBoostedGainMode() { uint8_t rxGain = 0; diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 9ddea78f3f..104ba08b20 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -18,6 +18,9 @@ class CustomSX1268Wrapper : public RadioLibWrapper { ((CustomSX1268 *)_radio)->setBandwidth(bw); ((CustomSX1268 *)_radio)->setCodingRate(cr); updatePreamble(sf); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + ((CustomSX1268 *)_radio)->setPreambleMillis(pm.preambleMillis); + ((CustomSX1268 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } bool isReceivingPacket() override { @@ -37,8 +40,8 @@ class CustomSX1268Wrapper : public RadioLibWrapper { void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - void setRxBoostedGainMode(bool en) override { - ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en); + bool setRxBoostedGainMode(bool en) override { + return ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { return ((CustomSX1268 *)_radio)->getRxBoostedGainMode(); diff --git a/src/helpers/radiolib/CustomSX1276.h b/src/helpers/radiolib/CustomSX1276.h index bee2527431..e6f3270be1 100644 --- a/src/helpers/radiolib/CustomSX1276.h +++ b/src/helpers/radiolib/CustomSX1276.h @@ -50,14 +50,14 @@ class CustomSX1276 : public SX1276 { setCurrentLimit(SX127X_CURRENT_LIMIT); #endif - #if defined(SX176X_RXEN) || defined(SX176X_TXEN) - #ifndef SX176X_RXEN - #define SX176X_RXEN RADIOLIB_NC + #if defined(SX127X_RXEN) || defined(SX127X_TXEN) + #ifndef SX127X_RXEN + #define SX127X_RXEN RADIOLIB_NC #endif - #ifndef SX176X_TXEN - #define SX176X_TXEN RADIOLIB_NC + #ifndef SX127X_TXEN + #define SX127X_TXEN RADIOLIB_NC #endif - setRfSwitchPins(SX176X_RXEN, SX176X_TXEN); + setRfSwitchPins(SX127X_RXEN, SX127X_TXEN); #endif setCRC(1); diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b6519aefa7..dc851e5cfe 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -36,6 +36,7 @@ void RadioLibWrapper::begin() { _noise_floor = 0; _threshold = 0; + _cad_enabled = false; // start average out some samples _num_floor_samples = 0; @@ -104,6 +105,9 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { + #if defined(USE_LR2021) + _radio->standby(); // without this LR2021 can throw -706 when calling startReceive after hardware CAD when side detectors are enabled + #endif int err = _radio->startReceive(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; @@ -132,7 +136,11 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { n_recv++; } } + #if defined(USE_LR2021) + state = STATE_RX; // LR2021 stays in Rx after readData, calling startReceive while still in Rx throws -706 errors + #else state = STATE_IDLE; // need another startReceive() + #endif } if (state != STATE_RX) { @@ -178,10 +186,26 @@ void RadioLibWrapper::onSendFinished() { state = STATE_IDLE; } +int16_t RadioLibWrapper::performChannelScan() { + return _radio->scanChannel(); +} + bool RadioLibWrapper::isChannelActive() { - return _threshold == 0 - ? false // interference check is disabled - : getCurrentRSSI() > _noise_floor + _threshold; + // int.thresh: RSSI-based interference detection (relative to noise floor) + if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; + + // cad: hardware channel activity detection + if (_cad_enabled) { + int16_t result = performChannelScan(); + // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY + // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't + // try to read a non-existent packet and count a spurious recv error. + state = STATE_IDLE; + startRecv(); + if (result != RADIOLIB_CHANNEL_FREE) return true; + } + + return false; } float RadioLibWrapper::getLastRSSI() const { @@ -211,3 +235,21 @@ float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { return max(0.0, min(1.0, success_rate_based_on_snr * collision_penalty)); } + +PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols) { + // based on RadioLib's calculateTimeOnAir() + uint32_t tsym_us = ((uint32_t)10000 << sf) / (bw * 10); + uint32_t sfCoeff1_x4 = (sf == 5 || sf == 6) ? 25 : 17; // 6.25 : 4.25, semtech magic numbers to account for sync word + sfd + + // preamble + syncword + sfd + header + uint32_t preamble_us = (((preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4; + + // airtime for max packet at current radio settings + uint32_t total_us = _radio->getTimeOnAir(MAX_TRANS_UNIT); + // airtime for payload only (no preamble, header or SOF) + uint32_t payload_us = total_us > preamble_us ? total_us - preamble_us : 4000 - preamble_us; // fallback to 4 secs at worst case + // rescale payload_us for max possible CR + if (cr >= 5 && cr < 8) { payload_us = (payload_us * 8) / cr; } + + return PacketMillis {(preamble_us + 999) / 1000, (payload_us + 999) / 1000}; +} \ No newline at end of file diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index efd3e17931..99f5ebbd8e 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -3,12 +3,21 @@ #include <Mesh.h> #include <RadioLib.h> +#ifdef USE_CC310_HW_CRYPTO +#include <Adafruit_nRFCrypto.h> +#endif +struct PacketMillis { + uint32_t preambleMillis; // preamble-detect -> header-valid deadline + uint32_t payloadMillis; // header-valid -> rx-done deadline +}; + class RadioLibWrapper : public mesh::Radio { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; + bool _cad_enabled; uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; @@ -32,7 +41,7 @@ class RadioLibWrapper : public mesh::Radio { bool isInRecvMode() const override; bool isChannelActive(); - bool isReceiving() override { + bool isReceiving() override { if (isReceivingPacket()) return true; return isChannelActive(); @@ -46,9 +55,12 @@ class RadioLibWrapper : public mesh::Radio { virtual uint8_t getSpreadingFactor() const { return LORA_SF; } static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } + PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols); + virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } void triggerNoiseFloorCalibrate(int threshold) override; + void setCADEnabled(bool enable) override { _cad_enabled = enable; } void resetAGC() override; void loop() override; @@ -63,8 +75,10 @@ class RadioLibWrapper : public mesh::Radio { float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 - virtual void setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } + + virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { return false; } }; /** @@ -77,8 +91,15 @@ class RadioNoiseListener : public mesh::RNG { RadioNoiseListener(PhysicalLayer& radio): _radio(&radio) { } void random(uint8_t* dest, size_t sz) override { +#ifdef USE_CC310_HW_CRYPTO + // CC310 TRNG is higher quality and environment-independent vs radio RSSI noise. + nRFCrypto.begin(); + nRFCrypto.Random.generate(dest, (uint16_t)sz); + nRFCrypto.end(); +#else for (int i = 0; i < sz; i++) { dest[i] = _radio->randomByte() ^ (::random(0, 256) & 0xFF); } +#endif } }; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 73842d9eeb..e2f0d33e72 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -807,6 +807,13 @@ void EnvironmentSensorManager::rakGPSInit(){ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){ + #if defined(ETHERNET_ENABLED) && defined(RAK_BOARD) + if (ioPin == WB_IO2) { + // WB_IO2 powers the Ethernet module on RAK baseboards. + return false; + } + #endif + //set initial waking state pinMode(ioPin,OUTPUT); digitalWrite(ioPin,LOW); @@ -889,11 +896,11 @@ void EnvironmentSensorManager::stop_gps() { void EnvironmentSensorManager::loop() { #if ENV_INCLUDE_GPS - static long next_gps_update = 0; + static unsigned long next_gps_update = 0; if (gps_active) { _location->loop(); } - if (millis() > next_gps_update) { + if ((long)(millis() - next_gps_update) > 0) { if(gps_active){ #ifdef RAK_WISBLOCK_GPS diff --git a/src/helpers/sensors/LPPDataHelpers.h b/src/helpers/sensors/LPPDataHelpers.h index 37b50f3f12..70a036c493 100644 --- a/src/helpers/sensors/LPPDataHelpers.h +++ b/src/helpers/sensors/LPPDataHelpers.h @@ -142,7 +142,7 @@ class LPPReader { case LPP_GPS: _pos += 9; break; case LPP_POLYLINE: - _pos += 8; break; // TODO: this is MINIMIUM + _pos += 8; break; // TODO: this is MINIMUM case LPP_GYROMETER: case LPP_ACCELEROMETER: _pos += 6; break; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index eec466d3aa..b66cb11458 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -13,8 +13,12 @@ #endif #endif -#ifndef PIN_GPS_EN_ACTIVE - #define PIN_GPS_EN_ACTIVE HIGH +#ifndef GPS_EN_ACTIVE + #ifdef PIN_GPS_EN_ACTIVE + #define GPS_EN_ACTIVE PIN_GPS_EN_ACTIVE + #else + #define GPS_EN_ACTIVE HIGH + #endif #endif #ifndef GPS_RESET @@ -25,11 +29,11 @@ #endif #endif -#ifndef GPS_RESET_FORCE +#ifndef GPS_RESET_ACTIVE #ifdef PIN_GPS_RESET_ACTIVE - #define GPS_RESET_FORCE PIN_GPS_RESET_ACTIVE + #define GPS_RESET_ACTIVE PIN_GPS_RESET_ACTIVE #else - #define GPS_RESET_FORCE LOW + #define GPS_RESET_ACTIVE LOW #endif #endif @@ -42,29 +46,27 @@ class MicroNMEALocationProvider : public LocationProvider { int8_t _claims = 0; int _pin_reset; int _pin_en; - long next_check = 0; + unsigned long next_check = 0; long time_valid = 0; unsigned long _last_time_sync = 0; static const unsigned long TIME_SYNC_INTERVAL = 1800000; // Re-sync every 30 minutes public : MicroNMEALocationProvider(Stream& ser, mesh::RTCClock* clock = NULL, int pin_reset = GPS_RESET, int pin_en = GPS_EN,RefCountedDigitalPin* peripher_power=NULL) : - _gps_serial(&ser), nmea(_nmeaBuffer, sizeof(_nmeaBuffer)), _pin_reset(pin_reset), _pin_en(pin_en), _clock(clock), _peripher_power(peripher_power) { + nmea(_nmeaBuffer, sizeof(_nmeaBuffer)), _clock(clock), _gps_serial(&ser), _peripher_power(peripher_power), _pin_reset(pin_reset), _pin_en(pin_en) { if (_pin_reset != -1) { pinMode(_pin_reset, OUTPUT); - digitalWrite(_pin_reset, GPS_RESET_FORCE); + digitalWrite(_pin_reset, GPS_RESET_ACTIVE); } if (_pin_en != -1) { pinMode(_pin_en, OUTPUT); - digitalWrite(_pin_en, LOW); + digitalWrite(_pin_en, !GPS_EN_ACTIVE); } } void claim() { _claims++; - if (_claims > 0) { - if (_peripher_power) _peripher_power->claim(); - } + if (_peripher_power) _peripher_power->claim(); } void release() { @@ -76,27 +78,27 @@ public : void begin() override { claim(); if (_pin_en != -1) { - digitalWrite(_pin_en, PIN_GPS_EN_ACTIVE); + digitalWrite(_pin_en, GPS_EN_ACTIVE); } if (_pin_reset != -1) { - digitalWrite(_pin_reset, !GPS_RESET_FORCE); + digitalWrite(_pin_reset, !GPS_RESET_ACTIVE); } } void reset() override { if (_pin_reset != -1) { - digitalWrite(_pin_reset, GPS_RESET_FORCE); + digitalWrite(_pin_reset, GPS_RESET_ACTIVE); delay(10); - digitalWrite(_pin_reset, !GPS_RESET_FORCE); + digitalWrite(_pin_reset, !GPS_RESET_ACTIVE); } } void stop() override { if (_pin_en != -1) { - digitalWrite(_pin_en, !PIN_GPS_EN_ACTIVE); + digitalWrite(_pin_en, !GPS_EN_ACTIVE); } if (_pin_reset != -1) { - digitalWrite(_pin_reset, GPS_RESET_FORCE); + digitalWrite(_pin_reset, GPS_RESET_ACTIVE); } release(); } @@ -105,7 +107,7 @@ public : // directly read the enable pin if present as gps can be // activated/deactivated outside of here ... if (_pin_en != -1) { - return digitalRead(_pin_en) == PIN_GPS_EN_ACTIVE; + return digitalRead(_pin_en) == GPS_EN_ACTIVE; } else { return true; // no enable so must be active } @@ -143,7 +145,7 @@ public : if (!isValid()) time_valid = 0; - if (millis() > next_check) { + if ((long)(millis() - next_check) > 0) { next_check = millis() + 1000; // Re-enable time sync periodically when GPS has valid fix if (!_time_sync_needed && _clock != NULL && (millis() - _last_time_sync) > TIME_SYNC_INTERVAL) { diff --git a/src/helpers/stm32/InternalFileSystem.cpp b/src/helpers/stm32/InternalFileSystem.cpp index dc032eb968..6a7d7064ed 100644 --- a/src/helpers/stm32/InternalFileSystem.cpp +++ b/src/helpers/stm32/InternalFileSystem.cpp @@ -37,7 +37,7 @@ static int _internal_flash_read(const struct lfs_config *c, lfs_block_t block, l } // Program a region in a block. The block must have previously -// been erased. Negative error codes are propogated to the user. +// been erased. Negative error codes are propagated to the user. // May return LFS_ERR_CORRUPT if the block should be considered bad. static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { @@ -62,7 +62,7 @@ static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, l // Erase a block. A block must be erased before being programmed. // The state of an erased block is undefined. Negative error codes -// are propogated to the user. +// are propagated to the user. // May return LFS_ERR_CORRUPT if the block should be considered bad. static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block) { @@ -87,7 +87,7 @@ static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block) } // Sync the state of the underlying block device. Negative error codes -// are propogated to the user. +// are propagated to the user. static int _internal_flash_sync(const struct lfs_config *c) { return LFS_ERR_OK; // don't need sync diff --git a/src/helpers/ui/DRV2605Vibration.cpp b/src/helpers/ui/DRV2605Vibration.cpp new file mode 100644 index 0000000000..3a42bb8842 --- /dev/null +++ b/src/helpers/ui/DRV2605Vibration.cpp @@ -0,0 +1,47 @@ +#ifdef HAS_DRV2605 + +#include "DRV2605Vibration.h" +#include <Wire.h> + +void DRV2605Vibration::begin() { +#ifdef PIN_DRV_EN + pinMode(PIN_DRV_EN, OUTPUT); + digitalWrite(PIN_DRV_EN, HIGH); // power up the haptic driver + delay(10); +#endif + if (!drv.begin(&Wire)) { + return; // no haptic driver found, stay silent + } +#ifdef DRV2605_USE_LRA + // LRA mode: 4x brake factor, medium loop gain, back-EMF gain 2 + drv.writeRegister8(DRV2605_REG_FEEDBACK, 0xB6); +#endif + drv.selectLibrary(1); + drv.setMode(DRV2605_MODE_INTTRIG); + _ready = true; +} + +void DRV2605Vibration::trigger(bool force) { + if (!_ready || _quiet) return; + unsigned long now = millis(); + if (!force && _last_trigger != 0 && now - _last_trigger < VIBRATION_TIMEOUT) return; + _last_trigger = now; + drv.setWaveform(0, DRV2605_EFFECT); + drv.setWaveform(1, 0); // pause + drv.setWaveform(2, DRV2605_EFFECT); + drv.setWaveform(3, 0); // end of sequence + drv.go(); +} + +void DRV2605Vibration::loop() { +} + +bool DRV2605Vibration::isVibrating() { + return false; // effects are short, treat as instantaneous +} + +void DRV2605Vibration::stop() { + if (_ready) drv.stop(); +} + +#endif // ifdef HAS_DRV2605 diff --git a/src/helpers/ui/DRV2605Vibration.h b/src/helpers/ui/DRV2605Vibration.h new file mode 100644 index 0000000000..a40894be11 --- /dev/null +++ b/src/helpers/ui/DRV2605Vibration.h @@ -0,0 +1,39 @@ +#pragma once + +#ifdef HAS_DRV2605 + +#include <Arduino.h> +#include <Adafruit_DRV2605.h> + +/* + * Vibration control class for boards where the motor is behind a + * DRV2605 haptic driver on I2C (e.g. Seeed SenseCAP MeshTracker X1). + * Same interface as GenericVibration. + */ + +#ifndef VIBRATION_TIMEOUT +#define VIBRATION_TIMEOUT 5000 // cooldown between vibrations +#endif + +#ifndef DRV2605_EFFECT +#define DRV2605_EFFECT 16 // "1000 ms alert" from the DRV2605 effect library +#endif + +class DRV2605Vibration { +public: + void begin(); // power up and init the DRV2605 + void trigger(bool force = false); // trigger vibration; force skips the cooldown + void loop(); // no-op, the DRV2605 plays effects autonomously + bool isVibrating(); + void stop(); // stop vibration immediately + void quiet(bool q) { _quiet = q; } + bool isQuiet() const { return _quiet; } + +private: + Adafruit_DRV2605 drv; + bool _ready = false; + bool _quiet = false; + unsigned long _last_trigger = 0; +}; + +#endif // ifdef HAS_DRV2605 diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index dcc5fe0318..b76a1b6ca0 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -3,12 +3,20 @@ #include <stdint.h> #include <string.h> +using ColorVal = uint16_t; + +class UIColor { +public: + // color definitions (by element _type_) + static ColorVal window_bkg, title_bkg, title_txt, primary_txt, secondary_txt, warning_txt, popup_bkg, popup_txt, corp_blue; +}; + class DisplayDriver { int _w, _h; protected: DisplayDriver(int w, int h) { _w = w; _h = h; } public: - enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light + //enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light int width() const { return _w; } int height() const { return _h; } @@ -18,9 +26,9 @@ class DisplayDriver { virtual void turnOn() = 0; virtual void turnOff() = 0; virtual void clear() = 0; - virtual void startFrame(Color bkg = DARK) = 0; + virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; virtual void setTextSize(int sz) = 0; - virtual void setColor(Color c) = 0; + virtual void setColor(ColorVal c) = 0; virtual void setCursor(int x, int y) = 0; virtual void print(const char* str) = 0; virtual void printWordWrap(const char* str, int max_width) { print(str); } // fallback to basic print() if no override diff --git a/src/helpers/ui/E213Display.cpp b/src/helpers/ui/E213Display.cpp index 814693a06b..daf26989be 100644 --- a/src/helpers/ui/E213Display.cpp +++ b/src/helpers/ui/E213Display.cpp @@ -2,6 +2,17 @@ #include "../../MeshCore.h" +// Color scheme +ColorVal UIColor::window_bkg = WHITE; +ColorVal UIColor::title_bkg = WHITE; +ColorVal UIColor::title_txt = BLACK; +ColorVal UIColor::primary_txt = BLACK; +ColorVal UIColor::secondary_txt = BLACK; +ColorVal UIColor::warning_txt = BLACK; +ColorVal UIColor::popup_bkg = WHITE; +ColorVal UIColor::popup_txt = BLACK; +ColorVal UIColor::corp_blue = BLACK; + BaseDisplay* E213Display::detectEInk() { // Test 1: Logic of BUSY pin @@ -108,16 +119,18 @@ void E213Display::clear() { display->clear(); } -void E213Display::startFrame(Color bkg) { +void E213Display::startFrame(ColorVal bkg) { display_crc.reset(); // Fill screen with white first to ensure clean background display->fillRect(0, 0, width(), height(), WHITE); - if (bkg == LIGHT) { + if (bkg == 0) { // Fill with black if light background requested (inverted for e-ink) display->fillRect(0, 0, width(), height(), BLACK); } + _color = UIColor::primary_txt; + display->setTextColor(_color); } void E213Display::setTextSize(int sz) { @@ -126,9 +139,10 @@ void E213Display::setTextSize(int sz) { display->setTextSize(sz); } -void E213Display::setColor(Color c) { - display_crc.update<Color>(c); - // implemented in individual display methods +void E213Display::setColor(ColorVal c) { + _color = c; + display_crc.update<ColorVal>(c); + display->setTextColor(_color); } void E213Display::setCursor(int x, int y) { @@ -147,7 +161,7 @@ void E213Display::fillRect(int x, int y, int w, int h) { display_crc.update<int>(y); display_crc.update<int>(w); display_crc.update<int>(h); - display->fillRect(x, y, w, h, BLACK); + display->fillRect(x, y, w, h, _color); } void E213Display::drawRect(int x, int y, int w, int h) { @@ -155,7 +169,7 @@ void E213Display::drawRect(int x, int y, int w, int h) { display_crc.update<int>(y); display_crc.update<int>(w); display_crc.update<int>(h); - display->drawRect(x, y, w, h, BLACK); + display->drawRect(x, y, w, h, _color); } void E213Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { @@ -179,7 +193,7 @@ void E213Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { // If the bit is set, draw the pixel if (bitSet) { - display->drawPixel(x + bx, y + by, BLACK); + display->drawPixel(x + bx, y + by, _color); } } } diff --git a/src/helpers/ui/E213Display.h b/src/helpers/ui/E213Display.h index add8f11b35..32567a79ac 100644 --- a/src/helpers/ui/E213Display.h +++ b/src/helpers/ui/E213Display.h @@ -16,6 +16,7 @@ class E213Display : public DisplayDriver { RefCountedDigitalPin* _periph_power; CRC32 display_crc; uint32_t last_display_crc_value = 0; + uint16_t _color; public: E213Display(RefCountedDigitalPin* periph_power = NULL) : DisplayDriver(250, 122), _periph_power(periph_power) {} @@ -30,9 +31,9 @@ class E213Display : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char *str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/E290Display.cpp b/src/helpers/ui/E290Display.cpp index ef4df05ed7..34bbcdbc53 100644 --- a/src/helpers/ui/E290Display.cpp +++ b/src/helpers/ui/E290Display.cpp @@ -2,6 +2,17 @@ #include "../../MeshCore.h" +// Color scheme +ColorVal UIColor::window_bkg = WHITE; +ColorVal UIColor::title_bkg = WHITE; +ColorVal UIColor::title_txt = BLACK; +ColorVal UIColor::primary_txt = BLACK; +ColorVal UIColor::secondary_txt = BLACK; +ColorVal UIColor::warning_txt = BLACK; +ColorVal UIColor::popup_bkg = WHITE; +ColorVal UIColor::popup_txt = BLACK; +ColorVal UIColor::corp_blue = BLACK; + bool E290Display::begin() { if (_init) return true; @@ -62,15 +73,17 @@ void E290Display::clear() { display.clear(); } -void E290Display::startFrame(Color bkg) { +void E290Display::startFrame(ColorVal bkg) { display_crc.reset(); // Fill screen with white first to ensure clean background display.fillRect(0, 0, width(), height(), WHITE); - if (bkg == LIGHT) { + if (bkg == 0) { // Fill with black if light background requested (inverted for e-ink) display.fillRect(0, 0, width(), height(), BLACK); } + _color = UIColor::primary_txt; + display.setTextColor(_color); } void E290Display::setTextSize(int sz) { @@ -79,9 +92,10 @@ void E290Display::setTextSize(int sz) { display.setTextSize(sz); } -void E290Display::setColor(Color c) { - display_crc.update<Color>(c); - // implemented in individual display methods +void E290Display::setColor(ColorVal c) { + _color = c; + display_crc.update<ColorVal>(c); + display.setTextColor(_color); } void E290Display::setCursor(int x, int y) { @@ -100,7 +114,7 @@ void E290Display::fillRect(int x, int y, int w, int h) { display_crc.update<int>(y); display_crc.update<int>(w); display_crc.update<int>(h); - display.fillRect(x, y, w, h, BLACK); + display.fillRect(x, y, w, h, _color); } void E290Display::drawRect(int x, int y, int w, int h) { @@ -108,7 +122,7 @@ void E290Display::drawRect(int x, int y, int w, int h) { display_crc.update<int>(y); display_crc.update<int>(w); display_crc.update<int>(h); - display.drawRect(x, y, w, h, BLACK); + display.drawRect(x, y, w, h, _color); } void E290Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { @@ -132,7 +146,7 @@ void E290Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { // If the bit is set, draw the pixel if (bitSet) { - display.drawPixel(x + bx, y + by, BLACK); + display.drawPixel(x + bx, y + by, _color); } } } diff --git a/src/helpers/ui/E290Display.h b/src/helpers/ui/E290Display.h index 88bf34ff17..bf6296bb72 100644 --- a/src/helpers/ui/E290Display.h +++ b/src/helpers/ui/E290Display.h @@ -16,6 +16,7 @@ class E290Display : public DisplayDriver { RefCountedDigitalPin* _periph_power; CRC32 display_crc; uint32_t last_display_crc_value = 0; + uint16_t _color; public: E290Display(RefCountedDigitalPin* periph_power = NULL) : DisplayDriver(296, 128), _periph_power(periph_power) {} @@ -26,9 +27,9 @@ class E290Display : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char *str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index ad47754bf9..13dafa3455 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -14,6 +14,18 @@ SPIClass SPI1 = SPIClass(FSPI); #endif +// Color scheme +ColorVal UIColor::window_bkg = GxEPD_WHITE; +ColorVal UIColor::title_bkg = GxEPD_WHITE; +ColorVal UIColor::title_txt = GxEPD_BLACK; +ColorVal UIColor::primary_txt = GxEPD_BLACK; +ColorVal UIColor::secondary_txt = GxEPD_BLACK; +ColorVal UIColor::warning_txt = GxEPD_BLACK; +ColorVal UIColor::popup_bkg = GxEPD_WHITE; +ColorVal UIColor::popup_txt = GxEPD_BLACK; +ColorVal UIColor::corp_blue = GxEPD_BLACK; + + bool GxEPDDisplay::begin() { display.epd2.selectSPI(SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0)); #ifdef ESP32 @@ -61,9 +73,9 @@ void GxEPDDisplay::clear() { display_crc.reset(); } -void GxEPDDisplay::startFrame(Color bkg) { - display.fillScreen(GxEPD_WHITE); - display.setTextColor(_curr_color = GxEPD_BLACK); +void GxEPDDisplay::startFrame(ColorVal bkg) { + display.fillScreen(bkg); + display.setTextColor(_curr_color = UIColor::primary_txt); display_crc.reset(); } @@ -85,14 +97,9 @@ void GxEPDDisplay::setTextSize(int sz) { } } -void GxEPDDisplay::setColor(Color c) { - display_crc.update<Color> (c); - // colours need to be inverted for epaper displays - if (c == DARK) { - display.setTextColor(_curr_color = GxEPD_WHITE); - } else { - display.setTextColor(_curr_color = GxEPD_BLACK); - } +void GxEPDDisplay::setColor(ColorVal c) { + display_crc.update<ColorVal> (c); + display.setTextColor(_curr_color = c); } void GxEPDDisplay::setCursor(int x, int y) { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 219b607644..c653eac4b4 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -51,9 +51,9 @@ class GxEPDDisplay : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/LGFXDisplay.cpp b/src/helpers/ui/LGFXDisplay.cpp index a53cbc620d..7bdd52a251 100644 --- a/src/helpers/ui/LGFXDisplay.cpp +++ b/src/helpers/ui/LGFXDisplay.cpp @@ -1,5 +1,16 @@ #include "LGFXDisplay.h" +// Color scheme +ColorVal UIColor::window_bkg = 0xFFFF; +ColorVal UIColor::title_bkg = 0x001F; +ColorVal UIColor::title_txt = 0xFFFF; +ColorVal UIColor::primary_txt = 0x0000; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = 0xFD20; +ColorVal UIColor::popup_bkg = 0x07FF; // CYAN +ColorVal UIColor::popup_txt = 0x0000; +ColorVal UIColor::corp_blue = 0x001A; + bool LGFXDisplay::begin() { turnOn(); display->init(); @@ -35,45 +46,20 @@ void LGFXDisplay::clear() { buffer.clearDisplay(); } -void LGFXDisplay::startFrame(Color bkg) { +void LGFXDisplay::startFrame(ColorVal bkg) { // display->startWrite(); // display->getScanLine(); - buffer.clearDisplay(); - buffer.setTextColor(TFT_WHITE); + _color = bkg; + buffer.fillScreen(_color); + buffer.setTextColor(_color = UIColor::primary_txt); } void LGFXDisplay::setTextSize(int sz) { buffer.setTextSize(sz); } -void LGFXDisplay::setColor(Color c) { - // _color = (c != 0) ? ILI9342_WHITE : ILI9342_BLACK; - switch (c) { - case DARK: - _color = TFT_BLACK; - break; - case LIGHT: - _color = TFT_WHITE; - break; - case RED: - _color = TFT_RED; - break; - case GREEN: - _color = TFT_GREEN; - break; - case BLUE: - _color = TFT_BLUE; - break; - case YELLOW: - _color = TFT_YELLOW; - break; - case ORANGE: - _color = TFT_ORANGE; - break; - default: - _color = TFT_WHITE; - } - buffer.setTextColor(_color); +void LGFXDisplay::setColor(ColorVal c) { + buffer.setTextColor(_color = c); } void LGFXDisplay::setCursor(int x, int y) { diff --git a/src/helpers/ui/LGFXDisplay.h b/src/helpers/ui/LGFXDisplay.h index ad7212ecf6..a2d660b22e 100644 --- a/src/helpers/ui/LGFXDisplay.h +++ b/src/helpers/ui/LGFXDisplay.h @@ -25,9 +25,9 @@ class LGFXDisplay : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/NV3001BDisplay.cpp b/src/helpers/ui/NV3001BDisplay.cpp new file mode 100644 index 0000000000..1ff7765cbb --- /dev/null +++ b/src/helpers/ui/NV3001BDisplay.cpp @@ -0,0 +1,551 @@ +#include "NV3001BDisplay.h" +#include <Arduino.h> +#include <string.h> + +#ifndef SPI_FREQUENCY + #define SPI_FREQUENCY 8000000 +#endif + +#ifndef PIN_TFT_SCL + #error "PIN_TFT_SCL must be defined" +#endif + +#ifndef PIN_TFT_SDA + #error "PIN_TFT_SDA must be defined" +#endif + +#ifndef PIN_TFT_CS + #error "PIN_TFT_CS must be defined" +#endif + +#ifndef PIN_TFT_DC + #error "PIN_TFT_DC must be defined" +#endif + +#ifndef PIN_TFT_MISO + #define PIN_TFT_MISO -1 +#endif + +#ifndef PIN_TFT_RST + #define PIN_TFT_RST -1 +#endif + +#ifndef PIN_TFT_EN + #define PIN_TFT_EN -1 +#endif + +#ifndef PIN_TFT_BL + #define PIN_TFT_BL -1 +#endif + +#ifndef PIN_TFT_EN_ACTIVE + #define PIN_TFT_EN_ACTIVE LOW +#endif + +#ifndef PIN_TFT_BL_ACTIVE + #define PIN_TFT_BL_ACTIVE HIGH +#endif + +#ifndef DISPLAY_ROTATION + #define DISPLAY_ROTATION 0 +#endif + +#ifndef NV3001B_SCREEN_WIDTH + #define NV3001B_SCREEN_WIDTH 220 +#endif + +#ifndef NV3001B_SCREEN_HEIGHT + #define NV3001B_SCREEN_HEIGHT 128 +#endif + +#ifndef DISPLAY_SCALE_X + #define DISPLAY_SCALE_X ((float)NV3001B_SCREEN_WIDTH / NV3001B_LOGICAL_WIDTH) +#endif + +#ifndef DISPLAY_SCALE_Y + #define DISPLAY_SCALE_Y ((float)NV3001B_SCREEN_HEIGHT / NV3001B_LOGICAL_HEIGHT) +#endif + +#define NV3001B_SWRESET 0x01 +#define NV3001B_SLPOUT 0x11 +#define NV3001B_DISPON 0x29 +#define NV3001B_CASET 0x2A +#define NV3001B_RASET 0x2B +#define NV3001B_RAMWR 0x2C +#define NV3001B_MADCTL 0x36 +#define NV3001B_COLMOD 0x3A + +#define NV3001B_MADCTL_MY 0x80 +#define NV3001B_MADCTL_MX 0x40 +#define NV3001B_MADCTL_MV 0x20 +#define NV3001B_MADCTL_RGB 0x00 + +#ifndef NV3001B_TEXT_SIZE1_SCALE_X + #define NV3001B_TEXT_SIZE1_SCALE_X 1 +#endif + +#ifndef NV3001B_TEXT_SIZE1_SCALE_Y + #define NV3001B_TEXT_SIZE1_SCALE_Y 2 +#endif + +#ifndef NV3001B_TEXT_SIZE2_SCALE_X + #define NV3001B_TEXT_SIZE2_SCALE_X 2 +#endif + +#ifndef NV3001B_TEXT_SIZE2_SCALE_Y + #define NV3001B_TEXT_SIZE2_SCALE_Y 3 +#endif + +// Color scheme +ColorVal UIColor::window_bkg = 0xFFFF; +ColorVal UIColor::title_bkg = 0x001F; +ColorVal UIColor::title_txt = 0xFFFF; +ColorVal UIColor::primary_txt = 0x0000; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = 0xFD20; +ColorVal UIColor::popup_bkg = 0x07FF; // CYAN +ColorVal UIColor::popup_txt = 0x0000; +ColorVal UIColor::corp_blue = 0x001A; + +static int scaleX(int x) { + return (int)(x * DISPLAY_SCALE_X); +} + +static int scaleY(int y) { + return (int)(y * DISPLAY_SCALE_Y); +} + +static int scaleWidth(int x, int w) { + if (w <= 0) return 0; + int scaled = scaleX(x + w) - scaleX(x); + return scaled > 0 ? scaled : 1; +} + +static int scaleHeight(int y, int h) { + if (h <= 0) return 0; + int scaled = scaleY(y + h) - scaleY(y); + return scaled > 0 ? scaled : 1; +} + +static uint8_t nv3001bMADCTL(uint8_t rotation) { + uint8_t madctl; + switch (rotation & 3) { + case 0: + madctl = NV3001B_MADCTL_MY | NV3001B_MADCTL_MV | NV3001B_MADCTL_RGB; + break; + case 1: + madctl = NV3001B_MADCTL_MY | NV3001B_MADCTL_MX | NV3001B_MADCTL_RGB; + break; + case 2: + madctl = NV3001B_MADCTL_RGB; + break; + default: + madctl = NV3001B_MADCTL_MX | NV3001B_MADCTL_MV | NV3001B_MADCTL_RGB; + break; + } + return madctl; +} + +static const uint8_t font5x7[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x07, 0x00, 0x07, 0x00, 0x14, + 0x7f, 0x14, 0x7f, 0x14, 0x24, 0x2a, 0x7f, 0x2a, 0x12, 0x23, 0x13, 0x08, 0x64, 0x62, 0x36, 0x49, + 0x55, 0x22, 0x50, 0x00, 0x05, 0x03, 0x00, 0x00, 0x00, 0x1c, 0x22, 0x41, 0x00, 0x00, 0x41, 0x22, + 0x1c, 0x00, 0x14, 0x08, 0x3e, 0x08, 0x14, 0x08, 0x08, 0x3e, 0x08, 0x08, 0x00, 0x50, 0x30, 0x00, + 0x00, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00, 0x60, 0x60, 0x00, 0x00, 0x20, 0x10, 0x08, 0x04, 0x02, + 0x3e, 0x51, 0x49, 0x45, 0x3e, 0x00, 0x42, 0x7f, 0x40, 0x00, 0x42, 0x61, 0x51, 0x49, 0x46, 0x21, + 0x41, 0x45, 0x4b, 0x31, 0x18, 0x14, 0x12, 0x7f, 0x10, 0x27, 0x45, 0x45, 0x45, 0x39, 0x3c, 0x4a, + 0x49, 0x49, 0x30, 0x01, 0x71, 0x09, 0x05, 0x03, 0x36, 0x49, 0x49, 0x49, 0x36, 0x06, 0x49, 0x49, + 0x29, 0x1e, 0x00, 0x36, 0x36, 0x00, 0x00, 0x00, 0x56, 0x36, 0x00, 0x00, 0x08, 0x14, 0x22, 0x41, + 0x00, 0x14, 0x14, 0x14, 0x14, 0x14, 0x00, 0x41, 0x22, 0x14, 0x08, 0x02, 0x01, 0x51, 0x09, 0x06, + 0x32, 0x49, 0x79, 0x41, 0x3e, 0x7e, 0x11, 0x11, 0x11, 0x7e, 0x7f, 0x49, 0x49, 0x49, 0x36, 0x3e, + 0x41, 0x41, 0x41, 0x22, 0x7f, 0x41, 0x41, 0x22, 0x1c, 0x7f, 0x49, 0x49, 0x49, 0x41, 0x7f, 0x09, + 0x09, 0x09, 0x01, 0x3e, 0x41, 0x49, 0x49, 0x7a, 0x7f, 0x08, 0x08, 0x08, 0x7f, 0x00, 0x41, 0x7f, + 0x41, 0x00, 0x20, 0x40, 0x41, 0x3f, 0x01, 0x7f, 0x08, 0x14, 0x22, 0x41, 0x7f, 0x40, 0x40, 0x40, + 0x40, 0x7f, 0x02, 0x0c, 0x02, 0x7f, 0x7f, 0x04, 0x08, 0x10, 0x7f, 0x3e, 0x41, 0x41, 0x41, 0x3e, + 0x7f, 0x09, 0x09, 0x09, 0x06, 0x3e, 0x41, 0x51, 0x21, 0x5e, 0x7f, 0x09, 0x19, 0x29, 0x46, 0x46, + 0x49, 0x49, 0x49, 0x31, 0x01, 0x01, 0x7f, 0x01, 0x01, 0x3f, 0x40, 0x40, 0x40, 0x3f, 0x1f, 0x20, + 0x40, 0x20, 0x1f, 0x3f, 0x40, 0x38, 0x40, 0x3f, 0x63, 0x14, 0x08, 0x14, 0x63, 0x07, 0x08, 0x70, + 0x08, 0x07, 0x61, 0x51, 0x49, 0x45, 0x43, 0x00, 0x7f, 0x41, 0x41, 0x00, 0x02, 0x04, 0x08, 0x10, + 0x20, 0x00, 0x41, 0x41, 0x7f, 0x00, 0x04, 0x02, 0x01, 0x02, 0x04, 0x40, 0x40, 0x40, 0x40, 0x40, + 0x00, 0x01, 0x02, 0x04, 0x00, 0x20, 0x54, 0x54, 0x54, 0x78, 0x7f, 0x48, 0x44, 0x44, 0x38, 0x38, + 0x44, 0x44, 0x44, 0x20, 0x38, 0x44, 0x44, 0x48, 0x7f, 0x38, 0x54, 0x54, 0x54, 0x18, 0x08, 0x7e, + 0x09, 0x01, 0x02, 0x0c, 0x52, 0x52, 0x52, 0x3e, 0x7f, 0x08, 0x04, 0x04, 0x78, 0x00, 0x44, 0x7d, + 0x40, 0x00, 0x20, 0x40, 0x44, 0x3d, 0x00, 0x7f, 0x10, 0x28, 0x44, 0x00, 0x00, 0x41, 0x7f, 0x40, + 0x00, 0x7c, 0x04, 0x18, 0x04, 0x78, 0x7c, 0x08, 0x04, 0x04, 0x78, 0x38, 0x44, 0x44, 0x44, 0x38, + 0x7c, 0x14, 0x14, 0x14, 0x08, 0x08, 0x14, 0x14, 0x18, 0x7c, 0x7c, 0x08, 0x04, 0x04, 0x08, 0x48, + 0x54, 0x54, 0x54, 0x20, 0x04, 0x3f, 0x44, 0x40, 0x20, 0x3c, 0x40, 0x40, 0x20, 0x7c, 0x1c, 0x20, + 0x40, 0x20, 0x1c, 0x3c, 0x40, 0x30, 0x40, 0x3c, 0x44, 0x28, 0x10, 0x28, 0x44, 0x0c, 0x50, 0x50, + 0x50, 0x3c, 0x44, 0x64, 0x54, 0x4c, 0x44, 0x00, 0x08, 0x36, 0x41, 0x00, 0x00, 0x00, 0x7f, 0x00, + 0x00, 0x00, 0x41, 0x36, 0x08, 0x00, 0x08, 0x08, 0x2a, 0x1c, 0x08, 0x00, 0x06, 0x09, 0x09, 0x06 +}; + +static int textPixelScaleX(uint8_t size) { + return size <= 1 ? NV3001B_TEXT_SIZE1_SCALE_X : NV3001B_TEXT_SIZE2_SCALE_X; +} + +static int textPixelScaleY(uint8_t size) { + return size <= 1 ? NV3001B_TEXT_SIZE1_SCALE_Y : NV3001B_TEXT_SIZE2_SCALE_Y; +} + +static void setupOptionalOutput(int pin, int level) { + if (pin < 0) return; + + pinMode(pin, OUTPUT); + digitalWrite(pin, level); +} + +static void writeOptionalPin(int pin, int level) { + if (pin < 0) return; + + digitalWrite(pin, level); +} + +void NV3001BDisplay::writeCommand(uint8_t cmd) { + spi.beginTransaction(SPISettings(SPI_FREQUENCY, MSBFIRST, SPI_MODE0)); + digitalWrite(PIN_TFT_DC, LOW); + digitalWrite(PIN_TFT_CS, LOW); + spi.transfer(cmd); + digitalWrite(PIN_TFT_CS, HIGH); + spi.endTransaction(); +} + +void NV3001BDisplay::writeBytes(const uint8_t* data, size_t len) { + if (!data || len == 0) return; + + spi.beginTransaction(SPISettings(SPI_FREQUENCY, MSBFIRST, SPI_MODE0)); + digitalWrite(PIN_TFT_DC, HIGH); + digitalWrite(PIN_TFT_CS, LOW); + for (size_t i = 0; i < len; i++) { + spi.transfer(data[i]); + } + digitalWrite(PIN_TFT_CS, HIGH); + spi.endTransaction(); +} + +void NV3001BDisplay::writeCommandData(uint8_t cmd, const uint8_t* data, size_t len) { + writeCommand(cmd); + writeBytes(data, len); +} + +void NV3001BDisplay::setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { + uint16_t x2 = x + w - 1; + uint16_t y2 = y + h - 1; + uint8_t data[4]; + + data[0] = x >> 8; + data[1] = x & 0xff; + data[2] = x2 >> 8; + data[3] = x2 & 0xff; + writeCommandData(NV3001B_CASET, data, sizeof(data)); + + data[0] = y >> 8; + data[1] = y & 0xff; + data[2] = y2 >> 8; + data[3] = y2 & 0xff; + writeCommandData(NV3001B_RASET, data, sizeof(data)); + + writeCommand(NV3001B_RAMWR); +} + +void NV3001BDisplay::writeColor(uint16_t rgb, uint32_t count) { + uint8_t hi = rgb >> 8; + uint8_t lo = rgb & 0xff; + + spi.beginTransaction(SPISettings(SPI_FREQUENCY, MSBFIRST, SPI_MODE0)); + digitalWrite(PIN_TFT_DC, HIGH); + digitalWrite(PIN_TFT_CS, LOW); + while (count--) { + spi.transfer(hi); + spi.transfer(lo); + } + digitalWrite(PIN_TFT_CS, HIGH); + spi.endTransaction(); +} + +void NV3001BDisplay::initPanel() { +#define CMD0(C) do { writeCommand(C); } while (0) +#define CMD1(C, A) do { const uint8_t d[] = { A }; writeCommandData(C, d, sizeof(d)); } while (0) +#define CMD2(C, A, B) do { const uint8_t d[] = { A, B }; writeCommandData(C, d, sizeof(d)); } while (0) + + CMD0(NV3001B_SWRESET); + delay(120); + CMD1(0xFF, 0xA5); + CMD1(0x41, 0x00); + CMD1(0x50, 0x02); + CMD1(0x52, 0x6E); + CMD1(0x57, 0x02); + CMD1(0x46, 0x11); + CMD2(0x47, 0x00, 0x01); + CMD2(0x8F, 0x22, 0x03); + CMD1(0x9A, 0x78); + CMD1(0x9B, 0x78); + CMD1(0x9C, 0xA0); + CMD1(0x9D, 0x17); + CMD1(0x9E, 0xC1); + CMD1(0x83, 0x5A); + CMD1(0x84, 0xB6); + CMD1(0xFF, 0xA5); + CMD1(0x85, 0x5F); + CMD1(0x6E, 0x0F); + CMD1(0x7E, 0x0F); + CMD1(0x60, 0x00); + CMD1(0x70, 0x00); + CMD1(0x6D, 0x33); + CMD1(0x7D, 0x37); + CMD1(0x61, 0x09); + CMD1(0x71, 0x0A); + CMD1(0x6C, 0x2A); + CMD1(0x7C, 0x36); + CMD1(0x62, 0x11); + CMD1(0x72, 0x10); + CMD1(0x68, 0x4E); + CMD1(0x78, 0x4E); + CMD1(0x66, 0x36); + CMD1(0x76, 0x3C); + CMD1(0x1A, 0x1C); + CMD1(0x7B, 0x14); + CMD1(0x63, 0x0D); + CMD1(0x73, 0x0A); + CMD1(0x6A, 0x16); + CMD1(0x7A, 0x12); + CMD1(0x64, 0x0B); + CMD1(0x74, 0x0A); + CMD1(0x69, 0x08); + CMD1(0x79, 0x0A); + CMD1(0x65, 0x06); + CMD1(0x75, 0x07); + CMD1(0x67, 0x23); + CMD1(0x77, 0x44); + CMD1(0xE0, 0x00); + CMD1(0xE9, 0x30); + CMD1(0xEB, 0xB7); + CMD1(0xEC, 0x00); + CMD1(0xED, 0x11); + CMD1(0xF0, 0xB7); + CMD1(0x53, 0x04); + CMD1(0x54, 0x04); + CMD1(0x55, 0x40); + CMD1(0x56, 0x40); + CMD2(0xA0, 0x60, 0x01); + CMD1(0xA1, 0x84); + CMD1(0xA2, 0x85); + CMD2(0xAB, 0x00, 0x02); + CMD2(0xAC, 0x00, 0x06); + CMD2(0xAD, 0x00, 0x03); + CMD2(0xAE, 0x00, 0x07); + CMD1(0xC7, 0x01); + CMD1(0xB9, 0x82); + CMD1(0xBA, 0x83); + CMD1(0xBB, 0x00); + CMD1(0xBC, 0x81); + CMD1(0xBD, 0x02); + CMD1(0xBE, 0x01); + CMD1(0xBF, 0x04); + CMD1(0xC0, 0x03); + CMD1(0xC8, 0x55); + CMD1(0xC9, 0xC9); + CMD1(0xCA, 0xC8); + CMD1(0xCB, 0xCB); + CMD1(0xCC, 0xCA); + CMD1(0xCD, 0x55); + CMD1(0xCE, 0xCE); + CMD1(0xCF, 0xCD); + CMD1(0xD0, 0xD0); + CMD1(0xD1, 0xCF); + CMD1(0xF2, 0x46); + CMD1(0xA8, 0x04); + CMD1(0xA9, 0xB0); + CMD1(0xAA, 0xA3); + CMD1(0xB6, 0x00); + CMD1(0xB7, 0xB0); + CMD1(0xB8, 0xA3); + CMD1(0xC4, 0x03); + CMD1(0xC5, 0xB0); + CMD1(0xC6, 0xA3); + CMD1(0x80, 0x10); + CMD1(0xFF, 0x00); + CMD1(0x35, 0x00); + CMD0(NV3001B_SLPOUT); + delay(120); + CMD1(NV3001B_COLMOD, 0x05); + CMD1(NV3001B_MADCTL, nv3001bMADCTL(DISPLAY_ROTATION)); + CMD0(NV3001B_DISPON); + delay(10); + +#undef CMD0 +#undef CMD1 +#undef CMD2 +} + +void NV3001BDisplay::fillPhysicalRect(int x, int y, int w, int h) { + if (!is_on || w <= 0 || h <= 0) return; + + if (x < 0) { + w += x; + x = 0; + } + if (y < 0) { + h += y; + y = 0; + } + if (x + w > NV3001B_SCREEN_WIDTH) w = NV3001B_SCREEN_WIDTH - x; + if (y + h > NV3001B_SCREEN_HEIGHT) h = NV3001B_SCREEN_HEIGHT - y; + if (w <= 0 || h <= 0) return; + + setAddrWindow(x, y, w, h); + writeColor(color, (uint32_t)w * h); +} + +void NV3001BDisplay::drawChar(int x, int y, char ch) { + if (ch < 32 || ch > 127) ch = '?'; + + uint16_t index = (uint16_t)(ch - 32) * 5; + int scale_x = textPixelScaleX(text_size); + int scale_y = textPixelScaleY(text_size); + for (int col = 0; col < 5; col++) { + uint8_t line = pgm_read_byte(font5x7 + index + col); + for (int row = 0; row < 7; row++) { + if (line & (1 << row)) { + fillPhysicalRect(x + col * scale_x, y + row * scale_y, scale_x, scale_y); + } + } + } +} + +bool NV3001BDisplay::begin() { + if (is_on) return true; + + if (periph_power) periph_power->claim(); + + setupOptionalOutput(PIN_TFT_EN, PIN_TFT_EN_ACTIVE); + setupOptionalOutput(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE); + pinMode(PIN_TFT_CS, OUTPUT); + pinMode(PIN_TFT_DC, OUTPUT); + digitalWrite(PIN_TFT_CS, HIGH); + digitalWrite(PIN_TFT_DC, HIGH); + delay(20); + + spi.begin(PIN_TFT_SCL, PIN_TFT_MISO, PIN_TFT_SDA, PIN_TFT_CS); + if (PIN_TFT_RST >= 0) { + pinMode(PIN_TFT_RST, OUTPUT); + digitalWrite(PIN_TFT_RST, HIGH); + delay(10); + digitalWrite(PIN_TFT_RST, LOW); + delay(20); + digitalWrite(PIN_TFT_RST, HIGH); + delay(120); + } + + initPanel(); + is_on = true; + color = 0x0000; + fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT); + color = 0xffff; + text_size = 1; + cursor_x = 0; + cursor_y = 0; + writeOptionalPin(PIN_TFT_BL, PIN_TFT_BL_ACTIVE); + return true; +} + +void NV3001BDisplay::turnOn() { + begin(); +} + +void NV3001BDisplay::turnOff() { + if (!is_on) return; + + writeOptionalPin(PIN_TFT_BL, !PIN_TFT_BL_ACTIVE); + writeOptionalPin(PIN_TFT_EN, !PIN_TFT_EN_ACTIVE); + is_on = false; + if (periph_power) periph_power->release(); +} + +void NV3001BDisplay::clear() { + uint16_t saved = color; + color = UIColor::window_bkg; + fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT); + color = saved; +} + +void NV3001BDisplay::startFrame(ColorVal bkg) { + color = bkg; + fillPhysicalRect(0, 0, NV3001B_SCREEN_WIDTH, NV3001B_SCREEN_HEIGHT); + color = UIColor::primary_txt; + text_size = 1; + cursor_x = 0; + cursor_y = 0; +} + +void NV3001BDisplay::setTextSize(int sz) { + text_size = sz < 1 ? 1 : sz; +} + +void NV3001BDisplay::setColor(ColorVal c) { + color = c; +} + +void NV3001BDisplay::setCursor(int x, int y) { + cursor_x = scaleX(x); + cursor_y = scaleY(y); +} + +void NV3001BDisplay::print(const char* str) { + if (!str || !is_on) return; + + int scale_x = textPixelScaleX(text_size); + int scale_y = textPixelScaleY(text_size); + while (*str) { + if (*str == '\n') { + cursor_x = 0; + cursor_y += 8 * scale_y; + } else if (*str == '\r') { + cursor_x = 0; + } else { + drawChar(cursor_x, cursor_y, *str); + cursor_x += 6 * scale_x; + } + str++; + } +} + +void NV3001BDisplay::fillRect(int x, int y, int w, int h) { + fillPhysicalRect(scaleX(x), scaleY(y), scaleWidth(x, w), scaleHeight(y, h)); +} + +void NV3001BDisplay::drawRect(int x, int y, int w, int h) { + int x1 = scaleX(x); + int y1 = scaleY(y); + int sw = scaleWidth(x, w); + int sh = scaleHeight(y, h); + + fillPhysicalRect(x1, y1, sw, 1); + fillPhysicalRect(x1, y1 + sh - 1, sw, 1); + fillPhysicalRect(x1, y1, 1, sh); + fillPhysicalRect(x1 + sw - 1, y1, 1, sh); +} + +void NV3001BDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { + if (!bits || !is_on) return; + + int byte_width = (w + 7) / 8; + for (int j = 0; j < h; j++) { + for (int i = 0; i < w; i++) { + uint8_t byte = pgm_read_byte(bits + j * byte_width + i / 8); + if (byte & (0x80 >> (i & 7))) { + fillPhysicalRect(scaleX(x + i), scaleY(y + j), scaleWidth(x + i, 1), scaleHeight(y + j, 1)); + } + } + } +} + +uint16_t NV3001BDisplay::getTextWidth(const char* str) { + if (!str) return 0; + + uint16_t len = 0; + while (str[len] && str[len] != '\n' && str[len] != '\r') len++; + return (uint16_t)((len * 6 * textPixelScaleX(text_size)) / DISPLAY_SCALE_X); +} + +void NV3001BDisplay::endFrame() { +} diff --git a/src/helpers/ui/NV3001BDisplay.h b/src/helpers/ui/NV3001BDisplay.h new file mode 100644 index 0000000000..76505b5d26 --- /dev/null +++ b/src/helpers/ui/NV3001BDisplay.h @@ -0,0 +1,68 @@ +#pragma once + +#include "DisplayDriver.h" +#include <SPI.h> +#include <helpers/RefCountedDigitalPin.h> + +#ifndef NV3001B_LOGICAL_WIDTH + #define NV3001B_LOGICAL_WIDTH 128 +#endif + +#ifndef NV3001B_LOGICAL_HEIGHT + #define NV3001B_LOGICAL_HEIGHT 64 +#endif + +#ifndef NV3001B_PANEL_WIDTH + #define NV3001B_PANEL_WIDTH 128 +#endif + +#ifndef NV3001B_PANEL_HEIGHT + #define NV3001B_PANEL_HEIGHT 220 +#endif + +#ifndef NV3001B_SPI_HOST + #define NV3001B_SPI_HOST HSPI +#endif + +class NV3001BDisplay : public DisplayDriver { + SPIClass spi; + RefCountedDigitalPin* periph_power; + bool is_on = false; + uint16_t color = 0xffff; + uint8_t text_size = 1; + int cursor_x = 0; + int cursor_y = 0; + + void writeCommand(uint8_t cmd); + void writeBytes(const uint8_t* data, size_t len); + void writeCommandData(uint8_t cmd, const uint8_t* data, size_t len); + void setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h); + void writeColor(uint16_t rgb, uint32_t count); + void fillPhysicalRect(int x, int y, int w, int h); + void initPanel(); + void drawChar(int x, int y, char ch); + +public: + NV3001BDisplay(RefCountedDigitalPin* power = nullptr) : + DisplayDriver(NV3001B_LOGICAL_WIDTH, NV3001B_LOGICAL_HEIGHT), spi(NV3001B_SPI_HOST), periph_power(power) { } + + bool begin(); + static const char* driverName() { return "NV3001B"; } + static uint16_t physicalWidth() { return NV3001B_PANEL_WIDTH; } + static uint16_t physicalHeight() { return NV3001B_PANEL_HEIGHT; } + + bool isOn() override { return is_on; } + void turnOn() override; + void turnOff() override; + void clear() override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; + void setTextSize(int sz) override; + void setColor(ColorVal c) override; + void setCursor(int x, int y) override; + void print(const char* str) override; + void fillRect(int x, int y, int w, int h) override; + void drawRect(int x, int y, int w, int h) override; + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; + uint16_t getTextWidth(const char* str) override; + void endFrame() override; +}; diff --git a/src/helpers/ui/NullDisplayDriver.cpp b/src/helpers/ui/NullDisplayDriver.cpp new file mode 100644 index 0000000000..1500f5c1b0 --- /dev/null +++ b/src/helpers/ui/NullDisplayDriver.cpp @@ -0,0 +1,11 @@ +#include "NullDisplayDriver.h" + +ColorVal UIColor::window_bkg = 0; +ColorVal UIColor::title_bkg = 0; +ColorVal UIColor::title_txt = 0; +ColorVal UIColor::primary_txt = 0; +ColorVal UIColor::secondary_txt = 0; +ColorVal UIColor::warning_txt = 0; +ColorVal UIColor::popup_bkg = 0; +ColorVal UIColor::popup_txt = 0; +ColorVal UIColor::corp_blue = 0; diff --git a/src/helpers/ui/NullDisplayDriver.h b/src/helpers/ui/NullDisplayDriver.h index 2a9670bd24..ff214c883b 100644 --- a/src/helpers/ui/NullDisplayDriver.h +++ b/src/helpers/ui/NullDisplayDriver.h @@ -11,9 +11,9 @@ class NullDisplayDriver : public DisplayDriver { void turnOn() override { } void turnOff() override { } void clear() override { } - void startFrame(Color bkg = DARK) override { } + void startFrame(ColorVal bkg = UIColor::window_bkg) override { } void setTextSize(int sz) override { } - void setColor(Color c) override { } + void setColor(ColorVal c) override { } void setCursor(int x, int y) override { } void print(const char* str) override { } void fillRect(int x, int y, int w, int h) override { } diff --git a/src/helpers/ui/OLEDDisplay.cpp b/src/helpers/ui/OLEDDisplay.cpp index a5d647a7fd..be4912a530 100644 --- a/src/helpers/ui/OLEDDisplay.cpp +++ b/src/helpers/ui/OLEDDisplay.cpp @@ -1166,7 +1166,7 @@ char DefaultFontTableLookup(const uint8_t ch) { uint8_t last = LASTCHAR; // get last char LASTCHAR = ch; - switch (last) { // conversion depnding on first UTF8-character + switch (last) { // conversion depending on first UTF8-character case 0xC2: return (uint8_t) ch; case 0xC3: return (uint8_t) (ch | 0xC0); case 0x82: if (ch == 0xAC) return (uint8_t) 0x80; // special case Euro-symbol diff --git a/src/helpers/ui/RotaryInput.h b/src/helpers/ui/RotaryInput.h new file mode 100644 index 0000000000..2dda695be1 --- /dev/null +++ b/src/helpers/ui/RotaryInput.h @@ -0,0 +1,18 @@ +#pragma once + +#include <Arduino.h> + +enum class RotaryInputEvent : uint8_t { + None, + Next, + Prev, +}; + +class RotaryInput { +public: + virtual ~RotaryInput() = default; + + virtual bool begin() = 0; + virtual RotaryInputEvent poll() = 0; + virtual bool isReady() const = 0; +}; diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index f383bb005d..c3840c02af 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -9,9 +9,23 @@ bool SH1106Display::i2c_probe(TwoWire &wire, uint8_t addr) return (error == 0); } +// Color scheme +ColorVal UIColor::window_bkg = SH110X_BLACK; +ColorVal UIColor::title_bkg = SH110X_BLACK; +ColorVal UIColor::title_txt = SH110X_WHITE; +ColorVal UIColor::primary_txt = SH110X_WHITE; +ColorVal UIColor::secondary_txt = SH110X_WHITE; +ColorVal UIColor::warning_txt = SH110X_WHITE; +ColorVal UIColor::popup_bkg = SH110X_BLACK; +ColorVal UIColor::popup_txt = SH110X_WHITE; +ColorVal UIColor::corp_blue = SH110X_WHITE; + bool SH1106Display::begin() { - return display.begin(DISPLAY_ADDRESS, true) && i2c_probe(Wire, DISPLAY_ADDRESS); + // Wire must already be initialised by board.begin() before this is called. + // Boards with non-standard SH1106 addresses should define DISPLAY_ADDRESS + // in their variant/platformio configuration. + return i2c_probe(Wire, DISPLAY_ADDRESS) && display.begin(DISPLAY_ADDRESS, true); } void SH1106Display::turnOn() @@ -32,7 +46,7 @@ void SH1106Display::clear() display.display(); } -void SH1106Display::startFrame(Color bkg) +void SH1106Display::startFrame(ColorVal bkg) { display.clearDisplay(); // TODO: apply 'bkg' _color = SH110X_WHITE; @@ -46,9 +60,9 @@ void SH1106Display::setTextSize(int sz) display.setTextSize(sz); } -void SH1106Display::setColor(Color c) +void SH1106Display::setColor(ColorVal c) { - _color = (c != 0) ? SH110X_WHITE : SH110X_BLACK; + _color = c; display.setTextColor(_color); } @@ -74,7 +88,7 @@ void SH1106Display::drawRect(int x, int y, int w, int h) void SH1106Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) { - display.drawBitmap(x, y, bits, w, h, SH110X_WHITE); + display.drawBitmap(x, y, bits, w, h, _color); } uint16_t SH1106Display::getTextWidth(const char *str) diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index b52a6adfb3..4e269d5ea5 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -30,9 +30,9 @@ class SH1106Display : public DisplayDriver void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char *str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index 464b2642a0..ab2d77fbc3 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -6,6 +6,17 @@ bool SSD1306Display::i2c_probe(TwoWire& wire, uint8_t addr) { return (error == 0); } +// Color scheme +ColorVal UIColor::window_bkg = SSD1306_BLACK; +ColorVal UIColor::title_bkg = SSD1306_BLACK; +ColorVal UIColor::title_txt = SSD1306_WHITE; +ColorVal UIColor::primary_txt = SSD1306_WHITE; +ColorVal UIColor::secondary_txt = SSD1306_WHITE; +ColorVal UIColor::warning_txt = SSD1306_WHITE; +ColorVal UIColor::popup_bkg = SSD1306_BLACK; +ColorVal UIColor::popup_txt = SSD1306_WHITE; +ColorVal UIColor::corp_blue = SSD1306_WHITE; + bool SSD1306Display::begin() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); @@ -44,7 +55,7 @@ void SSD1306Display::clear() { display.display(); } -void SSD1306Display::startFrame(Color bkg) { +void SSD1306Display::startFrame(ColorVal bkg) { display.clearDisplay(); // TODO: apply 'bkg' _color = SSD1306_WHITE; display.setTextColor(_color); @@ -56,8 +67,8 @@ void SSD1306Display::setTextSize(int sz) { display.setTextSize(sz); } -void SSD1306Display::setColor(Color c) { - _color = (c != 0) ? SSD1306_WHITE : SSD1306_BLACK; +void SSD1306Display::setColor(ColorVal c) { + _color = c; display.setTextColor(_color); } @@ -78,7 +89,7 @@ void SSD1306Display::drawRect(int x, int y, int w, int h) { } void SSD1306Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { - display.drawBitmap(x, y, bits, w, h, SSD1306_WHITE); + display.drawBitmap(x, y, bits, w, h, _color); } uint16_t SSD1306Display::getTextWidth(const char* str) { diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index d843da85b2..5dabbc58c0 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -35,9 +35,9 @@ class SSD1306Display : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index a6087dd813..344f5b1d9d 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -1,12 +1,409 @@ #include "ST7735Display.h" -#ifndef DISPLAY_ROTATION - #define DISPLAY_ROTATION 2 +//#include <Fonts/GFXFF/FreeSans9pt7b.h> + +// Optimised ST7735 display driver, derived from Adafruit_ST7735 library. + +#define ST_CMD_DELAY 0x80 // special signifier for command lists + +#define ST77XX_NOP 0x00 +#define ST77XX_SWRESET 0x01 +#define ST77XX_RDDID 0x04 +#define ST77XX_RDDST 0x09 + +#define ST77XX_SLPIN 0x10 +#define ST77XX_SLPOUT 0x11 +#define ST77XX_PTLON 0x12 +#define ST77XX_NORON 0x13 + +#define ST77XX_INVOFF 0x20 +#define ST77XX_INVON 0x21 +#define ST77XX_DISPOFF 0x28 +#define ST77XX_DISPON 0x29 +#define ST77XX_CASET 0x2A +#define ST77XX_RASET 0x2B +#define ST77XX_RAMWR 0x2C +#define ST77XX_RAMRD 0x2E + +#define ST77XX_PTLAR 0x30 +#define ST77XX_TEOFF 0x34 +#define ST77XX_TEON 0x35 +#define ST77XX_MADCTL 0x36 +#define ST77XX_COLMOD 0x3A + +#define ST77XX_MADCTL_MY 0x80 +#define ST77XX_MADCTL_MX 0x40 +#define ST77XX_MADCTL_MV 0x20 +#define ST77XX_MADCTL_ML 0x10 +#define ST77XX_MADCTL_RGB 0x00 + +#define ST77XX_RDID1 0xDA +#define ST77XX_RDID2 0xDB +#define ST77XX_RDID3 0xDC +#define ST77XX_RDID4 0xDD + +// Some ready-made 16-bit ('565') color settings: +#define ST77XX_BLACK 0x0000 +#define ST77XX_WHITE 0xFFFF +#define ST77XX_RED 0xF800 +#define ST77XX_GREEN 0x07E0 +#define ST77XX_BLUE 0x001F +#define ST77XX_CYAN 0x07FF +#define ST77XX_MAGENTA 0xF81F +#define ST77XX_YELLOW 0xFFE0 +#define ST77XX_ORANGE 0xFC00 + + +// some flags for initR() :( +#define INITR_GREENTAB 0x00 +#define INITR_REDTAB 0x01 +#define INITR_BLACKTAB 0x02 +#define INITR_18GREENTAB INITR_GREENTAB +#define INITR_18REDTAB INITR_REDTAB +#define INITR_18BLACKTAB INITR_BLACKTAB +#define INITR_144GREENTAB 0x01 +#define INITR_MINI160x80 0x04 +#define INITR_HALLOWING 0x05 +#define INITR_MINI160x80_PLUGIN 0x06 + +// Some register settings +#define ST7735_MADCTL_BGR 0x08 +#define ST7735_MADCTL_MH 0x04 + +#define ST7735_FRMCTR1 0xB1 +#define ST7735_FRMCTR2 0xB2 +#define ST7735_FRMCTR3 0xB3 +#define ST7735_INVCTR 0xB4 +#define ST7735_DISSET5 0xB6 + +#define ST7735_PWCTR1 0xC0 +#define ST7735_PWCTR2 0xC1 +#define ST7735_PWCTR3 0xC2 +#define ST7735_PWCTR4 0xC3 +#define ST7735_PWCTR5 0xC4 +#define ST7735_VMCTR1 0xC5 + +#define ST7735_PWCTR6 0xFC + +#define ST7735_GMCTRP1 0xE0 +#define ST7735_GMCTRN1 0xE1 + +// Some ready-made 16-bit ('565') color settings: +#define ST7735_BLACK ST77XX_BLACK +#define ST7735_WHITE ST77XX_WHITE +#define ST7735_RED ST77XX_RED +#define ST7735_GREEN ST77XX_GREEN +#define ST7735_BLUE ST77XX_BLUE +#define ST7735_CYAN ST77XX_CYAN +#define ST7735_MAGENTA ST77XX_MAGENTA +#define ST7735_YELLOW ST77XX_YELLOW +#define ST7735_ORANGE ST77XX_ORANGE + +static TFT_eSPI lcd = TFT_eSPI(160, 80); +static uint32_t curr_color; + +#if defined(HELTEC_LORA_V3) || defined(HELTEC_TRACKER_V2) + static SPIClass tft_spi(HSPI); + #define _spi (&tft_spi) +#else + #define _spi (&SPI1) #endif +SPISettings _spiSettings = SPISettings(40000000, MSBFIRST, SPI_MODE0); + +// clang-format off +static const uint8_t PROGMEM + Bcmd[] = { // Init commands for 7735B screens + 18, // 18 commands in list: + ST77XX_SWRESET, ST_CMD_DELAY, // 1: Software reset, no args, w/delay + 50, // 50 ms delay + ST77XX_SLPOUT, ST_CMD_DELAY, // 2: Out of sleep mode, no args, w/delay + 255, // 255 = max (500 ms) delay + ST77XX_COLMOD, 1+ST_CMD_DELAY, // 3: Set color mode, 1 arg + delay: + 0x05, // 16-bit color + 10, // 10 ms delay + ST7735_FRMCTR1, 3+ST_CMD_DELAY, // 4: Frame rate control, 3 args + delay: + 0x00, // fastest refresh + 0x06, // 6 lines front porch + 0x03, // 3 lines back porch + 10, // 10 ms delay + ST77XX_MADCTL, 1, // 5: Mem access ctl (directions), 1 arg: + 0x08, // Row/col addr, bottom-top refresh + ST7735_DISSET5, 2, // 6: Display settings #5, 2 args: + 0x15, // 1 clk cycle nonoverlap, 2 cycle gate + // rise, 3 cycle osc equalize + 0x02, // Fix on VTL + ST7735_INVCTR, 1, // 7: Display inversion control, 1 arg: + 0x0, // Line inversion + ST7735_PWCTR1, 2+ST_CMD_DELAY, // 8: Power control, 2 args + delay: + 0x02, // GVDD = 4.7V + 0x70, // 1.0uA + 10, // 10 ms delay + ST7735_PWCTR2, 1, // 9: Power control, 1 arg, no delay: + 0x05, // VGH = 14.7V, VGL = -7.35V + ST7735_PWCTR3, 2, // 10: Power control, 2 args, no delay: + 0x01, // Opamp current small + 0x02, // Boost frequency + ST7735_VMCTR1, 2+ST_CMD_DELAY, // 11: Power control, 2 args + delay: + 0x3C, // VCOMH = 4V + 0x38, // VCOML = -1.1V + 10, // 10 ms delay + ST7735_PWCTR6, 2, // 12: Power control, 2 args, no delay: + 0x11, 0x15, + ST7735_GMCTRP1,16, // 13: Gamma Adjustments (pos. polarity), 16 args + delay: + 0x09, 0x16, 0x09, 0x20, // (Not entirely necessary, but provides + 0x21, 0x1B, 0x13, 0x19, // accurate colors) + 0x17, 0x15, 0x1E, 0x2B, + 0x04, 0x05, 0x02, 0x0E, + ST7735_GMCTRN1,16+ST_CMD_DELAY, // 14: Gamma Adjustments (neg. polarity), 16 args + delay: + 0x0B, 0x14, 0x08, 0x1E, // (Not entirely necessary, but provides + 0x22, 0x1D, 0x18, 0x1E, // accurate colors) + 0x1B, 0x1A, 0x24, 0x2B, + 0x06, 0x06, 0x02, 0x0F, + 10, // 10 ms delay + ST77XX_CASET, 4, // 15: Column addr set, 4 args, no delay: + 0x00, 0x02, // XSTART = 2 + 0x00, 0x81, // XEND = 129 + ST77XX_RASET, 4, // 16: Row addr set, 4 args, no delay: + 0x00, 0x02, // XSTART = 1 + 0x00, 0x81, // XEND = 160 + ST77XX_NORON, ST_CMD_DELAY, // 17: Normal display on, no args, w/delay + 10, // 10 ms delay + ST77XX_DISPON, ST_CMD_DELAY, // 18: Main screen turn on, no args, delay + 255 }, // 255 = max (500 ms) delay + + Rcmd1[] = { // 7735R init, part 1 (red or green tab) + 14, // 14 commands in list: + /*ST77XX_SWRESET, ST_CMD_DELAY, // 1: Software reset, 0 args, w/delay + 150, */ // 150 ms delay + ST77XX_SLPOUT, ST_CMD_DELAY, // 2: Out of sleep mode, 0 args, w/delay + 120, // 120 ms delay + ST7735_FRMCTR1, 3, // 3: Framerate ctrl - normal mode, 3 arg: + 0x01, 0x2C, 0x2D, // Rate = fosc/(1x2+40) * (LINE+2C+2D) + ST7735_FRMCTR2, 3, // 4: Framerate ctrl - idle mode, 3 args: + 0x01, 0x2C, 0x2D, // Rate = fosc/(1x2+40) * (LINE+2C+2D) + ST7735_FRMCTR3, 6, // 5: Framerate - partial mode, 6 args: + 0x01, 0x2C, 0x2D, // Dot inversion mode + 0x01, 0x2C, 0x2D, // Line inversion mode + ST7735_INVCTR, 1, // 6: Display inversion ctrl, 1 arg: + 0x07, // No inversion + ST7735_PWCTR1, 3, // 7: Power control, 3 args, no delay: + 0xA2, + 0x02, // -4.6V + 0x84, // AUTO mode + ST7735_PWCTR2, 1, // 8: Power control, 1 arg, no delay: + 0xC5, // VGH25=2.4C VGSEL=-10 VGH=3 * AVDD + ST7735_PWCTR3, 2, // 9: Power control, 2 args, no delay: + 0x0A, // Opamp current small + 0x00, // Boost frequency + ST7735_PWCTR4, 2, // 10: Power control, 2 args, no delay: + 0x8A, // BCLK/2, + 0x2A, // opamp current small & medium low + ST7735_PWCTR5, 2, // 11: Power control, 2 args, no delay: + 0x8A, 0xEE, + ST7735_VMCTR1, 1, // 12: Power control, 1 arg, no delay: + 0x0E, + ST77XX_INVOFF, 0, // 13: Don't invert display, no args + ST77XX_MADCTL, 1, // 14: Mem access ctl (directions), 1 arg: + 0xC8, // row/col addr, bottom-top refresh + ST77XX_COLMOD, 1, // 15: set color mode, 1 arg, no delay: + 0x05 }, // 16-bit color + + Rcmd2green[] = { // 7735R init, part 2 (green tab only) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x02, // XSTART = 0 + 0x00, 0x7F+0x02, // XEND = 127 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x01, // XSTART = 0 + 0x00, 0x9F+0x01 }, // XEND = 159 + + Rcmd2red[] = { // 7735R init, part 2 (red tab only) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x7F, // XEND = 127 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x9F }, // XEND = 159 + + Rcmd2green144[] = { // 7735R init, part 2 (green 1.44 tab) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x7F, // XEND = 127 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x7F }, // XEND = 127 + + Rcmd2green160x80[] = { // 7735R init, part 2 (mini 160x80) + 2, // 2 commands in list: + ST77XX_CASET, 4, // 1: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x4F, // XEND = 79 + ST77XX_RASET, 4, // 2: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x9F }, // XEND = 159 + + Rcmd2green160x80plugin[] = { // 7735R init, part 2 (mini 160x80 with plugin FPC) + 3, // 3 commands in list: + ST77XX_INVON, 0, // 1: Display is inverted + ST77XX_CASET, 4, // 2: Column addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x4F, // XEND = 79 + ST77XX_RASET, 4, // 3: Row addr set, 4 args, no delay: + 0x00, 0x00, // XSTART = 0 + 0x00, 0x9F }, // XEND = 159 + + Rcmd2invert[] = { // Tracker V1, part 2 + 1, // 1 command in list: + ST77XX_INVON, 0 }, // 1: Display is inverted + + Rcmd3[] = { // 7735R init, part 3 (red or green tab) + 2, // 2 commands in list: + ST7735_GMCTRP1, 16 , // 1: Gamma Adjustments (pos. polarity), 16 args + delay: + 0x02, 0x1c, 0x07, 0x12, // (Not entirely necessary, but provides + 0x37, 0x32, 0x29, 0x2d, // accurate colors) + 0x29, 0x25, 0x2B, 0x39, + 0x00, 0x01, 0x03, 0x10, + ST7735_GMCTRN1, 16 , // 2: Gamma Adjustments (neg. polarity), 16 args + delay: + 0x03, 0x1d, 0x07, 0x06, // (Not entirely necessary, but provides + 0x2E, 0x2C, 0x29, 0x2D, // accurate colors) + 0x2E, 0x2E, 0x37, 0x3F, + 0x00, 0x00, 0x02, 0x10 }; // 100 ms delay + +static int16_t _xstart = 0; ///< Internal framebuffer X offset +static int16_t _ystart = 0; ///< Internal framebuffer Y offset +static uint8_t _colstart = 0; ///< Some displays need this changed to offset +static uint8_t _rowstart = 0; ///< Some displays need this changed to offset +static uint8_t rotation = 0; +static int16_t _width = 0; ///< Display width as modified by current rotation +static int16_t _height = 0; ///< Display height as modified by current rotation + +static void set_CS(uint8_t level) { + //if (_cs != (uint8_t) -1) { + digitalWrite(PIN_TFT_CS, level); + //} +} +static void sendCommand(uint8_t com) { + set_CS(HIGH); + digitalWrite(PIN_TFT_DC, LOW); + set_CS(LOW); + _spi->beginTransaction(_spiSettings); + _spi->transfer(com); + _spi->endTransaction(); + set_CS(HIGH); + digitalWrite(PIN_TFT_DC, HIGH); +} + +static void WriteData(uint8_t data) { + digitalWrite(PIN_TFT_CS, LOW); + _spi->beginTransaction(_spiSettings); + _spi->transfer(data); + _spi->endTransaction(); + digitalWrite(PIN_TFT_CS, HIGH); +} +static void SPI_WRITE32(uint32_t l) { + _spi->transfer(l >> 24); + _spi->transfer(l >> 16); + _spi->transfer(l >> 8); + _spi->transfer(l); +} +static void writeCommand(uint8_t cmd) { + digitalWrite(PIN_TFT_DC, LOW); + _spi->transfer(cmd); + digitalWrite(PIN_TFT_DC, HIGH); +} + +static void displayInit(const uint8_t *addr) { + uint8_t numCommands, cmd, numArgs; + uint16_t ms; + + numCommands = pgm_read_byte(addr++); // Number of commands to follow + while (numCommands--) { // For each command... + cmd = pgm_read_byte(addr++); // Read command + numArgs = pgm_read_byte(addr++); // Number of args to follow + ms = numArgs & ST_CMD_DELAY; // If hibit set, delay follows args + numArgs &= ~ST_CMD_DELAY; // Mask out delay bit + sendCommand(cmd); + for (int k = 0; k < numArgs; k++) { + WriteData(addr[k]); + } + addr += numArgs; + + if (ms) { + ms = pgm_read_byte(addr++); // Read post-command delay time (ms) + if (ms == 255) + ms = 500; // If 255, delay for 500 ms + delay(ms); + } + } +} + +static void setRotation(uint8_t m) { + uint8_t madctl = 0; + + rotation = m & 3; // can't be higher than 3 + + switch (rotation) { + case 0: + madctl = ST77XX_MADCTL_MX | ST77XX_MADCTL_MY | ST7735_MADCTL_BGR; + + _height = 160; + _width = 80; + _xstart = _colstart; + _ystart = _rowstart; + break; + case 1: + madctl = ST77XX_MADCTL_MY | ST77XX_MADCTL_MV | ST7735_MADCTL_BGR; + + _width = 160; + _height = 80; + _ystart = _colstart; + _xstart = _rowstart; + break; + case 2: + madctl = ST7735_MADCTL_BGR; + + _height = 160; + _width = 80; + _xstart = _colstart; + _ystart = _rowstart; + break; + case 3: + madctl = ST77XX_MADCTL_MX | ST77XX_MADCTL_MV | ST7735_MADCTL_BGR; + + _width = 160; + _height = 80; + _ystart = _colstart; + _xstart = _rowstart; + break; + } + + sendCommand(ST77XX_MADCTL); + WriteData(madctl); +} + +static void setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { + x += _xstart; + y += _ystart; + uint32_t xa = ((uint32_t)x << 16) | (x + w - 1); + uint32_t ya = ((uint32_t)y << 16) | (y + h - 1); + + writeCommand(ST77XX_CASET); // Column addr set + SPI_WRITE32(xa); + + writeCommand(ST77XX_RASET); // Row addr set + SPI_WRITE32(ya); + + writeCommand(ST77XX_RAMWR); // write to RAM +} + #define SCALE_X 1.25f // 160 / 128 #define SCALE_Y 1.25f // 80 / 64 +static TFT_eSprite *sprite = NULL; + bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) { return true; /* @@ -16,53 +413,121 @@ bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) { */ } +#ifndef PIN_TFT_LEDA_CTL_ACTIVE + #define PIN_TFT_LEDA_CTL_ACTIVE HIGH +#endif + +// Color scheme +ColorVal UIColor::window_bkg = ST77XX_WHITE; +ColorVal UIColor::title_bkg = ST77XX_BLUE; +ColorVal UIColor::title_txt = ST77XX_WHITE; +ColorVal UIColor::primary_txt = ST77XX_BLACK; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = ST77XX_ORANGE; +ColorVal UIColor::popup_bkg = ST77XX_CYAN; +ColorVal UIColor::popup_txt = ST77XX_BLACK; +ColorVal UIColor::corp_blue = 0x001A; + bool ST7735Display::begin() { + if (!sprite) { + // alloc offscreen canvas + sprite = new TFT_eSprite(&lcd); + if (sprite) { + if (sprite->createSprite(160, 80)) { + sprite->fillScreen(ST77XX_BLACK); + sprite->setTextColor(curr_color = ST77XX_WHITE); + } else { + Serial.printf("ST7735Display: failed to alloc canvas pixels"); + } + } else { + Serial.printf("ST7735Display: failed to alloc canvas"); + } + } + if (!_isOn) { if (_peripher_power) _peripher_power->claim(); + delay(100); // TEMP!! + pinMode(PIN_TFT_RST, OUTPUT); + pinMode(PIN_TFT_CS, OUTPUT); + pinMode(PIN_TFT_DC, OUTPUT); pinMode(PIN_TFT_LEDA_CTL, OUTPUT); -#if defined(PIN_TFT_LEDA_CTL_ACTIVE) - digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); + +#ifdef ESP_PLATFORM + _spi->begin(PIN_TFT_SCL, -1 /* _miso */, PIN_TFT_SDA /* _mosi */, -1); #else - digitalWrite(PIN_TFT_LEDA_CTL, HIGH); + _spi->begin(); #endif - digitalWrite(PIN_TFT_RST, HIGH); + _spi->setClockDivider(SPI_CLOCK_DIV2); -#if defined(HELTEC_T1) - display.initR(INITR_MINI160x80); - display.setRotation(DISPLAY_ROTATION); -#elif defined(HELTEC_TRACKER_V2) || defined(HELTEC_T096) - display.initR(INITR_MINI160x80); - display.setRotation(DISPLAY_ROTATION); - uint8_t madctl = ST77XX_MADCTL_MY | ST77XX_MADCTL_MV |ST7735_MADCTL_BGR;//Adjust color to BGR - display.sendCommand(ST77XX_MADCTL, &madctl, 1); + _height = 80; + _width = 160; +#if defined(HELTEC_LORA_V3) // Tracker v1 + _colstart = 26; + _rowstart = 1; #else - display.initR(INITR_MINI160x80_PLUGIN); - display.setRotation(DISPLAY_ROTATION); + _colstart = 24; + _rowstart = 0; #endif - display.setSPISpeed(40000000); - display.fillScreen(ST77XX_BLACK); - display.setTextColor(ST77XX_WHITE); - display.setTextSize(2); - display.cp437(true); // Use full 256 char 'Code Page 437' font + + _resetAndInit(); + + sendCommand(ST77XX_DISPON); _isOn = true; } return true; } +void ST7735Display::_resetAndInit() { + // Pulse Reset low for 10ms + digitalWrite(PIN_TFT_RST, HIGH); + delay(2); + digitalWrite(PIN_TFT_RST, LOW); + delay(10); + digitalWrite(PIN_TFT_RST, HIGH); + delay(2); + + // run init commands + displayInit(Rcmd1); +#if defined(HELTEC_TRACKER_V2) || defined(HELTEC_T096) + displayInit(Rcmd2green160x80); + //uint8_t madctl = ST77XX_MADCTL_MY | ST77XX_MADCTL_MV |ST7735_MADCTL_BGR;//Adjust color to BGR + //display.sendCommand(ST77XX_MADCTL, &madctl, 1); +#elif defined(HELTEC_LORA_V3) // Tracker v1 + displayInit(Rcmd2invert); // invert RGB +#endif + displayInit(Rcmd3); + setRotation(DISPLAY_ROTATION); + + // clear the buffer before display on + sprite->fillScreen(ST77XX_BLACK); + endFrame(); + + // turn on backlight + digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); + +} + void ST7735Display::turnOn() { - ST7735Display::begin(); + if (!_isOn) { + if (_peripher_power) _peripher_power->claim(); + _resetAndInit(); + sendCommand(ST77XX_DISPON); + + // Now turn on the backlight + // digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); + _isOn = true; + } } void ST7735Display::turnOff() { if (_isOn) { - digitalWrite(PIN_TFT_RST, LOW); -#if defined(PIN_TFT_LEDA_CTL_ACTIVE) + sendCommand(ST77XX_DISPOFF); + + //digitalWrite(PIN_TFT_RST, LOW); + // Now turn off the backlight digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE); -#else - digitalWrite(PIN_TFT_LEDA_CTL, LOW); -#endif _isOn = false; if (_peripher_power) _peripher_power->release(); @@ -70,78 +535,65 @@ void ST7735Display::turnOff() { } void ST7735Display::clear() { - //Serial.println("DBG: display.Clear"); - display.fillScreen(ST77XX_BLACK); + sprite->fillScreen(ST77XX_BLACK); } -void ST7735Display::startFrame(Color bkg) { - display.fillScreen(0x00); - display.setTextColor(ST77XX_WHITE); - display.setTextSize(1); // This one affects size of Please wait... message - display.cp437(true); // Use full 256 char 'Code Page 437' font +void ST7735Display::startFrame(ColorVal bkg) { + sprite->fillScreen(bkg); + sprite->setTextColor(curr_color = UIColor::primary_txt); + sprite->setFreeFont(); + sprite->setTextSize(1); // This one affects size of Please wait... message + //sprite->cp437(true); // Use full 256 char 'Code Page 437' font } void ST7735Display::setTextSize(int sz) { - display.setTextSize(sz); -} - -void ST7735Display::setColor(Color c) { - switch (c) { - case DisplayDriver::DARK : - _color = ST77XX_BLACK; - break; - case DisplayDriver::LIGHT : - _color = ST77XX_WHITE; - break; - case DisplayDriver::RED : - _color = ST77XX_RED; - break; - case DisplayDriver::GREEN : - _color = ST77XX_GREEN; - break; - case DisplayDriver::BLUE : - _color = ST77XX_BLUE; - break; - case DisplayDriver::YELLOW : - _color = ST77XX_YELLOW; - break; - case DisplayDriver::ORANGE : - _color = ST77XX_ORANGE; - break; - default: - _color = ST77XX_WHITE; - break; - } - display.setTextColor(_color); + sprite->setTextSize(sz); +} + +void ST7735Display::setColor(ColorVal c) { + curr_color = c; + sprite->setTextColor(curr_color); } void ST7735Display::setCursor(int x, int y) { - display.setCursor(x*SCALE_X, y*SCALE_Y); + sprite->setCursor(x*SCALE_X, y*SCALE_Y); } void ST7735Display::print(const char* str) { - display.print(str); + sprite->print(str); } void ST7735Display::fillRect(int x, int y, int w, int h) { - display.fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, _color); + sprite->fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, curr_color); } void ST7735Display::drawRect(int x, int y, int w, int h) { - display.drawRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, _color); + sprite->drawRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, curr_color); } void ST7735Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { - display.drawBitmap(x*SCALE_X, y*SCALE_Y, bits, w, h, _color); + sprite->drawBitmap(x*SCALE_X, y*SCALE_Y, bits, w, h, curr_color); } uint16_t ST7735Display::getTextWidth(const char* str) { - int16_t x1, y1; - uint16_t w, h; - display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); - return w / SCALE_X; + return sprite->textWidth(str) / SCALE_X; } void ST7735Display::endFrame() { - // display.display(); + // blit the canvas buffer to LCD + set_CS(LOW); + _spi->beginTransaction(_spiSettings); + uint16_t x, y; + uint16_t* pixels = (uint16_t *) ((TFT_eSprite *) sprite)->getPointer(); + for (y = 0; y < 80; y++, pixels += 160) { + setAddrWindow(0, y, 160, 1); +#ifdef ESP_PLATFORM + _spi->transferBytes((uint8_t *)pixels, NULL, 2 * 160); +#else + _spi->transfer(pixels, NULL, 2 * 160); +#endif + } + + _spi->endTransaction(); + set_CS(HIGH); } diff --git a/src/helpers/ui/ST7735Display.h b/src/helpers/ui/ST7735Display.h index 94797503f1..6e289b2193 100644 --- a/src/helpers/ui/ST7735Display.h +++ b/src/helpers/ui/ST7735Display.h @@ -3,28 +3,25 @@ #include "DisplayDriver.h" #include <Wire.h> #include <SPI.h> -#include <Adafruit_GFX.h> -#include <Adafruit_ST7735.h> +#include "TFT_eSPI.h" #include <helpers/RefCountedDigitalPin.h> class ST7735Display : public DisplayDriver { - Adafruit_ST7735 display; bool _isOn; - uint16_t _color; RefCountedDigitalPin* _peripher_power; bool i2c_probe(TwoWire& wire, uint8_t addr); public: #ifdef USE_PIN_TFT ST7735Display(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), - display(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_SDA, PIN_TFT_SCL, PIN_TFT_RST), + // display(PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_SDA, PIN_TFT_SCL, PIN_TFT_RST), _peripher_power(peripher_power) { _isOn = false; } #else ST7735Display(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), - display(&SPI1, PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST), + // display(&SPI1, PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST), _peripher_power(peripher_power) { _isOn = false; @@ -36,9 +33,9 @@ class ST7735Display : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; @@ -46,4 +43,7 @@ class ST7735Display : public DisplayDriver { void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; uint16_t getTextWidth(const char* str) override; void endFrame() override; + +protected: + void _resetAndInit(); }; diff --git a/src/helpers/ui/ST7789Display.cpp b/src/helpers/ui/ST7789Display.cpp index f7d20b8ab7..7d039a131a 100644 --- a/src/helpers/ui/ST7789Display.cpp +++ b/src/helpers/ui/ST7789Display.cpp @@ -18,6 +18,25 @@ #define SCALE_Y 2.109375f // 135 / 64 #endif +#ifdef DISPLAY_SCALE_X + #define SCALE_X DISPLAY_SCALE_X +#endif + +#ifdef DISPLAY_SCALE_Y + #define SCALE_Y DISPLAY_SCALE_Y +#endif + +// Color scheme +ColorVal UIColor::window_bkg = OLEDDISPLAY_COLOR::BLACK; +ColorVal UIColor::title_bkg = OLEDDISPLAY_COLOR::BLACK; +ColorVal UIColor::title_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::primary_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::secondary_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::warning_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::popup_bkg = OLEDDISPLAY_COLOR::BLACK; +ColorVal UIColor::popup_txt = OLEDDISPLAY_COLOR::WHITE; +ColorVal UIColor::corp_blue = OLEDDISPLAY_COLOR::WHITE; + bool ST7789Display::begin() { if(!_isOn) { pinMode(PIN_TFT_VDD_CTL, OUTPUT); @@ -32,6 +51,9 @@ bool ST7789Display::begin() { display.init(); display.landscapeScreen(); + #ifdef DISPLAY_FLIP_VERTICALLY + display.flipScreenVertically(); + #endif display.displayOn(); setCursor(0,0); @@ -49,6 +71,9 @@ void ST7789Display::turnOn() { // Re-initialize the display display.init(); display.displayOn(); + #ifdef DISPLAY_FLIP_VERTICALLY + display.flipScreenVertically(); + #endif delay(20); // Now turn on the backlight @@ -76,10 +101,9 @@ void ST7789Display::clear() { display.clear(); } -void ST7789Display::startFrame(Color bkg) { - display.clear(); - _color = ST77XX_WHITE; - display.setRGB(_color); +void ST7789Display::startFrame(ColorVal bkg) { + display.clear(); // TODO: use bkg + setColor(UIColor::primary_txt); display.setFont(ArialMT_Plain_16); } @@ -96,38 +120,10 @@ void ST7789Display::setTextSize(int sz) { } } -void ST7789Display::setColor(Color c) { - switch (c) { - case DisplayDriver::DARK : - _color = ST77XX_BLACK; - display.setColor(OLEDDISPLAY_COLOR::BLACK); - break; -#if 0 - case DisplayDriver::LIGHT : - _color = ST77XX_WHITE; - break; - case DisplayDriver::RED : - _color = ST77XX_RED; - break; - case DisplayDriver::GREEN : - _color = ST77XX_GREEN; - break; - case DisplayDriver::BLUE : - _color = ST77XX_BLUE; - break; - case DisplayDriver::YELLOW : - _color = ST77XX_YELLOW; - break; - case DisplayDriver::ORANGE : - _color = ST77XX_ORANGE; - break; -#endif - default: - _color = ST77XX_WHITE; - display.setColor(OLEDDISPLAY_COLOR::WHITE); - break; - } - display.setRGB(_color); +void ST7789Display::setColor(ColorVal c) { + _color = c; + display.setColor((OLEDDISPLAY_COLOR)_color); + display.setRGB(_color == OLEDDISPLAY_COLOR::WHITE ? ST77XX_WHITE : ST77XX_BLACK); } void ST7789Display::setCursor(int x, int y) { diff --git a/src/helpers/ui/ST7789Display.h b/src/helpers/ui/ST7789Display.h index cb56ff8a79..580ad463f6 100644 --- a/src/helpers/ui/ST7789Display.h +++ b/src/helpers/ui/ST7789Display.h @@ -14,8 +14,10 @@ class ST7789Display : public DisplayDriver { bool i2c_probe(TwoWire& wire, uint8_t addr); public: -#ifdef HELTEC_VISION_MASTER_T190 +#if defined(HELTEC_VISION_MASTER_T190) ST7789Display() : DisplayDriver(128, 64), display(&SPI, PIN_TFT_RST, PIN_TFT_DC, PIN_TFT_CS, GEOMETRY_RAWMODE, 320, 170,PIN_TFT_SDA,-1,PIN_TFT_SCL) {_isOn = false;} +#elif defined(THINKNODE_M9) + ST7789Display() : DisplayDriver(128, 64), display(&SPI, ST7789_RESET, ST7789_RS, ST7789_CS, GEOMETRY_RAWMODE, 320, 240, ST7789_SDA, ST7789_MISO, ST7789_SCK) {_isOn = false;} #else ST7789Display() : DisplayDriver(128, 64), display(&SPI1, PIN_TFT_RST, PIN_TFT_DC, PIN_TFT_CS, GEOMETRY_RAWMODE, 240, 135) {_isOn = false;} #endif @@ -25,9 +27,9 @@ class ST7789Display : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void printWordWrap(const char* str, int max_width) override; diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index dc75e96310..a9f30dd56a 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -1,5 +1,9 @@ #include "ST7789LCDDisplay.h" +#ifndef PIN_TFT_MISO + #define PIN_TFT_MISO -1 +#endif + #ifndef DISPLAY_ROTATION #define DISPLAY_ROTATION 3 #endif @@ -19,6 +23,17 @@ bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) { return true; } +// Color scheme +ColorVal UIColor::window_bkg = ST77XX_WHITE; +ColorVal UIColor::title_bkg = ST77XX_BLUE; +ColorVal UIColor::title_txt = ST77XX_WHITE; +ColorVal UIColor::primary_txt = ST77XX_BLACK; +ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray +ColorVal UIColor::warning_txt = ST77XX_ORANGE; +ColorVal UIColor::popup_bkg = ST77XX_CYAN; +ColorVal UIColor::popup_txt = ST77XX_BLACK; +ColorVal UIColor::corp_blue = 0x001A; + bool ST7789LCDDisplay::begin() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); @@ -29,8 +44,8 @@ bool ST7789LCDDisplay::begin() { } // Im not sure if this is just a t-deck problem or not, if your display is slow try this. - #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) - displaySPI.begin(PIN_TFT_SCL, -1, PIN_TFT_SDA, PIN_TFT_CS); + #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT) + displaySPI.begin(PIN_TFT_SCL, PIN_TFT_MISO, PIN_TFT_SDA, PIN_TFT_CS); #endif display.init(DISPLAY_WIDTH, DISPLAY_HEIGHT); @@ -74,9 +89,9 @@ void ST7789LCDDisplay::clear() { display.fillScreen(ST77XX_BLACK); } -void ST7789LCDDisplay::startFrame(Color bkg) { - display.fillScreen(ST77XX_BLACK); - display.setTextColor(ST77XX_WHITE); +void ST7789LCDDisplay::startFrame(ColorVal bkg) { + display.fillScreen(bkg); + display.setTextColor(_color = UIColor::primary_txt); display.setTextSize(1 * DISPLAY_SCALE_X); // This one affects size of Please wait... message display.cp437(true); // Use full 256 char 'Code Page 437' font } @@ -85,34 +100,8 @@ void ST7789LCDDisplay::setTextSize(int sz) { display.setTextSize(sz * DISPLAY_SCALE_X); } -void ST7789LCDDisplay::setColor(Color c) { - switch (c) { - case DisplayDriver::DARK : - _color = ST77XX_BLACK; - break; - case DisplayDriver::LIGHT : - _color = ST77XX_WHITE; - break; - case DisplayDriver::RED : - _color = ST77XX_RED; - break; - case DisplayDriver::GREEN : - _color = ST77XX_GREEN; - break; - case DisplayDriver::BLUE : - _color = ST77XX_BLUE; - break; - case DisplayDriver::YELLOW : - _color = ST77XX_YELLOW; - break; - case DisplayDriver::ORANGE : - _color = ST77XX_ORANGE; - break; - default: - _color = ST77XX_WHITE; - break; - } - display.setTextColor(_color); +void ST7789LCDDisplay::setColor(ColorVal c) { + display.setTextColor(_color = c); } void ST7789LCDDisplay::setCursor(int x, int y) { diff --git a/src/helpers/ui/ST7789LCDDisplay.h b/src/helpers/ui/ST7789LCDDisplay.h index 5b960ca198..b5127d3503 100644 --- a/src/helpers/ui/ST7789LCDDisplay.h +++ b/src/helpers/ui/ST7789LCDDisplay.h @@ -8,7 +8,7 @@ #include <helpers/RefCountedDigitalPin.h> class ST7789LCDDisplay : public DisplayDriver { - #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) + #if defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT) SPIClass displaySPI; #endif Adafruit_ST7789 display; @@ -25,7 +25,7 @@ class ST7789LCDDisplay : public DisplayDriver { { _isOn = false; } -#elif defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) +#elif defined(LILYGO_TDECK) || defined(HELTEC_LORA_V4_TFT) || defined(HELTEC_V4_R8_TFT) ST7789LCDDisplay(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), displaySPI(HSPI), display(&displaySPI, PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST), @@ -47,9 +47,9 @@ class ST7789LCDDisplay : public DisplayDriver { void turnOn() override; void turnOff() override; void clear() override; - void startFrame(Color bkg = DARK) override; + void startFrame(ColorVal bkg = UIColor::window_bkg) override; void setTextSize(int sz) override; - void setColor(Color c) override; + void setColor(ColorVal c) override; void setCursor(int x, int y) override; void print(const char* str) override; void fillRect(int x, int y, int w, int h) override; diff --git a/src/helpers/ui/U8g2Display.cpp b/src/helpers/ui/U8g2Display.cpp new file mode 100644 index 0000000000..e8d7fe2f10 --- /dev/null +++ b/src/helpers/ui/U8g2Display.cpp @@ -0,0 +1,12 @@ +#include "U8g2Display.h" + +// Color scheme +ColorVal UIColor::window_bkg = 0; +ColorVal UIColor::title_bkg = 0; +ColorVal UIColor::title_txt = 1; +ColorVal UIColor::primary_txt = 1; +ColorVal UIColor::secondary_txt = 1; +ColorVal UIColor::warning_txt = 1; +ColorVal UIColor::popup_bkg = 0; +ColorVal UIColor::popup_txt = 1; +ColorVal UIColor::corp_blue = 1; diff --git a/src/helpers/ui/U8g2Display.h b/src/helpers/ui/U8g2Display.h index 73c5893633..a87ee19b3e 100644 --- a/src/helpers/ui/U8g2Display.h +++ b/src/helpers/ui/U8g2Display.h @@ -72,10 +72,10 @@ class U8g2Display : public DisplayDriver { _u8g2.sendBuffer(); } - void startFrame(Color bkg = DARK) override { - _u8g2.clearBuffer(); - _drawColor = 1; - _u8g2.setDrawColor(1); + void startFrame(ColorVal bkg = UIColor::window_bkg) override { + _u8g2.clearBuffer(); // TODO: apply 'bkg' color + _drawColor = UIColor::primary_txt; + _u8g2.setDrawColor(_drawColor); applyFont(1); } @@ -83,8 +83,8 @@ class U8g2Display : public DisplayDriver { applyFont(sz); } - void setColor(Color c) override { - _drawColor = (c != DARK) ? 1 : 0; + void setColor(ColorVal c) override { + _drawColor = c; _u8g2.setDrawColor(_drawColor); } @@ -94,22 +94,18 @@ class U8g2Display : public DisplayDriver { } void print(const char* str) override { - _u8g2.setDrawColor(_drawColor); _u8g2.drawStr(_cursorX, _cursorY, str); } void fillRect(int x, int y, int w, int h) override { - _u8g2.setDrawColor(_drawColor); _u8g2.drawBox(x, y, w, h); } void drawRect(int x, int y, int w, int h) override { - _u8g2.setDrawColor(_drawColor); _u8g2.drawFrame(x, y, w, h); } void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { - _u8g2.setDrawColor(1); _u8g2.drawXBM(x, y, w, h, bits); } diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h new file mode 100644 index 0000000000..77499fe414 --- /dev/null +++ b/test/mocks/Arduino.h @@ -0,0 +1,17 @@ +#pragma once + +#include <cstdint> +#include <cmath> +#include "Stream.h" + +inline uint32_t g_mock_millis = 0; + +using std::isnan; + +inline uint32_t millis() { + return g_mock_millis; +} + +inline void delay(uint32_t ms) { + g_mock_millis += ms; +} diff --git a/test/mocks/CayenneLPP.h b/test/mocks/CayenneLPP.h new file mode 100644 index 0000000000..9d51c0fcfc --- /dev/null +++ b/test/mocks/CayenneLPP.h @@ -0,0 +1,11 @@ +#pragma once + +#include <cstddef> +#include <cstdint> + +class CayenneLPP { +public: + explicit CayenneLPP(size_t) {} + const uint8_t* getBuffer() const { return nullptr; } + uint16_t getSize() const { return 0; } +}; diff --git a/test/mocks/Identity.h b/test/mocks/Identity.h new file mode 100644 index 0000000000..5c3d25e94e --- /dev/null +++ b/test/mocks/Identity.h @@ -0,0 +1,39 @@ +#pragma once + +#include <cstdint> +#include <cstring> +#include "Utils.h" + +namespace mesh { + +class Identity { +public: + uint8_t pub_key[PUB_KEY_SIZE]; + + Identity() { + std::memset(pub_key, 0, sizeof(pub_key)); + } + + explicit Identity(const uint8_t* src) { + std::memcpy(pub_key, src, PUB_KEY_SIZE); + } + + bool verify(const uint8_t*, const uint8_t*, int) const { + return true; + } +}; + +class LocalIdentity : public Identity { +public: + LocalIdentity() : Identity() {} + + void sign(uint8_t* sig, const uint8_t*, int) const { + std::memset(sig, 0x5A, SIGNATURE_SIZE); + } + + void calcSharedSecret(uint8_t* secret, const uint8_t*) const { + std::memset(secret, 0x11, PUB_KEY_SIZE); + } +}; + +} diff --git a/test/mocks/Mesh.h b/test/mocks/Mesh.h new file mode 100644 index 0000000000..b6c263c199 --- /dev/null +++ b/test/mocks/Mesh.h @@ -0,0 +1,27 @@ +#pragma once + +#include <cstdint> + +namespace mesh { + +class Radio { +public: + virtual ~Radio() = default; + virtual bool isReceiving() { return false; } + virtual uint32_t getEstAirtimeFor(uint16_t) { return 10; } + virtual bool startSendRaw(const uint8_t*, uint16_t) { return true; } + virtual bool isSendComplete() { return true; } + virtual void onSendFinished() {} + virtual int16_t getNoiseFloor() { return -120; } +}; + +class MainBoard { +public: + virtual ~MainBoard() = default; + virtual uint16_t getBattMilliVolts() { return 4200; } + virtual float getMCUTemperature() { return 25.0f; } + virtual const char* getManufacturerName() { return "mock-board"; } + virtual void reboot() {} +}; + +} diff --git a/test/mocks/SHA256.h b/test/mocks/SHA256.h index b6e551a077..0cefe5c6e3 100644 --- a/test/mocks/SHA256.h +++ b/test/mocks/SHA256.h @@ -3,12 +3,33 @@ #include <stdint.h> #include <stddef.h> -// Mock SHA256 class for testing -// Provides minimal interface to allow Utils.cpp to compile +// Mock SHA256 for native testing — deterministic but not cryptographic. +// finalize() writes real (non-garbage) output so calculatePacketHash() produces +// distinguishable results for packets with different payloads. +#include <string.h> + class SHA256 { + uint8_t _state[32]; + size_t _len; public: - void update(const uint8_t* data, size_t len) {} - void finalize(uint8_t* hash, size_t hashLen) {} + SHA256() : _len(0) { memset(_state, 0, sizeof(_state)); } + + void update(const void* data, size_t len) { + const uint8_t* bytes = static_cast<const uint8_t*>(data); + for (size_t i = 0; i < len; i++) { + uint8_t b = bytes[i]; + _state[_len % 32] ^= b; + _state[(_len + 1) % 32] += (uint8_t)((b >> 1) | (b << 7)); + _len++; + } + } + + void finalize(uint8_t* hash, size_t hashLen) { + for (size_t i = 0; i < hashLen; i++) { + hash[i] = _state[i % 32]; + } + } + void resetHMAC(const uint8_t* key, size_t keyLen) {} void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {} }; diff --git a/test/mocks/Stream.h b/test/mocks/Stream.h index 195a302973..56675607e1 100644 --- a/test/mocks/Stream.h +++ b/test/mocks/Stream.h @@ -1,10 +1,73 @@ #pragma once +#include <stddef.h> +#include <stdint.h> +#include <string.h> + // Mock Stream class for native testing // Provides minimal interface needed by Utils.h -class Stream { +#define DEC 10 +#define HEX 16 +#define OCT 8 +#define BIN 2 + +class Print +{ public: - virtual void print(char c) {} - virtual void print(const char* str) {} + virtual size_t write(uint8_t b) { return 1; } + size_t write(const char *str) + { + if(str == NULL) { + return 0; + } + return write((const uint8_t *) str, strlen(str)); + } + virtual size_t write(const uint8_t *buffer, size_t size) { + size_t t = 0; + for (int i = 0; i < size; i++) { t += write(buffer[i]); } + return t; + } + size_t write(const char *buffer, size_t size) + { + return write((const uint8_t *) buffer, size); + } + + virtual size_t print(unsigned char b, int r = DEC) { return 0; } + virtual size_t print(int v, int r = DEC) { return 0; } + virtual size_t print(unsigned int v, int r = DEC) { return 0; } + virtual size_t print(long v, int r = DEC) { return 0; } + virtual size_t print(unsigned long v, int r = DEC) { return 0; } + virtual size_t print(long long v, int r = DEC) { return 0; } + virtual size_t print(unsigned long long v, int r = DEC) { return 0; } + virtual size_t print(double v, int p = 2) { return 0; } + + size_t print(char c) { return write(c); } + size_t print(const char* str) { return write(str); } + + //size_t println(void) { return 0; } + + virtual void flush() { /* Empty implementation for backward compatibility */ } }; + +class Stream: public Print +{ +public: + virtual ~Stream() = default; + virtual int available() { return 0; } + virtual int availableForWrite() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return 0; } + + virtual size_t readBytes(char *buffer, size_t length) { + size_t i = 0; + while (i < length && available()) { + buffer[i++] = read(); + } + return i; + } + virtual size_t readBytes(uint8_t *buffer, size_t length) + { + return readBytes((char *) buffer, length); + } +}; \ No newline at end of file diff --git a/test/mocks/Utils.h b/test/mocks/Utils.h new file mode 100644 index 0000000000..9bb6b060b0 --- /dev/null +++ b/test/mocks/Utils.h @@ -0,0 +1,44 @@ +#pragma once + +#include <cstddef> +#include <cstdint> +#include <cstring> +#include <algorithm> + +#define PUB_KEY_SIZE 32 +#define PRV_KEY_SIZE 64 +#define SIGNATURE_SIZE 64 +#define CIPHER_MAC_SIZE 16 + +namespace mesh { + +class RNG { +public: + virtual ~RNG() = default; + virtual void random(uint8_t* dest, size_t sz) = 0; +}; + +class Utils { +public: + static void sha256(uint8_t* hash, size_t hash_len, const uint8_t*, int) { + std::memset(hash, 0, hash_len); + } + + static int encryptThenMAC(const uint8_t*, uint8_t* dest, const uint8_t* src, int src_len) { + int out_len = src_len + CIPHER_MAC_SIZE; + std::memset(dest, 0xAA, CIPHER_MAC_SIZE); + std::memcpy(dest + CIPHER_MAC_SIZE, src, src_len); + return out_len; + } + + static int MACThenDecrypt(const uint8_t*, uint8_t* dest, const uint8_t* src, int src_len) { + if (src_len < CIPHER_MAC_SIZE) { + return 0; + } + int out_len = src_len - CIPHER_MAC_SIZE; + std::memcpy(dest, src + CIPHER_MAC_SIZE, out_len); + return out_len; + } +}; + +} diff --git a/test/mocks/helpers/SensorManager.h b/test/mocks/helpers/SensorManager.h new file mode 100644 index 0000000000..d1a41cb5df --- /dev/null +++ b/test/mocks/helpers/SensorManager.h @@ -0,0 +1,10 @@ +#pragma once + +#include <cstdint> +#include "CayenneLPP.h" + +class SensorManager { +public: + virtual ~SensorManager() = default; + virtual bool querySensors(uint8_t, CayenneLPP&) { return false; } +}; diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp new file mode 100644 index 0000000000..7a13f487e2 --- /dev/null +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -0,0 +1,180 @@ +#include <gtest/gtest.h> +#include "helpers/ConfigSerializer.h" + +#define TEST_INT_S "56" +#define TEST_INT 56 +#define TEST_FLOAT_S "-6.123" +#define TEST_FLOAT -6.1230f +#define TEST_DOUBLE_S "12.123456" +#define TEST_DOUBLE 12.123456 + +class MockInputStream : public Stream { + const char* _text; + int pos, len; +public: + MockInputStream(const char* text) : _text(text) { pos = 0; len = strlen(text); } + int available() override { return len - pos; } + int read() override { if (pos < len) { return _text[pos++]; } return -1; } + int peek() override { if (pos < len) { return _text[pos]; } return -1; } +}; + +class MockPrintStream : public Stream { + int len = 0; + uint8_t _buf[1024]; +public: + size_t write(uint8_t b) override { + if (len < sizeof(_buf)) { + _buf[len++] = b; + return 1; + } + return 0; + } + + size_t print(unsigned char b, int r) override { if (b == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(int v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(unsigned int v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(unsigned long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(long long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(unsigned long long v, int r) override { if (v == TEST_INT) return Print::print(TEST_INT_S); return 0; } + size_t print(double v, int p = 2) override { + if (p == 6) return Print::print(TEST_DOUBLE_S); + if (p == 4) return Print::print(TEST_FLOAT_S); + return 0; + } + + int getLength() const { return len; } + const uint8_t* getBytes() const { return _buf; } +}; + +class TestStruct : public ConfigSerializer { + protected: + void structure() override { + def("age", age); + def("flags", flags); + def("name", name, sizeof(name)); + } + public: + int32_t age; + char name[16]; + uint8_t flags; +}; + +// ── saveSerial: basic ─────────────────────────────────────────────────────── + +TEST(ConfigSerializer, SaveSerial_Basic) { + MockPrintStream s; + TestStruct data; + + data.age = TEST_INT; + data.flags = TEST_INT; + strcpy(data.name, "Scott"); + + bool success = data.saveSerial(s); + EXPECT_TRUE(success); + + auto l = s.getLength(); + const char* expect = "{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\"}"; + EXPECT_EQ(strlen(expect), l); + + bool match = memcmp(s.getBytes(), expect, l) == 0; + EXPECT_TRUE(match); +} + + +TEST(ConfigSerializer, SaveSerial_EscChars) { + MockPrintStream s; + TestStruct data; + + data.age = TEST_INT; + data.flags = TEST_INT; + strcpy(data.name, "\"Scott\"\n"); + + bool success = data.saveSerial(s); + EXPECT_TRUE(success); + + auto l = s.getLength(); + const char* expect = "{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"\\\"Scott\\\"\\n\"}"; + EXPECT_EQ(strlen(expect), l); + + bool match = memcmp(s.getBytes(), expect, l) == 0; + EXPECT_TRUE(match); +} + +// ── loadSerial: basic ─────────────────────────────────────────────────────── + +TEST(ConfigSerializer, LoadSerial_Basic) { + MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\"}"); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + EXPECT_EQ(TEST_INT, data.age); + EXPECT_EQ(TEST_INT, data.flags); + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); +} + +TEST(ConfigSerializer, LoadSerial_HandleWhitespace) { + MockInputStream s(" { age: " TEST_INT_S " , flags: " TEST_INT_S " , name: \"Scott\" } "); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + EXPECT_EQ(TEST_INT, data.age); + EXPECT_EQ(TEST_INT, data.flags); + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); +} + +TEST(ConfigSerializer, LoadSerial_EscChars) { + MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"\\\"Scott\\\"\\n\"}"); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + bool match = strcmp("\"Scott\"\n", data.name) == 0; + EXPECT_TRUE(match); +} + +TEST(ConfigSerializer, LoadSerial_UnmatchedBraces) { + MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\""); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_FALSE(success); +} + +TEST(ConfigSerializer, LoadSerial_MissingCommas) { + MockInputStream s("{age:" TEST_INT_S " flags:" TEST_INT_S " name:\"Scott\"}"); + TestStruct data; + + bool success = data.loadSerial(s); + EXPECT_FALSE(success); +} + +TEST(ConfigSerializer, LoadSerial_IgnoreUnknowns) { + MockInputStream s("{age:" TEST_INT_S ",xxx:" TEST_INT_S ",name:\"Scott\"}"); + TestStruct data; + data.flags = 1; + + // should ignore the 'xxx' property + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + EXPECT_EQ(TEST_INT, data.age); + EXPECT_EQ(1, data.flags); // flags should be unmodified + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); +} + + +// ── main ─────────────────────────────────────────────────────── + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_kiss_modem/test_tx_backpressure.cpp b/test/test_kiss_modem/test_tx_backpressure.cpp new file mode 100644 index 0000000000..39a5e3b742 --- /dev/null +++ b/test/test_kiss_modem/test_tx_backpressure.cpp @@ -0,0 +1,294 @@ +#include <gtest/gtest.h> + +#include <atomic> +#include <cstddef> +#include <condition_variable> +#include <future> +#include <mutex> +#include <queue> +#include <vector> + +#include "KissModem.h" + +static constexpr int TEST_TX_AVAILABLE_BYTES = 4096; +static constexpr size_t TEST_DEFAULT_MAX_WRITE_CHUNK = SIZE_MAX; +static constexpr size_t TEST_PARTIAL_WRITE_CHUNK = 2; +static constexpr int TEST_PARTIAL_WRITE_FLUSH_LOOPS = 3; +static constexpr uint8_t TEST_SNR = 8; +static constexpr uint8_t TEST_RSSI = 200; + +class BlockingStream : public Stream { +public: + void pushRx(const std::vector<uint8_t>& bytes) { + std::lock_guard<std::mutex> lock(_mutex); + for (uint8_t b : bytes) { + _rx.push(b); + } + } + + void setBlockWrites(bool blocked) { + { + std::lock_guard<std::mutex> lock(_mutex); + _block_writes = blocked; + } + _cv.notify_all(); + } + + bool isWriteBlocked() const { + return _entered_block.load(); + } + + size_t writesCount() const { + std::lock_guard<std::mutex> lock(_mutex); + return _writes.size(); + } + + std::vector<uint8_t> writesSnapshot() const { + std::lock_guard<std::mutex> lock(_mutex); + return _writes; + } + + int availableForWrite() override { + std::lock_guard<std::mutex> lock(_mutex); + return _block_writes ? 0 : TEST_TX_AVAILABLE_BYTES; + } + + void setMaxWriteChunk(size_t chunk) { + std::lock_guard<std::mutex> lock(_mutex); + _max_write_chunk = chunk; + } + + size_t write(const uint8_t* buffer, size_t size) override { + std::unique_lock<std::mutex> lock(_mutex); + while (_block_writes) { + _entered_block.store(true); + _cv.wait(lock); + } + const size_t chunk = (size < _max_write_chunk) ? size : _max_write_chunk; + for (size_t i = 0; i < chunk; i++) { + _writes.push_back(buffer[i]); + } + return chunk; + } + + size_t write(uint8_t b) override { + return write(&b, 1); + } + + int available() override { + std::lock_guard<std::mutex> lock(_mutex); + return static_cast<int>(_rx.size()); + } + + int read() override { + std::lock_guard<std::mutex> lock(_mutex); + if (_rx.empty()) { + return -1; + } + int b = _rx.front(); + _rx.pop(); + return b; + } + +private: + mutable std::mutex _mutex; + std::condition_variable _cv; + std::queue<uint8_t> _rx; + std::vector<uint8_t> _writes; + bool _block_writes = false; + std::atomic<bool> _entered_block = false; + size_t _max_write_chunk = TEST_DEFAULT_MAX_WRITE_CHUNK; +}; + +class FakeRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { + for (size_t i = 0; i < sz; i++) { + dest[i] = 0; + } + } +}; + +class FakeRadio : public mesh::Radio { +public: + bool isReceiving() override { return false; } + uint32_t getEstAirtimeFor(uint16_t) override { return 10; } + bool startSendRaw(const uint8_t*, uint16_t) override { + _start_send_count++; + return _start_send_result; + } + bool isSendComplete() override { return _send_complete; } + void onSendFinished() override { _send_finished_count++; } + int16_t getNoiseFloor() override { return -120; } + + void setStartSendResult(bool result) { _start_send_result = result; } + void setSendComplete(bool complete) { _send_complete = complete; } + int startSendCount() const { return _start_send_count; } + int sendFinishedCount() const { return _send_finished_count; } + +private: + bool _start_send_result = true; + bool _send_complete = true; + int _start_send_count = 0; + int _send_finished_count = 0; +}; + +class FakeBoard : public mesh::MainBoard { +public: + uint16_t getBattMilliVolts() override { return 4200; } + float getMCUTemperature() override { return 24.0f; } + const char* getManufacturerName() override { return "test-board"; } + void reboot() override {} +}; + +class FakeSensors : public SensorManager { +public: + bool querySensors(uint8_t, CayenneLPP&) override { return false; } +}; + +class KissModemFixture : public ::testing::Test { +protected: + BlockingStream serial; + mesh::LocalIdentity identity; + FakeRNG rng; + FakeRadio radio; + FakeBoard board; + FakeSensors sensors; + KissModem modem; + + KissModemFixture() + : modem(serial, identity, rng, radio, board, sensors) { + modem.begin(); + } + + static std::vector<uint8_t> dataFrame(const std::vector<uint8_t>& packet) { + std::vector<uint8_t> frame = {KISS_FEND, KISS_CMD_DATA}; + frame.insert(frame.end(), packet.begin(), packet.end()); + frame.push_back(KISS_FEND); + return frame; + } + + void advanceToTxSending() { + modem.loop(); + modem.loop(); + delay((uint32_t)KISS_DEFAULT_TXDELAY * 10); + modem.loop(); + } +}; + +TEST_F(KissModemFixture, PingResponseShouldNotStallLoopUnderTxBackpressure) { + serial.setBlockWrites(true); + serial.pushRx({KISS_FEND, KISS_CMD_SETHARDWARE, HW_CMD_PING, KISS_FEND}); + + auto future = std::async(std::launch::async, [this]() { + modem.loop(); + }); + + auto status = future.wait_for(std::chrono::milliseconds(100)); + EXPECT_EQ(status, std::future_status::ready) << "KissModem::loop blocked in serial write under TX backpressure"; + EXPECT_FALSE(serial.isWriteBlocked()) << "KissModem entered blocking write path"; + + serial.setBlockWrites(false); + future.wait(); + modem.loop(); + EXPECT_GT(serial.writesCount(), 0U) << "KissModem did not flush queued response after backpressure cleared"; +} + +TEST_F(KissModemFixture, PingResponseKeepsStandardKissFraming) { + serial.pushRx({KISS_FEND, KISS_CMD_SETHARDWARE, HW_CMD_PING, KISS_FEND}); + modem.loop(); + + const std::vector<uint8_t> expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_PING), KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, PingResponseKeepsFramingWithPartialBulkWrites) { + serial.setMaxWriteChunk(TEST_PARTIAL_WRITE_CHUNK); + serial.pushRx({KISS_FEND, KISS_CMD_SETHARDWARE, HW_CMD_PING, KISS_FEND}); + for (int i = 0; i < TEST_PARTIAL_WRITE_FLUSH_LOOPS; i++) { + modem.loop(); + } + + const std::vector<uint8_t> expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP(HW_CMD_PING), KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, PacketAndMetaAreQueuedTogetherUnderBackpressure) { + static constexpr uint8_t TEST_PACKET[] = {0x01, 0x02, 0x03}; + + serial.setBlockWrites(true); + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET, sizeof(TEST_PACKET)); + serial.setBlockWrites(false); + modem.loop(); + modem.loop(); + + const std::vector<uint8_t> expected = { + KISS_FEND, KISS_CMD_DATA, TEST_PACKET[0], TEST_PACKET[1], TEST_PACKET[2], KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, RadioTxCompletionAdvancesWhileHostOutputIsBackedUp) { + serial.pushRx(dataFrame({0x42})); + advanceToTxSending(); + ASSERT_EQ(radio.startSendCount(), 1); + + serial.setBlockWrites(true); + modem.loop(); + EXPECT_EQ(radio.sendFinishedCount(), 1); + EXPECT_TRUE(modem.isTxBusy()); + + serial.setBlockWrites(false); + modem.loop(); + + const std::vector<uint8_t> expected = {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_TX_DONE, 0x01, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); + EXPECT_FALSE(modem.isTxBusy()); +} + +TEST_F(KissModemFixture, QueueFullReportsBusyWithoutDroppingQueuedFrames) { + static constexpr uint8_t TEST_PACKET_ONE[] = {0x11, 0x12}; + static constexpr uint8_t TEST_PACKET_TWO[] = {0x21, 0x22}; + + serial.setBlockWrites(true); + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET_ONE, sizeof(TEST_PACKET_ONE)); + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET_TWO, sizeof(TEST_PACKET_TWO)); + serial.setBlockWrites(false); + modem.loop(); + + const std::vector<uint8_t> expected = { + KISS_FEND, KISS_CMD_DATA, TEST_PACKET_ONE[0], TEST_PACKET_ONE[1], KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_ERROR, HW_ERR_TX_BUSY, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, QueuedEncoderEscapesKissSpecialBytes) { + static constexpr uint8_t TEST_PACKET[] = {KISS_FEND, KISS_FESC, 0x01}; + + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, TEST_PACKET, sizeof(TEST_PACKET)); + + const std::vector<uint8_t> expected = { + KISS_FEND, KISS_CMD_DATA, KISS_FESC, KISS_TFEND, KISS_FESC, KISS_TFESC, 0x01, KISS_FEND, + KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND}; + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +TEST_F(KissModemFixture, MaxPacketWorstCaseEscapingFitsQueuedFrame) { + std::vector<uint8_t> packet(KISS_MAX_PACKET_SIZE, KISS_FEND); + std::vector<uint8_t> expected = {KISS_FEND, KISS_CMD_DATA}; + for (size_t i = 0; i < packet.size(); i++) { + expected.push_back(KISS_FESC); + expected.push_back(KISS_TFEND); + } + expected.push_back(KISS_FEND); + expected.insert(expected.end(), {KISS_FEND, KISS_CMD_SETHARDWARE, HW_RESP_RX_META, TEST_SNR, TEST_RSSI, KISS_FEND}); + + modem.onPacketReceived((int8_t)TEST_SNR, (int8_t)TEST_RSSI, packet.data(), packet.size()); + EXPECT_EQ(serial.writesSnapshot(), expected); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_mesh_tables/test_simple_mesh_tables.cpp b/test/test_mesh_tables/test_simple_mesh_tables.cpp new file mode 100644 index 0000000000..46b477d944 --- /dev/null +++ b/test/test_mesh_tables/test_simple_mesh_tables.cpp @@ -0,0 +1,103 @@ +#include <gtest/gtest.h> +#include "helpers/SimpleMeshTables.h" + +using namespace mesh; + +// Build a packet that calculatePacketHash() distinguishes by payload content. +// header selects ROUTE_TYPE_FLOOD so isRouteDirect() returns false. +static Packet makeFloodPacket(uint8_t seed) { + Packet p; + p.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT); + p.payload[0] = seed; + p.payload_len = 1; + p.path_len = 0; + return p; +} + +static Packet makeDirectPacket(uint8_t seed) { + Packet p; + p.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT); + p.payload[0] = seed; + p.payload_len = 1; + p.path_len = 0; + return p; +} + +// ── wasSeen: pure query ─────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, WasSeen_ReturnsFalseForUnseen) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + EXPECT_FALSE(t.wasSeen(&p)); +} + +// wasSeen shouldn't change state +TEST(SimpleMeshTables, WasSeen_IsPureQuery_DoesNotInsert) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + EXPECT_FALSE(t.wasSeen(&p)); + EXPECT_FALSE(t.wasSeen(&p)); +} + +// ── markSeen + wasSeen ─────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, MarkSeen_MakesWasSeenReturnTrue) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + t.markSeen(&p); + EXPECT_TRUE(t.wasSeen(&p)); +} + +TEST(SimpleMeshTables, MarkSeen_DoesNotAffectOtherPackets) { + SimpleMeshTables t; + Packet p1 = makeFloodPacket(0x01); + Packet p2 = makeFloodPacket(0x02); + t.markSeen(&p1); + EXPECT_FALSE(t.wasSeen(&p2)); +} + +// Canonical pattern used at every onRecvPacket call site: +// if (!wasSeen(pkt)) { markSeen(pkt); process(pkt); } +TEST(SimpleMeshTables, QueryThenMark_WorksCorrectly) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + EXPECT_FALSE(t.wasSeen(&p)); + t.markSeen(&p); + EXPECT_TRUE(t.wasSeen(&p)); +} + +// ── dup stats ──────────────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, WasSeen_IncrementsFloodDupStat) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + t.markSeen(&p); + t.wasSeen(&p); + EXPECT_EQ(1u, t.getNumFloodDups()); + EXPECT_EQ(0u, t.getNumDirectDups()); +} + +TEST(SimpleMeshTables, WasSeen_IncrementsDirectDupStat) { + SimpleMeshTables t; + Packet p = makeDirectPacket(0x01); + t.markSeen(&p); + t.wasSeen(&p); + EXPECT_EQ(0u, t.getNumFloodDups()); + EXPECT_EQ(1u, t.getNumDirectDups()); +} + +// ── clear ──────────────────────────────────────────────────────────────────── + +TEST(SimpleMeshTables, Clear_RemovesSeenPacket) { + SimpleMeshTables t; + Packet p = makeFloodPacket(0x01); + t.markSeen(&p); + ASSERT_TRUE(t.wasSeen(&p)); + t.clear(&p); + EXPECT_FALSE(t.wasSeen(&p)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_utf8_helpers/test_utf8_helpers.cpp b/test/test_utf8_helpers/test_utf8_helpers.cpp new file mode 100644 index 0000000000..ee95352207 --- /dev/null +++ b/test/test_utf8_helpers/test_utf8_helpers.cpp @@ -0,0 +1,38 @@ +#include <gtest/gtest.h> + +#include <helpers/UTF8Helpers.h> + +TEST(UTF8Helpers, KeepsCompleteNameWithinLimit) { + const char* name = "Example RPT 🔋🇵🇱"; + + EXPECT_EQ(24u, mesh::validUtf8PrefixLength(name, 24)); +} + +TEST(UTF8Helpers, StopsBeforeCodePointCrossingLimit) { + const char* name = "Example RPT 🔋🇵🇱"; + + EXPECT_EQ(20u, mesh::validUtf8PrefixLength(name, 23)); +} + +TEST(UTF8Helpers, RejectsMalformedAndTruncatedSequences) { + const char overlong[] = {'A', static_cast<char>(0xC0), static_cast<char>(0xAF), 0}; + const char surrogate[] = {'A', static_cast<char>(0xED), static_cast<char>(0xA0), static_cast<char>(0x80), 0}; + const char out_of_range[] = {'A', static_cast<char>(0xF4), static_cast<char>(0x90), static_cast<char>(0x80), static_cast<char>(0x80), 0}; + const char truncated[] = {'A', static_cast<char>(0xF0), static_cast<char>(0x9F), 0}; + + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(overlong, sizeof(overlong))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(surrogate, sizeof(surrogate))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(out_of_range, sizeof(out_of_range))); + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(truncated, sizeof(truncated))); +} + +TEST(UTF8Helpers, RejectsUnexpectedContinuationByte) { + const char invalid[] = {'A', static_cast<char>(0x80), 'B', 0}; + + EXPECT_EQ(1u, mesh::validUtf8PrefixLength(invalid, sizeof(invalid))); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/ebyte_eora_s3/platformio.ini b/variants/ebyte_eora_s3/platformio.ini index 15fe761ba5..1ab3d3fb1b 100644 --- a/variants/ebyte_eora_s3/platformio.ini +++ b/variants/ebyte_eora_s3/platformio.ini @@ -102,6 +102,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Ebyte_EoRa-S3.build_src_filter} diff --git a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h index ab7ecc2451..0b0f701c26 100644 --- a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h +++ b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h @@ -47,7 +47,7 @@ class GAT56230SMeshKitBoard : public NRF52BoardDCDC { uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/gat562_30s_mesh_kit/platformio.ini b/variants/gat562_30s_mesh_kit/platformio.ini index 2baac2561b..89276a49a7 100644 --- a/variants/gat562_30s_mesh_kit/platformio.ini +++ b/variants/gat562_30s_mesh_kit/platformio.ini @@ -75,6 +75,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${GAT562_30S_Mesh_Kit.build_src_filter} diff --git a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h index 33c3d05fc5..66c770994d 100644 --- a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h +++ b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h @@ -47,7 +47,7 @@ class GAT562EVBProBoard : public NRF52BoardDCDC { uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h index aa1772e4c8..39b55911a1 100644 --- a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h +++ b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h @@ -47,7 +47,7 @@ class GAT562MeshTrackerProBoard : public NRF52BoardDCDC { uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_tracker_pro/platformio.ini b/variants/gat562_mesh_tracker_pro/platformio.ini index af153b8fc2..142cfe4b8f 100644 --- a/variants/gat562_mesh_tracker_pro/platformio.ini +++ b/variants/gat562_mesh_tracker_pro/platformio.ini @@ -71,6 +71,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${GAT562_Mesh_Tracker_Pro.build_src_filter} diff --git a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h index da792b78b1..805ca67f24 100644 --- a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h +++ b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h @@ -38,7 +38,7 @@ class GAT56MeshWatch13Board : public NRF52BoardDCDC { uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/heltec_ct62/platformio.ini b/variants/heltec_ct62/platformio.ini index 0179d9658c..401a30d231 100644 --- a/variants/heltec_ct62/platformio.ini +++ b/variants/heltec_ct62/platformio.ini @@ -101,6 +101,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_ct62.build_src_filter} diff --git a/variants/heltec_e213/HeltecE213Board.cpp b/variants/heltec_e213/HeltecE213Board.cpp index af11531821..88737c4d53 100644 --- a/variants/heltec_e213/HeltecE213Board.cpp +++ b/variants/heltec_e213/HeltecE213Board.cpp @@ -20,33 +20,6 @@ void HeltecE213Board::begin() { } } - void HeltecE213Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecE213Board::powerOff() { - enterDeepSleep(0); - } - uint16_t HeltecE213Board::getBattMilliVolts() { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, HIGH); diff --git a/variants/heltec_e213/HeltecE213Board.h b/variants/heltec_e213/HeltecE213Board.h index 2192c14104..fadc038f94 100644 --- a/variants/heltec_e213/HeltecE213Board.h +++ b/variants/heltec_e213/HeltecE213Board.h @@ -3,7 +3,6 @@ #include <Arduino.h> #include <helpers/RefCountedDigitalPin.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> class HeltecE213Board : public ESP32Board { @@ -13,8 +12,6 @@ class HeltecE213Board : public ESP32Board { HeltecE213Board() : periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE) { } void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; }; diff --git a/variants/heltec_e213/platformio.ini b/variants/heltec_e213/platformio.ini index 123edd91c4..0219381905 100644 --- a/variants/heltec_e213/platformio.ini +++ b/variants/heltec_e213/platformio.ini @@ -73,6 +73,7 @@ build_flags = -D DISPLAY_CLASS=E213Display -D AUTO_OFF_MILLIS=0 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_E213_base.build_src_filter} +<helpers/ui/E213Display.cpp> +<helpers/esp32/*.cpp> diff --git a/variants/heltec_e290/HeltecE290Board.cpp b/variants/heltec_e290/HeltecE290Board.cpp index 3994a20616..96ec59c9bd 100644 --- a/variants/heltec_e290/HeltecE290Board.cpp +++ b/variants/heltec_e290/HeltecE290Board.cpp @@ -20,33 +20,6 @@ void HeltecE290Board::begin() { } } - void HeltecE290Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecE290Board::powerOff() { - enterDeepSleep(0); - } - uint16_t HeltecE290Board::getBattMilliVolts() { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, HIGH); diff --git a/variants/heltec_e290/HeltecE290Board.h b/variants/heltec_e290/HeltecE290Board.h index 645ec3481a..f287227c6e 100644 --- a/variants/heltec_e290/HeltecE290Board.h +++ b/variants/heltec_e290/HeltecE290Board.h @@ -3,7 +3,6 @@ #include <Arduino.h> #include <helpers/RefCountedDigitalPin.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> class HeltecE290Board : public ESP32Board { @@ -13,8 +12,6 @@ class HeltecE290Board : public ESP32Board { HeltecE290Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/heltec_mesh_solar/platformio.ini b/variants/heltec_mesh_solar/platformio.ini index 92d641a357..17f8ba2170 100644 --- a/variants/heltec_mesh_solar/platformio.ini +++ b/variants/heltec_mesh_solar/platformio.ini @@ -1,7 +1,6 @@ [Heltec_mesh_solar] extends = nrf52_base board = heltec_mesh_solar -platform_packages = framework-arduinoadafruitnrf52 board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} -I src/helpers/nrf52 @@ -14,6 +13,11 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 + -D HAS_EXTERNAL_WATCHDOG + -D EXTERNAL_WATCHDOG_DONE_PIN=9 + -D EXTERNAL_WATCHDOG_WAKE_PIN=10 + -D EXTERNAL_WATCHDOG_FEED_INTERVAL_MS=480000 ; 8 minute feed interval, safely inside the hardware watchdog timeout + build_src_filter = ${nrf52_base.build_src_filter} +<helpers/*.cpp> +<../variants/heltec_mesh_solar> @@ -83,6 +87,7 @@ build_flags = ${Heltec_mesh_solar.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index d140864cd1..94e2684c12 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -13,6 +13,7 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); SolarSensorManager sensors = SolarSensorManager(nmea); +SolarExternalWatchdog external_watchdog; #ifdef DISPLAY_CLASS DISPLAY_CLASS display; @@ -106,3 +107,34 @@ bool SolarSensorManager::setSettingValue(const char* name, const char* value) { } return false; // not supported } + +bool SolarExternalWatchdog::begin() { + last_feed_watchdog = 0; + pinMode(EXTERNAL_WATCHDOG_WAKE_PIN, INPUT); + pinMode(EXTERNAL_WATCHDOG_DONE_PIN, OUTPUT); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + delay(1); + feed(); + return true; +} +void SolarExternalWatchdog::loop() { + if (millis() - last_feed_watchdog >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + feed(); + } +} + +unsigned long SolarExternalWatchdog::getIntervalMs() const { + unsigned long elapsed_ms = millis() - last_feed_watchdog; + if (elapsed_ms >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + return 0; + } + return EXTERNAL_WATCHDOG_FEED_INTERVAL_MS - elapsed_ms; +} + +void SolarExternalWatchdog::feed() { + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, HIGH); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + last_feed_watchdog = millis(); +} diff --git a/variants/heltec_mesh_solar/target.h b/variants/heltec_mesh_solar/target.h index 1956f50ebd..8e13a9601c 100644 --- a/variants/heltec_mesh_solar/target.h +++ b/variants/heltec_mesh_solar/target.h @@ -8,6 +8,7 @@ #include <helpers/AutoDiscoverRTCClock.h> #include <helpers/SensorManager.h> #include <helpers/sensors/LocationProvider.h> +#include <helpers/ExternalWatchdogManager.h> #ifdef DISPLAY_CLASS #include <helpers/ui/ST7789Display.h> #endif @@ -30,10 +31,20 @@ class SolarSensorManager : public SensorManager { bool setSettingValue(const char* name, const char* value) override; }; +class SolarExternalWatchdog : public ExternalWatchdogManager { +public: + SolarExternalWatchdog() {} + bool begin() override; + void loop() override; + unsigned long getIntervalMs() const override; + void feed() override; +}; + extern MeshSolarBoard board; extern WRAPPER_CLASS radio_driver; extern AutoDiscoverRTCClock rtc_clock; extern SolarSensorManager sensors; +extern SolarExternalWatchdog external_watchdog; #ifdef DISPLAY_CLASS extern DISPLAY_CLASS display; diff --git a/variants/heltec_mesh_solar/variant.h b/variants/heltec_mesh_solar/variant.h index 14956619f0..3c1b378dfa 100644 --- a/variants/heltec_mesh_solar/variant.h +++ b/variants/heltec_mesh_solar/variant.h @@ -34,8 +34,8 @@ #define PIN_SERIAL1_RX (37) #define PIN_SERIAL1_TX (39) -#define PIN_SERIAL2_RX (9) -#define PIN_SERIAL2_TX (10) +#define PIN_SERIAL2_RX (-1) +#define PIN_SERIAL2_TX (-1) //////////////////////////////////////////////////////////////////////////////// // I2C pin definition diff --git a/variants/heltec_rc32/HeltecRC32Board.cpp b/variants/heltec_rc32/HeltecRC32Board.cpp new file mode 100644 index 0000000000..59920d238f --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32Board.cpp @@ -0,0 +1,68 @@ +#include "HeltecRC32Board.h" + +void HeltecRC32Board::begin() { + ESP32Board::begin(); + + pinMode(PIN_ADC_CTRL, OUTPUT); + digitalWrite(PIN_ADC_CTRL, !ADC_CTRL_ENABLED); + +#ifdef SENSOR_RST_PIN + pinMode(SENSOR_RST_PIN, OUTPUT); + digitalWrite(SENSOR_RST_PIN, HIGH); +#endif + +#ifdef LED_POWER + pinMode(LED_POWER, OUTPUT); + digitalWrite(LED_POWER, LOW); +#endif + + periph_power.begin(); + periph_power.claim(); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1L << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void HeltecRC32Board::powerOff() { + enterDeepSleep(0); +} + +void HeltecRC32Board::onBeforeTransmit() { + digitalWrite(P_LORA_TX_LED, HIGH); +} + +void HeltecRC32Board::onAfterTransmit() { + digitalWrite(P_LORA_TX_LED, LOW); +} + +uint16_t HeltecRC32Board::getBattMilliVolts() { + analogReadResolution(12); + analogSetAttenuation(ADC_2_5db); + digitalWrite(PIN_ADC_CTRL, ADC_CTRL_ENABLED); + delay(10); + uint32_t raw = 0; + for (int i = 0; i < 8; i++) { + raw += analogReadMilliVolts(PIN_VBAT_READ); + } + raw = raw / 8; + digitalWrite(PIN_ADC_CTRL, !ADC_CTRL_ENABLED); + + return (adc_mult * raw); +} + +bool HeltecRC32Board::setAdcMultiplier(float multiplier) { + adc_mult = multiplier == 0.0f ? ADC_MULTIPLIER : multiplier; + return true; +} + +const char* HeltecRC32Board::getManufacturerName() const { + return "Heltec RC32"; +} diff --git a/variants/heltec_rc32/HeltecRC32Board.h b/variants/heltec_rc32/HeltecRC32Board.h new file mode 100644 index 0000000000..5b2093c8f6 --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32Board.h @@ -0,0 +1,29 @@ +#pragma once + +#include <Arduino.h> +#include <driver/rtc_io.h> +#include <helpers/ESP32Board.h> +#include <helpers/RefCountedDigitalPin.h> + +#ifndef ADC_MULTIPLIER + #define ADC_MULTIPLIER 4.9f +#endif + +class HeltecRC32Board : public ESP32Board { +protected: + float adc_mult = ADC_MULTIPLIER; + +public: + RefCountedDigitalPin periph_power; + + HeltecRC32Board() : periph_power(SENSOR_POWER_CTRL_PIN, SENSOR_POWER_ON){} + + void begin(); + void onBeforeTransmit() override; + void onAfterTransmit() override; + void powerOff() override; + uint16_t getBattMilliVolts() override; + bool setAdcMultiplier(float multiplier) override; + float getAdcMultiplier() const override { return adc_mult; } + const char* getManufacturerName() const override; +}; diff --git a/variants/heltec_rc32/HeltecRC32RotaryInput.cpp b/variants/heltec_rc32/HeltecRC32RotaryInput.cpp new file mode 100644 index 0000000000..e8b282ff55 --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32RotaryInput.cpp @@ -0,0 +1,122 @@ +#include "HeltecRC32RotaryInput.h" + +#include <Wire.h> + +namespace { +constexpr uint8_t TCA6408_ADDR = 0x20; +constexpr uint8_t TCA6408_INPUT_REG = 0x00; +constexpr uint8_t TCA6408_POLARITY_REG = 0x02; +constexpr uint8_t TCA6408_CONFIG_REG = 0x03; +constexpr uint8_t TCA6408_ROTARY_A_MASK = 0x01; +constexpr uint8_t TCA6408_ROTARY_B_MASK = 0x02; +constexpr uint8_t TCA6408_ROTARY_MASK = TCA6408_ROTARY_A_MASK | TCA6408_ROTARY_B_MASK; +constexpr uint32_t TCA6408_DEBOUNCE_MS = 5; +} + +bool HeltecRC32RotaryInput::begin() { + initialized = true; + ready = false; + input_state = TCA6408_ROTARY_MASK; + active_low_phase = false; + + if (periph_power && !power_claimed) { + periph_power->claim(); + power_claimed = true; + delay(12); + } + + if (!writeRegister(TCA6408_POLARITY_REG, 0x00) || !writeRegister(TCA6408_CONFIG_REG, 0xFF)) { + return false; + } + + uint8_t state = 0; + if (!readInput(state)) { + return false; + } + + input_state = state & TCA6408_ROTARY_MASK; + ready = true; + return true; +} + +RotaryInputEvent HeltecRC32RotaryInput::poll() { + if (!initialized) { + begin(); + return RotaryInputEvent::None; + } + + if (!ready) { + return RotaryInputEvent::None; + } + + uint8_t new_state = 0; + if (!readInput(new_state)) { + return RotaryInputEvent::None; + } + + new_state &= TCA6408_ROTARY_MASK; + RotaryInputEvent event = handleTransition(new_state); + input_state = new_state; + return event; +} + +bool HeltecRC32RotaryInput::writeRegister(uint8_t reg, uint8_t value) { + Wire.beginTransmission(TCA6408_ADDR); + Wire.write(reg); + Wire.write(value); + return Wire.endTransmission() == 0; +} + +bool HeltecRC32RotaryInput::readInput(uint8_t& value) { + Wire.beginTransmission(TCA6408_ADDR); + Wire.write(TCA6408_INPUT_REG); + if (Wire.endTransmission(false) != 0) { + return false; + } + if (Wire.requestFrom(TCA6408_ADDR, static_cast<uint8_t>(1)) != 1) { + return false; + } + + value = Wire.read(); + return true; +} + +RotaryInputEvent HeltecRC32RotaryInput::handleTransition(uint8_t newState) { + uint8_t changed = (input_state ^ newState) & TCA6408_ROTARY_MASK; + RotaryInputEvent event = RotaryInputEvent::None; + bool a_low = (newState & TCA6408_ROTARY_A_MASK) == 0; + bool b_low = (newState & TCA6408_ROTARY_B_MASK) == 0; + + if (!a_low && !b_low) { + active_low_phase = false; + } + + if (!active_low_phase && (changed & TCA6408_ROTARY_A_MASK) && a_low && !b_low) { + event = RotaryInputEvent::Prev; + active_low_phase = true; + } else if (!active_low_phase && (changed & TCA6408_ROTARY_B_MASK) && b_low && !a_low) { + event = RotaryInputEvent::Next; + active_low_phase = true; + } + + if (event == RotaryInputEvent::None && !active_low_phase && (changed & TCA6408_ROTARY_A_MASK)) { + bool a_rising = (newState & TCA6408_ROTARY_A_MASK) != 0; + if (a_rising && b_low) { + event = RotaryInputEvent::Prev; + } + } + + if (event == RotaryInputEvent::None && !active_low_phase && (changed & TCA6408_ROTARY_B_MASK)) { + bool b_rising = (newState & TCA6408_ROTARY_B_MASK) != 0; + if (b_rising && a_low) { + event = RotaryInputEvent::Next; + } + } + + if (event == RotaryInputEvent::None || (millis() - last_event_ms) < TCA6408_DEBOUNCE_MS) { + return RotaryInputEvent::None; + } + + last_event_ms = millis(); + return event; +} diff --git a/variants/heltec_rc32/HeltecRC32RotaryInput.h b/variants/heltec_rc32/HeltecRC32RotaryInput.h new file mode 100644 index 0000000000..36557dca34 --- /dev/null +++ b/variants/heltec_rc32/HeltecRC32RotaryInput.h @@ -0,0 +1,26 @@ +#pragma once + +#include <helpers/RefCountedDigitalPin.h> +#include <helpers/ui/RotaryInput.h> + +class HeltecRC32RotaryInput : public RotaryInput { +public: + explicit HeltecRC32RotaryInput(RefCountedDigitalPin* periphPower = nullptr) : periph_power(periphPower) { } + + bool begin() override; + RotaryInputEvent poll() override; + bool isReady() const override { return ready; } + +private: + bool writeRegister(uint8_t reg, uint8_t value); + bool readInput(uint8_t& value); + RotaryInputEvent handleTransition(uint8_t newState); + + uint8_t input_state = 0x03; + uint32_t last_event_ms = 0; + bool ready = false; + bool initialized = false; + bool power_claimed = false; + bool active_low_phase = false; + RefCountedDigitalPin* periph_power = nullptr; +}; diff --git a/variants/heltec_rc32/pins_arduino.h b/variants/heltec_rc32/pins_arduino.h new file mode 100644 index 0000000000..8ea3a0c15e --- /dev/null +++ b/variants/heltec_rc32/pins_arduino.h @@ -0,0 +1,60 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include <stdint.h> + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 21; +static const uint8_t SCL = 18; + +static const uint8_t SS = 10; +static const uint8_t MOSI = 12; +static const uint8_t MISO = 13; +static const uint8_t SCK = 11; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +static const uint8_t RST_LoRa = 9; +static const uint8_t BUSY_LoRa = 1; +static const uint8_t DIO1_LoRa = 14; + +#endif diff --git a/variants/heltec_rc32/platformio.ini b/variants/heltec_rc32/platformio.ini new file mode 100644 index 0000000000..354004f071 --- /dev/null +++ b/variants/heltec_rc32/platformio.ini @@ -0,0 +1,353 @@ +[Heltec_RC32] +extends = esp32_base +board = heltec-rc32 +board_build.partitions = default_16MB.csv +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/heltec_rc32 + -I src/helpers/ui + -D HELTEC_RC32 + -D USE_SX1262 + -D ESP32_CPU_FREQ=160 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D P_LORA_DIO_1=14 + -D P_LORA_NSS=10 + -D P_LORA_RESET=9 + -D P_LORA_BUSY=1 + -D P_LORA_SCLK=11 + -D P_LORA_MISO=13 + -D P_LORA_MOSI=12 + -D LORA_TX_POWER=22 + -D PIN_USER_BTN=0 + -D PIN_BOARD_SDA=21 + -D PIN_BOARD_SCL=18 + -D SENSOR_POWER_CTRL_PIN=46 + -D SENSOR_POWER_ON=HIGH + -D SENSOR_RST_PIN=2 + -D P_LORA_TX_LED=47 + -D PIN_BUZZER=48 + -D PIN_TFT_SCL=17 + -D PIN_TFT_SDA=38 + -D PIN_TFT_CS=39 + -D PIN_TFT_DC=16 + -D PIN_TFT_RST=4 + -D PIN_TFT_EN=6 + -D PIN_TFT_EN_ACTIVE=LOW + -D PIN_TFT_BL=5 + -D PIN_TFT_BL_ACTIVE=HIGH + -D SPI_FREQUENCY=8000000 + -D PIN_GPS_TX=44 + -D PIN_GPS_RX=43 + -D PIN_GPS_EN=45 + -D PIN_GPS_EN_ACTIVE=HIGH + -D PIN_GPS_RESET=40 + -D PIN_GPS_RESET_ACTIVE=LOW + -D PIN_GPS_PPS=41 + -D GPS_BAUD_RATE=9600 + -D ENV_INCLUDE_GPS=1 + -D PIN_ADC_CTRL=15 + -D PIN_VBAT_READ=7 + -D ADC_CTRL_ENABLED=HIGH + -D ADC_MULTIPLIER=4.9 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D SX126X_REGISTER_PATCH=1 ; Patch register 0x8B5 for improved RX +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/heltec_rc32> + +<helpers/sensors> +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + +[Heltec_RC32_with_display] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D HELTEC_RC32_WITH_DISPLAY + -D DISPLAY_CLASS=NV3001BDisplay +build_src_filter = ${Heltec_RC32.build_src_filter} + +<helpers/ui/NV3001BDisplay.cpp> + +<helpers/ui/MomentaryButton.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + +[env:heltec_rc32_without_display_repeater] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"Heltec RC32 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_rc32_without_display_repeater_bridge_espnow] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<helpers/bridges/ESPNowBridge.cpp> + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_without_display_room_server] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"Heltec RC32 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_without_display_companion_radio_usb] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE +build_src_filter = ${Heltec_RC32.build_src_filter} + +<helpers/ui/MomentaryButton.cpp> + +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_without_display_companion_radio_ble] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<helpers/ui/MomentaryButton.cpp> + +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_without_display_companion_radio_wifi] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<helpers/ui/MomentaryButton.cpp> + +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_without_display_sensor] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D ADVERT_NAME='"Heltec RC32 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=21 + -D ENV_PIN_SCL=18 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_sensor> +lib_deps = + ${Heltec_RC32.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_repeater] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"Heltec RC32 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_rc32_repeater_bridge_espnow] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<helpers/bridges/ESPNowBridge.cpp> + +<../examples/simple_repeater> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_room_server] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"Heltec RC32 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_terminal_chat] +extends = Heltec_RC32 +build_flags = + ${Heltec_RC32.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${Heltec_RC32.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_rc32_companion_radio_usb] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -I examples/companion_radio/ui-new + -D UI_HAS_ROTARY_INPUT + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<helpers/ui/buzzer.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_companion_radio_ble] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -I examples/companion_radio/ui-new + -D UI_HAS_ROTARY_INPUT + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<helpers/ui/buzzer.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_companion_radio_wifi] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -I examples/companion_radio/ui-new + -D UI_HAS_ROTARY_INPUT + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<helpers/ui/buzzer.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_rc32_sensor] +extends = Heltec_RC32_with_display +build_flags = + ${Heltec_RC32_with_display.build_flags} + -D ADVERT_NAME='"Heltec RC32 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=21 + -D ENV_PIN_SCL=18 +build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + +<../examples/simple_sensor> +lib_deps = + ${Heltec_RC32_with_display.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_rc32_kiss_modem] +extends = Heltec_RC32 +build_src_filter = ${Heltec_RC32.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/heltec_rc32/target.cpp b/variants/heltec_rc32/target.cpp new file mode 100644 index 0000000000..cc7e4deb74 --- /dev/null +++ b/variants/heltec_rc32/target.cpp @@ -0,0 +1,52 @@ +#include <Arduino.h> +#include "target.h" +#if defined(UI_HAS_ROTARY_INPUT) +#include "HeltecRC32RotaryInput.h" +#endif + +HeltecRC32Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi(FSPI); + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include <helpers/sensors/MicroNMEALocationProvider.h> + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock, PIN_GPS_RESET, PIN_GPS_EN); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#if defined(UI_HAS_ROTARY_INPUT) + static HeltecRC32RotaryInput rotaryInputImpl(&board.periph_power); + RotaryInput& rotary_input = rotaryInputImpl; +#endif +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + return radio.std_init(&spi); +#else + return radio.std_init(); +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/heltec_rc32/target.h b/variants/heltec_rc32/target.h new file mode 100644 index 0000000000..04cb6d94a4 --- /dev/null +++ b/variants/heltec_rc32/target.h @@ -0,0 +1,37 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <HeltecRC32Board.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/radiolib/CustomSX1262Wrapper.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <helpers/sensors/EnvironmentSensorManager.h> + +#ifdef DISPLAY_CLASS +#include <helpers/ui/MomentaryButton.h> +#if defined(UI_HAS_ROTARY_INPUT) +#include <helpers/ui/RotaryInput.h> +#endif +#ifdef HELTEC_RC32_WITH_DISPLAY +#include <helpers/ui/NV3001BDisplay.h> +#else +#include <helpers/ui/NullDisplayDriver.h> +#endif +#endif + +extern HeltecRC32Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#if defined(UI_HAS_ROTARY_INPUT) + extern RotaryInput& rotary_input; +#endif +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/heltec_rc32/variant.h b/variants/heltec_rc32/variant.h new file mode 100644 index 0000000000..5f7b8d8198 --- /dev/null +++ b/variants/heltec_rc32/variant.h @@ -0,0 +1,29 @@ +#ifndef _VARIANT_HELTEC_RC32_ +#define _VARIANT_HELTEC_RC32_ + +#define BUTTON_PIN 0 + +#define I2C_SCL 18 +#define I2C_SDA 21 +#define SENSOR_INT_PIN 42 +#define PERIPHERAL_WARMUP_MS 100 + +#define LORA_SCK 11 +#define LORA_MISO 13 +#define LORA_MOSI 12 +#define LORA_CS 10 +#define LORA_DIO0 RADIOLIB_NC +#define LORA_DIO1 14 +#define LORA_RESET 9 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 1 +#define SX126X_RESET LORA_RESET + +#define BATTERY_PIN 7 +#define ADC_CHANNEL ADC_CHANNEL_6 +#define ADC_CTRL 15 +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 + +#endif diff --git a/variants/heltec_t096/LoRaFEMControl.h b/variants/heltec_t096/LoRaFEMControl.h index 2c50b74289..0ce60fffd8 100644 --- a/variants/heltec_t096/LoRaFEMControl.h +++ b/variants/heltec_t096/LoRaFEMControl.h @@ -12,8 +12,9 @@ class LoRaFEMControl void setRxModeEnable(void); void setRxModeEnableWhenMCUSleep(void); void setLNAEnable(bool enabled); - bool isLnaCanControl(void) { return lna_can_control; } + bool isLnaCanControl(void) const { return lna_can_control; } void setLnaCanControl(bool can_control) { lna_can_control = can_control; } + bool isLNAEnabled(void) const { return lna_enabled; } private: bool lna_enabled = false; diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index 550131571f..78af529d67 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -112,15 +112,29 @@ void T096Board::variant_shutdown() { } void T096Board::powerOff() { -#if ENV_INCLUDE_GPS == 1 - pinMode(PIN_GPS_EN, OUTPUT); - digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); -#endif - loRaFEMControl.setSleepModeEnable(); - variant_shutdown(); - sd_power_system_off(); + loRaFEMControl.setSleepModeEnable(); + nrf_gpio_cfg_default(PIN_GPS_EN); // 363uA down to 39uA + NRF52Board::powerOff(); } const char* T096Board::getManufacturerName() const { return "Heltec T096"; -} \ No newline at end of file +} + +bool T096Board::setLoRaFemLnaEnabled(bool enable) { + if (!loRaFEMControl.isLnaCanControl()) { + return false; + } + + loRaFEMControl.setLNAEnable(enable); + loRaFEMControl.setRxModeEnable(); + return true; +} + +bool T096Board::canControlLoRaFemLna() const { + return loRaFEMControl.isLnaCanControl(); +} + +bool T096Board::isLoRaFemLnaEnabled() const { + return loRaFEMControl.isLNAEnabled(); +} diff --git a/variants/heltec_t096/T096Board.h b/variants/heltec_t096/T096Board.h index d1e3bdfdee..15c7e68b5d 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -25,4 +25,7 @@ class T096Board : public NRF52BoardDCDC { uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; void powerOff() override; + bool setLoRaFemLnaEnabled(bool enable) override; + bool canControlLoRaFemLna() const override; + bool isLoRaFemLnaEnabled() const override; }; diff --git a/variants/heltec_t096/platformio.ini b/variants/heltec_t096/platformio.ini index e820bf58d3..02e883287e 100644 --- a/variants/heltec_t096/platformio.ini +++ b/variants/heltec_t096/platformio.ini @@ -51,7 +51,7 @@ build_src_filter = ${nrf52_base.build_src_filter} lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 debug_tool = jlink upload_protocol = nrfutil @@ -152,6 +152,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/heltec_t1/T1Board.cpp b/variants/heltec_t1/T1Board.cpp index 490f86787e..1ef5b766b3 100644 --- a/variants/heltec_t1/T1Board.cpp +++ b/variants/heltec_t1/T1Board.cpp @@ -81,34 +81,6 @@ uint16_t T1Board::getBattMilliVolts() { } void T1Board::variant_shutdown() { - nrf_gpio_cfg_default(PIN_TFT_CS); - nrf_gpio_cfg_default(PIN_TFT_DC); - nrf_gpio_cfg_default(PIN_TFT_SDA); - nrf_gpio_cfg_default(PIN_TFT_SCL); - nrf_gpio_cfg_default(PIN_TFT_RST); - nrf_gpio_cfg_default(PIN_TFT_LEDA_CTL); - nrf_gpio_cfg_default(PIN_TFT_VDD_CTL); - - nrf_gpio_cfg_default(PIN_WIRE_SDA); - nrf_gpio_cfg_default(PIN_WIRE_SCL); - - nrf_gpio_cfg_default(LORA_CS); - nrf_gpio_cfg_default(SX126X_DIO1); - nrf_gpio_cfg_default(SX126X_BUSY); - nrf_gpio_cfg_default(SX126X_RESET); - nrf_gpio_cfg_default(PIN_SPI_MISO); - nrf_gpio_cfg_default(PIN_SPI_MOSI); - nrf_gpio_cfg_default(PIN_SPI_SCK); - - nrf_gpio_cfg_default(PIN_SPI1_MOSI); - nrf_gpio_cfg_default(PIN_SPI1_SCK); - - nrf_gpio_cfg_default(PIN_GPS_RESET); - nrf_gpio_cfg_default(PIN_GPS_EN); - nrf_gpio_cfg_default(PIN_GPS_PPS); - nrf_gpio_cfg_default(PIN_GPS_RX); - nrf_gpio_cfg_default(PIN_GPS_TX); - nrf_gpio_cfg_default(PIN_BUZZER_VOLTAGE_MULTIPLIER_1); nrf_gpio_cfg_default(PIN_BUZZER_VOLTAGE_MULTIPLIER_2); @@ -127,7 +99,7 @@ void T1Board::variant_shutdown() { void T1Board::powerOff() { variant_shutdown(); - sd_power_system_off(); + NRF52Board::powerOff(); } const char* T1Board::getManufacturerName() const { diff --git a/variants/heltec_t1/platformio.ini b/variants/heltec_t1/platformio.ini index d5c3caf189..28ed67edba 100644 --- a/variants/heltec_t1/platformio.ini +++ b/variants/heltec_t1/platformio.ini @@ -23,7 +23,7 @@ build_src_filter = ${nrf52_base.build_src_filter} lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 debug_tool = jlink upload_protocol = nrfutil @@ -90,6 +90,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_t1.build_src_filter} diff --git a/variants/heltec_t114/T114Board.cpp b/variants/heltec_t114/T114Board.cpp index c03d39afb6..51db72fc87 100644 --- a/variants/heltec_t114/T114Board.cpp +++ b/variants/heltec_t114/T114Board.cpp @@ -33,7 +33,7 @@ void T114Board::initiateShutdown(uint8_t reason) { #endif // NRF52_POWER_MANAGEMENT void T114Board::begin() { - NRF52Board::begin(); + NRF52BoardDCDC::begin(); pinMode(PIN_VBAT_READ, INPUT); diff --git a/variants/heltec_t114/T114Board.h b/variants/heltec_t114/T114Board.h index f27dc291d0..bd76d3de44 100644 --- a/variants/heltec_t114/T114Board.h +++ b/variants/heltec_t114/T114Board.h @@ -50,10 +50,7 @@ class T114Board : public NRF52BoardDCDC { #ifdef LED_PIN digitalWrite(LED_PIN, HIGH); #endif -#if ENV_INCLUDE_GPS == 1 - pinMode(GPS_EN, OUTPUT); - digitalWrite(GPS_EN, LOW); -#endif - sd_power_system_off(); + + NRF52Board::powerOff(); } }; diff --git a/variants/heltec_t114/platformio.ini b/variants/heltec_t114/platformio.ini index 135babb1a2..48fb4b0f52 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -109,6 +109,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_t114.build_src_filter} + +<helpers/ui/NullDisplayDriver.cpp> +<helpers/nrf52/SerialBLEInterface.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> @@ -126,8 +127,10 @@ build_flags = -D DISPLAY_CLASS=NullDisplayDriver -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_t114.build_src_filter} +<helpers/nrf52/*.cpp> + +<helpers/ui/NullDisplayDriver.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = @@ -236,6 +239,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/heltec_t114/target.cpp b/variants/heltec_t114/target.cpp index cb8f75db51..90a902f804 100644 --- a/variants/heltec_t114/target.cpp +++ b/variants/heltec_t114/target.cpp @@ -22,7 +22,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #if ENV_INCLUDE_GPS #include <helpers/sensors/MicroNMEALocationProvider.h> -MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors; diff --git a/variants/heltec_t190/HeltecT190Board.cpp b/variants/heltec_t190/HeltecT190Board.cpp index 4f35be400b..0a16b52b8d 100644 --- a/variants/heltec_t190/HeltecT190Board.cpp +++ b/variants/heltec_t190/HeltecT190Board.cpp @@ -20,33 +20,6 @@ void HeltecT190Board::begin() { } } - void HeltecT190Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecT190Board::powerOff() { - enterDeepSleep(0); - } - uint16_t HeltecT190Board::getBattMilliVolts() { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, HIGH); diff --git a/variants/heltec_t190/HeltecT190Board.h b/variants/heltec_t190/HeltecT190Board.h index bc38c1e090..557c070ecd 100644 --- a/variants/heltec_t190/HeltecT190Board.h +++ b/variants/heltec_t190/HeltecT190Board.h @@ -3,7 +3,6 @@ #include <Arduino.h> #include <helpers/RefCountedDigitalPin.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> class HeltecT190Board : public ESP32Board { @@ -13,8 +12,6 @@ class HeltecT190Board : public ESP32Board { HeltecT190Board() : periph_power(PIN_VEXT_EN,PIN_VEXT_EN_ACTIVE) { } void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/heltec_t190/platformio.ini b/variants/heltec_t190/platformio.ini index 411fee8518..2835539379 100644 --- a/variants/heltec_t190/platformio.ini +++ b/variants/heltec_t190/platformio.ini @@ -74,6 +74,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_T190_base.build_src_filter} +<helpers/esp32/*.cpp> +<../examples/companion_radio/*.cpp> diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp new file mode 100644 index 0000000000..04213bda8c --- /dev/null +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp @@ -0,0 +1,103 @@ +#include "HeltecTowerV2Board.h" + +#include <Arduino.h> +#include <Wire.h> + +extern void variant_shutdown(); + +#ifdef NRF52_POWER_MANAGEMENT +const PowerMgtConfig power_config = { + .lpcomp_ain_channel = PWRMGT_LPCOMP_AIN, + .lpcomp_refsel = PWRMGT_LPCOMP_REFSEL, + .voltage_bootlock = PWRMGT_VOLTAGE_BOOTLOCK +}; + +void HeltecTowerV2Board::initiateShutdown(uint8_t reason) { + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, LOW); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); + loRaFEMControl.setSleepModeEnable(); + + bool enable_lpcomp = (reason == SHUTDOWN_REASON_LOW_VOLTAGE || + reason == SHUTDOWN_REASON_BOOT_PROTECT); + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, enable_lpcomp ? HIGH : LOW); + + if (enable_lpcomp) { + configureVoltageWake(power_config.lpcomp_ain_channel, power_config.lpcomp_refsel); + } + + variant_shutdown(); + enterSystemOff(reason); +} +#endif + +void HeltecTowerV2Board::begin() { + NRF52Board::begin(); + + pinMode(P_LORA_TX_LED, OUTPUT); + digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); + + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, LOW); + +#ifdef NRF52_POWER_MANAGEMENT + checkBootVoltage(&power_config); +#endif + + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); + Wire.begin(); + + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, HIGH); + loRaFEMControl.init(); +} + +void HeltecTowerV2Board::onBeforeTransmit() { + digitalWrite(P_LORA_TX_LED, LED_STATE_ON); + loRaFEMControl.setTxModeEnable(); +} + +void HeltecTowerV2Board::onAfterTransmit() { + digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); + loRaFEMControl.setRxModeEnable(); +} + +uint16_t HeltecTowerV2Board::getBattMilliVolts() { + analogReadResolution(12); + analogReference(VBAT_AR_INTERNAL); + pinMode(PIN_VBAT_READ, INPUT); + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, HIGH); + + delay(10); + int adcvalue = analogRead(PIN_VBAT_READ); + digitalWrite(PIN_BAT_CTL, LOW); + + return (uint16_t)((float)adcvalue * MV_LSB * ADC_MULTIPLIER); +} + +const char* HeltecTowerV2Board::getManufacturerName() const { + return "Heltec Tower V2"; +} + +void HeltecTowerV2Board::powerOff() { + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); + pinMode(PIN_GPS_STANDBY, OUTPUT); + digitalWrite(PIN_GPS_STANDBY, LOW); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); + loRaFEMControl.setSleepModeEnable(); + pinMode(PIN_BAT_CTL, OUTPUT); + digitalWrite(PIN_BAT_CTL, LOW); + variant_shutdown(); + sd_power_system_off(); +} diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.h b/variants/heltec_tower_v2/HeltecTowerV2Board.h new file mode 100644 index 0000000000..6912acdec7 --- /dev/null +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.h @@ -0,0 +1,24 @@ +#pragma once + +#include <Arduino.h> +#include <MeshCore.h> +#include <helpers/NRF52Board.h> +#include "LoRaFEMControl.h" + +class HeltecTowerV2Board : public NRF52BoardDCDC { +protected: +#ifdef NRF52_POWER_MANAGEMENT + void initiateShutdown(uint8_t reason) override; +#endif + +public: + LoRaFEMControl loRaFEMControl; + + HeltecTowerV2Board() : NRF52Board("TOWER_V2_OTA") {} + void begin(); + void onBeforeTransmit() override; + void onAfterTransmit() override; + uint16_t getBattMilliVolts() override; + const char* getManufacturerName() const override; + void powerOff() override; +}; diff --git a/variants/heltec_tower_v2/LoRaFEMControl.cpp b/variants/heltec_tower_v2/LoRaFEMControl.cpp new file mode 100644 index 0000000000..d87d1849eb --- /dev/null +++ b/variants/heltec_tower_v2/LoRaFEMControl.cpp @@ -0,0 +1,40 @@ +#include "LoRaFEMControl.h" + +#include <Arduino.h> +#include "variant.h" + +static void enableFEMPower() { + bool wasOff = digitalRead(LORA_KCT8103L_EN) != HIGH; + digitalWrite(LORA_KCT8103L_EN, HIGH); + if (wasOff) { + delay(5); + } +} + +void LoRaFEMControl::init() { + pinMode(LORA_KCT8103L_EN, OUTPUT); + digitalWrite(LORA_KCT8103L_EN, HIGH); + delay(1); + pinMode(LORA_KCT8103L_TX_RX, OUTPUT); + digitalWrite(LORA_KCT8103L_TX_RX, LOW); +} + +void LoRaFEMControl::setSleepModeEnable() { + pinMode(LORA_KCT8103L_EN, OUTPUT); + digitalWrite(LORA_KCT8103L_EN, LOW); +} + +void LoRaFEMControl::setTxModeEnable() { + enableFEMPower(); + digitalWrite(LORA_KCT8103L_TX_RX, HIGH); +} + +void LoRaFEMControl::setRxModeEnable() { + enableFEMPower(); + digitalWrite(LORA_KCT8103L_TX_RX, LOW); +} + +void LoRaFEMControl::setRxModeEnableWhenMCUSleep() { + enableFEMPower(); + digitalWrite(LORA_KCT8103L_TX_RX, LOW); +} diff --git a/variants/heltec_tower_v2/LoRaFEMControl.h b/variants/heltec_tower_v2/LoRaFEMControl.h new file mode 100644 index 0000000000..e6c0ad2f11 --- /dev/null +++ b/variants/heltec_tower_v2/LoRaFEMControl.h @@ -0,0 +1,13 @@ +#pragma once + +class LoRaFEMControl { +public: + LoRaFEMControl() {} + virtual ~LoRaFEMControl() {} + + void init(); + void setSleepModeEnable(); + void setTxModeEnable(); + void setRxModeEnable(); + void setRxModeEnableWhenMCUSleep(); +}; diff --git a/variants/heltec_tower_v2/platformio.ini b/variants/heltec_tower_v2/platformio.ini new file mode 100644 index 0000000000..207e0a508d --- /dev/null +++ b/variants/heltec_tower_v2/platformio.ini @@ -0,0 +1,107 @@ +[Heltec_tower_v2] +extends = nrf52_base +board = heltec_tower_v2 +board_build.ldscript = boards/nrf52840_s140_v6.ld +build_flags = ${nrf52_base.build_flags} + -D ENV_INCLUDE_GPS=1 + -I lib/nrf52/s140_nrf52_6.1.1_API/include + -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 + -I variants/heltec_tower_v2 + -D HELTEC_TOWER_V2 + -D NRF52_POWER_MANAGEMENT + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=12 + -D MAX_LORA_TX_POWER=22 ; Max SX1262 output -> ~29dBm at antenna + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${nrf52_base.build_src_filter} + +<helpers/*.cpp> + +<helpers/sensors> + +<../variants/heltec_tower_v2> +lib_deps = + ${nrf52_base.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 +debug_tool = jlink +upload_protocol = nrfutil + +[env:Heltec_tower_v2_repeater] +extends = Heltec_tower_v2 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<../examples/simple_repeater> +build_flags = + ${Heltec_tower_v2.build_flags} + -D ADVERT_NAME='"Heltec_Tower_V2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + +[env:Heltec_tower_v2_room_server] +extends = Heltec_tower_v2 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<../examples/simple_room_server> +build_flags = + ${Heltec_tower_v2.build_flags} + -D ADVERT_NAME='"Heltec_Tower_V2 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + +[env:Heltec_tower_v2_companion_radio_ble] +extends = Heltec_tower_v2 +board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld +board_upload.maximum_size = 712704 +build_flags = + ${Heltec_tower_v2.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 +; -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<helpers/ui/NullDisplayDriver.cpp> + +<helpers/nrf52/SerialBLEInterface.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_tower_v2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:Heltec_tower_v2_companion_radio_usb] +extends = Heltec_tower_v2 +board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld +board_upload.maximum_size = 712704 +build_flags = + ${Heltec_tower_v2.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE +; -D BLE_PIN_CODE=123456 +; -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<helpers/ui/NullDisplayDriver.cpp> + +<helpers/nrf52/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_tower_v2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:Heltec_tower_v2_kiss_modem] +extends = Heltec_tower_v2 +build_src_filter = ${Heltec_tower_v2.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/heltec_tower_v2/target.cpp b/variants/heltec_tower_v2/target.cpp new file mode 100644 index 0000000000..ec8e7a17da --- /dev/null +++ b/variants/heltec_tower_v2/target.cpp @@ -0,0 +1,64 @@ +#include "target.h" + +#include <Arduino.h> +#include <helpers/ArduinoHelpers.h> +#include <helpers/sensors/MicroNMEALocationProvider.h> + +HeltecTowerV2Board board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); + +WRAPPER_CLASS radio_driver(radio, board); + +VolatileRTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +TowerV2ExternalWatchdog external_watchdog; + +#ifdef DISPLAY_CLASS +DISPLAY_CLASS display; +MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + return radio.std_init(&SPI); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} + +bool TowerV2ExternalWatchdog::begin() { + last_feed_watchdog = 0; + pinMode(EXTERNAL_WATCHDOG_WAKE_PIN, INPUT); + pinMode(EXTERNAL_WATCHDOG_DONE_PIN, OUTPUT); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + delay(1); + feed(); + return true; +} + +void TowerV2ExternalWatchdog::loop() { + if (millis() - last_feed_watchdog >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + feed(); + } +} + +unsigned long TowerV2ExternalWatchdog::getIntervalMs() const { + unsigned long elapsed_ms = millis() - last_feed_watchdog; + if (elapsed_ms >= EXTERNAL_WATCHDOG_FEED_INTERVAL_MS) { + return 0; + } + return EXTERNAL_WATCHDOG_FEED_INTERVAL_MS - elapsed_ms; +} + +void TowerV2ExternalWatchdog::feed() { + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, HIGH); + delay(1); + digitalWrite(EXTERNAL_WATCHDOG_DONE_PIN, LOW); + last_feed_watchdog = millis(); +} diff --git a/variants/heltec_tower_v2/target.h b/variants/heltec_tower_v2/target.h new file mode 100644 index 0000000000..0b9de40ca4 --- /dev/null +++ b/variants/heltec_tower_v2/target.h @@ -0,0 +1,39 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <HeltecTowerV2Board.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/radiolib/CustomSX1262Wrapper.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <helpers/sensors/EnvironmentSensorManager.h> +#include <helpers/sensors/LocationProvider.h> +#include <helpers/ExternalWatchdogManager.h> + +#ifdef DISPLAY_CLASS +#include <helpers/ui/MomentaryButton.h> +#include "helpers/ui/NullDisplayDriver.h" +#endif + +class TowerV2ExternalWatchdog : public ExternalWatchdogManager { +public: + TowerV2ExternalWatchdog() {} + bool begin() override; + void loop() override; + unsigned long getIntervalMs() const override; + void feed() override; +}; + +extern HeltecTowerV2Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; +extern TowerV2ExternalWatchdog external_watchdog; + +#ifdef DISPLAY_CLASS +extern DISPLAY_CLASS display; +extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/heltec_tower_v2/variant.cpp b/variants/heltec_tower_v2/variant.cpp new file mode 100644 index 0000000000..cfb88c4a3e --- /dev/null +++ b/variants/heltec_tower_v2/variant.cpp @@ -0,0 +1,42 @@ +#include "variant.h" + +#include "Arduino.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47 +}; + +void initVariant() +{ + +} + +void variant_shutdown() +{ + nrf_gpio_cfg_default(PIN_GPS_EN); + nrf_gpio_cfg_default(PIN_GPS_PPS); + nrf_gpio_cfg_default(PIN_GPS_RESET); + nrf_gpio_cfg_default(PIN_GPS_STANDBY); + nrf_gpio_cfg_default(GPS_RX_PIN); + nrf_gpio_cfg_default(GPS_TX_PIN); + pinMode(LORA_KCT8103L_EN, OUTPUT); + digitalWrite(LORA_KCT8103L_EN, LOW); + nrf_gpio_cfg_default(LORA_KCT8103L_TX_RX); + nrf_gpio_cfg_default(RF_PA_DETECT_PIN); + nrf_gpio_cfg_default(SX126X_CS); + nrf_gpio_cfg_default(SX126X_DIO1); + nrf_gpio_cfg_default(SX126X_BUSY); + nrf_gpio_cfg_default(SX126X_RESET); + nrf_gpio_cfg_default(PIN_SPI_MISO); + nrf_gpio_cfg_default(PIN_SPI_MOSI); + nrf_gpio_cfg_default(PIN_SPI_SCK); + nrf_gpio_cfg_default(PIN_LED); + detachInterrupt(PIN_GPS_PPS); + detachInterrupt(PIN_BUTTON1); +} diff --git a/variants/heltec_tower_v2/variant.h b/variants/heltec_tower_v2/variant.h new file mode 100644 index 0000000000..14116c6f34 --- /dev/null +++ b/variants/heltec_tower_v2/variant.h @@ -0,0 +1,108 @@ +#pragma once + +#include "WVariant.h" + +#define USE_LFXO +#define VARIANT_MCK (64000000ul) + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +#define WIRE_INTERFACES_COUNT (1) +#define PIN_WIRE_SDA (0 + 30) +#define PIN_WIRE_SCL (0 + 5) +#define PIN_BOARD_SDA PIN_WIRE_SDA +#define PIN_BOARD_SCL PIN_WIRE_SCL + +#define SPI_INTERFACES_COUNT (1) +#define PIN_SPI_MISO (0 + 23) +#define PIN_SPI_MOSI (0 + 22) +#define PIN_SPI_SCK (0 + 19) +#define PIN_SPI_NSS LORA_CS + +#define LED_BUILTIN (32 + 15) +#define PIN_LED LED_BUILTIN +#define LED_RED (-1) +#define LED_GREEN (-1) +#define LED_BLUE (-1) +#define LED_PIN (-1) +#define P_LORA_TX_LED LED_BUILTIN +#define LED_STATE_ON LOW + +#define PIN_BUTTON1 (32 + 10) +#define BUTTON_PIN PIN_BUTTON1 +#define PIN_USER_BTN BUTTON_PIN + +#define USE_SX1262 +#define SX126X_CS (0 + 24) +#define LORA_CS SX126X_CS +#define SX126X_DIO1 (0 + 20) +#define SX126X_BUSY (0 + 17) +#define SX126X_RESET (0 + 25) +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define P_LORA_NSS LORA_CS +#define P_LORA_DIO_1 SX126X_DIO1 +#define P_LORA_BUSY SX126X_BUSY +#define P_LORA_RESET SX126X_RESET +#define P_LORA_MISO PIN_SPI_MISO +#define P_LORA_MOSI PIN_SPI_MOSI +#define P_LORA_SCLK PIN_SPI_SCK + +#define USE_KCT8103L_PA_ONLY +#define LORA_KCT8103L_EN (0 + 15) +#define LORA_KCT8103L_TX_RX (0 + 16) +#define LORA_PA_POWER LORA_KCT8103L_EN +#define RF_PA_DETECT_PIN (0 + 13) +#define RF_PA_HIGH_POWER_VALUE HIGH + +#define GPS_L76K +#define GPS_RESET_MODE LOW +#define PIN_GPS_RESET (32 + 6) +#define PIN_GPS_RESET_ACTIVE GPS_RESET_MODE +#define PIN_GPS_EN (0 + 7) +#define PIN_GPS_EN_ACTIVE LOW +#define GPS_EN_ACTIVE PIN_GPS_EN_ACTIVE +#define PIN_GPS_STANDBY (32 + 2) +#define PIN_GPS_PPS (32 + 4) +#define GPS_BAUD_RATE 9600 + +// Upstream names are from the GPS perspective. MeshCore's PIN_GPS_TX is the +// CPU RX pin because EnvironmentSensorManager passes it as Serial1 RX. +#define GPS_TX_PIN (32 + 7) +#define GPS_RX_PIN (32 + 5) +#define PIN_GPS_TX GPS_RX_PIN +#define PIN_GPS_RX GPS_TX_PIN + +#define PIN_SERIAL1_RX PIN_GPS_TX +#define PIN_SERIAL1_TX PIN_GPS_RX +#define PIN_SERIAL2_RX (-1) +#define PIN_SERIAL2_TX (-1) + +#define HAS_EXTERNAL_WATCHDOG +#define EXTERNAL_WATCHDOG_DONE_PIN (0 + 9) +#define EXTERNAL_WATCHDOG_WAKE_PIN (0 + 10) +#define EXTERNAL_WATCHDOG_FEED_INTERVAL_MS (8 * 60 * 1000) + +#define SERIAL_PRINT_PORT 0 + +#define PIN_BAT_CTL (0 + 21) +#define ADC_CTRL PIN_BAT_CTL +#define ADC_CTRL_ENABLED HIGH +#define BATTERY_PIN (0 + 4) +#define PIN_VBAT_READ BATTERY_PIN +#define ADC_RESOLUTION 14 +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER (4.916F) +#define MV_LSB (3000.0F / 4096.0F) + +#define NRF52_POWER_MANAGEMENT +#define PWRMGT_VOLTAGE_BOOTLOCK 3100 +#define PWRMGT_LPCOMP_AIN 2 +#define PWRMGT_LPCOMP_REFSEL 1 diff --git a/variants/heltec_tracker/platformio.ini b/variants/heltec_tracker/platformio.ini index 69293d7070..75d1cadf3c 100644 --- a/variants/heltec_tracker/platformio.ini +++ b/variants/heltec_tracker/platformio.ini @@ -47,7 +47,7 @@ build_src_filter = ${esp32_base.build_src_filter} lib_deps = ${esp32_base.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 [env:Heltec_Wireless_Tracker_companion_radio_usb] extends = Heltec_tracker_base @@ -59,6 +59,7 @@ build_flags = -D DISPLAY_CLASS=ST7735Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; HWT will use display for pin ; -D OFFLINE_QUEUE_SIZE=256 ; -D BLE_DEBUG_LOGGING=1 diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index aabfed7967..99b1cdfe08 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -35,33 +35,12 @@ void HeltecTrackerV2Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecTrackerV2Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + void HeltecTrackerV2Board::powerOff() { + // Turn off PA + digitalWrite(P_LORA_PA_POWER, LOW); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - loRaFEMControl.setRxModeEnableWhenMCUSleep();//It also needs to be enabled in receive mode - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecTrackerV2Board::powerOff() { - enterDeepSleep(0); + ESP32Board::powerOff(); } uint16_t HeltecTrackerV2Board::getBattMilliVolts() { @@ -82,3 +61,21 @@ void HeltecTrackerV2Board::begin() { const char* HeltecTrackerV2Board::getManufacturerName() const { return "Heltec Tracker V2"; } + + bool HeltecTrackerV2Board::setLoRaFemLnaEnabled(bool enable) { + if (!loRaFEMControl.isLnaCanControl()) { + return false; + } + + loRaFEMControl.setLNAEnable(enable); + loRaFEMControl.setRxModeEnable(); + return true; + } + + bool HeltecTrackerV2Board::canControlLoRaFemLna() const { + return loRaFEMControl.isLnaCanControl(); + } + + bool HeltecTrackerV2Board::isLoRaFemLnaEnabled() const { + return loRaFEMControl.isLNAEnabled(); + } diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h index 33c897bc94..2bd6a02544 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -3,7 +3,6 @@ #include <Arduino.h> #include <helpers/RefCountedDigitalPin.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> #include "LoRaFEMControl.h" class HeltecTrackerV2Board : public ESP32Board { @@ -17,9 +16,11 @@ class HeltecTrackerV2Board : public ESP32Board { void begin(); void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; + bool setLoRaFemLnaEnabled(bool enable) override; + bool canControlLoRaFemLna() const override; + bool isLoRaFemLnaEnabled() const override; }; diff --git a/variants/heltec_tracker_v2/LoRaFEMControl.h b/variants/heltec_tracker_v2/LoRaFEMControl.h index 2c50b74289..0ce60fffd8 100644 --- a/variants/heltec_tracker_v2/LoRaFEMControl.h +++ b/variants/heltec_tracker_v2/LoRaFEMControl.h @@ -12,8 +12,9 @@ class LoRaFEMControl void setRxModeEnable(void); void setRxModeEnableWhenMCUSleep(void); void setLNAEnable(bool enabled); - bool isLnaCanControl(void) { return lna_can_control; } + bool isLnaCanControl(void) const { return lna_can_control; } void setLnaCanControl(bool can_control) { lna_can_control = can_control; } + bool isLNAEnabled(void) const { return lna_enabled; } private: bool lna_enabled = false; diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index d57c2113f0..d040b72f9d 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -55,7 +55,7 @@ build_src_filter = ${esp32_base.build_src_filter} lib_deps = ${esp32_base.lib_deps} ${sensor_base.lib_deps} - adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + bodmer/TFT_eSPI @ ^2.4.31 [env:heltec_tracker_v2_repeater] extends = Heltec_tracker_v2 @@ -140,6 +140,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=ST7735Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_tracker_v2.build_src_filter} diff --git a/variants/heltec_v2/HeltecV2Board.h b/variants/heltec_v2/HeltecV2Board.h index fe800890b8..9b08fe9430 100644 --- a/variants/heltec_v2/HeltecV2Board.h +++ b/variants/heltec_v2/HeltecV2Board.h @@ -7,8 +7,6 @@ #define PIN_VBAT_READ 37 #define PIN_LED_BUILTIN 25 -#include <driver/rtc_io.h> - class HeltecV2Board : public ESP32Board { public: void begin() { @@ -26,29 +24,6 @@ class HeltecV2Board : public ESP32Board { } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_0, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_0); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_0), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_0) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - uint16_t getBattMilliVolts() override { analogReadResolution(10); diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini index ba4f869422..78561a14ab 100644 --- a/variants/heltec_v2/platformio.ini +++ b/variants/heltec_v2/platformio.ini @@ -137,6 +137,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v2.build_src_filter} diff --git a/variants/heltec_v3/HeltecV3Board.h b/variants/heltec_v3/HeltecV3Board.h index ba22a7f2b9..7e7abe313b 100644 --- a/variants/heltec_v3/HeltecV3Board.h +++ b/variants/heltec_v3/HeltecV3Board.h @@ -17,8 +17,6 @@ #define PIN_ADC_CTRL_ACTIVE LOW #define PIN_ADC_CTRL_INACTIVE HIGH -#include <driver/rtc_io.h> - class HeltecV3Board : public ESP32Board { private: bool adc_active_state; @@ -52,33 +50,6 @@ class HeltecV3Board : public ESP32Board { } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void powerOff() override { - enterDeepSleep(0); - } - uint16_t getBattMilliVolts() override { analogReadResolution(10); digitalWrite(PIN_ADC_CTRL, adc_active_state); diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index a70a93a508..65e636eb4e 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -144,6 +144,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} @@ -326,6 +327,7 @@ build_flags = ${Heltec_lora32_v3.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 8f01379714..3f13f41f6e 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -32,33 +32,12 @@ void HeltecV4Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecV4Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + void HeltecV4Board::powerOff() { + // Turn off PA + digitalWrite(P_LORA_PA_POWER, LOW); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - loRaFEMControl.setRxModeEnableWhenMCUSleep();//It also needs to be enabled in receive mode - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void HeltecV4Board::powerOff() { - enterDeepSleep(0); + ESP32Board::powerOff(); } uint16_t HeltecV4Board::getBattMilliVolts() { @@ -83,3 +62,21 @@ void HeltecV4Board::begin() { return loRaFEMControl.getFEMType() == KCT8103L_PA ? "Heltec V4.3 OLED" : "Heltec V4 OLED"; #endif } + + bool HeltecV4Board::setLoRaFemLnaEnabled(bool enable) { + if (!loRaFEMControl.isLnaCanControl()) { + return false; + } + + loRaFEMControl.setLNAEnable(enable); + loRaFEMControl.setRxModeEnable(); + return true; + } + + bool HeltecV4Board::canControlLoRaFemLna() const { + return loRaFEMControl.isLnaCanControl(); + } + + bool HeltecV4Board::isLoRaFemLnaEnabled() const { + return loRaFEMControl.isLNAEnabled(); + } diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index 95def06c95..55166bb37f 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -3,7 +3,6 @@ #include <Arduino.h> #include <helpers/RefCountedDigitalPin.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> #include "LoRaFEMControl.h" #ifndef ADC_MULTIPLIER @@ -23,8 +22,10 @@ class HeltecV4Board : public ESP32Board { void begin(); void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); void powerOff() override; + bool setLoRaFemLnaEnabled(bool enable) override; + bool canControlLoRaFemLna() const override; + bool isLoRaFemLnaEnabled() const override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override { if (multiplier == 0.0f) { diff --git a/variants/heltec_v4/LoRaFEMControl.h b/variants/heltec_v4/LoRaFEMControl.h index 13225bd56b..d312762d3d 100644 --- a/variants/heltec_v4/LoRaFEMControl.h +++ b/variants/heltec_v4/LoRaFEMControl.h @@ -18,8 +18,9 @@ class LoRaFEMControl void setRxModeEnable(void); void setRxModeEnableWhenMCUSleep(void); void setLNAEnable(bool enabled); - bool isLnaCanControl(void) { return lna_can_control; } + bool isLnaCanControl(void) const { return lna_can_control; } void setLnaCanControl(bool can_control) { lna_can_control = can_control; } + bool isLNAEnabled(void) const { return lna_enabled; } LoRaFEMType getFEMType(void) const { return fem_type; } private: LoRaFEMType fem_type=OTHER_FEM_TYPES; diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index fabf38272d..d718c006c4 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -187,6 +187,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${heltec_v4_oled.build_src_filter} @@ -351,6 +352,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=ST7789LCDDisplay + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${heltec_v4_tft.build_src_filter} @@ -432,5 +434,10 @@ lib_deps = [env:heltec_v4_kiss_modem] extends = Heltec_lora32_v4 +build_unflags = + -DARDUINO_USB_MODE=0 +build_flags = + ${Heltec_lora32_v4.build_flags} + -DARDUINO_USB_MODE=1 build_src_filter = ${Heltec_lora32_v4.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp new file mode 100644 index 0000000000..1fb123b2f6 --- /dev/null +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -0,0 +1,86 @@ +#include "HeltecV4R8Board.h" + +void HeltecV4R8Board::begin() { + ESP32Board::begin(); + + periph_power.begin(); + periph_power.claim(); // R8 VEXT also feeds the LoRa antenna boost rail. + + loRaFEMControl.init(); + +#ifdef PIN_TOUCH_RST + pinMode(PIN_TOUCH_RST, OUTPUT); + digitalWrite(PIN_TOUCH_RST, HIGH); + delay(10); + digitalWrite(PIN_TOUCH_RST, LOW); + delay(100); + digitalWrite(PIN_TOUCH_RST, HIGH); +#endif + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void HeltecV4R8Board::onBeforeTransmit(void) { + digitalWrite(P_LORA_TX_LED, HIGH); + loRaFEMControl.setTxModeEnable(); +} + +void HeltecV4R8Board::onAfterTransmit(void) { + digitalWrite(P_LORA_TX_LED, LOW); + loRaFEMControl.setRxModeEnable(); +} + +void HeltecV4R8Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + + rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); + + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + loRaFEMControl.setRxModeEnableWhenMCUSleep(); + + if (pin_wake_btn < 0) { + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); + } else { + esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000); + } + + esp_deep_sleep_start(); +} + +void HeltecV4R8Board::powerOff() { + enterDeepSleep(0); +} + +uint16_t HeltecV4R8Board::getBattMilliVolts() { + analogReadResolution(12); + + uint32_t raw = 0; + for (int i = 0; i < 8; i++) { + raw += analogReadMilliVolts(PIN_VBAT_READ); + } + raw = raw / 8; + + return (adc_mult * raw); +} + +const char* HeltecV4R8Board::getManufacturerName() const { +#ifdef HELTEC_V4_R8_TFT + return "Heltec V4 R8 TFT"; +#else + return "Heltec V4 R8 OLED"; +#endif +} diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h new file mode 100644 index 0000000000..20811abb15 --- /dev/null +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -0,0 +1,39 @@ +#pragma once + +#include <Arduino.h> +#include <driver/rtc_io.h> +#include <helpers/ESP32Board.h> +#include <helpers/RefCountedDigitalPin.h> +#include "LoRaFEMControl.h" + +#ifndef ADC_MULTIPLIER + #define ADC_MULTIPLIER (4.9f * 1.035f) +#endif + +class HeltecV4R8Board : public ESP32Board { +protected: + float adc_mult = ADC_MULTIPLIER; + +public: + RefCountedDigitalPin periph_power; + LoRaFEMControl loRaFEMControl; + + HeltecV4R8Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } + + void begin(); + void onBeforeTransmit(void) override; + void onAfterTransmit(void) override; + void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); + void powerOff() override; + uint16_t getBattMilliVolts() override; + bool setAdcMultiplier(float multiplier) override { + if (multiplier == 0.0f) { + adc_mult = ADC_MULTIPLIER; + } else { + adc_mult = multiplier; + } + return true; + } + float getAdcMultiplier() const override { return adc_mult; } + const char* getManufacturerName() const override; +}; diff --git a/variants/heltec_v4_r8/LoRaFEMControl.cpp b/variants/heltec_v4_r8/LoRaFEMControl.cpp new file mode 100644 index 0000000000..bb530de3c6 --- /dev/null +++ b/variants/heltec_v4_r8/LoRaFEMControl.cpp @@ -0,0 +1,52 @@ +#include "LoRaFEMControl.h" + +#include <Arduino.h> +#include <driver/rtc_io.h> +#include <esp_sleep.h> + +void LoRaFEMControl::init(void) { + pinMode(P_LORA_PA_POWER, OUTPUT); + digitalWrite(P_LORA_PA_POWER, HIGH); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP) { + delay(1); + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + + pinMode(P_LORA_KCT8103L_PA_CSD, OUTPUT); + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + pinMode(P_LORA_KCT8103L_PA_CTX, OUTPUT); + digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); +} + +void LoRaFEMControl::setSleepModeEnable(void) { + digitalWrite(P_LORA_KCT8103L_PA_CSD, LOW); +} + +void LoRaFEMControl::setTxModeEnable(void) { + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); +} + +void LoRaFEMControl::setRxModeEnable(void) { + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); +} + +void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void) { + digitalWrite(P_LORA_PA_POWER, HIGH); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + + digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); + rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); + rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CTX); +} + +void LoRaFEMControl::setLNAEnable(bool enabled) { + lna_enabled = enabled; +} diff --git a/variants/heltec_v4_r8/LoRaFEMControl.h b/variants/heltec_v4_r8/LoRaFEMControl.h new file mode 100644 index 0000000000..961cfd070e --- /dev/null +++ b/variants/heltec_v4_r8/LoRaFEMControl.h @@ -0,0 +1,24 @@ +#pragma once + +typedef enum { + KCT8103L_PA, + OTHER_FEM_TYPES +} LoRaFEMType; + +class LoRaFEMControl { +public: + LoRaFEMControl() { } + virtual ~LoRaFEMControl() { } + void init(void); + void setSleepModeEnable(void); + void setTxModeEnable(void); + void setRxModeEnable(void); + void setRxModeEnableWhenMCUSleep(void); + void setLNAEnable(bool enabled); + bool isLnaCanControl(void) { return true; } + void setLnaCanControl(bool can_control) { } + LoRaFEMType getFEMType(void) const { return KCT8103L_PA; } + +private: + bool lna_enabled = false; +}; diff --git a/variants/heltec_v4_r8/pins_arduino.h b/variants/heltec_v4_r8/pins_arduino.h new file mode 100644 index 0000000000..9e412aac5a --- /dev/null +++ b/variants/heltec_v4_r8/pins_arduino.h @@ -0,0 +1,56 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include <stdint.h> + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini new file mode 100644 index 0000000000..f523ebf9b2 --- /dev/null +++ b/variants/heltec_v4_r8/platformio.ini @@ -0,0 +1,344 @@ +[Heltec_v4_r8] +extends = esp32_base +board = heltec_v4_r8 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/heltec_v4_r8 + -D HELTEC_V4_R8 + -D USE_SX1262 + -D ESP32_CPU_FREQ=80 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D P_LORA_DIO_1=14 + -D P_LORA_NSS=8 + -D P_LORA_RESET=12 + -D P_LORA_BUSY=13 + -D P_LORA_SCLK=9 + -D P_LORA_MISO=11 + -D P_LORA_MOSI=10 + -D P_LORA_PA_POWER=7 + -D P_LORA_KCT8103L_PA_CSD=2 + -D P_LORA_KCT8103L_PA_CTX=5 + -D P_LORA_TX_LED=46 + -D PIN_USER_BTN=0 + -D PIN_VEXT_EN=40 + -D PIN_VEXT_EN_ACTIVE=LOW + -D ADC_MULTIPLIER=5.0715f + -D PIN_VBAT_READ=1 + -D LORA_TX_POWER=10 + -D MAX_LORA_TX_POWER=22 + -D SX126X_REGISTER_PATCH=1 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D PIN_GPS_RX=38 + -D PIN_GPS_TX=39 + -D PIN_GPS_EN=42 + -D PIN_GPS_EN_ACTIVE=LOW + -D ENV_INCLUDE_GPS=1 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/heltec_v4_r8> + +<helpers/sensors> +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + +[heltec_v4_r8_oled] +extends = Heltec_v4_r8 +build_flags = + ${Heltec_v4_r8.build_flags} + -D HELTEC_V4_R8_OLED + -D PIN_BOARD_SDA=17 + -D PIN_BOARD_SCL=18 + -D PIN_OLED_RESET=21 +build_src_filter = ${Heltec_v4_r8.build_src_filter} +lib_deps = ${Heltec_v4_r8.lib_deps} + +[heltec_v4_r8_tft] +extends = Heltec_v4_r8 +build_flags = + ${Heltec_v4_r8.build_flags} + -D HELTEC_V4_R8_TFT + -D PIN_BOARD_SDA=17 + -D PIN_BOARD_SCL=18 + -D DISPLAY_SCALE_X=2.5 + -D DISPLAY_SCALE_Y=3.75 + -D PIN_TFT_RST=-1 + -D PIN_TFT_VDD_CTL=-1 + -D PIN_TFT_LEDA_CTL=44 + -D PIN_TFT_LEDA_CTL_ACTIVE=HIGH + -D PIN_TFT_CS=47 + -D PIN_TFT_DC=48 + -D PIN_TFT_SCL=16 + -D PIN_TFT_SDA=15 + -D PIN_TFT_MISO=45 + -D PIN_BUZZER=4 + -D PIN_TOUCH_RST=21 +build_src_filter = ${Heltec_v4_r8.build_src_filter} + +<helpers/ui/buzzer.cpp> +lib_deps = + ${Heltec_v4_r8.lib_deps} + adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:heltec_v4_r8_repeater] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec R8 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_r8_room_server] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Heltec R8 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_terminal_chat] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_companion_radio_usb] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_companion_radio_ble] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_companion_radio_wifi] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=SSD1306Display + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_sensor] +extends = heltec_v4_r8_oled +build_flags = + ${heltec_v4_r8_oled.build_flags} + -D ADVERT_NAME='"Heltec R8 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D DISPLAY_CLASS=SSD1306Display +build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_sensor> +lib_deps = + ${heltec_v4_r8_oled.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_tft_repeater] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"Heltec R8 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<helpers/ui/ST7789LCDDisplay.cpp> + +<../examples/simple_repeater> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:heltec_v4_r8_tft_room_server] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ADVERT_NAME='"Heltec R8 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<helpers/ui/ST7789LCDDisplay.cpp> + +<../examples/simple_room_server> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_tft_terminal_chat] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_companion_radio_usb] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=ST7789LCDDisplay + -D ENABLE_USB_INTERFACE +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<helpers/ui/ST7789LCDDisplay.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_companion_radio_ble] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=ST7789LCDDisplay + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<helpers/ui/ST7789LCDDisplay.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_companion_radio_wifi] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=ST7789LCDDisplay + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<helpers/ui/ST7789LCDDisplay.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:heltec_v4_r8_tft_sensor] +extends = heltec_v4_r8_tft +build_flags = + ${heltec_v4_r8_tft.build_flags} + -D ADVERT_NAME='"Heltec R8 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D DISPLAY_CLASS=ST7789LCDDisplay +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<helpers/ui/ST7789LCDDisplay.cpp> + +<../examples/simple_sensor> +lib_deps = + ${heltec_v4_r8_tft.lib_deps} + ${esp32_ota.lib_deps} + +[env:heltec_v4_r8_kiss_modem] +extends = Heltec_v4_r8 +build_src_filter = ${Heltec_v4_r8.build_src_filter} + +<../examples/kiss_modem/> + +[env:heltec_v4_r8_tft_kiss_modem] +extends = heltec_v4_r8_tft +build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/heltec_v4_r8/target.cpp b/variants/heltec_v4_r8/target.cpp new file mode 100644 index 0000000000..0b38531e3c --- /dev/null +++ b/variants/heltec_v4_r8/target.cpp @@ -0,0 +1,45 @@ +#include <Arduino.h> +#include "target.h" + +HeltecV4R8Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi; + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include <helpers/sensors/MicroNMEALocationProvider.h> + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock, GPS_RESET, GPS_EN, &board.periph_power); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display(&board.periph_power); + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + return radio.std_init(&spi); +#else + return radio.std_init(); +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/heltec_v4_r8/target.h b/variants/heltec_v4_r8/target.h new file mode 100644 index 0000000000..2d1d7a5bb4 --- /dev/null +++ b/variants/heltec_v4_r8/target.h @@ -0,0 +1,31 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <HeltecV4R8Board.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/SensorManager.h> +#include <helpers/radiolib/CustomSX1262Wrapper.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <helpers/sensors/EnvironmentSensorManager.h> +#ifdef DISPLAY_CLASS + #ifdef HELTEC_V4_R8_OLED + #include <helpers/ui/SSD1306Display.h> + #elif defined(HELTEC_V4_R8_TFT) + #include <helpers/ui/ST7789LCDDisplay.h> + #endif + #include <helpers/ui/MomentaryButton.h> +#endif + +extern HeltecV4R8Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/heltec_wireless_paper/platformio.ini b/variants/heltec_wireless_paper/platformio.ini index 48723d169a..55ed62b34f 100644 --- a/variants/heltec_wireless_paper/platformio.ini +++ b/variants/heltec_wireless_paper/platformio.ini @@ -73,6 +73,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=E213Display -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${Heltec_Wireless_Paper_base.build_src_filter} +<helpers/ui/E213Display.cpp> +<helpers/esp32/*.cpp> diff --git a/variants/ikoka_handheld_nrf/platformio.ini b/variants/ikoka_handheld_nrf/platformio.ini index 51b602e403..1c6f17c24c 100644 --- a/variants/ikoka_handheld_nrf/platformio.ini +++ b/variants/ikoka_handheld_nrf/platformio.ini @@ -74,6 +74,7 @@ extends = ikoka_handheld_nrf board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D LORA_TX_POWER=20 + -D ENABLE_USB_INTERFACE build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_usb] @@ -82,6 +83,7 @@ board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D LORA_TX_POWER=20 -D DISPLAY_ROTATION=2 + -D ENABLE_USB_INTERFACE build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_repeater] diff --git a/variants/ikoka_nano_nrf/platformio.ini b/variants/ikoka_nano_nrf/platformio.ini index e72f83ce0d..87c6240eec 100644 --- a/variants/ikoka_nano_nrf/platformio.ini +++ b/variants/ikoka_nano_nrf/platformio.ini @@ -31,6 +31,8 @@ debug_tool = jlink upload_protocol = nrfutil lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} +build_src_filter = ${nrf52_base.build_src_filter} + +<helpers/ui/NullDisplayDriver.cpp> [ikoka_nano_nrf_e22_22dbm] extends = ikoka_nano_nrf @@ -42,7 +44,6 @@ build_flags = build_src_filter = ${ikoka_nano_nrf.build_src_filter} +<helpers/*.cpp> +<helpers/sensors> - +<helpers/ui/NullDisplayDriver.cpp> +<../variants/ikoka_nano_nrf> [ikoka_nano_nrf_e22_30dbm] @@ -56,7 +57,6 @@ build_flags = build_src_filter = ${ikoka_nano_nrf.build_src_filter} +<helpers/*.cpp> +<helpers/sensors> - +<helpers/ui/NullDisplayDriver.cpp> +<../variants/ikoka_nano_nrf> [ikoka_nano_nrf_e22_33dbm] @@ -70,7 +70,6 @@ build_flags = build_src_filter = ${ikoka_nano_nrf.build_src_filter} +<helpers/*.cpp> +<helpers/sensors> - +<helpers/ui/NullDisplayDriver.cpp> +<../variants/ikoka_nano_nrf> [ikoka_nano_nrf_companion_radio_ble] @@ -106,6 +105,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -I examples/companion_radio/ui-new -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ikoka_nano_nrf.build_src_filter} diff --git a/variants/ikoka_stick_nrf/platformio.ini b/variants/ikoka_stick_nrf/platformio.ini index 06e39e84c3..c8184fc405 100644 --- a/variants/ikoka_stick_nrf/platformio.ini +++ b/variants/ikoka_stick_nrf/platformio.ini @@ -111,6 +111,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -I examples/companion_radio/ui-new -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ikoka_stick_nrf.build_src_filter} diff --git a/variants/inhero_mr2/BoardConfigContainer.cpp b/variants/inhero_mr2/BoardConfigContainer.cpp new file mode 100644 index 0000000000..a32b09b41f --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.cpp @@ -0,0 +1,2185 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Board Configuration Container Implementation + */ +#include "BoardConfigContainer.h" +#include "InheroMr2Board.h" +#include "target.h" + +#include "lib/BqDriver.h" +#include "lib/Ina228Driver.h" +#include "lib/SimplePreferences.h" + +#include <FreeRTOS.h> +#include <task.h> +#include <MeshCore.h> + +#include "helpers/Watchdog.h" +#include <nrf_soc.h> // For NRF_POWER (GPREGRET2) + +#if ENV_INCLUDE_BME280 +#include <Adafruit_BME280.h> +#endif + +// rtc_clock is defined in target.cpp +extern AutoDiscoverRTCClock rtc_clock; + +namespace { + inline uint32_t getRTCTime() { + return rtc_clock.getCurrentTime(); + } + + void blinkRed(uint8_t count, uint16_t on_ms, uint16_t off_ms, bool led_enabled) { + if (!led_enabled) { + return; + } + for (uint8_t i = 0; i < count; i++) { + digitalWrite(LED_RED, HIGH); + delay(on_ms); + digitalWrite(LED_RED, LOW); + delay(off_ms); + } + } +} + +// Hardware drivers +static BqDriver bq; +static Ina228Driver ina228(0x40); // A0=GND, A1=GND + +static SimplePreferences prefs; + +// Forward declare board instance +extern InheroMr2Board board; + +// Initialize singleton pointer +BqDriver* BoardConfigContainer::bqDriverInstance = nullptr; +Ina228Driver* BoardConfigContainer::ina228DriverInstance = nullptr; +TaskHandle_t BoardConfigContainer::heartbeatTaskHandle = NULL; +volatile bool BoardConfigContainer::lowVoltageAlertFired = false; +MpptStatistics BoardConfigContainer::mpptStats = {}; +BatterySOCStats BoardConfigContainer::socStats = {}; +BoardConfigContainer::BatteryType BoardConfigContainer::cachedBatteryType = BAT_UNKNOWN; +bool BoardConfigContainer::leds_enabled = true; // Default: enabled +bool BoardConfigContainer::usbInputActive = false; // Default: no USB connected +float BoardConfigContainer::tcCalOffset = 0.0f; // Default: no temperature calibration offset +float BoardConfigContainer::lastValidBatteryTemp = 25.0f; // 25°C = no derating until first valid reading +uint32_t BoardConfigContainer::lastTempUpdateMs = 0; // 0 = never updated + +// Battery voltage thresholds live in the BatteryProperties table (see .h file) +// Rev 1.1: INA228 ALERT pin (P1.02) triggers low-voltage sleep via ISR → volatile flag → tickPeriodic(). +// No hardware UVLO (TPS EN tied to VDD). Low-voltage handling is always active when battery configured. + +// PG-Stuck recovery: timestamp of last HIZ toggle (0 = never) +static uint32_t lastPgStuckToggleTime = 0; +#define PG_STUCK_COOLDOWN_MS (5 * 60 * 1000) // 5 minutes between toggles + +void BoardConfigContainer::setupWatchdog() { inhero::setupWatchdog(leds_enabled); } +void BoardConfigContainer::feedWatchdog() { inhero::feedWatchdog(); } +void BoardConfigContainer::disableWatchdog() { inhero::disableWatchdog(); } + +// Re-enables MPPT if BQ25798 disabled it (e.g., during !PG state). +// BQ25798 does not persist MPPT=1 and automatically sets MPPT=0 when PG=0; +// this restores MPPT=1 when PG returns to 1. +// Only runs when PowerGood=1 to avoid false positives; exception: PG-stuck +// recovery toggles HIZ when VBUS is present but PG=0. +void BoardConfigContainer::checkAndFixSolarLogic() { + if (!bqDriverInstance) return; + + // Check if MPPT is enabled in configuration + bool mpptEnabled; + BoardConfigContainer::loadMpptEnabled(mpptEnabled); + + if (!mpptEnabled) { + // MPPT disabled in config - only disable if currently enabled (avoid unnecessary writes) + uint8_t mpptVal = bqDriverInstance->readReg(0x15); + if ((mpptVal & 0x01) != 0) { + bqDriverInstance->writeReg(0x15, mpptVal & ~0x01); + MESH_DEBUG_PRINTLN("MPPT disabled via config"); + } + return; + } + + // Check if PowerGood is currently set + bool powerGood = bqDriverInstance->getChargerStatusPowerGood(); + + if (!powerGood) { + // PG-Stuck recovery: Panel may be connected but BQ didn't qualify it. + // Typical at sunrise when VBUS ramps slowly past the input threshold. + // Toggling HIZ forces a new input source qualification cycle (per datasheet). + // Cooldown: max once per 5 minutes to prevent excessive toggling + uint32_t now = millis(); + if (lastPgStuckToggleTime != 0 && (now - lastPgStuckToggleTime) < PG_STUCK_COOLDOWN_MS) { + return; + } + + uint16_t vbus_mv = bqDriverInstance->getVBUS(); + if (vbus_mv >= PG_STUCK_VBUS_THRESHOLD_MV) { + bqDriverInstance->setHIZMode(true); + delay(50); // BQ needs time to enter HIZ and reset input detection + bqDriverInstance->setHIZMode(false); + lastPgStuckToggleTime = now; + MESH_DEBUG_PRINTLN("PG-Stuck recovery: VBUS=%dmV but PG=0, toggled HIZ", vbus_mv); + } + return; + } + + // Re-enable MPPT when PGOOD=1 + uint8_t mpptVal = bqDriverInstance->readReg(0x15); + + if ((mpptVal & 0x01) == 0) { + bqDriverInstance->writeReg(0x15, mpptVal | 0x01); + MESH_DEBUG_PRINTLN("MPPT re-enabled via register"); + } +} + +// Single MPPT cycle — called from tickPeriodic() every 60s +// Checks solar logic and updates MPPT stats. +void BoardConfigContainer::runMpptCycle() { + // Clear any pending BQ25798 flags so the INT line stays de-asserted + // (we don't wire INT to an MCU IRQ, but leaving flags latched costs current). + if (bqDriverInstance) { + BqDriver::clearInterruptFlags(); + } + + checkAndFixSolarLogic(); + bool mpptEnabled; + BoardConfigContainer::loadMpptEnabled(mpptEnabled); + if (mpptEnabled && bqDriverInstance) { + updateMpptStats(); + } +} + +// Stops heartbeat task and disarms alerts before OTA. +// MPPT and SOC work are tick-based (no tasks to stop); +// only the heartbeat LED task and INA228 alert need cleanup. +void BoardConfigContainer::stopBackgroundTasks() { + MESH_DEBUG_PRINTLN("Stopping background tasks for OTA..."); + + // Delete heartbeat task if running + if (heartbeatTaskHandle != NULL) { + vTaskDelete(heartbeatTaskHandle); + heartbeatTaskHandle = NULL; + MESH_DEBUG_PRINTLN("Heartbeat task stopped"); + } + + // Disarm INA228 low-voltage alert (Rev 1.1) + disarmLowVoltageAlert(); + + delay(200); + MESH_DEBUG_PRINTLN("Background cleanup complete"); +} + +void BoardConfigContainer::heartbeatTask(void* pvParameters) { + (void)pvParameters; + + pinMode(LED_BLUE, OUTPUT); + + while (true) { + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + } + vTaskDelay(pdMS_TO_TICKS(10)); // 10ms flash - well visible, minimal power + if (leds_enabled) { + digitalWrite(LED_BLUE, LOW); + } + vTaskDelay(pdMS_TO_TICKS(5000)); // 5s interval - lower power consumption + } +} + +// Enable or disable heartbeat LED and BQ25798 stat LED +bool BoardConfigContainer::setLEDsEnabled(bool enabled) { + leds_enabled = enabled; + + // Save to filesystem + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString(LEDSKEY, enabled ? "1" : "0"); + prefs.end(); + + // Control heartbeat task + if (enabled) { + // Start heartbeat if not running + if (heartbeatTaskHandle == NULL) { + xTaskCreate(heartbeatTask, "Heartbeat", 512, NULL, 1, &heartbeatTaskHandle); + } + } else { + // Stop heartbeat task + if (heartbeatTaskHandle != NULL) { + vTaskDelete(heartbeatTaskHandle); + heartbeatTaskHandle = NULL; + // Turn off LED + pinMode(LED_BLUE, OUTPUT); + digitalWrite(LED_BLUE, LOW); + } + } + + // Control BQ25798 STAT LED (only if BQ is initialized) + if (bqInitialized && bqDriverInstance) { + bqDriverInstance->setStatPinEnable(enabled); + } + + return true; +} + +// Get current LED enable state +bool BoardConfigContainer::getLEDsEnabled() const { + return leds_enabled; +} + +// Updates MPPT statistics based on elapsed time and current status +// Should be called when MPPT status changes or periodically for time accounting +void BoardConfigContainer::updateMpptStats() { + if (!bqDriverInstance) return; + + static bool lastMpptStatus = false; + static bool initialized = false; + + // Get current time - prefer RTC, fallback to millis() + uint32_t currentTime; + uint32_t rtcTime = getRTCTime(); + + // Check if RTC is initialized (returns > 0 if time was set) + // AutoDiscoverRTCClock returns 0 if no RTC found and time not set + if (rtcTime > 1000000000) { // Sanity check: After year 2001 + currentTime = rtcTime; + if (!mpptStats.usingRTC) { + // Switch from millis to RTC + mpptStats.usingRTC = true; + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = bqDriverInstance->getMPPTenable(); + initialized = true; + return; // Reset timing on switch + } + } else { + // RTC not available or not set - use millis() in seconds + currentTime = millis() / 1000; + } + + bool currentMpptStatus = bqDriverInstance->getMPPTenable(); + + // Initialize on first run + if (!initialized) { + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + initialized = true; + return; + } + + // Calculate elapsed time since last update + uint32_t elapsedSeconds = currentTime - mpptStats.lastUpdateTime; + + // Sanity check: If more than 48 hours passed, reset + const uint32_t MAX_INTERVAL_SEC = 48UL * 60UL * 60UL; + if (elapsedSeconds > MAX_INTERVAL_SEC) { + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + return; + } + + uint32_t elapsedMinutes = elapsedSeconds / 60; + + if (elapsedMinutes == 0 && lastMpptStatus == currentMpptStatus) { + return; // No time passed and no status change + } + + // Add time to current hour accumulator if MPPT was enabled + if (lastMpptStatus && elapsedMinutes > 0) { + mpptStats.currentHourMinutes += elapsedMinutes; + if (mpptStats.currentHourMinutes > 60) { + mpptStats.currentHourMinutes = 60; // Cap at 60 minutes per hour + } + } + + // Calculate energy harvested since last update if MPPT was enabled + if (lastMpptStatus && elapsedSeconds > 0) { + // Use last measured power and integrate over time: E = P × t + // Energy in mWh = Power in mW × Time in hours + float hours = elapsedSeconds / 3600.0f; + uint32_t energy_mWh = (uint32_t)(mpptStats.lastPower_mW * hours); + mpptStats.currentHourEnergy_mWh += energy_mWh; + } + + // Sample current solar power for next integration period + if (currentMpptStatus) { + uint16_t vbat_mppt = ina228DriverInstance ? ina228DriverInstance->readVoltage_mV() : 0; + const Telemetry* telem = bqDriverInstance->getTelemetryData(vbat_mppt); + if (telem) { + // Calculate power: P = U * I (both in mV and mA, result in mW) + mpptStats.lastPower_mW = (int32_t)telem->solar.voltage * telem->solar.current / 1000; + } + } else { + mpptStats.lastPower_mW = 0; // No power when MPPT disabled + } + + mpptStats.lastUpdateTime = currentTime; + lastMpptStatus = currentMpptStatus; + + // Check if we need to move to the next hour + static uint32_t lastHourCheck = 0; + uint32_t currentHour = currentTime / 3600; + uint32_t lastHour = lastHourCheck / 3600; + + if (currentHour > lastHour) { + // Store the completed hour's data + mpptStats.hours[mpptStats.currentIndex].mpptEnabledMinutes = mpptStats.currentHourMinutes; + mpptStats.hours[mpptStats.currentIndex].timestamp = currentTime; + mpptStats.hours[mpptStats.currentIndex].harvestedEnergy_mWh = mpptStats.currentHourEnergy_mWh; + + // Move to next index (circular buffer) + mpptStats.currentIndex = (mpptStats.currentIndex + 1) % MPPT_STATS_HOURS; + + // Reset for new hour + mpptStats.currentHourMinutes = 0; + mpptStats.currentHourEnergy_mWh = 0; + lastHourCheck = currentTime; + } +} + +// Returns current max charge current as string +const char* BoardConfigContainer::getChargeCurrentAsStr() { + static char buffer[16]; + snprintf(buffer, sizeof(buffer), "%dmA", this->getMaxChargeCurrent_mA()); + return buffer; +} + +// Writes charger status information into provided buffer +void BoardConfigContainer::getChargerInfo(char* buffer, uint32_t bufferSize) { + // Check if buffer is valid + if (!buffer || bufferSize == 0) { + return; + } + + // Clear buffer to prevent garbage data + memset(buffer, 0, bufferSize); + + // Check if BQ25798 is initialized and responsive + if (!bqInitialized) { + snprintf(buffer, bufferSize, "BQ25798 not initialized"); + return; + } + + const char* powerGood = bq.getChargerStatusPowerGood() ? "PG" : "!PG"; + const char* statusString = "Unknown"; // Initialize with default value + bq25798_charging_status status = bq.getChargingStatus(); + + switch (status) { + case bq25798_charging_status::BQ25798_CHARGER_STATE_NOT_CHARGING: { + statusString = "!CHG"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_PRE_CHARGING: { + statusString = "PRE"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_CC_CHARGING: { + statusString = "CC"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_CV_CHARGING: { + statusString = "CV"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_TRICKLE_CHARGING: { + statusString = "TRICKLE"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_TOP_OF_TIMER_ACTIVE_CHARGING: { + statusString = "TOP"; + break; + } + case bq25798_charging_status::BQ25798_CHARGER_STATE_DONE_CHARGING: { + statusString = "DONE"; + break; + } + default: + statusString = "Unknown"; + break; + } + + if (lastPgStuckToggleTime == 0) { + snprintf(buffer, bufferSize, "%s / %s HIZ:never", powerGood, statusString); + } else { + uint32_t agoSec = (millis() - lastPgStuckToggleTime) / 1000; + if (agoSec < 60) { + snprintf(buffer, bufferSize, "%s / %s HIZ:%ds ago", powerGood, statusString, agoSec); + } else if (agoSec < 3600) { + snprintf(buffer, bufferSize, "%s / %s HIZ:%dm ago", powerGood, statusString, agoSec / 60); + } else { + snprintf(buffer, bufferSize, "%s / %s HIZ:%dh ago", powerGood, statusString, agoSec / 3600); + } + } +} + +// RV-3028 self-test: address ACK plus user-RAM write/readback (see getSelfTest()). +bool BoardConfigContainer::probeRtc() { + // Address ACK + Wire.beginTransmission(0x52); + if (Wire.endTransmission() != 0) return false; + + // User-RAM 0x1F write/readback (two patterns, save/restore original). + Wire.beginTransmission(0x52); + Wire.write(0x1F); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)0x52, (uint8_t)1) != 1) return false; + uint8_t saved = Wire.read(); + + for (uint8_t pat : {0xA5, 0x5A}) { + Wire.beginTransmission(0x52); + Wire.write(0x1F); + Wire.write(pat); + if (Wire.endTransmission() != 0) return false; + + Wire.beginTransmission(0x52); + Wire.write(0x1F); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)0x52, (uint8_t)1) != 1) return false; + if (Wire.read() != pat) return false; + } + + // Restore original byte + Wire.beginTransmission(0x52); + Wire.write(0x1F); + Wire.write(saved); + Wire.endTransmission(); + return true; +} + +namespace { + bool probeI2CAddr(uint8_t addr) { + Wire.beginTransmission(addr); + return Wire.endTransmission() == 0; + } +} + +void BoardConfigContainer::getSelfTest(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + const char* ina = probeI2CAddr(0x40) ? "OK" : "NACK"; + const char* bq = probeI2CAddr(BQ25798_I2C_ADDR) ? "OK" : "NACK"; + const char* bme = probeI2CAddr(0x76) ? "OK" : "NACK"; + + // RTC: distinguish bus-NACK from write-failure + const char* rtc; + if (!probeI2CAddr(0x52)) { + rtc = "NACK"; + } else if (!probeRtc()) { + rtc = "WR_FAIL"; + } else { + rtc = "OK"; + } + + snprintf(buffer, bufferSize, "INA:%s BQ:%s RTC:%s BME:%s", ina, bq, rtc, bme); +} + +// Reads BQ25798 status/fault registers and produces a compact diagnostic string. +// Register layout (BQ25798 datasheet SLUSDV2B): +// 0x1B STATUS_0: IINDPM[7] VINDPM[6] WD[5] rsvd[4] PG[3] AC2[2] AC1[1] VBUS[0] +// 0x1C STATUS_1: CHG_STAT[7:5] VBUS_STAT[4:1] BC12[0] +// 0x1D STATUS_2: ICO[7:6] rsvd[5:3] TREG[2] DPDM[1] VBAT_PRESENT[0] +// 0x1E STATUS_3: ACRB2[7] ACRB1[6] ADC_DONE[5] VSYS[4] CHG_TMR[3] TRICHG_TMR[2] PRECHG_TMR[1] rsvd[0] +// 0x1F STATUS_4: rsvd[7:5] VBATOTG_LOW[4] TS_COLD[3] TS_COOL[2] TS_WARM[1] TS_HOT[0] +// 0x20 FAULT_0: IBAT_REG[7] VBUS_OVP[6] VBAT_OVP[5] IBUS_OCP[4] IBAT_OCP[3] CONV_OCP[2] VAC2_OVP[1] VAC1_OVP[0] +// 0x21 FAULT_1: rsvd[7] OTG_UVP[6] OTG_OVP[5] rsvd[4] VSYS_SHORT[3] VSYS_OVP[2] rsvd[1:0] +// 0x0F CTRL_0: AUTO_IBATDIS[7] FORCE_IBATDIS[6] EN_CHG[5] EN_ICO[4] FORCE_ICO[3] EN_HIZ[2] EN_TERM[1] EN_BACKUP[0] +// 0x18 NTC_1: TS_COOL[7:6] TS_WARM[5:4] BHOT[3:2] BCOLD[1] TS_IGNORE[0] +void BoardConfigContainer::getBqDiagnostics(char* buffer, uint32_t bufferSize) { + if (!buffer || bufferSize == 0) return; + memset(buffer, 0, bufferSize); + + if (!bqInitialized) { + snprintf(buffer, bufferSize, "BQ not init"); + return; + } + + // Read status registers (read-only, safe to read) + uint8_t s0 = bq.readReg(0x1B); // CHARGER_STATUS_0 + uint8_t s1 = bq.readReg(0x1C); // CHARGER_STATUS_1 + uint8_t s2 = bq.readReg(0x1D); // CHARGER_STATUS_2 + uint8_t s3 = bq.readReg(0x1E); // CHARGER_STATUS_3 + uint8_t s4 = bq.readReg(0x1F); // CHARGER_STATUS_4 + uint8_t f0 = bq.readReg(0x20); // FAULT_STATUS_0 + uint8_t f1 = bq.readReg(0x21); // FAULT_STATUS_1 + + // Read control registers + uint8_t ctrl0 = bq.readReg(0x0F); // CHARGER_CONTROL_0: EN_CHG[5], EN_HIZ[2] + uint8_t ntc1 = bq.readReg(0x18); // NTC_CONTROL_1 + + // Decode TS region from STATUS_4 (0x1F): TS_COLD[3] TS_COOL[2] TS_WARM[1] TS_HOT[0] + const char* ts_str = "OK"; + if (s4 & 0x01) ts_str = "HOT"; + else if (s4 & 0x02) ts_str = "WARM"; + else if (s4 & 0x04) ts_str = "COOL"; + else if (s4 & 0x08) ts_str = "COLD"; + + // Build active-flags substring (only show abnormal conditions) + char flags[50] = ""; + int pos = 0; + if (s0 & 0x80) pos += snprintf(flags + pos, sizeof(flags) - pos, " IINDPM"); + if (s0 & 0x40) pos += snprintf(flags + pos, sizeof(flags) - pos, " VINDPM"); + if (s0 & 0x20) pos += snprintf(flags + pos, sizeof(flags) - pos, " WD!"); + if (s2 & 0x04) pos += snprintf(flags + pos, sizeof(flags) - pos, " TREG"); + if (s3 & 0x08) pos += snprintf(flags + pos, sizeof(flags) - pos, " CHG_TMR"); + if (s3 & 0x04) pos += snprintf(flags + pos, sizeof(flags) - pos, " TCTMR"); + if (s3 & 0x02) pos += snprintf(flags + pos, sizeof(flags) - pos, " PCTMR"); + if (f0 & 0x40) pos += snprintf(flags + pos, sizeof(flags) - pos, " VBUS_OVP"); + if (f0 & 0x20) pos += snprintf(flags + pos, sizeof(flags) - pos, " VBAT_OVP"); + + bool en_chg = (ctrl0 >> 5) & 1; // EN_CHG: bit 5 of CHARGER_CONTROL_0 + bool en_hiz = (ctrl0 >> 2) & 1; // EN_HIZ: bit 2 of CHARGER_CONTROL_0 + + // Read actual IINDPM from REG06 for verification + uint16_t iindpm_mA = (uint16_t)(bq.getInputLimitA() * 1000); + + // Compact output: TS region, active flags, control bits, IINDPM readback, faults, raw status hex, NTC config + snprintf(buffer, bufferSize, + "TS:%s%s CE:%d HIZ:%d IINDPM:%umA F:%02X/%02X S:%02X.%02X.%02X.%02X.%02X N:%02X", + ts_str, flags, en_chg, en_hiz, iindpm_mA, f0, f1, s0, s1, s2, s3, s4, ntc1); +} + +// Initializes battery manager, preferences, and background tasks +bool BoardConfigContainer::begin() { + // Initialize LEDs early for boot sequence visualization + pinMode(LED_BLUE, OUTPUT); // Blue LED (P1.03) + pinMode(LED_RED, OUTPUT); // Red LED (P1.04) + digitalWrite(LED_BLUE, LOW); + digitalWrite(LED_RED, LOW); + + // Load LED enable state from filesystem (default: enabled) + SimplePreferences prefs_led; + if (prefs_led.begin(PREFS_NAMESPACE)) { + char led_buffer[8]; + prefs_led.getString(LEDSKEY, led_buffer, sizeof(led_buffer), "1"); + leds_enabled = (strcmp(led_buffer, "1") == 0); + prefs_led.end(); + } else { + leds_enabled = true; // Default: enabled + } + + bool skip_fs_writes = ((NRF_POWER->GPREGRET2 & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE); + + // === MR2 Hardware (Rev 1.1): INA228 Power Monitor with ALERT-based low-voltage sleep === + // MR2 uses INA228 at 0x40 (A0=GND, A1=GND) + MESH_DEBUG_PRINTLN("=== INA228 Detection @ 0x40 ==="); + delay(10); // Let serial output flush + + // Visual indicator: Red LED on = INA228 detection in progress + if (leds_enabled) { + digitalWrite(LED_RED, HIGH); + delay(50); + } + + // First test I2C communication + Wire.beginTransmission(0x40); + uint8_t i2c_result = Wire.endTransmission(); + MESH_DEBUG_PRINTLN("INA228: I2C probe result = %d (0=OK)", i2c_result); + delay(10); + + if (i2c_result == 0) { + // Device responds, read ID registers + Wire.beginTransmission(0x40); + Wire.write(0x3E); // Manufacturer ID register + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)0x40, (uint8_t)2); + if (Wire.available() >= 2) { + uint16_t mfg_id = (Wire.read() << 8) | Wire.read(); + MESH_DEBUG_PRINTLN("INA228: MFG_ID = 0x%04X (expect 0x5449)", mfg_id); + delay(10); + } + + Wire.beginTransmission(0x40); + Wire.write(0x3F); // Device ID register + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)0x40, (uint8_t)2); + if (Wire.available() >= 2) { + uint16_t dev_id = (Wire.read() << 8) | Wire.read(); + MESH_DEBUG_PRINTLN("INA228: DEV_ID = 0x%04X (expect 0x0228)", dev_id); + delay(10); + } + + // Try to initialize + if (ina228.begin(100.0f)) { // 100mΩ shunt resistor (optimal SNR for 10mA standby / 1A max) + ina228Initialized = true; + ina228DriverInstance = &ina228; + + // Turn off red LED (INA228 detection complete) + if (leds_enabled) { + digitalWrite(LED_RED, LOW); + delay(10); + } + + // Blue LED flash: INA228 initialized + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + + // Arm INA228 low-voltage alert for this battery chemistry + // Rev 1.1: Always active when battery type is configured (no CLI toggle) + // ISR on ALERT pin → volatile flag → tickPeriodic() → System Sleep with GPIO latch + armLowVoltageAlert(); + + // NOTE: Low-voltage recovery SOC=0% is handled in InheroMr2Board::begin() + // (after setLowVoltageRecovery()), not here, because lowVoltageRecovery isn't set yet. + } else { + MESH_DEBUG_PRINTLN("INA228 begin() failed (check MFG_ID/DEV_ID above)"); + ina228Initialized = false; + } + } else { + MESH_DEBUG_PRINTLN("INA228 no I2C ACK @ 0x40"); + ina228Initialized = false; + } + delay(10); + + // Initialize BQ25798 + if (bq.begin()) { + bqInitialized = true; + bqDriverInstance = &bq; + MESH_DEBUG_PRINTLN("BQ25798 found. "); + + // Blue LED flash: BQ25798 initialized + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + } else { + MESH_DEBUG_PRINTLN("BQ25798 not found."); + bqInitialized = false; + } + + // Load NTC temperature calibration offset (applies to all BQ temperature readings) + float tc_offset = 0.0f; + if (loadTcCalOffset(tc_offset)) { + tcCalOffset = tc_offset; + MESH_DEBUG_PRINTLN("TC calibration offset loaded: %+.2f C", tc_offset); + } else { + MESH_DEBUG_PRINTLN("TC using default calibration (0.0)"); + } + + // === RV-3028 RTC Initialization === + // Address probe + user-RAM write/readback test (catches "zombie" RTCs that + // ACK on bus but reject writes — see probeRtc() for details). + // Retry up to 3 times — after OTA/warm-reset the I2C bus may need recovery. + bool rtc_initialized = false; + for (int attempt = 0; attempt < 3; attempt++) { + if (probeRtc()) { + rtc_initialized = true; + MESH_DEBUG_PRINTLN("RV-3028 RTC OK (attempt %d)", attempt + 1); + if (leds_enabled) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + break; + } + MESH_DEBUG_PRINTLN("RV-3028 RTC self-test failed (attempt %d)", attempt + 1); + delay(20); + } + + // === MR2 Configuration === + SimplePreferences prefs_init; + prefs_init.begin(PREFS_NAMESPACE); + + BatteryType bat = DEFAULT_BATTERY_TYPE; + FrostChargeBehaviour frost = DEFAULT_FROST_BEHAVIOUR; + uint16_t maxChargeCurrent_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + + if (!loadBatType(bat)) { + if (!skip_fs_writes) { + prefs_init.putString(BATTKEY, getBatteryTypeCommandString(bat)); + } + } + if (!loadFrost(frost)) { + if (!skip_fs_writes) { + prefs_init.putString(FROSTKEY, getFrostChargeBehaviourCommandString(frost)); + } + } + if (!loadMaxChrgI(maxChargeCurrent_mA)) { + if (!skip_fs_writes) { + prefs_init.putInt(MAXCHARGECURRENTKEY, maxChargeCurrent_mA); + } + } + + this->configureBaseBQ(); + this->configureChemistry(bat); + cachedBatteryType = bat; // Cache for static methods (updateBatterySOC, calculateTTL) + + // Charger active by default — HIZ-Gate removed (Rev 1.1 PCB stable). + bq.setHIZMode(false); + + this->setFrostChargeBehaviour(frost); + this->setMaxChargeCurrent_mA(maxChargeCurrent_mA); + + // Mask ALL BQ25798 interrupts — INT pin is not used (polling only). + // Default mask registers are 0x00 (all unmasked!) → every event pulls INT LOW. + // With INPUT_PULLUP on BQ_INT_PIN: LOW = ~254µA wasted through pull-up. + BqDriver::maskAllInterrupts(); + + // Clear any latched flag/interrupt status from previous operation/boot + // so the INT line is de-asserted before we leave begin(). + BqDriver::clearInterruptFlags(); + + // Heartbeat LED task (GPIO only — no I2C, safe as FreeRTOS task) + if (heartbeatTaskHandle == NULL && leds_enabled) { + BaseType_t taskCreated = xTaskCreate(BoardConfigContainer::heartbeatTask, "Heartbeat", 1024, + NULL, 1, &heartbeatTaskHandle); + if (taskCreated != pdPASS) { + MESH_DEBUG_PRINTLN("Failed to create Heartbeat task!"); + return false; + } + } + + // BQ_INT_PIN no longer used — solar checks run via polling in tickPeriodic() + // Pull up to prevent floating trace on PCB + pinMode(BQ_INT_PIN, INPUT_PULLUP); + + // Check if all critical components initialized + bool all_components_ok = bqInitialized && ina228Initialized && rtc_initialized; + + if (!all_components_ok) { + // Start permanent slow red LED blink to indicate missing component + MESH_DEBUG_PRINTLN("WARNING: Missing components - starting error LED"); + if (!bqInitialized) MESH_DEBUG_PRINTLN(" - BQ25798 missing"); + if (!ina228Initialized) MESH_DEBUG_PRINTLN(" - INA228 missing"); + if (!rtc_initialized) MESH_DEBUG_PRINTLN(" - RV-3028 RTC missing"); + + // Create error LED blink task (GPIO only) + if (leds_enabled) { + xTaskCreate([](void* param) { + while (1) { + digitalWrite(LED_RED, HIGH); // Red LED on + vTaskDelay(pdMS_TO_TICKS(500)); + digitalWrite(LED_RED, LOW); // Red LED off + vTaskDelay(pdMS_TO_TICKS(500)); + } + }, "ErrorLED", 512, NULL, 1, NULL); + } + } + + // MPPT, SOC updates, and voltage monitoring are handled in tickPeriodic() + // (called from InheroMr2Board::tick() — no FreeRTOS tasks doing I2C) + + // Load battery capacity from preferences (or default based on chemistry) + float cap_mah = 0.0f; + loadBatteryCapacity(cap_mah); + socStats.capacity_mah = cap_mah; + socStats.nominal_voltage = getNominalVoltage(bat); + MESH_DEBUG_PRINTLN("SOC: capacity=%.0f mAh, nominal=%.2f V", cap_mah, socStats.nominal_voltage); + + // MR2 requires BQ25798 + INA228 (RTC is optional for basic operation) + return bqInitialized && ina228Initialized; +} + +// Loads battery type from preferences +bool BoardConfigContainer::loadBatType(BatteryType& type) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + if (prefs.getString(BATTKEY, buffer, sizeof(buffer), "") > 0) { + type = this->getBatteryTypeFromCommandString(buffer); + if (type != BAT_UNKNOWN) { + return true; + } else { + type = DEFAULT_BATTERY_TYPE; + return false; + } + } + + // No preference found - use default + type = DEFAULT_BATTERY_TYPE; + return false; +} + +// Loads frost charge behavior from preferences +bool BoardConfigContainer::loadFrost(FrostChargeBehaviour& behaviour) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + if (prefs.getString(FROSTKEY, buffer, sizeof(buffer), "") > 0) { + behaviour = this->getFrostChargeBehaviourFromCommandString(buffer); + if (behaviour != REDUCE_UNKNOWN) { + return true; + } else { + behaviour = DEFAULT_FROST_BEHAVIOUR; + return false; + } + } + + // No preference found - use default + behaviour = DEFAULT_FROST_BEHAVIOUR; + return false; +} + +// Loads maximum charge current from preferences +bool BoardConfigContainer::loadMaxChrgI(uint16_t& maxCharge_mA) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + + if (prefs.getString(MAXCHARGECURRENTKEY, buffer, sizeof(buffer), "") > 0) { + + int val = atoi(buffer); + // Bounds check: Reasonable charge current range + if (val > 0 && val <= 3000) { // Max 3A for safety + maxCharge_mA = val; + return true; + } else { + maxCharge_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + return false; + } + } + + // No preference found - use default + maxCharge_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + return false; +} + +// Loads MPPT enabled setting from preferences +bool BoardConfigContainer::loadMpptEnabled(bool& enabled) { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[10]; + + if (prefs.getString(MPPTENABLEKEY, buffer, sizeof(buffer), "") > 0) { + if (buffer[0] != '\0') { + enabled = buffer[0] == '1' ? true : false; + return true; + } else { + enabled = DEFAULT_MPPT_ENABLED; + return false; + } + } + + // No preference found - use default + enabled = DEFAULT_MPPT_ENABLED; + return false; +} + +// Returns combined telemetry from INA228 (battery) and BQ25798 (solar + temperature). +// Battery voltage/current come from the INA228 (24-bit ADC, ±0.1% accuracy); +// solar data and battery temperature from the BQ25798 ADC. +// +// Temperature availability depends on power conditions: +// VBUS > 3.4V → BQ25798 ADC runs → temperature available +// VBAT >= 3.2V → BQ25798 ADC runs → temperature available +// VBAT < 3.2V → TS channel disabled (datasheet 9.3.16) → temperature = N/A +// VBAT < 2.9V → ADC cannot operate at all → temperature = N/A, solar = 0 +// +// Temperature sentinel values (propagated from BqDriver::calculateBatteryTemp): +// -999.0f = I2C communication error or NTC unavailable +// -888.0f = ADC not ready / TS disabled due to low VBAT +// -99.0f = NTC open circuit (disconnected) +// 99.0f = NTC short circuit +// Values outside -50..+90°C are treated as invalid → displayed as "N/A" +const Telemetry* BoardConfigContainer::getTelemetryData() { + static Telemetry telemetry; + + // Battery voltage/current ALWAYS from INA228 (no fallback to BQ25798) + // INA228 for precise battery monitoring + uint16_t batt_voltage = 0; + float batt_current = 0.0f; + int32_t batt_power = 0; + if (ina228DriverInstance != nullptr) { + batt_voltage = ina228DriverInstance->readVoltage_mV(); + batt_current = ina228DriverInstance->readCurrent_mA_precise(); + batt_power = (int32_t)((batt_voltage * batt_current) / 1000.0f); + } + + // Get base telemetry from BQ25798 (solar data + temperature) + // Pass VBAT so BqDriver can disable TS channel when VBAT < 3.2V + // (BQ25798 ADC requires VBAT >= 3.2V with TS enabled, else ADC won't start) + const Telemetry* bqData = bq.getTelemetryData(batt_voltage); + if (!bqData) { + memset(&telemetry, 0, sizeof(Telemetry)); + return &telemetry; + } + + // Copy BQ25798 data (solar, system) + telemetry.solar = bqData->solar; + telemetry.system = bqData->system; + + // Temperature: BQ25798 TS ADC reads NTC via REGN-biased divider. + // Error codes from calculateBatteryTemp: -999 (I2C), -888 (ADC not ready), -99 (open), 99 (short). + // Valid NTC range: approx -40..+85 °C. Anything outside -50..+90 is treated as unavailable. + float bqTemp = bqData->battery.temperature; + if (bqTemp >= -50.0f && bqTemp <= 90.0f) { + telemetry.battery.temperature = bqTemp + tcCalOffset; + // Cache for temperature derating in updateBatterySOC() (static context) + lastValidBatteryTemp = telemetry.battery.temperature; + lastTempUpdateMs = millis(); + } else { + // NTC unavailable (no solar / I2C error / ADC not ready) → propagate sentinel + telemetry.battery.temperature = -999.0f; + } + + telemetry.battery.voltage = batt_voltage; + telemetry.battery.current = batt_current; + telemetry.battery.power = batt_power; + + return &telemetry; +} + +// Configures base BQ25798 settings (timers, watchdog, input limits, MPPT) +bool BoardConfigContainer::configureBaseBQ() { + if (!bqInitialized) { + return false; + } + + bq.setRechargeThreshOffsetV(.2); + bq.setPrechargeTimerEnable(false); + bq.setFastChargeTimerEnable(false); + bq.setTsIgnore(false); + bq.setWDT(BQ25798_WDT_DISABLE); + bq.setExtILIMpin(false); // Disable ILIM_HIZ pin clamp — IINDPM managed by software + bq.setInputLimitA(IINDPM_MAX_A); // Safe default before chemistry is known; updateSolarIINDPM() refines later + bq.setICOEnable(false); // Disable ICO — IINDPM is explicitly managed, ICO must not overwrite it + + bq.setVOCdelay(BQ25798_VOC_DLY_2S); + bq.setVOCrate(BQ25798_VOC_RATE_2MIN); + bq.setVOCpercent(BQ25798_VOC_PCT_81_25); // 81.25% matches Vmp/Voc of typical crystalline Si panels (~80-83%) + bq.setAutoDPinsDetection(false); + bq.setMPPTenable(true); + + bq.setMinSystemV(2.75); // 2.75V = next valid step above 2.7V (250mV steps: 2.5, 2.75, 3.0...) + bq.setStatPinEnable(leds_enabled); // Configure STAT LED based on user preference + bq.setTsCool(BQ25798_TS_COOL_5C); + bq.setTsWarm(BQ25798_TS_WARM_55C); // 37.7% REGN → ~52°C with Inhero divider (default 45°C was ~42°C) + + // JEITA WARM: keep VREG unchanged. Default -400mV triggers VBAT_OVP on LiFePO4 + // and is unnecessarily conservative for Li-Ion (4.1V / 3.5V are already safe). + bq.setJeitaVSet(BQ25798_JEITA_VSET_UNCHANGED); + + // Disable auto battery discharge during VBAT_OVP (EN_AUTO_IBATDIS). + // POR default = enabled → BQ actively sinks 30mA from battery during OVP to lower VBAT. + // With JEITA_VSET fixed, VBAT_OVP should no longer trigger. Belt-and-suspenders safety. + bq.setAutoIBATDIS(false); + + // Flush stale ADC registers by running one discard conversion. + // After reboot (e.g. low-voltage recovery), BQ25798 retains old ADC values + // from before shutdown. A fresh one-shot ensures registers reflect actual state. + bq.getTelemetryData(0); // VBAT unknown at this point, assume sufficient + + return true; +} + +// Configures battery chemistry-specific parameters (cell count, charge voltage) +bool BoardConfigContainer::configureChemistry(BatteryType type) { + if (!bqInitialized) { + return false; + } + + // Get battery properties from lookup table + const BatteryProperties* props = getBatteryProperties(type); + if (!props) { + MESH_DEBUG_PRINTLN("ERROR: Invalid battery type"); + return false; + } + + // Apply charge enable/disable based on battery type + bq.setChargeEnable(props->charge_enable); + + // CE-Pin hardware safety: Only pull CE HIGH (enable charging via FET) when chemistry is known + // Rev 1.1: DMN2004TK-7 N-FET inverts CE logic — HIGH=enable, LOW=disable + // External pull-down ensures CE stays LOW (charging disabled) when RAK is off or unbooted +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, props->charge_enable ? HIGH : LOW); + MESH_DEBUG_PRINTLN("BQ CE pin %s (charge_enable=%d)", + props->charge_enable ? "HIGH (enabled via FET)" : "LOW (disabled via FET)", + props->charge_enable); +#endif + + // Apply TS_IGNORE before potential early return — configureBaseBQ() resets it to false, + // so chemistries with ts_ignore=true (BAT_UNKNOWN, LTO, NAION) need it set here. + bq.setTsIgnore(props->ts_ignore); + if (props->ts_ignore) { + bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED); + bq.setJeitaISetH(BQ25798_JEITA_ISETH_UNCHANGED); + } + + if (!props->charge_enable) { + MESH_DEBUG_PRINTLN("WARNING: Battery type UNKNOWN - Charging DISABLED for safety!"); + return true; // No further configuration needed for unknown battery + } + + // Configure chemistry-specific parameters, starting with the cell count + bq25798_cell_count_t cellCount = (type == BoardConfigContainer::BatteryType::LTO_2S) + ? BQ25798_CELL_COUNT_2S : BQ25798_CELL_COUNT_1S; + bq.setCellCount(cellCount); + + bq.setChargeLimitV(props->charge_voltage); + + return true; +} + +// Gets current battery type from preferences +BoardConfigContainer::BatteryType BoardConfigContainer::getBatteryType() const { + BatteryType bat; + if (loadBatType(bat)) { + return bat; + } else { + return DEFAULT_BATTERY_TYPE; + } +} + +// Gets current frost charge behavior from preferences +BoardConfigContainer::FrostChargeBehaviour BoardConfigContainer::getFrostChargeBehaviour() const { + FrostChargeBehaviour frost; + if (loadFrost(frost)) { + return frost; + } else { + return NO_CHARGE; + } +} + +// Gets maximum charge current from preferences +uint16_t BoardConfigContainer::getMaxChargeCurrent_mA() const { + uint16_t maxI = 100; + loadMaxChrgI(maxI); + return maxI; +} + +// Gets current MPPT enable status from preferences +bool BoardConfigContainer::getMPPTEnabled() const { + bool enabled; + loadMpptEnabled(enabled); + return enabled; +} + +// Enables or disables MPPT +bool BoardConfigContainer::setMPPTEnable(bool enableMPPT) { + // Save to preferences first + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + if (!prefs.putString(MPPTENABLEKEY, enableMPPT ? "1" : "0")) { + return false; + } + + // Set the hardware register + if (!enableMPPT) { + // Disable MPPT in hardware + bq.setMPPTenable(false); + } else { + // Enable MPPT in hardware register + bq.setMPPTenable(true); + } + + return true; +} + +// Gets current maximum charge voltage +float BoardConfigContainer::getMaxChargeVoltage() const { + return bq.getChargeLimitV(); +} + +// Sets battery type and reconfigures BQ accordingly +bool BoardConfigContainer::setBatteryType(BatteryType type) { + bool bqBaseConfigured = this->configureBaseBQ(); + bool bqConfigured = this->configureChemistry(type); + cachedBatteryType = type; // Update cache for static methods (updateBatterySOC, calculateTTL) + + // Invalidate SOC — voltage-to-SOC mapping changes with chemistry. + // SOC will remain NA until next "Charging Done" sync or manual set. + socStats.soc_valid = false; + socStats.nominal_voltage = getNominalVoltage(type); + + // Restore correct IINDPM — configureBaseBQ() sets safe 2A default, + // but USB must be capped to 500mA per USB 2.0 spec. + if (usbInputActive && bqDriverInstance) { + bqDriverInstance->setInputLimitA(IINDPM_USB_A); + MESH_DEBUG_PRINTLN("USB active: IINDPM restored to %dmA after chemistry change", (int)(IINDPM_USB_A * 1000)); + } else { + updateSolarIINDPM(); + } + + // === CRITICAL: Update INA228 low-voltage alert threshold when battery type changes === + if (ina228DriverInstance) { + armLowVoltageAlert(); + delay(10); + } + + // Store battery type in preferences + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString(BATTKEY, getBatteryTypeCommandString(type)); + + // Safety: When switching to Li-Ion or LiFePO4, reset frost charge to NO_CHARGE + // These chemistries should not be charged at low temperatures + if (type == BatteryType::LIION_1S || type == BatteryType::LIFEPO4_1S) { + setFrostChargeBehaviour(FrostChargeBehaviour::NO_CHARGE); + } + + return bqBaseConfigured && bqConfigured; +} + +// Sets frost charge behavior (JEITA cold region) +bool BoardConfigContainer::setFrostChargeBehaviour(FrostChargeBehaviour behaviour) { + switch (behaviour) { + case BoardConfigContainer::FrostChargeBehaviour::NO_CHARGE: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_SUSPEND); + break; + case BoardConfigContainer::FrostChargeBehaviour::NO_REDUCE: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_UNCHANGED); + break; + case BoardConfigContainer::FrostChargeBehaviour::I_REDUCE_TO_40: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_40_PERCENT); + break; + case BoardConfigContainer::FrostChargeBehaviour::I_REDUCE_TO_20: + bq.setJeitaISetC(BQ25798_JEITA_ISETC_20_PERCENT); + break; + } + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putString(FROSTKEY, getFrostChargeBehaviourCommandString(behaviour)); + return true; +} + +// Sets maximum charge current (ICHG) and recalculates solar IINDPM +// Note: Also calls updateSolarIINDPM() because IINDPM depends on ICHG. +bool BoardConfigContainer::setMaxChargeCurrent_mA(uint16_t maxChrgI) { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + prefs.putInt(MAXCHARGECURRENTKEY, maxChrgI); + + bool ok = bq.setChargeLimitA(maxChrgI / 1000.0f); + + // Readback verification — detect silent I2C failures + float readback = bq.getChargeLimitA(); + uint16_t readback_mA = (uint16_t)(readback * 1000.0f + 0.5f); + + MESH_DEBUG_PRINTLN("ICHG: set=%dmA, readback=%dmA, ok=%d", maxChrgI, readback_mA, ok); + + if (readback_mA != maxChrgI) { + MESH_DEBUG_PRINTLN("WARNING: ICHG readback mismatch! Expected %d, got %d", maxChrgI, readback_mA); + } + + // Recalculate solar IINDPM — it depends on charge current + updateSolarIINDPM(); + + return ok; +} + +// Notify USB connection state change — adjusts IINDPM accordingly +// USB: IINDPM = 500mA (USB 2.0 spec). No USB: IINDPM calculated from battery/charge config. +void BoardConfigContainer::setUsbConnected(bool connected) { + if (usbInputActive == connected) return; // No state change + usbInputActive = connected; + + if (!bqDriverInstance) return; + + if (connected) { + bqDriverInstance->setInputLimitA(IINDPM_USB_A); + MESH_DEBUG_PRINTLN("USB connected: IINDPM = %dmA", (int)(IINDPM_USB_A * 1000)); + } else { + updateSolarIINDPM(); + } +} + +// Calculate IINDPM for solar input from battery chemistry and charge current. +// Power conservation: I_in = IINDPM_MARGIN × (V_charge × I_charge) / V_panel. +// Prevents weak panels from POORSRC fault after PG qualification. +float BoardConfigContainer::calculateSolarIINDPM() { + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + if (!props || !props->charge_enable) { + return IINDPM_MAX_A; // Unknown chemistry: use safe max + } + + // Read current imax from preferences + uint16_t imax_mA = DEFAULT_MAX_CHARGE_CURRENT_MA; + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + char buffer[10]; + if (prefs.getString(MAXCHARGECURRENTKEY, buffer, sizeof(buffer), "") > 0) { + int val = atoi(buffer); + if (val > 0 && val <= 3000) { + imax_mA = val; + } + } + + float i_charge_A = imax_mA / 1000.0f; + float v_charge = props->charge_voltage; + + // I_input = margin × (V_bat × I_bat) / V_panel + float iindpm = IINDPM_MARGIN * (v_charge * i_charge_A) / IINDPM_PANEL_V; + + // Clamp to hardware limits + if (iindpm > IINDPM_MAX_A) iindpm = IINDPM_MAX_A; + if (iindpm < 0.1f) iindpm = 0.1f; // BQ25798 register minimum + + return iindpm; +} + +// Apply calculated solar IINDPM to BQ25798 (skipped when USB active) +void BoardConfigContainer::updateSolarIINDPM() { + if (usbInputActive || !bqDriverInstance) return; + + float iindpm = calculateSolarIINDPM(); + bqDriverInstance->setInputLimitA(iindpm); + MESH_DEBUG_PRINTLN("Solar IINDPM = %dmA (Vchg=%.1fV, margin=%.1fx, Vpanel=%.0fV)", + (int)(iindpm * 1000), + getBatteryProperties(cachedBatteryType) + ? getBatteryProperties(cachedBatteryType)->charge_voltage : 0.0f, + IINDPM_MARGIN, IINDPM_PANEL_V); +} + +// Calculates 7-day moving average of MPPT enabled percentage +float BoardConfigContainer::getMpptEnabledPercentage7Day() const { + // Return 0 if MPPT is disabled in config + bool mpptEnabled; + loadMpptEnabled(mpptEnabled); + if (!mpptEnabled) { + return 0.0f; + } + + uint32_t totalMinutes = 0; + uint32_t enabledMinutes = 0; + uint32_t validHours = 0; + + // Count backwards through the circular buffer + for (int i = 0; i < MPPT_STATS_HOURS; i++) { + int index = (mpptStats.currentIndex - 1 - i + MPPT_STATS_HOURS) % MPPT_STATS_HOURS; + + // Skip entries that haven't been filled yet (timestamp == 0) + if (mpptStats.hours[index].timestamp == 0) { + continue; + } + + validHours++; + enabledMinutes += mpptStats.hours[index].mpptEnabledMinutes; + } + + if (validHours == 0) { + return 0.0f; // No data yet + } + + totalMinutes = validHours * 60; // Each hour has 60 minutes + + return (enabledMinutes * 100.0f) / totalMinutes; +} + +// ===== Battery SOC & Coulomb Counter Methods ===== + +// Get current State of Charge in percent +float BoardConfigContainer::getStateOfCharge() const { + return socStats.current_soc_percent; +} + +// Get nominal voltage for battery chemistry type +float BoardConfigContainer::getNominalVoltage(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->nominal_voltage : 3.7f; +} + +// Get battery capacity in mAh +float BoardConfigContainer::getBatteryCapacity() const { + return socStats.capacity_mah; +} + +// Check if battery capacity was explicitly set via CLI +bool BoardConfigContainer::isBatteryCapacitySet() const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + size_t len = prefs.getString(BATTERY_CAPACITY_KEY, buffer, sizeof(buffer), ""); + return (len > 0 && buffer[0] != '\0'); +} + +// Set battery capacity manually via CLI (converts to mWh internally) +bool BoardConfigContainer::setBatteryCapacity(float capacity_mah) { + if (capacity_mah < 100.0f || capacity_mah > 100000.0f) { + return false; // Sanity check + } + + // Store user-configured capacity in mAh + socStats.capacity_mah = capacity_mah; + + // Get nominal voltage for current chemistry + BatteryType batType = getBatteryType(); + float v_nominal = getNominalVoltage(batType); + socStats.nominal_voltage = v_nominal; + + // Invalidate SOC until next "Charging Done" sync + socStats.soc_valid = false; + + // Save to preferences + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + snprintf(buffer, sizeof(buffer), "%.1f", capacity_mah); + prefs.putString(BATTERY_CAPACITY_KEY, buffer); + + MESH_DEBUG_PRINTLN("Battery capacity set to %.0f mAh @ %.1fV", + capacity_mah, v_nominal); + return true; +} + +// Time To Live in hours (see calculateTTL() for the full formula/model). +uint16_t BoardConfigContainer::getTTL_Hours() const { + return socStats.ttl_hours; +} + +// Check if living on battery (net deficit) +bool BoardConfigContainer::isLivingOnBattery() const { + return socStats.living_on_battery; +} + +// Sync SOC to 100% after "Charging Done" event from BQ25798 +// Resets INA228 Coulomb Counter baseline and marks SOC as valid +void BoardConfigContainer::syncSOCToFull() { + if (!ina228DriverInstance) { + return; + } + + // Reset INA228 Coulomb Counter (clears ENERGY and CHARGE registers) + ina228DriverInstance->resetCoulombCounter(); + + // Set baseline to 0 (we just reset the counter) + socStats.ina228_baseline_mah = 0; + socStats.last_soc_update_ms = millis(); // Reset time reference + + // Mark as fully charged + socStats.current_soc_percent = 100.0f; + socStats.soc_valid = true; + + // Update temperature derating factor immediately + refreshTempDerating(); + + MESH_DEBUG_PRINTLN("SOC: Synced to 100%% (Charging Done) - INA228 baseline reset, d=%.2f", + socStats.temp_derating_factor); +} + +void BoardConfigContainer::refreshTempDerating() { + // BME280 fallback: if NTC hasn't updated for >5 min, try BME280. + // If BME280 also fails, lastValidBatteryTemp keeps its previous value + // (default 25°C = no derating). + const BatteryProperties* props = getBatteryProperties(cachedBatteryType); + uint32_t now = millis(); + if (lastTempUpdateMs == 0 || (now - lastTempUpdateMs) > 300000UL) { + float bmeTemp = readBmeTemperature(); + if (bmeTemp > -100.0f && bmeTemp < 100.0f) { + lastValidBatteryTemp = bmeTemp; + lastTempUpdateMs = now; + } + } + socStats.temp_derating_factor = getTemperatureDerating(props, lastValidBatteryTemp); + socStats.last_battery_temp_c = lastValidBatteryTemp; +} + +// Manually set SOC to specific percentage (e.g. after reboot with known SOC) +bool BoardConfigContainer::setSOCManually(float soc_percent) { + if (!ina228DriverInstance) { + MESH_DEBUG_PRINTLN("SOC: Cannot set - INA228 not initialized"); + return false; + } + + // Validate SOC range + if (soc_percent < 0.0f || soc_percent > 100.0f) { + MESH_DEBUG_PRINTLN("SOC: Invalid value %.1f%% (must be 0-100)", soc_percent); + return false; + } + + if (socStats.capacity_mah <= 0) { + MESH_DEBUG_PRINTLN("SOC: Cannot set - battery capacity unknown"); + return false; + } + + // Read current CHARGE register value + float current_charge_mah = ina228DriverInstance->readCharge_mAh(); + + // Calculate remaining capacity at desired SOC + float remaining_mah = (soc_percent / 100.0f) * socStats.capacity_mah; + + // Calculate baseline: charge_mah = baseline + net_charge + // We want: remaining_mah = capacity + net_charge = capacity + (charge - baseline) + // Therefore: baseline = charge - (remaining - capacity) + socStats.ina228_baseline_mah = current_charge_mah - (remaining_mah - socStats.capacity_mah); + socStats.last_soc_update_ms = millis(); // Reset time reference + + // Set SOC and mark as valid + socStats.current_soc_percent = soc_percent; + socStats.soc_valid = true; + + // Update temperature derating factor immediately so telem/TTL are correct + // without waiting for the next periodic updateBatterySOC() cycle. + refreshTempDerating(); + + MESH_DEBUG_PRINTLN("SOC: Manually set to %.1f%% (CHARGE=%.1fmAh, Baseline=%.1fmAh, d=%.2f)", + soc_percent, current_charge_mah, socStats.ina228_baseline_mah, + socStats.temp_derating_factor); + + return true; +} + +// Load battery capacity from preferences +bool BoardConfigContainer::loadBatteryCapacity(float& capacity_mah) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + if (prefs.getString(BATTERY_CAPACITY_KEY, buffer, sizeof(buffer), "") > 0) { + if (buffer[0] != '\0') { + capacity_mah = atof(buffer); + return (capacity_mah > 0.0f); + } + } + + // Default capacity based on battery type (estimate) + BatteryType type; + if (loadBatType(type)) { + switch (type) { + case BatteryType::LTO_2S: + capacity_mah = 2000.0f; // Typical LTO capacity + break; + case BatteryType::LIFEPO4_1S: + capacity_mah = 1500.0f; // Typical LiFePO4 capacity + break; + case BatteryType::NAION_1S: + capacity_mah = 2000.0f; // Typical Na-Ion capacity + break; + case BatteryType::LIION_1S: + default: + capacity_mah = 2000.0f; // Typical Li-Ion capacity + break; + } + } else { + capacity_mah = 2000.0f; // Default fallback + } + + return false; // Not loaded from prefs +} + +// Get INA228 driver instance +Ina228Driver* BoardConfigContainer::getIna228Driver() { + return ina228DriverInstance; +} + +// ===== NTC Temperature Calibration ===== + +// Load NTC temperature calibration offset from preferences +bool BoardConfigContainer::loadTcCalOffset(float& offset) const { + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + if (prefs.getString(TCCAL_KEY, buffer, sizeof(buffer), "") > 0) { + if (buffer[0] != '\0') { + offset = atof(buffer); + + // Validate offset is in reasonable range (±20°C) + if (offset >= -20.0f && offset <= 20.0f) { + return true; + } + } + } + + // Default: no offset + offset = 0.0f; + return false; +} + +// Set NTC temperature calibration offset and save to preferences +bool BoardConfigContainer::setTcCalOffset(float offset_c) { + // Clamp to reasonable range + if (offset_c < -20.0f) offset_c = -20.0f; + if (offset_c > 20.0f) offset_c = 20.0f; + + // Apply to runtime variable + tcCalOffset = offset_c; + + // Save to preferences + SimplePreferences prefs; + prefs.begin(PREFS_NAMESPACE); + + char buffer[20]; + snprintf(buffer, sizeof(buffer), "%.2f", offset_c); + + if (prefs.putString(TCCAL_KEY, buffer)) { + MESH_DEBUG_PRINTLN("TC calibration offset saved: %.2f C", offset_c); + return true; + } + + return false; +} + +// Get current NTC temperature calibration offset +float BoardConfigContainer::getTcCalOffset() const { + return tcCalOffset; +} + +// Perform NTC temperature calibration using a reference temperature +// Averages 5 NTC readings to reduce ADC noise, computes offset = reference - avg, stores it. +float BoardConfigContainer::performTcCalibration(float actual_temp_c) { + if (!bqDriverInstance) { + return -999.0f; + } + + // Temporarily remove any existing offset to get raw NTC readings + float old_offset = tcCalOffset; + tcCalOffset = 0.0f; + + // Average multiple NTC readings to reduce ADC noise + const int NUM_SAMPLES = 5; + const int SAMPLE_DELAY_MS = 200; + float ntc_sum = 0.0f; + int valid_count = 0; + + for (int i = 0; i < NUM_SAMPLES; i++) { + if (i > 0) delay(SAMPLE_DELAY_MS); + + const Telemetry* bqData = bqDriverInstance->getTelemetryData(0); // TC calibration: VBAT unknown, assume sufficient + if (!bqData) continue; + + float raw = bqData->battery.temperature; + // Skip error codes + if (raw <= -800.0f || raw >= 98.0f) continue; + + ntc_sum += raw; + valid_count++; + } + + if (valid_count < 3) { + tcCalOffset = old_offset; // Restore old offset + MESH_DEBUG_PRINTLN("TC Cal: Only %d/%d valid NTC readings", valid_count, NUM_SAMPLES); + return -999.0f; + } + + float raw_ntc_avg = ntc_sum / valid_count; + + // Compute offset: calibrated = raw + offset → offset = reference - raw + float new_offset = actual_temp_c - raw_ntc_avg; + + MESH_DEBUG_PRINTLN("TC Cal: ref=%.2f NTC_avg=%.2f (%d samples) offset=%.2f", + actual_temp_c, raw_ntc_avg, valid_count, new_offset); + + // Store persistently + if (!setTcCalOffset(new_offset)) { + tcCalOffset = old_offset; // Restore on failure + return -999.0f; + } + + return new_offset; +} + +// Perform NTC temperature calibration using on-board BME280 as reference +// Averages 5 BME280 readings, then delegates to performTcCalibration(float). +float BoardConfigContainer::performTcCalibration(float* bme_temp_out) { + // Average multiple BME280 readings to reduce noise + const int NUM_SAMPLES = 5; + const int SAMPLE_DELAY_MS = 200; + float bme_sum = 0.0f; + int valid_count = 0; + + for (int i = 0; i < NUM_SAMPLES; i++) { + float t = readBmeTemperature(); + if (t <= -900.0f) continue; + bme_sum += t; + valid_count++; + if (i < NUM_SAMPLES - 1) delay(SAMPLE_DELAY_MS); + } + + if (valid_count < 3) { + MESH_DEBUG_PRINTLN("TC Cal: Only %d/%d valid BME readings", valid_count, NUM_SAMPLES); + return -999.0f; + } + + float bme_avg = bme_sum / valid_count; + MESH_DEBUG_PRINTLN("TC Cal: BME avg=%.2f (%d samples)", bme_avg, valid_count); + + if (bme_temp_out) { + *bme_temp_out = bme_avg; + } + + return performTcCalibration(bme_avg); +} + +// Read BME280 temperature directly via I2C (temporary instance, no core code changes) +float BoardConfigContainer::readBmeTemperature() { +#if ENV_INCLUDE_BME280 + Adafruit_BME280 bme; + if (!bme.begin(0x76, &Wire)) { + MESH_DEBUG_PRINTLN("TC Cal: BME280 not found at 0x76"); + return -999.0f; + } + bme.setSampling(Adafruit_BME280::MODE_FORCED, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::SAMPLING_X1, + Adafruit_BME280::FILTER_OFF, + Adafruit_BME280::STANDBY_MS_1000); + if (!bme.takeForcedMeasurement()) { + MESH_DEBUG_PRINTLN("TC Cal: BME280 forced measurement failed"); + return -999.0f; + } + float temp = bme.readTemperature(); + MESH_DEBUG_PRINTLN("TC Cal: BME280 reads %.2f C", temp); + return temp; +#else + MESH_DEBUG_PRINTLN("TC Cal: BME280 not compiled in (ENV_INCLUDE_BME280=0)"); + return -999.0f; +#endif +} + +// Arm INA228 BUVL alert at the chemistry's lowv_sleep_mv threshold. +// Fires → ISR → flag → tickPeriodic() → System Sleep. BAT_UNKNOWN = disabled. +void BoardConfigContainer::armLowVoltageAlert() { + if (!ina228DriverInstance) { + return; + } + + BatteryType bat_type = getBatteryType(); + const BatteryProperties* props = getBatteryProperties(bat_type); + uint16_t sleep_mv = props ? props->lowv_sleep_mv : 0; + + if (bat_type == BAT_UNKNOWN || sleep_mv == 0) { + // No battery configured — disarm alert + ina228DriverInstance->setUnderVoltageAlert(0); + ina228DriverInstance->enableAlert(false, false, false); + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: DISABLED (BAT_UNKNOWN)"); + return; + } + + bool buvl_ok = ina228DriverInstance->setUnderVoltageAlert(sleep_mv); + ina228DriverInstance->enableAlert(true, false, true); // active-LOW, LATCHED + + // Attach ISR on ALERT pin (active-LOW, falling edge) + pinMode(INA_ALERT_PIN, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(INA_ALERT_PIN), lowVoltageAlertISR, FALLING); + + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: ARMED @ %dmV (BUVL write %s)", sleep_mv, buvl_ok ? "OK" : "FAILED"); +} + +void BoardConfigContainer::disarmLowVoltageAlert() { + if (!ina228DriverInstance) { + return; + } + + detachInterrupt(digitalPinToInterrupt(INA_ALERT_PIN)); + ina228DriverInstance->setUnderVoltageAlert(0); + ina228DriverInstance->enableAlert(false, false, false); + lowVoltageAlertFired = false; + MESH_DEBUG_PRINTLN("INA228 Low-V Alert: DISARMED"); +} + +// ISR: falling edge on INA228 ALERT (active-LOW, latched). Just sets the flag; +// tickPeriodic() consumes it and initiates shutdown. +void BoardConfigContainer::lowVoltageAlertISR() { + lowVoltageAlertFired = true; +} + +// Get low-voltage sleep threshold (INA228 ALERT fires at this level) +uint16_t BoardConfigContainer::getLowVoltageSleepThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_sleep_mv : 2000; +} + +// Get low-voltage wake threshold (RTC wake boots if VBAT >= this, 0% SOC marker) +uint16_t BoardConfigContainer::getLowVoltageWakeThreshold(BatteryType type) { + const BatteryProperties* props = getBatteryProperties(type); + return props ? props->lowv_wake_mv : 2200; +} + +// Update battery SOC from INA228 Hardware Coulomb Counter +// Uses INA228 CHARGE register (mAh) for accurate charge tracking +void BoardConfigContainer::updateBatterySOC() { + if (!ina228DriverInstance) { + return; + } + + // Periodic SHUNT_CAL self-heal (~every 5 min via static counter) + // If SHUNT_CAL got wiped (clone chip glitch, I2C error, etc.), + // CURRENT and CHARGE registers read 0 forever → stats stay at 0. + static uint8_t scal_check_counter = 0; + if (++scal_check_counter >= 5) { // Every 5th call = ~5 minutes (called every 60s) + scal_check_counter = 0; + ina228DriverInstance->validateAndRepairShuntCal(); + } + + // Read INA228 Hardware Coulomb Counter (mAh) - TWO'S COMPLEMENT, has correct sign! + // Positive = charging (into battery), Negative = discharging (from battery) + float charge_mah = ina228DriverInstance->readCharge_mAh(); + + uint32_t now_ms = millis(); + socStats.last_soc_update_ms = now_ms; + socStats.soc_update_count++; + + // Update current hour statistics (track charged/discharged charge in mAh) + // This runs ALWAYS, independent of SOC validity + static float last_charge_mah = 0.0f; + static bool first_read = true; + + if (first_read) { + // Initialize baseline on first read, don't count initial value as delta + last_charge_mah = charge_mah; + first_read = false; + } else { + float delta_mah = charge_mah - last_charge_mah; + last_charge_mah = charge_mah; + + // Handle potential counter wrap or reset (ignore huge jumps > 10Ah) + if (delta_mah > 10000.0f || delta_mah < -10000.0f) { + MESH_DEBUG_PRINTLN("SOC: Large charge delta %.0fmAh - ignoring (counter reset?)", delta_mah); + } else { + // CHARGE register inverted in driver: positive delta = charging, negative delta = discharging + if (delta_mah > 0.0f) { + // Charging (positive delta) + socStats.current_hour_charged_mah += delta_mah; + socStats.current_hour_solar_mah += delta_mah; // Assume solar (BQ tracks this) + } else if (delta_mah < 0.0f) { + // Discharging (negative delta) + socStats.current_hour_discharged_mah += (-delta_mah); + } + } + } + socStats.last_charge_reading_mah = charge_mah; // Always update for diagnostics + + // Check if BQ reports charging done → auto-sync + if (bqDriverInstance) { + bq25798_charging_status status = bqDriverInstance->getChargingStatus(); + if (status == BQ25798_CHARGER_STATE_DONE_CHARGING) { + if (!socStats.soc_valid) { + MESH_DEBUG_PRINTLN("SOC: First \"Charging Done\" detected - syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after counter reset to prevent false discharge spike + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + } else if (socStats.current_soc_percent < 99.0f) { + MESH_DEBUG_PRINTLN("SOC: \"Charging Done\" detected - re-syncing to 100%%"); + syncSOCToFull(); + // Re-read CHARGE after counter reset to prevent false discharge spike + last_charge_mah = ina228DriverInstance->readCharge_mAh(); + } + } + } + + // SOC calculation is only valid after first "Charging Done" sync via syncSOCToFull() + if (!socStats.soc_valid) { + return; // Wait for first sync + } + + // Net charge since last baseline reset (using CHARGE register in mAh) + // Driver inverted: positive = charged into battery, negative = discharged from battery + float net_charge_mah = charge_mah - socStats.ina228_baseline_mah; + + // Remaining capacity = Initial capacity + net charge (positive=charged adds, negative=discharged subtracts) + float remaining_mah = socStats.capacity_mah + net_charge_mah; + + // Temperature derating: calculate factor for TTL and display purposes. + // The derating factor is NOT applied to SOC% — SOC% is purely Coulomb-based + // (remaining_mah / capacity_mah) and represents the actual stored charge. + // Derating only affects TTL calculation (extractable capacity) and is shown + // separately in CLI output as "derated SOC%". + + // Temperature source priority: 1) NTC via BQ25798 TS ADC (cached), + // 2) BME280 fallback after >5min of no NTC (covers ts_ignore chemistries). + refreshTempDerating(); + + // Calculate SOC percentage — purely Coulomb-based, NO temperature derating + if (socStats.capacity_mah > 0) { + socStats.current_soc_percent = (remaining_mah / socStats.capacity_mah) * 100.0f; + + // Clamp to 0-100% + if (socStats.current_soc_percent > 100.0f) socStats.current_soc_percent = 100.0f; + if (socStats.current_soc_percent < 0.0f) socStats.current_soc_percent = 0.0f; + } +} + +uint32_t BoardConfigContainer::getRTCTimestamp() { + return getRTCTime(); +} + +// Update hourly battery statistics and advance rolling window +void BoardConfigContainer::updateHourlyStats() { + uint32_t currentTime = getRTCTime(); + + // Calculate hour boundary (align to full hours) + uint32_t currentHour = (currentTime / 3600) * 3600; // Truncate to hour boundary + + // Check if hour has changed + if (socStats.lastHourUpdateTime == 0) { + // First run - initialize + socStats.lastHourUpdateTime = currentHour; + MESH_DEBUG_PRINTLN("SOC: Hourly stats initialized at timestamp %u", currentHour); + return; + } + + uint32_t lastHour = (socStats.lastHourUpdateTime / 3600) * 3600; + + if (currentHour > lastHour) { + // Hour boundary crossed - save current hour stats + MESH_DEBUG_PRINTLN("SOC: Hour changed (%u -> %u) - saving stats: C:%.1f D:%.1f S:%.1f mAh", + lastHour, currentHour, + socStats.current_hour_charged_mah, + socStats.current_hour_discharged_mah, + socStats.current_hour_solar_mah); + + // Move to next hour slot in circular buffer + uint8_t nextIndex = (socStats.currentIndex + 1) % HOURLY_STATS_HOURS; + + // Save completed hour's stats + HourlyBatteryStats& completedHour = socStats.hours[socStats.currentIndex]; + completedHour.timestamp = lastHour; + completedHour.charged_mah = socStats.current_hour_charged_mah; + completedHour.discharged_mah = socStats.current_hour_discharged_mah; + completedHour.solar_mah = socStats.current_hour_solar_mah; + + // Reset accumulators for new hour + socStats.currentIndex = nextIndex; + socStats.current_hour_charged_mah = 0.0f; + socStats.current_hour_discharged_mah = 0.0f; + socStats.current_hour_solar_mah = 0.0f; + socStats.lastHourUpdateTime = currentHour; + + // Recalculate rolling window statistics (24h and 3-day averages) + calculateRollingStats(); + } +} + +// Calculate 24h and 3-day rolling averages from hourly buffer +void BoardConfigContainer::calculateRollingStats() { + // Calculate last 24 hours net balance + float sum_24h_charged = 0.0f; + float sum_24h_discharged = 0.0f; + float sum_24h_solar = 0.0f; + int valid_hours_24h = 0; + + // Sum up last 24 hours (most recent 24 entries) + for (int i = 0; i < 24 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_24h_charged += socStats.hours[idx].charged_mah; + sum_24h_discharged += socStats.hours[idx].discharged_mah; + sum_24h_solar += socStats.hours[idx].solar_mah; + valid_hours_24h++; + } + } + + // Last 24h net: solar - discharged (positive = surplus, negative = deficit) + socStats.last_24h_net_mah = sum_24h_solar - sum_24h_discharged; + socStats.last_24h_charged_mah = sum_24h_charged; + socStats.last_24h_discharged_mah = sum_24h_discharged; + socStats.living_on_battery = (socStats.last_24h_net_mah < 0.0f); + + // Calculate 3-day average daily net (72 hours) + float sum_72h_charged = 0.0f; + float sum_72h_discharged = 0.0f; + float sum_72h_solar = 0.0f; + int valid_hours_72h = 0; + + for (int i = 0; i < 72 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_72h_charged += socStats.hours[idx].charged_mah; + sum_72h_discharged += socStats.hours[idx].discharged_mah; + sum_72h_solar += socStats.hours[idx].solar_mah; + valid_hours_72h++; + } + } + + // Average daily net over 3 days (divide 72h sum by 3) + if (valid_hours_72h >= 24) { // Need at least 24h of data + float net_72h = sum_72h_solar - sum_72h_discharged; + socStats.avg_3day_daily_net_mah = net_72h / 3.0f; // Divide by 3 days + socStats.avg_3day_daily_charged_mah = sum_72h_charged / 3.0f; + socStats.avg_3day_daily_discharged_mah = sum_72h_discharged / 3.0f; + } else { + socStats.avg_3day_daily_net_mah = 0.0f; + socStats.avg_3day_daily_charged_mah = 0.0f; + socStats.avg_3day_daily_discharged_mah = 0.0f; + } + + // Calculate 7-day average daily net (168 hours) + float sum_168h_charged = 0.0f; + float sum_168h_discharged = 0.0f; + float sum_168h_solar = 0.0f; + int valid_hours_168h = 0; + + for (int i = 0; i < 168 && i < HOURLY_STATS_HOURS; i++) { + int idx = (socStats.currentIndex - 1 - i + HOURLY_STATS_HOURS) % HOURLY_STATS_HOURS; + if (socStats.hours[idx].timestamp != 0) { + sum_168h_charged += socStats.hours[idx].charged_mah; + sum_168h_discharged += socStats.hours[idx].discharged_mah; + sum_168h_solar += socStats.hours[idx].solar_mah; + valid_hours_168h++; + } + } + + // Average daily net over 7 days (divide 168h sum by 7) + if (valid_hours_168h >= 24) { // Need at least 24h of data + float net_168h = sum_168h_solar - sum_168h_discharged; + socStats.avg_7day_daily_net_mah = net_168h / 7.0f; // Divide by 7 days + socStats.avg_7day_daily_charged_mah = sum_168h_charged / 7.0f; + socStats.avg_7day_daily_discharged_mah = sum_168h_discharged / 7.0f; + } else { + socStats.avg_7day_daily_net_mah = 0.0f; + socStats.avg_7day_daily_charged_mah = 0.0f; + socStats.avg_7day_daily_discharged_mah = 0.0f; + } + + MESH_DEBUG_PRINTLN("SOC: Rolling stats - 24h net: %+.1fmAh, 3d avg: %+.1fmAh/day, 7d avg: %+.1fmAh/day", + socStats.last_24h_net_mah, socStats.avg_3day_daily_net_mah, socStats.avg_7day_daily_net_mah); + + // Calculate TTL + calculateTTL(); +} + +// Calculate Time To Live (hours until battery empty). +// TTL is based on the 7-day rolling average of daily net energy consumption +// (avg_7day_daily_net_mah), computed from a 168-hour ring buffer of hourly +// INA228 Coulomb-counter measurements (charged/discharged/solar mAh). +// +// Data flow: +// 1. INA228 hardware Coulomb counter measures charge flow continuously (24-bit ADC) +// 2. updateHourlyStats() samples the counter every hour, storing per-hour deltas +// (charged_mah, discharged_mah, solar_mah) in the hours[168] ring buffer +// 3. calculateRollingStats() sums the last 168 hours and divides by 7 to get +// avg_7day_daily_net_mah (= solar - discharged per day) +// 4. This method extrapolates: remaining_mah / deficit_per_day * 24 = TTL hours +// +// Preconditions for TTL > 0: +// - living_on_battery == true (24h net is negative, i.e. energy deficit) +// - avg_7day_daily_net_mah < 0 (7-day average shows net discharge) +// - capacity_mah > 0 (battery capacity is known) +// - at least 24 hours of valid hourly data exist in the ring buffer +// +// When the device is solar-powered with energy surplus (net >= 0), TTL is 0 +// and callers interpret this as "infinite" via the living_on_battery flag. +void BoardConfigContainer::calculateTTL() { + if (!socStats.living_on_battery || socStats.avg_7day_daily_net_mah >= 0) { + socStats.ttl_hours = 0; // Not draining or charging + return; + } + + if (socStats.capacity_mah <= 0) { + socStats.ttl_hours = 0; // Capacity unknown + return; + } + + // Trapped Charge model: cold temperatures "lock" the bottom of the discharge + // curve — the cell shuts down (OCV near cutoff + TX-peak IR-drop) while charge + // is still physically stored. trapped_mah is the unusable floor. + // trapped_mah = capacity × (1 − f(T)) e.g. 8000 × 0.17 = 1360 mAh at −10 °C + // extractable = max(0, remaining − trapped) + // This is more realistic than proportional scaling (remaining × f) because + // capacity loss at cold is not uniform — it steals from the bottom. + float remaining_mah = (socStats.current_soc_percent / 100.0f) * socStats.capacity_mah; + float trapped_mah = socStats.capacity_mah * (1.0f - socStats.temp_derating_factor); + float extractable_mah = remaining_mah - trapped_mah; + if (extractable_mah < 0.0f) extractable_mah = 0.0f; + + // Daily deficit (negative value) + float deficit_per_day = -socStats.avg_7day_daily_net_mah; + + if (deficit_per_day <= 0) { + socStats.ttl_hours = 0; + return; + } + + // Days until empty (based on extractable capacity) + float days_remaining = extractable_mah / deficit_per_day; + + // Convert to hours + socStats.ttl_hours = (uint16_t)(days_remaining * 24.0f); + + MESH_DEBUG_PRINTLN("TTL: %.1f days (%.0f mAh stored, %.0f trapped, %.0f extractable @d=%.2f, -%.0f mAh/day)", + days_remaining, remaining_mah, trapped_mah, extractable_mah, + socStats.temp_derating_factor, deficit_per_day); +} + +// ===== Tick-based Periodic Dispatch ===== + +// Called from InheroMr2Board::tick() — dispatches all periodic I2C work with +// millis()-based scheduling in the main loop context. +// Also checks the ISR-set lowVoltageAlertFired flag for immediate shutdown. +void BoardConfigContainer::tickPeriodic() { + // First-call init: clear MPPT stats + if (!tickInitialized) { + memset(&mpptStats, 0, sizeof(mpptStats)); + tickInitialized = true; + } + + // Check low-voltage alert flag (set by INA228 ALERT ISR) + if (lowVoltageAlertFired) { + MESH_DEBUG_PRINTLN("PWRMGT: Low-voltage alert fired - initiating System Sleep"); + blinkRed(1, 100, 100, leds_enabled); + blinkRed(3, 300, 300, leds_enabled); + + NRF_POWER->GPREGRET2 |= GPREGRET2_LOW_VOLTAGE_SLEEP; + board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE); + // Never returns + } + + uint32_t now = millis(); + + // Every ~60s: MPPT cycle (solar charging control) + if (now - lastMpptMs >= SOLAR_MPPT_INTERVAL_MS) { + lastMpptMs = now; + runMpptCycle(); + } + + // Every ~60s: SOC update from Coulomb Counter + if (now - lastSocMs >= 60000UL) { + lastSocMs = now; + updateBatterySOC(); + } + + // Every ~60 min: hourly statistics + if (now - lastHourlyMs >= 3600000UL) { + lastHourlyMs = now; + MESH_DEBUG_PRINTLN("SOC: 60 minutes elapsed - updating hourly stats"); + updateHourlyStats(); + } +} + +// ===== Helper Functions ===== + +// Trim whitespace from string +char* BoardConfigContainer::trim(char* str) { + char* end; + + while (isspace((unsigned char)*str)) + str++; + + if (*str == 0) { + return str; + } + + end = str + strlen(str) - 1; + + while (end > str && isspace((unsigned char)*end)) + end--; + + *(end + 1) = 0; + + return str; +} + +// Convert command string to battery type enum +BoardConfigContainer::BatteryType BoardConfigContainer::getBatteryTypeFromCommandString(const char* cmdStr) { + for (const auto& entry : bat_map) { + if (entry.command_string == nullptr) break; + if (strcmp(entry.command_string, cmdStr) == 0) { + return entry.type; + } + } + return BatteryType::BAT_UNKNOWN; +} + +// Get battery properties for a given battery type +const BoardConfigContainer::BatteryProperties* BoardConfigContainer::getBatteryProperties(BatteryType type) { + for (const auto& props : battery_properties) { + if (props.type == type) { + return &props; + } + } + return nullptr; // Should never happen if battery_properties is complete +} + +// Temperature derating factor (0..1) — extractable-capacity scaling at cold temps. +// Linear model: f(T)=1 for T>=T_ref, else max(f_min, 1 - k*(T_ref-T)). Used only +// for TTL/display, never for SOC% (SOC is purely Coulomb-based). +float BoardConfigContainer::getTemperatureDerating(const BatteryProperties* props, float temp_c) { + if (!props) return 1.0f; + if (temp_c >= props->temp_ref_c) return 1.0f; + + float delta = props->temp_ref_c - temp_c; + float factor = 1.0f - props->temp_derating_k * delta; + if (factor < props->temp_derating_min) factor = props->temp_derating_min; + return factor; +} + +// Convert battery type enum to command string +const char* BoardConfigContainer::getBatteryTypeCommandString(BatteryType type) { + for (const auto& entry : bat_map) { + if (entry.command_string == nullptr) break; + if (entry.type == type) { + return entry.command_string; + } + } + return "unknown"; +} + +// Convert frost charge behaviour enum to command string +const char* BoardConfigContainer::getFrostChargeBehaviourCommandString(FrostChargeBehaviour type) { + for (const auto& entry : frostchargebehaviour_map) { + if (entry.command_string == nullptr) break; + if (entry.type == type) { + return entry.command_string; + } + } + return "unknown"; +} + +// Convert command string to frost charge behaviour enum +BoardConfigContainer::FrostChargeBehaviour BoardConfigContainer::getFrostChargeBehaviourFromCommandString(const char* cmdStr) { + for (const auto& entry : frostchargebehaviour_map) { + if (entry.command_string == nullptr) break; + if (strcmp(entry.command_string, cmdStr) == 0) { + return entry.type; + } + } + return FrostChargeBehaviour::REDUCE_UNKNOWN; +} + +// Get available frost charge behaviour option strings +const char* BoardConfigContainer::getAvailableFrostChargeBehaviourOptions() { + static char buffer[64]; + + if (buffer[0] != '\0') return buffer; + + buffer[0] = '\0'; + + for (const auto& entry : frostchargebehaviour_map) { + if (entry.command_string == nullptr) break; + + size_t space_needed = strlen(buffer) + 1 + strlen(entry.command_string) + 1; + + if (space_needed >= sizeof(buffer)) { + break; + } + + if (buffer[0] != '\0') { + strcat(buffer, "|"); + } + strcat(buffer, entry.command_string); + } + + return buffer; +} + +// Get available battery type option strings +const char* BoardConfigContainer::getAvailableBatOptions() { + static char buffer[64]; + + if (buffer[0] != '\0') return buffer; + + buffer[0] = '\0'; + + for (const auto& entry : bat_map) { + if (entry.command_string == nullptr) break; + + size_t space_needed = strlen(buffer) + 1 + strlen(entry.command_string) + 1; + + if (space_needed >= sizeof(buffer)) { + break; + } + + if (buffer[0] != '\0') { + strcat(buffer, "|"); + } + strcat(buffer, entry.command_string); + } + + return buffer; +} diff --git a/variants/inhero_mr2/BoardConfigContainer.h b/variants/inhero_mr2/BoardConfigContainer.h new file mode 100644 index 0000000000..dadd503e6c --- /dev/null +++ b/variants/inhero_mr2/BoardConfigContainer.h @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once +#include "lib/BqDriver.h" +#include "lib/Ina228Driver.h" + +#include <Arduino.h> + +#define SOLAR_MPPT_INTERVAL_MS (1 * 60 * 1000) // 1 minute + +#define MPPT_STATS_HOURS 168 // 7 days + +typedef struct { + uint8_t mpptEnabledMinutes; // 0–60 + uint32_t timestamp; // unix seconds + uint32_t harvestedEnergy_mWh; +} MpptHourlyStats; + +typedef struct { + MpptHourlyStats hours[MPPT_STATS_HOURS]; + uint8_t currentIndex; + uint32_t lastUpdateTime; // unix seconds, or millis if RTC unavailable + uint16_t currentHourMinutes; + bool usingRTC; + uint32_t currentHourEnergy_mWh; + int32_t lastPower_mW; +} MpptStatistics; + +// Battery SOC: mAh-based, uses INA228 hardware coulomb counter (CHARGE register) +#define HOURLY_STATS_HOURS 168 // 7 days + +typedef struct { + uint32_t timestamp; // start of hour, unix seconds + float charged_mah; + float discharged_mah; + float solar_mah; +} HourlyBatteryStats; + +typedef struct { + // Battery configuration + float capacity_mah; + float nominal_voltage; + + // SOC tracking via INA228 CHARGE register + float current_soc_percent; // 0–100 + bool soc_valid; // true after first "Charging Done" sync + float ina228_baseline_mah; // INA228 CHARGE at last 100% sync + + uint32_t last_soc_update_ms; + + // 168-hour rolling buffer + HourlyBatteryStats hours[HOURLY_STATS_HOURS]; + uint8_t currentIndex; + uint32_t lastHourUpdateTime; + + // Current hour accumulators + float current_hour_charged_mah; + float current_hour_discharged_mah; + float current_hour_solar_mah; + float last_charge_reading_mah; // last INA228 CHARGE for delta calc + + // Rolling window stats (computed from hourly buffer) + float last_24h_net_mah; + float last_24h_charged_mah; + float last_24h_discharged_mah; + float avg_3day_daily_net_mah; + float avg_3day_daily_charged_mah; + float avg_3day_daily_discharged_mah; + float avg_7day_daily_net_mah; + float avg_7day_daily_charged_mah; + float avg_7day_daily_discharged_mah; + // TTL: hours until battery empty (0 = not calculated). Based on 7-day rolling + // avg of daily net deficit from INA228 coulomb-counter samples. + uint16_t ttl_hours; + bool living_on_battery; // net deficit over last 24h + uint16_t soc_update_count; + float temp_derating_factor; // 0.0–1.0 + float last_battery_temp_c; +} BatterySOCStats; + +class BoardConfigContainer { + +public: + enum BatteryType : uint8_t { BAT_UNKNOWN = 0, LTO_2S = 1, LIFEPO4_1S = 2, LIION_1S = 3, NAION_1S = 4 }; + typedef struct { + const char* command_string; + BatteryType type; + } BatteryMapping; + + // Battery type properties + typedef struct { + BatteryType type; + float charge_voltage; + float nominal_voltage; + uint16_t lowv_sleep_mv; // INA228 ALERT → System Sleep + uint16_t lowv_wake_mv; // 0% SOC marker, RTC wake decision + bool charge_enable; + bool ts_ignore; // disables JEITA temperature monitoring + // Capacity derating at cold temps. Calibrated for ~0.01C avg / ~0.05C TX + // peak loads on 2–8 Ah cells (much milder than datasheet 0.2C–0.5C values). + // f(T) = 1.0 for T >= temp_ref_c + // f(T) = max(temp_derating_min, 1 - k*(Tref - T)) for T < temp_ref_c + float temp_derating_k; + float temp_derating_min; + float temp_ref_c; + } BatteryProperties; + + static inline constexpr BatteryProperties battery_properties[] = { + // Type ChgV NomV SleepMv WakeMv ChgEn TsIgn k min Tref + { BAT_UNKNOWN, 0.0f, 0.0f, 2000, 2200, false, true, 0.000f, 1.00f, 25.0f }, + { LTO_2S, 5.4f, 4.6f, 3900, 4100, true, true, 0.002f, 0.88f, 25.0f }, + { LIFEPO4_1S, 3.5f, 3.2f, 2700, 2900, true, false, 0.006f, 0.70f, 25.0f }, + { LIION_1S, 4.1f, 3.7f, 3100, 3300, true, false, 0.005f, 0.75f, 25.0f }, + { NAION_1S, 3.9f, 3.1f, 2500, 2700, true, true, 0.003f, 0.85f, 25.0f } + }; + + static inline constexpr BatteryMapping bat_map[] = { { "lto2s", LTO_2S }, + { "lifepo1s", LIFEPO4_1S }, + { "liion1s", LIION_1S }, + { "naion1s", NAION_1S }, + { "none", BAT_UNKNOWN }, + { nullptr, BAT_UNKNOWN } }; + + enum FrostChargeBehaviour : uint8_t { + NO_CHARGE = 4, + I_REDUCE_TO_20 = 3, + I_REDUCE_TO_40 = 2, + NO_REDUCE = 1, + REDUCE_UNKNOWN = 0 + }; + typedef struct { + const char* command_string; + FrostChargeBehaviour type; + } FrostChargeBehaviourMapping; + + static inline constexpr FrostChargeBehaviourMapping frostchargebehaviour_map[] = { + { "0%", NO_CHARGE }, + { "20%", I_REDUCE_TO_20 }, + { "40%", I_REDUCE_TO_40 }, + { "100%", NO_REDUCE }, + { nullptr, REDUCE_UNKNOWN } + }; + + // Defaults for newly flashed boards + static constexpr BatteryType DEFAULT_BATTERY_TYPE = BAT_UNKNOWN; + static constexpr FrostChargeBehaviour DEFAULT_FROST_BEHAVIOUR = NO_CHARGE; + static constexpr uint16_t DEFAULT_MAX_CHARGE_CURRENT_MA = 200; + static constexpr bool DEFAULT_MPPT_ENABLED = false; + + // IINDPM = 1.2 × (V_charge × I_charge) / V_panel_assumed. + // Prevents weak panels from tripping POORSRC after PG qualification. + static constexpr float IINDPM_MAX_A = 2.0f; // JST connector limit + static constexpr float IINDPM_USB_A = 0.5f; // USB 2.0 max + static constexpr float IINDPM_PANEL_V = 4.0f; + static constexpr float IINDPM_MARGIN = 1.2f; + + // If PG=0 but VBUS >= this, toggle HIZ to force input re-qualification. + static constexpr uint16_t PG_STUCK_VBUS_THRESHOLD_MV = 4500; + + static BatteryType getBatteryTypeFromCommandString(const char* cmdStr); + static char* trim(char* str); + static const char* getBatteryTypeCommandString(BatteryType type); + static const char* getFrostChargeBehaviourCommandString(FrostChargeBehaviour type); + static FrostChargeBehaviour getFrostChargeBehaviourFromCommandString(const char* cmdStr); + static const char* getAvailableFrostChargeBehaviourOptions(); + static const char* getAvailableBatOptions(); + static const BatteryProperties* getBatteryProperties(BatteryType type); + + // Returns 0.0–1.0; reduces SOC% and TTL at cold temps. Coulomb counter unaffected. + static float getTemperatureDerating(const BatteryProperties* props, float temp_c); + + static void heartbeatTask(void* pvParameters); + + // Re-enable MPPT if BQ disabled it (when PG=1). + static void checkAndFixSolarLogic(); + + static bool loadMpptEnabled(bool& enabled); + void tickPeriodic(); // periodic I2C work (MPPT, SOC, hourly stats) + static void stopBackgroundTasks(); + + bool setBatteryType(BatteryType type); + + BatteryType getBatteryType() const; + + bool setFrostChargeBehaviour(FrostChargeBehaviour behaviour); + FrostChargeBehaviour getFrostChargeBehaviour() const; + + bool setMaxChargeCurrent_mA(uint16_t maxChrgI); + uint16_t getMaxChargeCurrent_mA() const; + + // Caps IINDPM to 500mA when USB is the input source. + static void setUsbConnected(bool connected); + static bool isUsbConnected() { return usbInputActive; } + static float calculateSolarIINDPM(); + static void updateSolarIINDPM(); + + bool getMPPTEnabled() const; + bool setMPPTEnable(bool enableMPPT); + + float getMaxChargeVoltage() const; + + bool begin(); + + // INA228 for VBAT/IBAT, BQ25798 for solar. + const Telemetry* getTelemetryData(); + + const char* getChargeCurrentAsStr(); + void getChargerInfo(char* buffer, uint32_t bufferSize); + void getBqDiagnostics(char* buffer, uint32_t bufferSize); + + // "INA:OK BQ:OK RTC:OK BME:OK". RTC probe writes/reads user-RAM to catch + // zombie chips that ACK but don't persist. + void getSelfTest(char* buffer, uint32_t bufferSize); + + // Address ACK + user-RAM write/readback verify (bytes 0x1F, 0x20 are scratch). + static bool probeRtc(); + + float getMpptEnabledPercentage7Day() const; + + // Battery SOC & coulomb counter + float getStateOfCharge() const; + float getBatteryCapacity() const; + bool setBatteryCapacity(float capacity_mah); + bool isBatteryCapacitySet() const; + uint16_t getTTL_Hours() const; + bool isLivingOnBattery() const; + // Sync SOC to 100% after "Charging Done". + static void syncSOCToFull(); + static bool setSOCManually(float soc_percent); + const BatterySOCStats* getSOCStats() const { return &socStats; } + const MpptStatistics* getMpptStats() const { return &mpptStats; } + static void updateBatterySOC(); + static uint32_t getRTCTimestamp(); + + static float getNominalVoltage(BatteryType type); + void setLowVoltageRecovery() { lowVoltageRecovery = true; } + Ina228Driver* getIna228Driver(); + + // NTC calibration via BME280 reference + bool setTcCalOffset(float offset_c); + float getTcCalOffset() const; + float performTcCalibration(float* bme_temp_out = nullptr); + static float readBmeTemperature(); + + // INA228 ALERT on P1.02 (Rev 1.1) + void armLowVoltageAlert(); + static void disarmLowVoltageAlert(); + static void lowVoltageAlertISR(); + + static uint16_t getLowVoltageSleepThreshold(BatteryType type); + static uint16_t getLowVoltageWakeThreshold(BatteryType type); + + // 600s timeout; nRF52 WDT cannot truly be disabled. + static void setupWatchdog(); + static void feedWatchdog(); + static void disableWatchdog(); + + bool setLEDsEnabled(bool enabled); + bool getLEDsEnabled() const; + +private: + static BqDriver* bqDriverInstance; + static Ina228Driver* ina228DriverInstance; + static TaskHandle_t heartbeatTaskHandle; + static volatile bool lowVoltageAlertFired; // INA228 ALERT fired, checked in tickPeriodic + + // Tick scheduling (millis-based, overflow-safe) + uint32_t lastMpptMs = 0; + uint32_t lastSocMs = 0; + uint32_t lastHourlyMs = 0; // Last updateHourlyStats() execution + bool tickInitialized = false; // First-call init flag for MPPT stats + + void runMpptCycle(); // Single MPPT cycle + static MpptStatistics mpptStats; // MPPT statistics data + static BatterySOCStats socStats; // Battery SOC statistics + // Cached battery type for static methods (set by begin()/setBatteryType()) + static BatteryType cachedBatteryType; + + bool bqInitialized = false; + bool ina228Initialized = false; + bool lowVoltageRecovery = false; // Set in begin() if booting from low-voltage sleep (GPREGRET2) + static bool leds_enabled; // Heartbeat and BQ stat LED control (static for ISR access) + static bool usbInputActive; // True when USB VBUS detected — caps IINDPM to 500mA + static float tcCalOffset; // NTC temperature calibration offset in °C (0.0 = no calibration) + // Last valid battery temperature in °C, updated by getTelemetryData() or + // BME280 fallback (default 25.0 = no derating) + static float lastValidBatteryTemp; + static uint32_t lastTempUpdateMs; // millis() of last valid temperature update (0 = never updated) + + // Refresh socStats.temp_derating_factor and last_battery_temp_c. + // Falls back to BME280 if NTC has not updated for >5 min. + static void refreshTempDerating(); + + bool configureBaseBQ(); + bool configureChemistry(BatteryType type); + float performTcCalibration(float actual_temp_c); // Internal: calibrate NTC given reference temp (called by BME auto-cal) + static constexpr const char* PREFS_NAMESPACE = "inheromr2"; + static constexpr const char* BATTKEY = "batType"; + static constexpr const char* FROSTKEY = "frost"; + static constexpr const char* MAXCHARGECURRENTKEY = "maxChrg"; + static constexpr const char* MPPTENABLEKEY = "mpptEn"; + static constexpr const char* LEDSKEY = "leds_en"; + static constexpr const char* BATTERY_CAPACITY_KEY = "batCap"; + static constexpr const char* TCCAL_KEY = "tcCal"; // NTC temperature calibration offset + + bool loadBatType(BatteryType& type) const; + bool loadFrost(FrostChargeBehaviour& behaviour) const; + bool loadMaxChrgI(uint16_t& maxCharge_mA) const; + bool loadBatteryCapacity(float& capacity_mah) const; + bool loadTcCalOffset(float& offset) const; // NTC temperature calibration + + // MPPT Statistics helper + static void updateMpptStats(); + + // Battery SOC helpers + static void updateHourlyStats(); // Update hourly statistics (called every 60 minutes) + static void calculateRollingStats(); // Calculate 24h and 3-day averages from rolling buffer + static void calculateTTL(); // Calculate TTL from 7-day avg net deficit and remaining SOC capacity +}; \ No newline at end of file diff --git a/variants/inhero_mr2/InheroMr2Board.cpp b/variants/inhero_mr2/InheroMr2Board.cpp new file mode 100644 index 0000000000..8a2a8d7c8e --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.cpp @@ -0,0 +1,549 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * Inhero MR-2 Board Implementation + */ + +#include "InheroMr2Board.h" + +#include "BoardConfigContainer.h" +#include "helpers/BatteryOcvMapping.h" +#include "helpers/BqLowPowerSetup.h" +#include "helpers/CliCommands.h" +#include "helpers/I2cBusRecovery.h" +#include "helpers/Rv3028Wake.h" +#include "helpers/SystemSleepGpio.h" +#include "helpers/UsbAutoManagement.h" +#include "target.h" + +#include <Arduino.h> +#include <Wire.h> +#include <nrf_soc.h> + +static BoardConfigContainer boardConfig; +volatile bool InheroMr2Board::rtc_irq_pending = false; +volatile uint32_t InheroMr2Board::ota_dfu_reset_at = 0; + +// ===== Public Methods ===== + +void InheroMr2Board::begin() { + // === FAST PATH: RTC wake from low-voltage sleep === + // Check GPREGRET2 FIRST — before ANY GPIO setup. + // Note: System Sleep wake triggers a System-ON reset. The bootloader runs before our code, + // and the reset clears all PIN_CNF to Input/Disconnect defaults. BSP init() only does + // OUTSET=0xFFFFFFFF which has no physical effect on Input-configured pins. + // Therefore we MUST explicitly re-assert any GPIO we need (CE, etc.) in this path. + uint8_t shutdown_reason = NRF_POWER->GPREGRET2; + + if ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE) { + // Minimal I2C setup — only thing we need +#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + Wire.begin(); + delay(10); + + // SYSTEMOFF wake is a reset, so the FALLING-edge ISR never sees the RTC event. + // Clear TF here before we arm RTC_INT pull-up + SENSE again. + inhero::clearTimerFlag(); + + // RTC INT: must have SENSE_Low for System Sleep wake-up + NRF_GPIO->PIN_CNF[RTC_INT_PIN] = + (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Connect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Pullup << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Low << GPIO_PIN_CNF_SENSE_Pos); + + uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR); + uint16_t wake_threshold = getLowVoltageWakeThreshold(); + + MESH_DEBUG_PRINTLN("LV-Wake: VBAT=%dmV, wake=%dmV", vbat_mv, wake_threshold); + + if (vbat_mv == 0 || vbat_mv < wake_threshold) { + // Still too low or read failed — go back to sleep immediately. + // INA228 ADC needs shutdown (readVBATDirect left it in one-shot mode). + + // BQ CE pin: The System-ON reset after System Sleep wake resets all PIN_CNF + // to Input/Disconnect defaults. The previous cycle's OUTPUT latch is lost. + // Must explicitly re-assert OUTPUT HIGH so solar charging stays active. +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, HIGH); + MESH_DEBUG_PRINTLN("LV-Wake: CE re-latched HIGH (solar charging active)"); +#endif + + // Put INA228 + BQ25798 into a state that draws minimal current during System Sleep. + inhero::prepareIcsForSystemOff(); + + // SX1262: Send SetSleep command AND latch NSS HIGH. + // After System-ON reset, SX1262 may be in Standby RC (~600µA). + // Both are needed: SetSleep puts it to Cold Sleep, NSS latch prevents re-wake. + inhero::prepareRadioForSystemOff(false); + + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + Wire.end(); + + // Disconnect GPIO pull-ups before System Sleep (Wire.end() keeps SDA/SCL + // pull-ups active on nRF52 — each held-LOW line wastes ~250µA). + inhero::disconnectLeakyPullups(); + + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + sd_power_system_off(); + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); + } + + // Voltage recovered — close I2C and fall through to normal boot + Wire.end(); + + // Recovery LED flash + pinMode(LED_BLUE, OUTPUT); + for (int i = 0; i < 3; i++) { + digitalWrite(LED_BLUE, HIGH); + delay(150); + digitalWrite(LED_BLUE, LOW); + delay(150); + } + + NRF_POWER->GPREGRET2 = SHUTDOWN_REASON_NONE; + // setLowVoltageRecovery + setSOCManually deferred to after boardConfig.begin() + MESH_DEBUG_PRINTLN("LV-Wake: Voltage recovered (%dmV >= %dmV) - normal boot", vbat_mv, wake_threshold); + } + + // === Standard boot path (ColdBoot, recovery, or non-LV wake) === + bool isLowVoltageRecovery = ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE); + + pinMode(PIN_VBAT_READ, INPUT); + + // BQ25798 CE: drive LOW on boot so the external FET stays OFF (Rev 1.1 inverts + // logic via DMN2004TK-7). configureChemistry() raises it after successful I2C init. +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, LOW); +#endif + + // PE4259 RF switch VDD (P1.05 → PE4259 pin 6). Required for TX/RX; DIO2 drives CTRL. + pinMode(SX126X_POWER_EN, OUTPUT); + digitalWrite(SX126X_POWER_EN, HIGH); + delay(10); // Give PE4259 time to power up + +#ifdef PIN_USER_BTN + pinMode(PIN_USER_BTN, INPUT_PULLUP); +#endif + +#ifdef PIN_USER_BTN_ANA + pinMode(PIN_USER_BTN_ANA, INPUT_PULLUP); +#endif + +#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + + // === I2C Bus Recovery === + // After OTA/warm-reset, a slave may hold SDA low (stuck mid-transaction). + inhero::recoverI2cBus(PIN_BOARD_SDA, PIN_BOARD_SCL); + + Wire.begin(); + delay(50); // Give I2C bus time to stabilize + + MESH_DEBUG_PRINTLN("Inhero MR2 - Hardware Rev 1.1 (INA228 ALERT + RTC + CE-FET)"); + + // === CRITICAL: Configure RTC INT pin for wake-up from System Sleep === + // attachInterrupt() alone is NOT sufficient for System Sleep wake-up! + // We MUST configure the pin with SENSE for nRF52 SYSTEMOFF wake capability + pinMode(RTC_INT_PIN, INPUT_PULLUP); + + // Configure GPIO SENSE for wake-up from System Sleep (nRF52 SYSTEMOFF mode) + // This is essential - without SENSE configuration, System Sleep wake-up will not work + NRF_GPIO->PIN_CNF[RTC_INT_PIN] = + (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Connect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Pullup << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Low << GPIO_PIN_CNF_SENSE_Pos); // Wake on LOW (RTC interrupt is active-low) + + attachInterrupt(digitalPinToInterrupt(RTC_INT_PIN), rtcInterruptHandler, FALLING); + + // === Early Boot Voltage Check (ColdBoot only) === + // LV-wake resleep is handled by the fast path above. + // This section handles ColdBoot below sleep threshold and normal ColdBoot. + + if (!isLowVoltageRecovery) { + MESH_DEBUG_PRINTLN("Early Boot: Reading VBAT from INA228 @ 0x40..."); + uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR); + MESH_DEBUG_PRINTLN("Early Boot: readVBATDirect returned %dmV", vbat_mv); + + if (vbat_mv == 0) { + MESH_DEBUG_PRINTLN("Early Boot: Failed to read battery voltage, assuming OK"); + } else { + BoardConfigContainer::BatteryType bootBatType = boardConfig.getBatteryType(); + uint16_t wake_threshold = getLowVoltageWakeThreshold(); + uint16_t sleep_threshold = getLowVoltageSleepThreshold(); + + MESH_DEBUG_PRINTLN("Early Boot Check: VBAT=%dmV, Wake=%dmV (0%% SOC), Sleep=%dmV, Reason=0x%02X", + vbat_mv, wake_threshold, sleep_threshold, shutdown_reason); + + if (bootBatType == BoardConfigContainer::BAT_UNKNOWN) { + MESH_DEBUG_PRINTLN("Early Boot: BAT_UNKNOWN - skipping low-voltage check (configure battery type first)"); + if ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE || + (shutdown_reason & GPREGRET2_LOW_VOLTAGE_SLEEP)) { + NRF_POWER->GPREGRET2 = SHUTDOWN_REASON_NONE; + MESH_DEBUG_PRINTLN("Early Boot: Cleared stale GPREGRET2 flags (was 0x%02X)", shutdown_reason); + } + } + // ColdBoot with voltage below sleep threshold — first entry into LV sleep + else if (vbat_mv < sleep_threshold) { + MESH_DEBUG_PRINTLN("ColdBoot below sleep threshold (%dmV < %dmV)", vbat_mv, sleep_threshold); + MESH_DEBUG_PRINTLN("Going to sleep for %d min to avoid motorboating", LOW_VOLTAGE_SLEEP_MINUTES); + + delay(100); + inhero::prepareRadioForSystemOff(false); + + // INA228 → Shutdown mode with readback verification + for (int retry = 0; retry < 3; retry++) { + Wire.beginTransmission(INA228_I2C_ADDR); + Wire.write(0x01); // ADC_CONFIG register + Wire.write(0x00); // Shutdown (MSB) + Wire.write(0x00); // (LSB) + if (Wire.endTransmission() != 0) { + delay(10); + continue; + } + delay(2); + Wire.beginTransmission(INA228_I2C_ADDR); + Wire.write(0x01); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)INA228_I2C_ADDR, (uint8_t)2); + uint16_t rb = 0; + if (Wire.available() >= 2) { + rb = (Wire.read() << 8) | Wire.read(); + } + if ((rb & 0xF000) == 0x0000) break; + delay(10); + } + + // Read DIAG_ALRT to clear any latched alert flag + Wire.beginTransmission(INA228_I2C_ADDR); + Wire.write(0x0B); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)INA228_I2C_ADDR, (uint8_t)2); + while (Wire.available()) Wire.read(); + + // Latch BQ CE pin HIGH (solar charging active in sleep) +#ifdef BQ_CE_PIN + digitalWrite(BQ_CE_PIN, HIGH); +#endif + + // BQ25798 — Disable ADC (saves ~500µA continuous draw) + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(0x2E); // ADC_CONTROL + Wire.write(0x00); // ADC_EN=0 + Wire.endTransmission(); + + // BQ25798 — Mask all interrupts + clear flags to de-assert INT + { + const uint8_t mask_regs[] = {0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D}; + for (uint8_t r : mask_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.write(0xFF); + Wire.endTransmission(); + } + const uint8_t flag_regs[] = {0x22, 0x23, 0x24, 0x25, 0x26, 0x27}; + for (uint8_t r : flag_regs) { + Wire.beginTransmission(BQ25798_I2C_ADDR); + Wire.write(r); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)BQ25798_I2C_ADDR, (uint8_t)1); + while (Wire.available()) Wire.read(); + } + } + + // BME280 — Force Sleep mode + Wire.beginTransmission(BME280_I2C_ADDR); + Wire.write(0xF4); // ctrl_meas + Wire.write(0x00); // Sleep mode + Wire.endTransmission(); + + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + + Wire.end(); + inhero::disconnectLeakyPullups(); + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + + sd_power_system_off(); + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); + } + // Normal ColdBoot — voltage OK + else { + MESH_DEBUG_PRINTLN("Normal ColdBoot - voltage OK (%dmV >= %dmV)", vbat_mv, sleep_threshold); + } + } + } + + // === Normal boot path: Initialize board hardware === + // Only reached when voltage is OK (or unreadable) — resleep paths exit above. + // boardConfig.begin() initializes BQ25798, INA228, CE pin, alerts, LEDs, etc. + MESH_DEBUG_PRINTLN("Initializing Rev 1.1 features (BQ25798, INA228, RTC, CE-FET)"); + boardConfig.begin(); + + // Handle low-voltage recovery (deferred until after boardConfig.begin()) + if (isLowVoltageRecovery) { + boardConfig.setLowVoltageRecovery(); + BoardConfigContainer::setSOCManually(0.0f); + MESH_DEBUG_PRINTLN("SOC: Set to 0%% (low-voltage recovery)"); + } + + // Enable DC/DC REG1 (VDD 3.3V → 1.3V core, ~1.5mA saving). REG0 not needed — + // RAK4630 is powered from TPS62840 VDD, not VBUS. Done after peripheral init. + NRF52BoardDCDC::begin(); + + // LEDs already initialized in boardConfig.begin() + // Blue LED was used for boot sequence visualization + // Red LED indicates missing components (if blinking) + + // Start hardware watchdog (600s timeout) + // Must be last - after all initializations are complete + BoardConfigContainer::setupWatchdog(); + + // Set initial USB IINDPM limit based on VBUS state at boot + if (inhero::isUsbPowered()) { + BoardConfigContainer::setUsbConnected(true); + } +} + +void InheroMr2Board::tick() { + inhero::serviceUsbAutoManagement(); + + // Deferred OTA DFU reset: wait for CLI reply to be sent, then enter bootloader + if (ota_dfu_reset_at != 0 && millis() >= ota_dfu_reset_at) { + enterOTADfu(); // disables SoftDevice & interrupts, sets GPREGRET, resets — does not return + } + + if (rtc_irq_pending) { + rtc_irq_pending = false; + + // Clear TF here (not in ISR) to avoid I2C bus collisions with core RTC access. + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.endTransmission(false); + Wire.requestFrom(RTC_I2C_ADDR, (uint8_t)1); + + if (Wire.available()) { + uint8_t status = Wire.read(); + status &= ~(1 << 3); // Clear TF bit (bit 3) + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(status); + Wire.endTransmission(); + } + } + + // Dispatch all periodic I2C work (MPPT, SOC, hourly stats, low-V alert check) + boardConfig.tickPeriodic(); + + // All healthy — feed watchdog at the END (after I2C operations completed successfully) + BoardConfigContainer::feedWatchdog(); + + // Briefly idle via WFE until next interrupt (radio DIO1, SysTick, USB, I2C). + // Typically wakes within 1ms. Reduces CPU current from ~3mA (busy-loop) to ~0.5-0.8mA. + // Harmless when powersaving also calls sleep() — on nRF52 both are just WFE. + sleep(0); +} + +uint16_t InheroMr2Board::getBattMilliVolts() { + // WORKAROUND: The MeshCore protocol currently only transmits battery voltage + // (via getBattMilliVolts), not a direct SOC percentage. The companion app then + // interprets this voltage using a hardcoded Li-Ion discharge curve to derive SOC%. + // This gives wrong readings for LiFePO4/LTO chemistries whose voltage profiles + // differ significantly from Li-Ion. + + // Solution: When we have a valid Coulomb-counted SOC, we reverse-map it to + // the Li-Ion 1S OCV (Open Circuit Voltage) that the app expects. + // This way the app always displays our accurate chemistry-independent SOC. + + // TODO: Remove this workaround once MeshCore supports transmitting the actual + // SOC percentage alongside (or instead of) battery millivolts. At that point, + // this function should return the real battery voltage again. + + const BatterySOCStats* socStats = boardConfig.getSOCStats(); + if (socStats && socStats->soc_valid) { + return inhero::socToLiIonMilliVolts(boardConfig.getStateOfCharge()); + } + + // Fallback: no valid Coulomb-counting SOC yet — return real voltage + const Telemetry* telemetry = boardConfig.getTelemetryData(); + if (!telemetry) { + return 0; + } + return telemetry->battery.voltage; +} + +bool InheroMr2Board::startOTAUpdate(const char* id, char reply[]) { + // Skip in-app BLE DFU (unstable here) and jump to the Adafruit bootloader's + // native OTA DFU via enterOTADfu() (sets GPREGRET=0xA8 + reset). + MESH_DEBUG_PRINTLN("OTA: Scheduling Adafruit bootloader DFU mode..."); + + // Read BLE MAC address from nRF52 hardware registers (no Bluefruit needed) + uint32_t addr0 = NRF_FICR->DEVICEADDR[0]; + uint32_t addr1 = NRF_FICR->DEVICEADDR[1]; + snprintf(reply, 64, "OK DFU - mac: %02X:%02X:%02X:%02X:%02X:%02X", + (addr1 >> 8) & 0xFF, addr1 & 0xFF, + (addr0 >> 24) & 0xFF, (addr0 >> 16) & 0xFF, (addr0 >> 8) & 0xFF, addr0 & 0xFF); + + // Schedule deferred reset into bootloader DFU mode. + // Return immediately so the CLI handler can send the reply first. + // tick() will handle cleanup (stop tasks, radio off) and reset after the delay. + ota_dfu_reset_at = millis() + 3000; // 3s delay to ensure reply is transmitted + + return true; +} + +// Collects board telemetry and appends to CayenneLPP packet +bool InheroMr2Board::queryBoardTelemetry(CayenneLPP& telemetry) { + return inhero::appendBoardTelemetry(boardConfig, telemetry); +} + +// Handles custom CLI getter commands for board configuration +bool InheroMr2Board::getCustomGetter(const char* getCommand, char* reply, uint32_t maxlen) { + return inhero::handleGet(boardConfig, getCommand, reply, maxlen); +} + +// Handles custom CLI setter commands for board configuration +const char* InheroMr2Board::setCustomSetter(const char* setCommand) { + return inhero::handleSet(boardConfig, setCommand); +} + +// ===== Power Management Methods (Rev 1.1) ===== + +// Get low-voltage sleep threshold (chemistry-specific) +uint16_t InheroMr2Board::getLowVoltageSleepThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageSleepThreshold(chemType); +} + +// Get low-voltage wake threshold (chemistry-specific) +uint16_t InheroMr2Board::getLowVoltageWakeThreshold() { + BoardConfigContainer::BatteryType chemType = boardConfig.getBatteryType(); + return BoardConfigContainer::getLowVoltageWakeThreshold(chemType); +} + +// Initiate controlled shutdown with filesystem protection (Rev 1.1) + +// Rev 1.1 low-voltage shutdown uses System Sleep with GPIO latch (< 500µA total): +// - INA228 enters shutdown mode (~3.5µA) +// - BQ CE pin latched HIGH via FET (solar charging continues autonomously) +// - RTC countdown timer configured for periodic wake +// - nRF52 enters SYSTEMOFF (~1.5µA) — GPIO latches preserved +// - RTC wake triggers reboot; Early Boot checks voltage for boot vs sleep-again +void InheroMr2Board::initiateShutdown(uint8_t reason) { + MESH_DEBUG_PRINTLN("PWRMGT: Initiating shutdown (reason=0x%02X)", reason); + + // 1. Stop background tasks to prevent filesystem corruption + BoardConfigContainer::stopBackgroundTasks(); + + // 2. INA228: Shutdown mode to minimize sleep current (~3.5µA vs ~300µA continuous) + // No BUVL monitoring needed in sleep — RTC wakes us for voltage check. + Ina228Driver* ina = boardConfig.getIna228Driver(); + if (ina) { + // Release ALERT pin: latched LOW after LV trip wastes ~330µA through the + // RAK4630 pull-up. Clear ALATCH + BUVL (transparent mode) before ADC shutdown. + ina->enableAlert(false, false, false); // DIAG_ALRT=0: ALATCH=0, clear all flags + ina->setUnderVoltageAlert(0); // BUVL=0: disable under-voltage comparison + ina->shutdown(); + } + + // 3. SX1262 sleep + SPI cleanup (prevents ~4mA leakage in System Sleep) + inhero::prepareRadioForSystemOff(); + + // 4. LEDs off before sleep + digitalWrite(PIN_LED1, LOW); + digitalWrite(PIN_LED2, LOW); + + if (reason == SHUTDOWN_REASON_LOW_VOLTAGE) { + MESH_DEBUG_PRINTLN("PWRMGT: Low voltage shutdown - entering System Sleep with CE latched"); + + delay(100); // Allow I/O to complete + + // 5. Latch BQ CE pin HIGH (FET ON = CE LOW = charge enabled) + // GPIO output latch survives System Sleep as long as VDD is present +#ifdef BQ_CE_PIN + digitalWrite(BQ_CE_PIN, HIGH); + MESH_DEBUG_PRINTLN("PWRMGT: CE latched HIGH (solar charging active in sleep)"); +#endif + + // 5b. INA228 + BQ25798 \u2192 minimum sleep current. Must be AFTER CE=HIGH + // (charge enable may re-enable BQ ADC). Repeats INA228 shutdown via raw I2C + // with readback as a safety net if the driver call in step 2 silently failed. + inhero::prepareIcsForSystemOff(); + + // 5c. BME280 @ 0x76 — Force Sleep mode (saves ~1-7µA) + // After normal operation readBmeTemperature() may have left BME280 in NORMAL mode. + // Harmless NACK if no BME280 populated. + Wire.beginTransmission(BME280_I2C_ADDR); + Wire.write(0xF4); // ctrl_meas register + Wire.write(0x00); // MODE=00 (Sleep), all oversampling off + Wire.endTransmission(); + MESH_DEBUG_PRINTLN("PWRMGT: BQ25798 ADC/INT + BME280 shut down"); + + // 6. Configure RTC to wake us up periodically for voltage check + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + + // 7. Clear GPIO LATCH for RTC INT pin. + // If a previous RTC wake cycle set the LATCH (retained across System Sleep), + // DETECT would fire immediately → instant wake → boot loop. + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + + // 8. Release I2C buses (done AFTER RTC config, which uses Wire) + Wire.end(); + + // 9. Disconnect all GPIO pull-ups on OD/I2C pins to prevent leakage + inhero::disconnectLeakyPullups(); + + // 10. Store shutdown reason for Early Boot decision + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | reason; + + MESH_DEBUG_PRINTLN("PWRMGT: Entering System Sleep (< 500uA)"); + delay(50); + + sd_power_system_off(); + // Fallback if SoftDevice not enabled + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); + } + + // Non-low-voltage shutdown (user request, thermal): use System OFF + Wire.end(); + inhero::disconnectLeakyPullups(); + NRF_POWER->GPREGRET2 = reason; + + MESH_DEBUG_PRINTLN("PWRMGT: Entering SYSTEMOFF"); + delay(50); + + // Clear LATCH to prevent spurious wake + NRF_P0->LATCH = (1UL << RTC_INT_PIN); + + sd_power_system_off(); + // Fallback if SoftDevice not enabled + NRF_POWER->SYSTEMOFF = 1; + while (1) __WFE(); +} + +void InheroMr2Board::configureRTCWake(uint32_t minutes) { + uint16_t ticks = static_cast<uint16_t>( + minutes == 0 ? LOW_VOLTAGE_SLEEP_MINUTES + : (minutes > 4095 ? 4095 : minutes)); + inhero::configurePeriodicWake(ticks); +} + +void InheroMr2Board::rtcInterruptHandler() { + // Defer I2C work to the main loop to avoid ISR I2C collisions. + rtc_irq_pending = true; +} diff --git a/variants/inhero_mr2/InheroMr2Board.h b/variants/inhero_mr2/InheroMr2Board.h new file mode 100644 index 0000000000..8b67eb6885 --- /dev/null +++ b/variants/inhero_mr2/InheroMr2Board.h @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include <Arduino.h> +#include <CayenneLPP.h> +#include <MeshCore.h> +#include <helpers/NRF52Board.h> + +// LoRa (SX1262) +#define P_LORA_DIO_1 47 +#define P_LORA_NSS 42 +#define P_LORA_RESET RADIOLIB_NC +#define P_LORA_BUSY 46 +#define P_LORA_SCLK 43 +#define P_LORA_MISO 45 +#define P_LORA_MOSI 44 +#define SX126X_POWER_EN 37 // P1.05, PE4259 RF switch VDD + +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define PIN_VBAT_READ 5 +#define ADC_MULTIPLIER (3 * 1.73 * 1.187 * 1000) + +// Power management (INA228 + RV-3028) +#define RTC_INT_PIN 17 // GPIO17 (WB_IO1) +#define RTC_I2C_ADDR 0x52 +#define INA228_I2C_ADDR 0x40 // A0=GND, A1=GND +#define BQ25798_I2C_ADDR 0x6B +#define BME280_I2C_ADDR 0x76 + +// RV-3028-C7 registers +#define RV3028_REG_STATUS 0x0E +#define RV3028_REG_CTRL1 0x0F +#define RV3028_REG_CTRL2 0x10 +#define RV3028_REG_TIMER_VALUE_0 0x0A +#define RV3028_REG_TIMER_VALUE_1 0x0B + +// GPREGRET2 layout: [1:0] shutdown reason, [7:2] state flags +#define SHUTDOWN_REASON_NONE 0x00 +#define SHUTDOWN_REASON_LOW_VOLTAGE 0x01 +#define SHUTDOWN_REASON_USER_REQUEST 0x02 +#define SHUTDOWN_REASON_THERMAL 0x03 +#define GPREGRET2_LOW_VOLTAGE_SLEEP 0x04 + +#define LOW_VOLTAGE_SLEEP_MINUTES (60) + +class InheroMr2Board : public NRF52BoardDCDC { +public: + InheroMr2Board() : NRF52Board("InheroMR2_OTA") {} + void begin(); + void tick() override; + + uint16_t getBattMilliVolts() override; + + void initiateShutdown(uint8_t reason); + void configureRTCWake(uint32_t minutes); + uint16_t getLowVoltageSleepThreshold(); + uint16_t getLowVoltageWakeThreshold(); + + static void rtcInterruptHandler(); + + const char *getManufacturerName() const override { return "Inhero MR2"; } + void reboot() override { NVIC_SystemReset(); } + + bool startOTAUpdate(const char *id, char reply[]) override; + bool getCustomGetter(const char *getCommand, char *reply, uint32_t maxlen) override; + const char *setCustomSetter(const char *setCommand) override; + bool queryBoardTelemetry(CayenneLPP &telemetry) override; + +private: + static volatile bool rtc_irq_pending; + static volatile uint32_t ota_dfu_reset_at; // millis() of deferred DFU reset (0 = inactive) +}; diff --git a/variants/inhero_mr2/docs/BATTERY_GUIDE.md b/variants/inhero_mr2/docs/BATTERY_GUIDE.md new file mode 100644 index 0000000000..361fe01fbd --- /dev/null +++ b/variants/inhero_mr2/docs/BATTERY_GUIDE.md @@ -0,0 +1,519 @@ +# Inhero MR2 — Battery Chemistry Guide + +## Contents + +- [Introduction](#introduction) +- [1. Chemistry Overview](#1-chemistry-overview) + - [Li-Ion (NMC/NCA, 1S)](#li-ion-nmcnca-1s) + - [LiFePO4 (LFP, 1S)](#lifepo4-lfp-1s) + - [LTO (Lithium Titanate, 2S)](#lto-lithium-titanate-2s) + - [Na-Ion (Sodium Ion, 1S)](#na-ion-sodium-ion-1s) +- [2. Comparison Table](#2-comparison-table) +- [3. Temperature Behavior](#3-temperature-behavior) + - [Cold Performance Ranking](#cold-performance-ranking) + - [Charging in Cold Conditions](#charging-in-cold-conditions) + - [Temperature Derating](#temperature-derating) +- [4. Cell Selection & Form Factors](#4-cell-selection--form-factors) + - [Li-Ion Cells](#li-ion-cells) + - [LiFePO4 Cells](#lifepo4-cells) + - [LTO Cells](#lto-cells) + - [Na-Ion Cells](#na-ion-cells) +- [5. Capacity Planning](#5-capacity-planning) + - [Power Consumption](#power-consumption) + - [Why current depends on battery voltage](#why-current-depends-on-battery-voltage) + - [Sizing for Autonomy](#sizing-for-autonomy) + - [The 90% Rule](#the-90-rule) +- [6. Solar Charging Considerations](#6-solar-charging-considerations) +- [7. Safety & Protection](#7-safety--protection) +- [8. Deployment Recommendations](#8-deployment-recommendations) +- [9. Long-Term Aging & Cycle Life](#9-long-term-aging--cycle-life) +- [10. Future Outlook](#10-future-outlook) +- [See Also](#see-also) + +--- + +## Introduction + +Choosing the right battery chemistry is one of the most impactful decisions when deploying an Inhero MR2 repeater. It affects runtime, cold-weather reliability, service life, safety, and total cost of ownership. This guide provides the information needed to make an informed choice. + +The Inhero MR2 supports **four battery chemistries**, each with distinct characteristics. There is no single "best" chemistry — the right choice depends on your deployment conditions. + +--- + +## 1. Chemistry Overview + +### Li-Ion (NMC/NCA, 1S) + +The most common rechargeable chemistry. NMC (Nickel-Manganese-Cobalt) and NCA (Nickel-Cobalt-Aluminum) cells dominate the consumer market. + +**Strengths:** +- **Highest energy density** (~250 Wh/kg) — smallest and lightest for a given capacity +- Widely available in many form factors (18650, 21700, pouch) +- Inexpensive — large-scale production drives prices down +- Well-understood technology with decades of field data + +**Weaknesses:** +- Limited cycle life (500–1000 cycles to 80% capacity) +- **Thermal runaway risk** — can ignite under abuse (overcharge, short circuit, puncture) +- Sensitive to cold: significant capacity loss below 0 °C +- Must not be charged below 0 °C — lithium plating risk damages the cell permanently +- Sensitive to heat: accelerated degradation above 40 °C +- **Calendar aging at high SOC + heat** — the #1 aging driver for solar repeaters, where the battery sits at 95–100% SOC for months in summer enclosures reaching 50 °C. This is why the Inhero MR2 lowers Vco to 4.1 V +- Requires protection circuit (BMS) to prevent overcharge/over-discharge + +**Inhero MR2 specifics:** +- Charge voltage set to **4.1 V** (conservative, vs. typical 4.2 V) for improved cycle life +- **JEITA active** — NTC required; charging blocked below −2 °C (T-Cold) +- Frost charge reduction configurable via `set board.fmax` + +### LiFePO4 (LFP, 1S) + +Iron-phosphate cathode chemistry. Popular in solar and off-grid applications for its safety and longevity. + +**Strengths:** +- **Excellent cycle life** (2000–5000 cycles to 80% capacity) +- **No thermal runaway** — inherently safe cathode chemistry +- Good energy density for most deployments (~160 Wh/kg) +- Very flat discharge curve — voltage remains stable over a wide SOC range +- Tolerant to moderate overcharge/over-discharge + +**Weaknesses:** +- **Most cold-sensitive** of all supported chemistries +- Flat discharge curve makes voltage-based SOC estimation unreliable (the Inhero MR2 solves this with a coulomb counter) +- Slightly lower energy density than Li-Ion +- Must not be charged below 0 °C — lithium plating risk damages the cell permanently + +**Inhero MR2 specifics:** +- Charge voltage: **3.5 V** +- **JEITA active** — NTC required; charging blocked below −2 °C (T-Cold) +- Frost charge reduction configurable via `set board.fmax` +- JEITA WARM zone neutralized in firmware to prevent VBAT_OVP (see [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md#jeita-warm-zone--vbat_ovp-prevention)) + +### LTO (Lithium Titanate, 2S) + +Lithium titanate anode chemistry. Used in industrial and transit applications for extreme durability. + +**Strengths:** +- **Best cold performance** of all supported chemistries — 82% extractable at −20 °C +- **Extreme cycle life** (10 000+ cycles, some manufacturers claim 20 000+) +- Very safe — no lithium plating, no thermal runaway +- Wide operating temperature range (−30 °C to +60 °C charging) +- **Can be charged in frost** — no JEITA restriction needed +- Fast-charge capable (up to 5C or more) +- Very flat discharge curve + +**Weaknesses:** +- **Low energy density** (~80 Wh/kg) — needs 2–3× the volume of Li-Ion for the same capacity +- **2S configuration requires an external balancer** — without balancer, cells drift over time and risk over-charge/over-discharge of individual cells +- Exotic form factors — typically cylindrical with screw terminals or aluminum housing +- Difficult to spot-weld (aluminum housing) +- Expensive (2–3× per Wh vs. Li-Ion) +- Limited retail availability — specialty suppliers only + +**Inhero MR2 specifics:** +- Charge voltage: **5.4 V** (2× 2.7 V per cell) +- **JEITA disabled** (`ts_ignore = true`) — no NTC required for charging +- Temperature for SOC derating comes from **BME280 fallback** if no NTC connected +- `set board.fmax` has no effect (shown as "N/A") +- Cell count set to 2S in BQ25798 configuration + +### Na-Ion (Sodium Ion, 1S) + +Sodium-ion technology — the sustainable alternative using abundant, non-critical raw materials. + +**Strengths:** +- **Good cold performance** — 78% extractable at −20 °C +- **No cobalt, no lithium** — ethically sourced, sustainable materials (sodium, iron, manganese) +- **Can be stored and shipped at 0 V** — no deep discharge damage (unique among all chemistries) +- **Can be charged in frost** — no JEITA restriction needed +- Good safety profile — no thermal runaway under normal conditions +- Rapidly improving technology — energy density and cycle life increase with each generation + +**Weaknesses:** +- **New technology** — limited cell availability as of 2025/2026 +- Lower energy density than Li-Ion (~130 Wh/kg, improving) +- Fewer validated cell options and public datasheets +- Cycle life still below LiFePO4 in most current cells (1000–3000 cycles) +- Market still maturing — quality variation between manufacturers + +**Inhero MR2 specifics:** +- Charge voltage: **3.9 V** +- **JEITA disabled** (`ts_ignore = true`) — no NTC required for charging +- Temperature for SOC derating comes from **BME280 fallback** if no NTC connected +- `set board.fmax` has no effect (shown as "N/A") + +--- + +## 2. Comparison Table + +| | Li-Ion 1S | LiFePO4 1S | LTO 2S | Na-Ion 1S | +|---|---|---|---|---| +| **Energy density** | ~250 Wh/kg | ~160 Wh/kg | ~80 Wh/kg | ~130 Wh/kg | +| **Cycle life (to 80%)** | 500–1000 | 2000–5000 | 10 000+ | 1000–3000 | +| **Cold performance** | Moderate | Weakest | Best | Good | +| **Extractable at −20 °C** | 55% | 46% | 82% | 78% | +| **Extractable at −10 °C** | 65% | 58% | 86% | 83% | +| **Extractable at 0 °C** | 75% | 70% | 90% | 88% | +| **Thermal runaway risk** | Yes | No | No | No | +| **NTC required?** | Yes | Yes | No | No | +| **JEITA** | Active | Active | Disabled | Disabled | +| **Charge in frost?** | No (blocked <−2 °C) | No (blocked <−2 °C) | Yes | Yes | +| **Charge voltage** | 4.1 V | 3.5 V | 5.4 V (2S) | 3.9 V | +| **Low-V sleep** | 3100 mV | 2700 mV | 3900 mV | 2500 mV | +| **Low-V wake** | 3300 mV | 2900 mV | 4100 mV | 2700 mV | +| **Nominal voltage** | 3.7 V | 3.2 V | 4.6 V | 3.1 V | +| **Cell formats** | 18650, 21700, pouch | 18650, 26650, prismatic | Screw-terminal, aluminum | 18650, prismatic | +| **Availability** | Excellent | Good | Limited | Limited | +| **Relative cost (per Wh)** | Low | Low–Medium | High | Medium | + +> **Note on the extractable-capacity figures:** These are typical datasheet values at 0.2C–0.5C discharge loads. The MR2's sub-0.05C load is much gentler; the firmware's derating model (section 3) therefore shows higher extractable values in `get board.telem`. + +--- + +## 3. Temperature Behavior + +### Cold Performance Ranking + +From best to worst cold-weather performance: + +1. **LTO** — 82% extractable at −20 °C, charges in frost +2. **Na-Ion** — 78% extractable at −20 °C, charges in frost +3. **Li-Ion** — 55% extractable at −20 °C, charging blocked in frost +4. **LiFePO4** — 46% extractable at −20 °C, charging blocked in frost + +*(Datasheet values at 0.2C–0.5C loads — see the note in section 2; under the MR2's much gentler load the firmware's derating model below shows higher values.)* + +> The ranking may surprise users familiar with LiFePO4's reputation as a "workhorse." While LiFePO4 excels in cycle life and safety, it is actually the **worst performer in cold** among the four supported chemistries. This matters significantly for alpine and winter deployments. + +### Charging in Cold Conditions + +| Chemistry | Charging in frost? | Mechanism | +|---|---|---| +| **Li-Ion** | No — blocked below −2 °C | JEITA T-Cold (hardware, BQ25798) | +| **LiFePO4** | No — blocked below −2 °C | JEITA T-Cold (hardware, BQ25798) | +| **LTO** | Yes — charges at any temperature | JEITA disabled (`ts_ignore = true`) | +| **Na-Ion** | Yes — charges at any temperature | JEITA disabled (`ts_ignore = true`) | + +For Li-Ion and LiFePO4, charging in the **T-Cool range** (+3 °C to −2 °C with the Inhero voltage divider) is blocked by default and can be set to a reduced rate via `set board.fmax` (20%, 40% or 100%). Note that selecting Li-Ion or LiFePO4 via `set board.bat` resets `board.fmax` to 0%. See [FAQ #6](FAQ.md#6-what-does-set-boardfmax-control). + +**Why is frost charging dangerous for Li-Ion and LiFePO4?** At low temperatures, lithium ions cannot intercalate properly into the graphite anode. Instead, they deposit as metallic lithium on the anode surface ("lithium plating"). This permanently reduces capacity and can create internal short circuits — a safety hazard. + +LTO and Na-Ion use different anode materials (lithium titanate and hard carbon respectively) that do not suffer from lithium plating, making frost charging safe. + +> **Field experience vs. theory:** Many repeater operators successfully charge Li-Ion cells in frost with low solar currents (<0.1C) and report no measurable degradation over multiple winters. The [YYCMesh community](https://yycmesh.com/blog/cold-weather-charging) documented two years of alpine deployments in the Canadian Rockies (down to −40 °C) with standard 18650 cells and found internal resistance still within factory spec. Their key factors: very low charge rates (<0.05C), passive solar heating of enclosures, and charging coinciding with the warmest part of the day. +> +> This is valuable real-world data and the practice clearly works for many setups. However, these results apply specifically to configurations with **large battery capacities and relatively low PV power** (keeping charge rates well below 0.05C). They should not be taken as a general dismissal of lithium plating risks. *It depends* — on charge rate, cell quality, panel size, and temperature. The degradation from lithium plating is **cumulative and subtle** — it may not manifest as sudden failure but as gradual capacity loss over years. Two additional risks are often underestimated: +> +> 1. **PV panels produce more power in cold weather** (silicon temperature coefficient ~−0.35%/°C). A 5 W panel at −10 °C delivers significantly more current than at +25 °C. Snow reflection can push output even beyond rated wattage. +> 2. **Cell quality varies.** Results with premium cells (low internal resistance, consistent chemistry) may not transfer to budget cells. +> +> The Inhero MR2 takes a conservative approach: the NTC battery temperature sensor causes the BQ25798 to block charging until the cell warms above −2 °C (JEITA T-Cold) and, by default, keeps charging suspended through the T-Cool zone as well (`board.fmax` default 0%). Setting `set board.fmax` to 20%, 40% or 100% instead allows reduced-rate charging between −2 °C and +3 °C. On sunny winter days, the board runs from solar via the power path while the battery stays protected. Once direct sunlight heats up the enclosure and the battery temperature rises above the threshold — which happens surprisingly fast with proper enclosure design — charging resumes automatically. This gives the best of both worlds: no plating risk, yet minimal lost charge time. + +### Temperature Derating + +The firmware uses a **Trapped Charge** model to estimate **extractable** capacity at the current battery temperature. Cold temperatures lock the bottom of the discharge curve — the cell reaches its cutoff voltage while charge is still stored. SOC% itself is purely Coulomb-based (stored charge) and temperature-independent. Derating is applied to: +- **TTL calculation** — Trapped Charge: extractable = max(0, remaining − capacity × (1−f(T))) +- **CLI display** — `get board.telem` shows the derated value in parentheses: `SOC:95.0% (78%)` + +The derating model uses a per-chemistry linear function: + +``` +f(T) = max(f_min, 1.0 - k × (T_ref - T)) for T < T_ref +f(T) = 1.0 for T >= T_ref +``` + +T_ref = 25 °C for all chemistries (no derating at room temperature and above). + +| Chemistry | k (/°C) | f_min | At −20 °C | At −10 °C | At 0 °C | At 10 °C | +|-----------|---------|-------|-----------|-----------|---------|---------| +| Li-Ion | 0.005 | 0.75 | 0.78 | 0.83 | 0.88 | 0.93 | +| LiFePO4 | 0.006 | 0.70 | 0.73 | 0.79 | 0.85 | 0.91 | +| Na-Ion | 0.003 | 0.85 | 0.87 | 0.90 | 0.93 | 0.96 | +| LTO | 0.002 | 0.88 | 0.91 | 0.93 | 0.95 | 0.97 | + +**Practical effects:** +- SOC% is temperature-independent — only changes with actual charge flow +- The derated (extractable) value in `telem` decreases as the battery cools +- TTL estimates reflect extractable capacity at the current temperature (Trapped Charge model) + +→ See [FAQ #13 — How does temperature derating work?](FAQ.md#13-how-does-temperature-derating-work) for further technical details. + +--- + +## 4. Cell Selection & Form Factors + +### Li-Ion Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **18650** | 2500–3500 mAh | Most common; widely available; easy to source | +| **21700** | 4000–5000 mAh | Higher capacity; becoming the new standard | +| **Pouch** | Varies | Custom shapes; requires careful mounting | + +**Tips:** +- Prefer cells with built-in protection circuit (PCM) for standalone use +- For parallel packs, ensure cells are matched (same manufacturer, same batch) +- Cells with integrated NTC simplify wiring to the TS pin +- Recommended: Samsung, Sony/Murata, LG, Panasonic/Sanyo — avoid no-name cells + +### LiFePO4 Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **18650** | 1400–1800 mAh | Lower capacity than Li-Ion 18650; less common | +| **26650** | 2500–3600 mAh | Larger diameter; popular for LFP | +| **32650** | 5000–6000 mAh | Large cylindrical; good for high-capacity packs | +| **Prismatic** | 5000–50 000 mAh | Flat cells; efficient use of space | + +**Tips:** +- The flat discharge curve means voltage tells you little about SOC — rely on the Inhero MR2's coulomb counter +- Avoid charging below 0 °C — the board's JEITA protection handles this automatically +- EVE, BYD, CATL are reputable manufacturers + +### LTO Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **Cylindrical (screw terminal)** | 10 000–40 000 mAh | Most common LTO format; M6/M8 screw terminals | +| **Prismatic (aluminum)** | 10 000–30 000 mAh | Aluminum housing; cannot be spot-welded easily | + +**⚠️ Important: 2S requires a balancer** + +The Inhero MR2 configures the BQ25798 for 2S operation but provides **no built-in cell balancing**. An external balancer module is required to prevent cell voltage drift over time. Without a balancer, one cell may be overcharged while the other is undercharged — this degrades capacity and can damage cells. + +**Tips:** +- Use a passive or active balancer board rated for your cell voltage range (2.0–2.7 V per cell) +- Yinlong/Toshiba SCiB are common LTO cell brands +- Expect 2–3× the volume and weight compared to Li-Ion for the same energy +- Screw terminals are robust for outdoor deployments — no spot-welding needed + +### Na-Ion Cells + +| Form Factor | Typical Capacity | Notes | +|---|---|---| +| **18650** | 1000–1500 mAh | Emerging; first-generation cells | +| **Prismatic** | 5000–20 000 mAh | Larger formats appearing from HiNa, CATL, Faradion | + +**Tips:** +- Technology is evolving rapidly — check latest available cells before purchasing +- Can be shipped at 0 V (unlike all lithium chemistries) — simplifies logistics +- No special handling required for storage +- HiNa, CATL, Faradion/Reliance are key manufacturers (as of 2025/2026) + +--- + +## 5. Capacity Planning + +### Power Consumption + +Typical Inhero MR2 power consumption (repeater mode, LEDs off): + +| Condition | Current Draw | Notes | +|---|---|---| +| Idle (RX, no TX) | ~7.6 mA @ 3.3 V | USB off, SX1262 in RX | +| **Measured typical** | **~12.3 mA @ 3.3 V** | 24h measurement, repeater with typical traffic | +| **Worst case (10% DC, EU868 g3)** | **~19.8 mA @ 3.3 V** | 10% duty cycle × ~130 mA TX + 90% × 7.6 mA idle | +| TX burst (SX1262 +22 dBm) | ~130 mA @ 3.3 V | Short bursts only, regulated by duty cycle | +| Low-voltage sleep | <0.5 mA | Solar charging continues | + +> **How these values are determined:** MeshCore operates on **869.618 MHz** in the EU868 **g3 sub-band** (869.4–869.65 MHz), which allows up to +27 dBm ERP and a **10% duty cycle**. At +22 dBm the SX1262 + MCU draw ~130 mA during TX. With 10% TX time: `0.90 × 7.6 + 0.10 × 130 = 19.8 mA` → **~65 mW or ~1.57 Wh/day** — the regulatory maximum. +> +> **Measured typical (~12.3 mA):** Validated 24h measurement in repeater mode with typical traffic: **295 mAh/day @ 3.32 V** = 0.98 Wh/day → **~41 mW or ~0.98 Wh/day**. + +### Why current depends on battery voltage + +The Inhero MR2 uses a high-efficiency **buck converter** to produce the 3.3 V rail that powers the MCU and radio. This means the board draws roughly **constant power** (watts), not constant current (amps). + +Since Power = Voltage × Current, a higher battery voltage means lower current from the battery — but the power stays the same: + +| Chemistry | Nominal Voltage | Idle | Measured typical | Worst case (10% DC) | +|---|---|---|---|---| +| Na-Ion | 3.1 V | ~8.1 mA (25 mW) | ~13.2 mA (41 mW) | ~21.0 mA (65 mW) | +| LiFePO4 | 3.2 V | ~7.8 mA (25 mW) | ~12.8 mA (41 mW) | ~20.3 mA (65 mW) | +| Li-Ion | 3.7 V | ~6.8 mA (25 mW) | ~11.1 mA (41 mW) | ~17.6 mA (65 mW) | +| LTO (2S) | 4.6 V | ~5.4 mA (25 mW) | ~8.9 mA (41 mW) | ~14.1 mA (65 mW) | + +> **Important for capacity planning:** Don't just multiply mA × hours to get mAh — that only works within one chemistry at one voltage. When comparing across chemistries, always calculate in **Wh** (energy): `Energy (Wh) = Wh/day × Days`. Then convert to your chemistry: `mAh = Wh × 1000 ÷ V_nominal`. Use **0.98 Wh/day** (measured typical) or **1.57 Wh/day** (worst case 10% DC) depending on expected traffic and safety margin. +> +> **Example:** 30 days autonomy at measured typical = 29 Wh needed (worst case: 47 Wh). +> - LiFePO4 (3.2 V): 29 000 ÷ 3.2 = **9 063 mAh** (worst case: 14 688 mAh) +> - LTO 2S (4.6 V): 29 000 ÷ 4.6 = **6 304 mAh** (worst case: 10 217 mAh) + +### Sizing for Autonomy + +**Energy approach (recommended):** `Energy (Wh) = Wh/day × Days of autonomy` — use **0.98 Wh/day** (measured typical) or **1.57 Wh/day** (worst case 10% DC) for conservative sizing + +**Convert to mAh for your chemistry:** `mAh = Wh × 1000 ÷ V_nominal` + +The mAh column below is calculated at 3.3 V (≈ LiFePO4 / Na-Ion nominal). For Li-Ion or LTO, use the energy column with the formula above — see [Why current depends on battery voltage](#why-current-depends-on-battery-voltage). + +| Desired Autonomy | Measured typical (0.98 Wh/day) | Worst case (1.57 Wh/day) | mAh @ 3.3 V (typical) | Recommended | +|---|---|---|---|---| +| **3 days** (indoor, grid backup) | 2.9 Wh | 4.7 Wh | 891 mAh | 1500 mAh | +| **7 days** (solar, summer) | 6.9 Wh | 11.0 Wh | 2079 mAh | 3500 mAh | +| **14 days** (solar, winter) | 13.7 Wh | 22.0 Wh | 4158 mAh | 6000 mAh | +| **30 days** (alpine, minimal solar) | 29.4 Wh | 47.1 Wh | 8909 mAh | 12000+ mAh | + +> **Cold-weather margin:** For deployments below 0 °C, increase capacity by the inverse of the derating factor to compensate for reduced extractable capacity. Example: LiFePO4 at −10 °C has f(T) = 0.79, so you need `capacity / 0.79 ≈ 1.27×` the capacity compared to room temperature. The TTL calculation applies this derating automatically. + +### The 90% Rule + +Set `board.batcap` to **90% of nominal capacity**. The Inhero MR2 uses conservative charge voltages (e.g. 4.1 V instead of 4.2 V for Li-Ion), which means the top ~10% of nominal capacity is intentionally not used — this significantly improves cycle life. + +**Example:** 10 000 mAh nominal → `set board.batcap 9000` + +→ See [FAQ #4](FAQ.md#4-what-mah-value-should-i-enter-for-set-boardbatcap) + +--- + +## 6. Solar Charging Considerations + +**Maximum charge current formula:** `I_charge (mA) = Panel power (W) / Nominal battery voltage (V)` + +| Chemistry | Panel | Charge Current | `set board.imax` | +|---|---|---|---| +| Li-Ion (3.7 V) | 2 W | 540 mA | `set board.imax 540` | +| LiFePO4 (3.2 V) | 1 W | 310 mA | `set board.imax 310` | +| LTO (4.6 V) | 5 W | 1090 mA | `set board.imax 1090` | +| Na-Ion (3.1 V) | 3 W | 970 mA | `set board.imax 970` | + +**Panel sizing guidelines:** +- The Inhero MR2 consumes ~7.6 mA @ 3.3 V idle, **measured ~12.3 mA typical** (~0.98 Wh/day), worst case ~19.8 mA at full EU868 g3 10% duty cycle (~1.57 Wh/day) +- With vertical south-facing mounting (see below), **2 W monocrystalline is safely sufficient** for winter autonomy in central Europe with a 9 Ah LiFePO4 battery. Field-tested: even a 1 W panel (vertical, south, unshaded, exposed) with 9 Ah LiFePO4 survived a full central European winter +- For locations with frequent overcast periods or partial shading, add margin — 3–5 W recommended +- MPPT is essential for extracting maximum power — enable with `set board.mppt 1` + +**Panel orientation — vertical is better for autonomy:** + +Conventional PV installations tilt panels at ~30–40° to maximize annual yield. For off-grid repeaters, the goal is different: **maximize winter performance**, especially in the critical months of December and January when the sun is lowest and days are shortest. Mounting panels **vertically (90°)** has significant advantages: + +- **Low winter sun** hits a vertical panel at near-optimal angle while a 30°-tilted panel receives the same light at a glancing angle +- **Self-cleaning:** vertical panels shed snow, ice, and dirt far more effectively — a snow-covered panel produces zero power regardless of rated wattage +- **Practical rule of thumb (central Europe):** expect approximately **1 Wh/day per 1 Wp** of panel rating in January with a vertical, south-facing, unshaded, exposed setup. PVGIS data confirms ~36 Wh/month (after system losses) for a 1 Wp panel in this configuration. In practice, the MR2 cannot harvest on very overcast days when the charger reports !PG (power not good), so a conservative estimate is **~30 Wh/month or ~1 Wh/day** usable. With ~0.98 Wh/day measured typical consumption, a 1 Wp panel provides a positive energy balance in January. **2 Wp provides comfortable headroom** for cloudy stretches and higher-traffic deployments +- In summer, vertical panels produce less than optimally tilted ones — but summer yield is never the bottleneck for autonomy + +**Chemistry-specific considerations:** +- **Li-Ion / LiFePO4:** Solar charging is blocked in frost (<−2 °C). On cold winter days, the panel may produce power but the battery won't accept charge until it warms above −2 °C. Meanwhile, the board runs directly on solar if power is sufficient. Note that PV panels produce **more power in cold weather** (silicon temperature coefficient ~−0.35%/°C), so actual charge currents can exceed nominal ratings — another reason why hardware-level charge blocking via JEITA is essential rather than relying on "low current" assumptions. +- **LTO / Na-Ion:** Solar charging works even in deep frost — a significant advantage for alpine deployments where frost can persist for days or weeks. + +--- + +## 7. Safety & Protection + +| Chemistry | Thermal Runaway | Requires BMS/Protection? | Inhero MR2 Protection | +|---|---|---|---| +| **Li-Ion** | ⚠️ Yes — fire/explosion risk under abuse | Yes — mandatory | JEITA, low-V sleep, OVP, charge voltage limit | +| **LiFePO4** | ✅ No — inherently safe | Recommended | JEITA, low-V sleep, OVP | +| **LTO** | ✅ No — inherently safe | Recommended (balancer!) | Low-V sleep, OVP, cell count config | +| **Na-Ion** | ✅ No under normal conditions | Recommended | Low-V sleep, OVP | + +**Inhero MR2 built-in safety features (all chemistries):** +- **Low-voltage sleep** — INA228 ALERT ISR triggers system sleep to prevent deep discharge +- **Charge voltage limit** — BQ25798 configured per chemistry to prevent overcharge +- **VBAT_OVP** — Hardware overvoltage protection in BQ25798 +- **200 mV hysteresis** — Prevents motorboating (rapid on/off cycling) near empty +- **JEITA temperature protection** (Li-Ion/LiFePO4 only) — Hardware charge control via NTC + +**User responsibilities:** +- Li-Ion: Use cells with protection circuit (PCM) or a proper BMS +- LTO 2S: **External balancer is mandatory** for long-term operation +- All chemistries: Set correct chemistry via `set board.bat` — wrong chemistry = wrong voltages = damage risk + +--- + +## 8. Deployment Recommendations + +| Scenario | Recommended | Alternative | Notes | +|---|---|---|---| +| **Indoor, moderate climate (0–40 °C)** | **LiFePO4** | Li-Ion | LiFePO4: best safety + cycle life balance | +| **Outdoor, temperate (−5 to +35 °C)** | **LiFePO4** | Li-Ion | Frost is rare; fmax handles occasional cold | +| **Space-constrained enclosure** | **Li-Ion** | — | Highest energy density; nothing else fits | +| **Alpine, extreme cold (−20 °C and below)** | **LTO** | Na-Ion | LTO: charges in frost, 82% capacity at −20 °C | +| **Cold climate, moderate frost (−10 to −15 °C)** | **Na-Ion** or **LTO** | LiFePO4 (with margin) | Both charge in frost; Na-Ion balances density and cold | +| **Maximum service life (>10 years)** | **LTO** | LiFePO4 | LTO: 10 000+ cycles; solar repeater essentially unlimited | +| **Sustainability / ethical sourcing** | **Na-Ion** | LiFePO4 | No cobalt, no lithium; improving rapidly | +| **Maritime / coastal (salt, humidity)** | **LiFePO4** | Li-Ion | Sealed prismatic cells; inherent safety in harsh environments | +| **Mobile / portable** | **Li-Ion** | LiFePO4 | Weight and volume matter most | +| **Budget-constrained** | **Li-Ion** | LiFePO4 | Lowest cost per Wh | + +*(Extractable-capacity percentages are datasheet values at 0.2C–0.5C loads — see the note in section 2.)* + +> **Winter alpine checklist:** +> 1. Choose LTO or Na-Ion for frost charging +> 2. Oversize battery capacity by 1.5–2× for cold derating +> 3. Oversize solar panel by 3–5× for short winter days +> 4. Enable MPPT (`set board.mppt 1`) +> 5. If using Li-Ion/LiFePO4: configure `set board.fmax` and run `set board.tccal` +> 6. Monitor via `get board.stats` and `get board.socdebug` + +--- + +## 9. Long-Term Aging & Cycle Life + +| Chemistry | Cycles to 80% | Calendar Aging | Optimal Storage SOC | +|---|---|---|---| +| **Li-Ion** | 500–1000 | Moderate (faster at high temp/SOC) | 40–60% at 15–25 °C | +| **LiFePO4** | 2000–5000 | Low | 50% at room temperature | +| **LTO** | 10 000+ | Very low | Any SOC; very tolerant | +| **Na-Ion** | 1000–3000 | Low | 0 V (unique — no damage) | + +**Solar repeater context:** A well-dimensioned solar repeater does **not** do 1 full cycle per day. The actual profile is shallow micro-cycling with strong seasonal variation: + +- **Daily discharge** is only **2–3%** of battery capacity (with proper sizing: ≥ 7 Ah for a sub-1 W load) +- **Daily recharge** adds **10–20%** on sunny days, depending on panel size +- **Winter (Dec–Jan):** The battery slowly drains over multi-day overcast periods — it "carries" the repeater through the dark weeks. SOC may drop to 30–50% before the next sunny spell +- **Summer:** The battery permanently floats between **95–100% SOC**, rarely dropping below 90% + +This means the battery experiences perhaps **10–30 equivalent full cycles per year** — not 365. + +| Chemistry | Cycles to 80% | Equivalent Full Cycles/Year | Expected Service Life | +|---|---|---|---| +| **Li-Ion** | 500–1000 | ~10–30 | **15–50+ years** (cycle-limited) | +| **LiFePO4** | 2000–5000 | ~10–30 | **65+ years** (cycle-limited) | +| **LTO** | 10 000+ | ~10–30 | **effectively unlimited** | +| **Na-Ion** | 1000–3000 | ~10–30 | **30–100+ years** (cycle-limited) | + +> **The real aging threat is not cycling — it's calendar aging at high SOC and temperature.** +> +> In summer, the battery sits at 95–100% SOC for months in an enclosure that can reach 50 °C in direct sun. For Li-Ion, this combination (high SOC + high temperature) is the #1 aging accelerator. This is exactly why the Inhero MR2 uses **conservative charge cutoff voltages** (e.g. 4.1 V instead of 4.2 V for Li-Ion) — lower Vco reduces the resting SOC and dramatically slows calendar aging. + +**How bad is it? Typical NMC Li-Ion calendar capacity loss per year (no cycling, storage only):** + +| Temperature | 4.2 V (100% SOC) | 4.1 V (~85% SOC) | Reduction | +|---|---|---|---| +| 25 °C (indoor) | ~3–5%/year | ~1–2%/year | 2–3× slower | +| 40 °C (warm enclosure) | ~8–15%/year | ~3–5%/year | 2–3× slower | +| **50 °C (sun-exposed)** | **~20–30%/year** | **~6–10%/year** | **3× slower** | + +> At 4.2 V and 50 °C, a Li-Ion cell reaches 80% capacity (end of life) in roughly **3 years**. At 4.1 V and 50 °C, the same cell lasts **8–10 years**. The 100 mV Vco reduction alone buys 2–3× more service life — at the cost of only ~10% less usable capacity. +> +> **Bottom line:** For sun-exposed outdoor deployments, the Vco reduction from 4.2→4.1 V is the single most effective measure to extend Li-Ion life. If the enclosure regularly exceeds 40 °C, consider LiFePO4 or LTO instead — these chemistries are largely immune to calendar aging at high SOC. +> +> LiFePO4 and LTO are far more tolerant of sustained high SOC. Na-Ion ages moderately. For hot/exposed deployments, prefer LiFePO4 or LTO. + +--- + +## 10. Future Outlook + +**Na-Ion** is the chemistry to watch. As of 2025/2026: +- Energy density is improving with each generation (target: 160+ Wh/kg) +- Major manufacturers (CATL, BYD, HiNa) are ramping production +- Cell costs are expected to drop below Li-Ion within 2–3 years +- Ideal for stationary applications where absolute energy density is less critical + +**Solid-state batteries** may appear in the 2028+ timeframe but are unlikely to be relevant for off-grid repeater applications in the near term. + +**LTO** remains the gold standard for extreme environments and will likely stay relevant for specialized deployments. Its high cost and low density will continue to limit adoption to cases where cold performance and cycle life are critical. + +**LiFePO4** will remain the mainstream choice for the foreseeable future — proven, safe, affordable, and available in many formats. + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware datasheet, pinouts and specifications +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [FAQ.md](FAQ.md) — Frequently asked questions +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/CLI_CHEAT_SHEET.md b/variants/inhero_mr2/docs/CLI_CHEAT_SHEET.md new file mode 100644 index 0000000000..ad3d994b6a --- /dev/null +++ b/variants/inhero_mr2/docs/CLI_CHEAT_SHEET.md @@ -0,0 +1,202 @@ +# Inhero MR2 – CLI Cheat-Sheet + +All board-specific CLI commands at a glance. +Prefix is always `board.` — i.e. `get board.<cmd>` or `set board.<cmd> <value>`. + +See [FAQ.md](FAQ.md) for explanations of key parameters (`imax`, `fmax`, `batcap`) and [DATASHEET.md](DATASHEET.md#supported-battery-chemistries) for chemistry details. + +--- + +## Setters (Change Configuration) + +```bash +# Battery chemistry +set board.bat liion1s # Li-Ion 1S (3.7V nominal) +set board.bat lifepo1s # LiFePO4 1S (3.2V nominal) +set board.bat lto2s # LTO 2S (2x 2.3V nominal) +set board.bat naion1s # Na-Ion 1S (3.1V nominal) +set board.bat none # No battery / unknown (charging disabled) + +# Battery capacity (100–100000 mAh) +# Rule of thumb: 90% of nominal capacity (see FAQ #4) +set board.batcap 10000 + +# Maximum charge current (50–1500 mA) +set board.imax 500 + +# Frost charge current reduction (T-Cool approx. -2 °C to +3 °C, see JEITA table in README) +set board.fmax 0% # Charging blocked +set board.fmax 20% # max. 20% of imax +set board.fmax 40% # max. 40% of imax +set board.fmax 100% # no reduction +# Note: No effect on LTO / Na-Ion (JEITA disabled) + +# MPPT on/off +set board.mppt 1 # Enable MPPT +set board.mppt 0 # Disable MPPT + +# LEDs on/off (Heartbeat + BQ Stat) +set board.leds on # Enable LEDs (on/1) +set board.leds off # Disable LEDs (off/0) + +# Manually set SOC (0–100%) +set board.soc 85.0 +``` + +### Calibration + +```bash +# NTC temperature calibration +# Best practice: run in the early morning before sunrise, +# when battery temperature has equalized with ambient (see FAQ #12). +set board.tccal # Auto-calibration via BME280 +set board.tccal reset # Reset offset to 0.00 +``` + +--- + +## Getters (Query Status) + +```bash +# Configuration & Hardware +get board.bat # Current battery type +get board.batcap # Battery capacity in mAh (set/default) +get board.imax # Maximum charge current in mA +get board.fmax # Frost charge behavior (0%/20%/40%/100% or N/A) +get board.mppt # MPPT status (0/1) +get board.leds # LED status (ON/OFF) +get board.conf # Summary of all configs (B, F, M, I, Vco, V0) + +# Real-time telemetry +get board.telem # Battery+Solar: V, I, T, SOC + +# Energy & Statistics +get board.stats # Energy balance (24h/3d/7d), C/D, MPPT%, TTL + # TTL = Time To Live (hours until battery empty) + # Basis: 7-day average of daily net deficit + # from hourly INA228 coulomb counter samples (168h ring buffer) + # Formula: extractable / |7d-avg-deficit| × 24, where +# extractable = SOC% × capacity − trapped charge +# (cold-temperature derating; 0 at normal temperatures) + # TTL only shown in BAT mode (net deficit) + # Prerequisite: min. 24h data + capacity known + +# Charger & Diagnostics +get board.cinfo # Charger status + last PG-stuck HIZ toggle +get board.bqdiag # Diagnostic/debug: compact BQ25798 register dump + # PG/charge state, TS region (COLD/COOL/WARM/HOT), + # active status/fault flags (e.g. VINDPM, VBAT_OVP) +get board.selftest # Probe all I2C devices (INA228/BQ25798/RV-3028/BME280) + # Output: "INA:OK BQ:OK RTC:OK BME:OK" + # RTC includes user-RAM write/readback verify + # to catch cold-solder joints (chip ACKs but + # rejects writes). Possible per-device states: + # OK — device responds and (RTC) persists writes + # NACK — device does not ACK on I2C bus + # WR_FAIL — (RTC only) ACKs but write/readback mismatched +get board.socdebug # Diagnostic/debug: SOC tracking internals + # SHUNT_CAL, precise current, CHARGE register (mAh), + # current-hour charge/discharge accumulators, + # update count, RTC time, temperature derating factor + +# Calibration +get board.tccal # NTC temperature offset in °C (0.00 = default) +``` + +--- + +## Getter Quick Reference + +| Command | Description | +|---|---| +| `get board.bat` | Battery type (`liion1s`, `lifepo1s`, `lto2s`, `naion1s`, `none`) | +| `get board.batcap` | Battery capacity in mAh (set/default) | +| `get board.imax` | Maximum charge current in mA | +| `get board.fmax` | Frost charge behavior (`0%`/`20%`/`40%`/`100%`, LTO/Na-Ion: `N/A`) | +| `get board.mppt` | MPPT status (`0`/`1`) | +| `get board.leds` | LED status Heartbeat + BQ Stat (`ON`/`OFF`) | +| `get board.conf` | Summary: B(at) F(max) M(ppt) I(max) Vco V0 | +| `get board.telem` | Real-time telemetry: Battery/Solar V, I, T, SOC — see [TELEMETRY.md](TELEMETRY.md) | +| `get board.stats` | Energy balance (24h/3d/7d), C/D, MPPT%, TTL (7d-avg-based) | +| `get board.cinfo` | Charger status + PG-stuck HIZ toggle (e.g. "PG / CC HIZ:3m ago") | +| `get board.bqdiag` | Diagnostic/debug: BQ25798 register dump — PG/charge state, TS region, active fault flags | +| `get board.selftest` | I2C device probe — `INA:OK BQ:OK RTC:OK BME:OK` (RTC also write-verified) | +| `get board.socdebug` | Diagnostic/debug: SOC internals — SHUNT_CAL, current, CHARGE, hour accumulators, derating factor | +| `get board.tccal` | NTC temperature offset in °C (`0.00` = default) | + +--- + +## Setter Quick Reference + +| Command | Range | Description | +|---|---|---| +| `set board.bat` | `liion1s` · `lifepo1s` · `lto2s` · `naion1s` · `none` | Set battery chemistry | +| `set board.batcap` | `100`–`100000` (mAh) | Set battery capacity | +| `set board.imax` | `50`–`1500` (mA) | Set max charge current | +| `set board.fmax` | `0%` · `20%` · `40%` · `100%` | Frost charge reduction (not for LTO/Na-Ion) | +| `set board.mppt` | `0`/`1` · `true`/`false` | Enable/disable MPPT | +| `set board.leds` | `on`/`off` · `1`/`0` | Enable/disable LEDs | +| `set board.soc` | `0`–`100` (%) | Manually set SOC | +| `set board.tccal` | `reset` · *(empty = auto)* | Calibrate or reset NTC temperature | + +--- + +## Quick-Start Recipes + +### Li-Ion 1S with 10Ah and Solar +```bash +set board.bat liion1s +set board.batcap 10000 +set board.imax 500 +set board.fmax 20% +set board.mppt 1 +set board.leds off +``` + +### LiFePO4 1S with 6Ah and Solar +```bash +set board.bat lifepo1s +set board.batcap 6000 +set board.imax 300 +set board.fmax 40% +set board.mppt 1 +set board.leds off +``` + +### LTO 2S with 18Ah and Solar +```bash +set board.bat lto2s +set board.batcap 18000 +set board.imax 700 +set board.mppt 1 +set board.leds off +``` + +### Na-Ion 1S with 10Ah and Solar +```bash +set board.bat naion1s +set board.batcap 10000 +set board.imax 500 +set board.mppt 1 +set board.leds off +``` + +### Status Check (everything at a glance) +```bash +get board.conf +get board.telem +get board.stats +get board.cinfo +``` + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/DATASHEET.md b/variants/inhero_mr2/docs/DATASHEET.md new file mode 100644 index 0000000000..fc92e7b0ec --- /dev/null +++ b/variants/inhero_mr2/docs/DATASHEET.md @@ -0,0 +1,235 @@ +# Inhero MR2 — Datasheet + +> **Inhero MR2 – Smart Solar Mesh Board** +> Hardware Revision 1.1 + +--- + +## Board Overview + +The Inhero MR2 is a LoRa mesh repeater board based on the **RAK4630** module (nRF52840 + SX1262) with integrated smart solar charging, power monitoring, and low-voltage protection. Supported battery configurations are 1S Li-Ion, 1S LiFePO4, 2S LTO, and 1S Na-Ion. The board was specifically designed for autonomous long-term deployment at remote or hard-to-reach locations. In Central Europe, uninterrupted continuous repeater operation is possible with unshaded solar panels ≥ 1 W and battery capacities ≥ 9 Ah. + +The charge and discharge cutoff voltages (see table [Supported Battery Chemistries](#supported-battery-chemistries)) are chosen to avoid excessive stress on the batteries during summer while ensuring that sleep mode can be reliably initiated when energy is low. + +In low-voltage sleep, current consumption is < 500 µA. Once the battery voltage has risen above the respective low-V wake threshold (see table [Supported Battery Chemistries](#supported-battery-chemistries)) through solar charging, the board boots normally. The 200 mV hysteresis between sleep and wake thresholds prevents motorboating – an uncontrolled, rapid on/off cycling of the system that would occur if the sleep and wake thresholds were too close together. + +### Safety & Protection Features + +| Feature | Description | +|---------|-------------| +| **Watchdog Timer (WDT)** | nRF52840 hardware watchdog. Automatically reboots the board if the firmware hangs – essential for unattended long-term operation. | +| **Low-Voltage Protection** | INA228 ALERT interrupt on chemistry-specific threshold → controlled System Sleep with RTC wake. Solar charging remains active during sleep (CE pin latched). | +| **Charger requires active firmware** | The BQ25798 only charges when the firmware is actively running. Without flashed firmware or with the 3.3V off switch engaged, charging remains disabled. The nRF52840 must be able to monitor the charger at all times as host. | +| **JEITA Temperature Protection** | Temperature-dependent charge current reduction via the NTC sensor (TS pin). Frost charge protection configurable via `set board.fmax`. JEITA is disabled for LTO and Na-Ion. The Inhero voltage divider (RT1=5.6 kΩ, RT2=27 kΩ) shifts TS thresholds lower than TI reference (~2–3 °C; effective T-Cool range approx. −2 °C to +3 °C, see JEITA table in README). WARM zone configured to start at ~52 °C (register: 55 °C), effectively neutralized (VREG + ICHG unchanged in WARM), auto battery discharge disabled — see [README.md — JEITA](README.md#jeita-temperature-zone-configuration) for details. **Note:** JEITA thresholds are evaluated by the BQ25798 directly in hardware. The `set board.tccal` calibration corrects only the CLI/telemetry temperature readout and does not affect JEITA behavior — see [FAQ #12](FAQ.md#12-when-should-i-run-set-boardtccal). | + +> **⚠ WARNING — No Reverse Polarity Protection:** The board has **no hardware reverse polarity protection** on the battery or solar input. Connecting a battery or solar panel with reversed polarity will cause **immediate, irreversible damage** to the board. Always double-check the polarity before connecting any power source. + +### Solar Power Management + +| Feature | Description | +|---------|-------------| +| **MPPT (Maximum Power Point Tracking)** | The BQ25798 optimizes solar harvesting via MPPT (VOC_PCT = 81.25%, matched for crystalline silicon solar cells). Automatic recovery on power-good loss and stuck-PGOOD detection with HIZ toggle. | +| **PFM Forward Mode** | Enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it. Improves efficiency at low solar currents. | + +### Specifications + +| Parameter | Value | +|---|---| +| **MCU** | nRF52840 (ARM Cortex-M4, 64 MHz) | +| **Radio** | Semtech SX1262 (via RAK4630) | +| **Frequency** | LoRa Sub-GHz (region-dependent) | +| **Connectivity** | LoRa, BLE 5.0, USB-C | +| **Supply Voltage** | 1S Li-Ion / 1S LiFePO4 / 2S LTO / 1S Na-Ion (via firmware config) | +| **Solar Input** | 3.6 V – 24 V (MPPT) | +| **Max. Solar Voc** | 25 V | +| **USB Charging** | 5 V via USB-C (Schottky diode to VBUS-BQ, same charger path as solar) | +| **Charger** | BQ25798 (MPPT, JEITA) | +| **Max. Charge Current** | 50 – 1500 mA (configurable) | +| **Power Monitor** | INA228 (Coulomb Counter, ALERT) | +| **RTC** | RV-3028-C7 (time base / wake-up timer). See [FAQ #23](FAQ.md#23-why-does-the-repeater-board-need-a-correct-time) | +| **Buck Converter** | TPS62840 (3.3 V rail, max. 750 mA) | +| **System-Off Current** | via 3.3V off switch ~15 µA | +| **System Sleep Current** | < 500 µA (firmware sleep with GPIO latch, CE active, RTC wake) | +| **Idle Current (active)** | 6.0 mA @ 4.2 V / 7.7 mA @ 3.3 V (USB off, no radio TX) | +| **USB Peripheral** | ~0.8–1.0 mA additional (auto-enabled on VBUS detect, auto-disabled on removal) | +| **CPU Idle Mode** | WFE (Wait-For-Event) between loop iterations, reduces CPU current from ~3 mA to ~0.5–0.8 mA | +| **PCB Size** | 45 × 40 mm | +| **Mounting Holes** | 4× M2.5, hole spacing 40 × 35 mm | +| **Operating Temperature** | –40 °C to +85 °C (MCU spec) | +| **Bootloader** | Adafruit nRF52 OTA-Fix Bootloader (factory-installed), UF2-capable | + +--- + +## PCB – Front Side (Component Side) + +![Inhero MR2 Front](img/front.jpg) + +![Inhero MR2 Front – Annotated](img/front-annotated_.png) + +### Connectors, Buttons & LEDs – Front Side + +| Label (→ image) | Name | Description | +|-----------------|------|-------------| +| **Ble-Conn** | U.FL – BLE | Antenna connector for Bluetooth Low Energy (top left on RAK4630) | +| **LoRa-Conn** | U.FL – LoRa | Antenna connector for LoRa Sub-GHz (left center on RAK4630) | +| **USB-C** | USB-C Port | USB interface for power supply, charging, firmware flashing and CLI access (top right). CC1/CC2 pulled to GND via 4.7 kΩ (USB sink). VBUS-USB is connected to VBUS-BQ (solar input) via a Schottky diode — USB power feeds the same charger input as the solar panel. | +| **Reset** | Reset Button | Single click: reset the nRF52840. Double click: enter USB mass storage mode for UF2 firmware updates (right side, below USB-C) | +| **Led 1+2** | Status LEDs | LED1 + LED2 = RAK4630 user LEDs (heartbeat / boot indicator, right side, stacked) | +| **Chrg. Led** | Charge LED | BQ25798 STAT output – indicates charge status (bottom right, next to solar connector) | +| **3.3V off** | Power Switch | Slide switch to disconnect the 3.3 V supply (bottom left). **⚠ Caution: Inverted logic!** Switch position "ON" = EN pin low = board **off**. Switch position "OFF" = EN pin high = board **on**. | +| **Bat-Conn** (JST PH2.0-3P) | Battery Connector | 3-pin JST PH2.0 connector: **Batt+**, **Batt−**, **TS** (bottom left) | +| **Solar-Conn** (JST PH2.0-2P) | Solar Connector | 2-pin JST PH2.0 connector: **Solar+**, **Solar−** (bottom right) | +| **Ø 2.5mm** | Mounting Holes | 4× M2.5 mounting holes in the corners | + +### Key Components – Front Side + +| Component | Name | Description | +|-----------|------|-------------| +| **RAK4630** | Core Module | nRF52840 SoC + SX1262 LoRa transceiver (center, shielded) | +| **BME280** | Environmental Sensor | Temperature, humidity, pressure | +| **BQ25798** | Battery Charger | MPPT, JEITA temperature protection, 15-bit ADC | +| **INA228** | Power Monitor | Coulomb counter with ALERT interrupt | +| **TPS62840** | Buck Converter | DC/DC, 750 mA, EN switched via 3.3V off switch | + +### Pinout – Battery Connector (JST PH2.0-3P, left to right) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **Batt +** | Battery positive terminal | +| 2 | **Batt −** | Battery negative terminal (GND) | +| 3 | **TS** | Temperature sensor (NTC) for JEITA charge protection. Required type: NCP15XH103F03RC (10 kΩ @ 25 °C, Beta 3380) or compatible | + +> **⚠ WARNING:** No reverse polarity protection. Verify correct polarity before connecting. + +### Pinout – Solar Connector (JST PH2.0-2P, left to right) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **Solar +** | Solar panel positive (3.6 V – 24 V, max. Voc 25 V) | +| 2 | **Solar −** | Solar panel negative (GND) | + +> **⚠ WARNING:** No reverse polarity protection. Verify correct polarity before connecting. + +### USB Charging Path + +USB-C VBUS is connected to the BQ25798 VBUS input (same single input as solar) via a **Schottky diode**. The BQ25798 has only one VBUS input and does not distinguish between USB and solar. CC1 and CC2 are pulled to GND via 4.7 kΩ resistors, advertising the board as a USB power sink (5 V default). The Schottky diode prevents backflow from the solar panel to the USB bus, but current **can** flow from USB-VBUS out through the solar connector. + +#### USB Auto-Management + +The nRF52840 USB peripheral is automatically managed based on VBUS detection: + +- **VBUS detected** → USB peripheral enabled (Serial available) +- **VBUS removed** → USB peripheral disabled (saves ~0.8–1.0 mA) +- **Boot without USB** → USB disabled on first loop iteration + +No manual CLI commands are required. USB is always available when a cable is connected. See also [FAQ #7 — USB charging](FAQ.md#7-can-i-charge-the-board-via-usb). + +> **⚠ Warning:** Since VBUS-USB and VBUS-BQ (solar input) are connected via the Schottky diode, a **short circuit on the solar connector** will also short VBUS-USB. Never short-circuit the solar input while USB is connected. + +See also [FAQ #16 — 3.3V off switch](FAQ.md#16-what-does-the-33v-off-switch-do-and-when-would-i-use-it) for practical use cases. + +--- + +## PCB – Back Side + +![Inhero MR2 Back](img/back.jpg) + +![Inhero MR2 Back – Annotated](img/back-annotated_.png) + +### Headers & Pads – Back Side + +#### UART/I2C – Header Row 1 (top row, castellated pads) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **GND** | Ground | +| 2 | **RX** | UART Receive | +| 3 | **TX** | UART Transmit | +| 4 | **SDA** | I2C Data | +| 5 | **SCL** | I2C Clock | +| 6 | **3.3V** | 3.3 V output (max. 500 mA, shared with board consumption) | + +#### SWD – Header Row 2 (bottom row, castellated pads) + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | **RESET** | nRF52840 Reset | +| 2 | **GND** | Ground | +| 3 | **SWCLK** | SWD Clock (debug interface) | +| 4 | **SWDIO** | SWD Data (debug interface) | +| 5 | **3.3V** | 3.3 V output (max. 500 mA, shared with board consumption) | + +#### Solder Bridge – Onboard Temperature Sensor (bottom right) + +| Label (→ image) | Description | +|-----------------|-------------| +| **Solder-Bridge** (close for onboard Temp-Sensor) | Solder bridge for the onboard NTC temperature sensor (NCP15XH103F03RC, 10 kΩ @ 25 °C, Beta 3380). **Closed** = onboard NTC active. **Open** = external NTC of type NCP15XH103F03RC (10 kΩ @ 25 °C, Beta 3380) or compatible required via TS pin on the battery connector. See [FAQ #2](FAQ.md#2-can-i-use-battery-packs-without-a-built-in-ntc). | + +--- + +## I2C Bus – Address Map + +| Address | Component | Function | +|---------|-----------|----------| +| 0x40 | INA228 | Power Monitor / Coulomb Counter | +| 0x52 | RV-3028-C7 | Real-Time Clock (RTC) | +| 0x6B | BQ25798 | Battery Charger (MPPT, JEITA) | +| 0x76 | BME280 | Environmental Sensor (T, H, P) | + +--- + +## Pin Assignment (Key GPIOs) + +| nRF52840 Pin | RAK Module Pin | Function | +|--------------|----------------|----------| +| P0.04 | WB_IO4 | BQ CE pin (via DMN2004TK-7 N-FET, inverted) | +| P1.02 | WB_IO2 | INA228 ALERT (low-voltage interrupt) | +| P0.17 | WB_IO1 | RV-3028 RTC interrupt | +| P0.21 | WB_IO3 | BQ25798 INT (unused, polled; pulled up) | + +--- + +## Supported Battery Chemistries + +| Type | Nominal Voltage | Charge Voltage | Low-V Sleep | Low-V Wake | Hysteresis | +|------|----------------|----------------|-------------|------------|------------| +| **Li-Ion 1S** | 3.7 V | 4.1 V | 3100 mV | 3300 mV | 200 mV | +| **LiFePO4 1S** | 3.2 V | 3.5 V | 2700 mV | 2900 mV | 200 mV | +| **LTO 2S** | 4.6 V (2× 2.3 V) | 5.4 V | 3900 mV | 4100 mV | 200 mV | +| **Na-Ion 1S** | 3.1 V | 3.9 V | 2500 mV | 2700 mV | 200 mV | +| **none** | — | — | — | — | — | + +> **Choosing the right chemistry:** See [BATTERY_GUIDE.md](BATTERY_GUIDE.md) for a detailed comparison of pros, cons, and deployment recommendations. A brief summary is also in [FAQ #1](FAQ.md#1-which-battery-chemistry-should-i-choose). + +--- + +## Firmware Environments + +| Build Target | Description | +|---|---| +| `Inhero_MR2_repeater` | Standard repeater | +| `Inhero_MR2_repeater_bridge_rs232` | Repeater with RS232 bridge (Serial2 on P0.19/P0.20) | +| `Inhero_MR2_sensor` | Sensor firmware | + +--- + +## Absolute Maximum Ratings + +| Parameter | Min | Max | Unit | +|---|---|---|---| +| Solar input voltage (Voc) | — | 25 | V | +| Charge current (configurable) | 50 | 1500 | mA | +| Shunt current (INA228, 100 mΩ) | — | 1600 | mA | +| Ambient temperature (operating) | –40 | +85 | °C | + +--- + +## See Also + +- [README.md](README.md) – Overview, feature matrix and diagnostics +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) – Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) – Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) – Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) – All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) – Complete technical documentation diff --git a/variants/inhero_mr2/docs/FAQ.md b/variants/inhero_mr2/docs/FAQ.md new file mode 100644 index 0000000000..1d95269390 --- /dev/null +++ b/variants/inhero_mr2/docs/FAQ.md @@ -0,0 +1,403 @@ +# Inhero MR2 — FAQ + +## Contents + + +**⚡ Battery & Chemistry** + +1. [Which battery chemistry should I choose?](#1-which-battery-chemistry-should-i-choose) +2. [Can I use battery packs without a built-in NTC?](#2-can-i-use-battery-packs-without-a-built-in-ntc) +3. [Why does current draw increase when battery voltage drops?](#3-why-does-current-draw-increase-when-battery-voltage-drops) + +**🔋 Charging & Solar** + +4. [What mAh value should I enter for `set board.batcap`?](#4-what-mah-value-should-i-enter-for-set-boardbatcap) +5. [Why is it important to set the maximum charge current with `set board.imax`?](#5-why-is-it-important-to-set-the-maximum-charge-current-with-set-boardimax) +6. [What does `set board.fmax` control?](#6-what-does-set-boardfmax-control) +7. [Can I charge the board via USB?](#7-can-i-charge-the-board-via-usb) +8. [Which solar panels can I connect?](#8-which-solar-panels-can-i-connect) +9. [The red LED (BQ status LED) blinks slowly and the battery is not charging.](#9-the-red-led-bq-status-led-blinks-slowly-and-the-battery-is-not-charging) +10. [Why doesn't the board charge without flashed firmware?](#10-why-doesnt-the-board-charge-without-flashed-firmware) + +**📊 SOC & Monitoring** + +11. [Why does the SOC show 0% or N/A?](#11-why-does-the-soc-show-0-or-na) +12. [When should I run `set board.tccal`?](#12-when-should-i-run-set-boardtccal) +13. [How does temperature derating work?](#13-how-does-temperature-derating-work) +14. [What is TTL (Time-To-Live)?](#14-what-is-ttl-time-to-live) + +**🔩 Hardware** + +15. [Does the board have reverse polarity protection?](#15-does-the-board-have-reverse-polarity-protection) +16. [What does the "3.3V off" switch do, and when would I use it?](#16-what-does-the-33v-off-switch-do-and-when-would-i-use-it) +17. [What do the LEDs mean?](#17-what-do-the-leds-mean) +18. [Can I operate the board without an antenna?](#18-can-i-operate-the-board-without-an-antenna) +19. [Why does the RTC have no backup battery?](#19-why-does-the-rtc-have-no-backup-battery) +20. [What are the dimensions of the mounting holes?](#20-what-are-the-dimensions-of-the-mounting-holes) +21. [Are interfaces (UART/I2C) exposed on the board?](#21-are-interfaces-uarti2c-exposed-on-the-board) + +**⚙️ Firmware** + +22. [Are my settings preserved during a firmware update?](#22-are-my-settings-preserved-during-a-firmware-update) +23. [Why does the repeater board need a correct time?](#23-why-does-the-repeater-board-need-a-correct-time) +24. [Why can't the repeater clock be set backwards?](#24-why-cant-the-repeater-clock-be-set-backwards) + +--- + +**⚡ Battery & Chemistry** + +### 1. Which battery chemistry should I choose? + +The Inhero MR2 supports **Li-Ion**, **LiFePO4**, **LTO (2S)**, and **Na-Ion**. The right choice depends on your deployment conditions — especially temperature range, available space, and expected service life. + +In short: **LiFePO4** for most indoor/temperate setups, **LTO** for extreme cold or maximum cycle life, **Li-Ion** when space is tight, **Na-Ion** for sustainable cold-weather deployments. + +→ **Full guide:** [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Detailed comparison, pros & cons, deployment recommendations, capacity planning, solar sizing, safety tips, and long-term aging. + +→ **Setup:** [QUICK_START.md — Step 6](QUICK_START.md#6-set-battery-chemistry) | [CLI_CHEAT_SHEET.md — Quick-Start Recipes](CLI_CHEAT_SHEET.md#quick-start-recipes) + +--- + +### 2. Can I use battery packs without a built-in NTC? + +**Yes, but only with the onboard NTC.** Close the solder bridge on the back side of the board — this activates the onboard NTC (NCP15XH103F03RC, 10 kΩ @ 25 °C, Beta 3380). The TS pin on the battery connector remains unused in this case. + +If your battery pack has a built-in NTC, it must be wired between **TS (Pin 3)** and **GND (Pin 2)** (see [DATASHEET.md — Battery Connector](DATASHEET.md#pinout--battery-connector-jst-ph20-3p-left-to-right)). A compatible 10k NTC (Beta ~3380) is sufficient for basic frost protection — however, temperature accuracy will be slightly reduced. + +**Important:** Without an NTC (solder bridge open and no external NTC connected), the BQ25798 interprets the TS pin as a frost condition for Li-Ion and LiFePO4 — charging is blocked and the BQ status LED blinks. This does not occur with LTO and Na-Ion, as JEITA is disabled for those chemistries. + +--- + +### 3. Why does current draw increase when battery voltage drops? + +The Inhero MR2 has a high-efficiency **buck converter** that converts the battery voltage down to 3.3 V for the MCU and radio. Because this converter is efficient, the board draws roughly **constant power** (watts), not constant current (amps). + +Since Power = Voltage × Current: +- At 4.6 V (LTO full): ~6.3 mA +- At 3.7 V (Li-Ion nominal): ~7.8 mA +- At 3.2 V (LiFePO4 nominal): ~9.1 mA + +All three cases consume exactly **29 mW**. This is normal, not a fault. + +**Practical consequence:** When sizing batteries, always calculate in **Wh** (energy), not mAh — especially when comparing different chemistries. A naive "mA × hours" calculation overestimates the capacity needed for higher-voltage chemistries like LTO. + +→ **Full explanation:** [BATTERY_GUIDE.md — Why current depends on battery voltage](BATTERY_GUIDE.md#why-current-depends-on-battery-voltage) + +--- + +**🔋 Charging & Solar** + +### 4. What mAh value should I enter for `set board.batcap`? + +Enter the **nominal capacity minus a deduction**. Since the charge cutoff voltage is reduced for battery longevity, the full nominal capacity is not available. A slightly pessimistic value is safer: when the SOC shows 10 %, there really is ≥ 10 % left in the battery. This prevents being surprised by an unexpected low-voltage sleep. The TTL prediction (Time-To-Live) also becomes more conservative and reliable. + +**Rule of thumb: 90 % of nominal capacity.** Example: 10,000 mAh nominal → `set board.batcap 9000`. + +For parallel cells, add capacities before the deduction: Two 5,000 mAh cells in parallel = 10,000 mAh nominal → `set board.batcap 9000`. + +--- + +### 5. Why is it important to set the maximum charge current with `set board.imax`? + +`imax` sets the **maximum charge current** — the maximum current flowing into the battery. The firmware also uses `imax` together with the configured chemistry's charge voltage to automatically calculate how much current it may draw from the solar panel. This prevents weak panels from being overloaded and the charger from shutting down. + +Why set `imax` correctly? + +1. **Basis for frost protection:** `imax` is the reference value for `fmax`. Example: `imax 500` with `fmax 20%` results in a maximum of 100 mA charge current in the T-Cool range (+3 °C to –2 °C). + +2. **Battery care:** Lower charge currents are always gentler on the battery. Set `imax` only as high as necessary. + +3. **Panel compatibility:** If `imax` is set too high, the board briefly tries to draw more current from the panel than it can deliver — the charger detects the voltage drop and stops charging. + +**Calculation:** Panel power ÷ battery voltage = imax. +Example: 2 W panel, Li-Ion (3.7 V) → 2000 / 3.7 ≈ 540 mA → `set board.imax 540`. + +--- + +### 6. What does `set board.fmax` control? + +`fmax` limits the maximum charge current in the **T-Cool range** (+3 °C to –2 °C with the Inhero voltage divider) to a percentage of `imax`: + +| Setting | Behavior in T-Cool range | +|---|---| +| `0%` | Charging completely blocked | +| `20%` | Max. 20 % of imax (e.g., 500 mA → 100 mA) | +| `40%` | Max. 40 % of imax (e.g., 500 mA → 200 mA) | +| `100%` | No reduction, full charge current | + +**Below approx. –2 °C (T-Cold),** charging is always completely blocked by JEITA for **Li-Ion and LiFePO4** — regardless of `fmax`. + +**Important:** Only charging is restricted. With sufficient solar power, the board continues to run on solar — the battery is neither charged nor discharged. + +**LTO / Na-Ion:** `fmax` has no effect, as JEITA is disabled for these chemistries. + +--- + +### 7. Can I charge the board via USB? + +**Yes.** USB-C VBUS (5 V) is connected to the BQ25798 VBUS input via a **Schottky diode** — the **same single input** as the solar panel (see [DATASHEET.md — USB Charging Path](DATASHEET.md#usb-charging-path)). The BQ25798 has only one VBUS input and does not distinguish between the two sources. + +When USB is detected (nRF52840 VBUS sense), the firmware automatically limits the input current to **500 mA** (USB 2.0 spec). When USB is removed, the input current limit is recalculated from the configured chemistry and `board.imax`. + +Whichever source provides the higher voltage at the VBUS input is active: If USB voltage (minus Schottky drop) exceeds the solar voltage, USB charges. Otherwise, solar charges. Both sources cannot charge simultaneously. + +> **⚠ WARNING:** The Schottky diode prevents backflow from the solar panel to the USB bus, but current **can** flow from USB-VBUS out through the solar connector. A **short circuit on the solar connector will also short USB-VBUS**. Never short-circuit the solar input while USB is connected. + +--- + +### 8. Which solar panels can I connect? + +**Requirements:** +- **Input voltage:** 3.6 V – 24 V (MPPT range of the BQ25798) +- **Max. open-circuit voltage (Voc):** 25 V — do not exceed! +- **Connector:** JST PH2.0-2P (Solar+, Solar–) + +**Typical panels:** 5 V or 6 V monocrystalline solar panels. The buck/boost charger can also charge higher battery voltages from lower panel voltages (e.g., 5 V panel → LTO 2S at 5.4 V). + +**Not suitable:** 24 V panels or series connections whose Voc can exceed 25 V. See [DATASHEET.md — Specifications](DATASHEET.md#specifications) for the full electrical limits. + +**Sizing (Central Europe):** +- **1 W monocrystalline** is the minimum requirement — only with south-facing, vertical mounting, unshaded, and battery capacity ≥ 7 Ah. +- **From 2 W**, reliable year-round operation is possible. + +--- + +### 9. The red LED (BQ status LED) blinks slowly and the battery is not charging. + +Slow blinking of the BQ status LED indicates a **charger fault**. Most common causes: + +1. **No NTC connected (most frequent):** Neither an external NTC on the TS pin nor the solder bridge for the onboard NTC is closed. The BQ25798 interprets the open TS pin as a frost condition and blocks charging. → **Solution:** Close the solder bridge or connect a compatible NTC (10 kΩ @ 25 °C, Beta ~3380) between TS (Pin 3) and GND (Pin 2). + +2. **Actually too cold / too warm:** Below –2 °C (T-Cold threshold with Inhero voltage divider), charging is completely blocked by JEITA for Li-Ion and LiFePO4. Above ~58 °C (T-Hot threshold), charging is also suspended. → This does not occur with LTO and Na-Ion (JEITA disabled). + +3. **Other charger fault:** The BQ25798 can also signal faults such as VBAT overvoltage (VBAT_OVP), input overvoltage (VBUS_OVP), or watchdog timeout. These are less common in normal operation. + +→ Check with [`get board.telem`](CLI_CHEAT_SHEET.md#getters-query-status) for the current temperature and [`get board.cinfo`](CLI_CHEAT_SHEET.md#getters-query-status) for the charger status; fault flags are shown by [`get board.bqdiag`](CLI_CHEAT_SHEET.md#getters-query-status). + +--- + +### 10. Why doesn’t the board charge without flashed firmware? + +This is a deliberate safety feature. The BQ25798 charger is controlled via the **CE pin (Charge Enable)**, which requires the firmware to actively drive GPIO4 HIGH. + +**Without firmware** (or with the 3.3V off switch engaged): +- External pull-down on the DMN2004TK-7 FET gate → FET OFF → CE HIGH → **charging disabled** + +This ensures the battery cannot be overcharged if the firmware locks up or is not installed. Flash the firmware via USB and configure the battery chemistry (`set board.bat …`) to enable charging. See [POWER_MANAGEMENT.md — CE Pin Safety](POWER_MANAGEMENT.md#10-bq25798-ce-pin-safety-rev-11--fet-inverted) for the hardware design. + +--- + +**📊 SOC & Monitoring** + +### 11. Why does the SOC show 0% or N/A? + +**SOC shows N/A** until the battery has been **fully charged at the board for the first time**. The coulomb counter needs a known reference point (100% = "Charge Done" event) to calculate SOC accurately. Charge the battery completely once via USB after commissioning. See [POWER_MANAGEMENT.md — Coulomb Counter & SOC](POWER_MANAGEMENT.md#2-coulomb-counter--soc-state-of-charge) for the tracking mechanism. + +**SOC shows 0%** after the board wakes from **low-voltage sleep**. This is intentional: the coulomb counter was not running during sleep, so the charge state is unknown. SOC restarts at 0% and begins accumulating again. When the battery next reaches "Charge Done", the SOC synchronizes cleanly to 100%. + +**Note:** In cold conditions, the extractable capacity is lower than stored charge. `get board.telem` shows this as `SOC:95.0% (79%)`. The TTL accounts for this automatically. See [FAQ #13](FAQ.md#13-how-does-temperature-derating-work). + +--- + +### 12. When should I run `set board.tccal`? + +**Ideally in the early morning**, before sunrise. At that time the battery temperature has equalized with the ambient temperature overnight, and no solar radiation has warmed the enclosure yet. This gives the BME280 and the NTC the most consistent baseline for calibration. + +**Why timing matters:** During the day, solar radiation heats the enclosure unevenly — the NTC (close to the battery) and the BME280 (on the PCB) may report different temperatures, resulting in an inaccurate offset. In the early morning, both sensors are at thermal equilibrium. + +**Why TCCal exists:** An NTC and its associated voltage divider resistors are subject to component tolerances that produce measurement errors significantly larger than those of the BME280. Since the BME280 is on the board, it can serve as a reference to calibrate the NTC reading. + +**Important limitations:** +- **Affects telemetry and CLI only.** TCCal corrects the battery temperature displayed via `get board.telem` and transmitted over telemetry. The BQ25798 JEITA thresholds are **not** affected — the charger evaluates the TS pin directly in hardware. Therefore, the actual JEITA switching temperatures may differ slightly from the calibrated CLI readout. +- **Single-point calibration.** The offset is determined at one temperature. Away from the calibration temperature the correction drifts, because NTC non-linearity and divider errors are temperature-dependent. + +**Command:** `set board.tccal` — auto-calibrates the NTC offset using the BME280 as reference. Use `set board.tccal reset` to reset the offset to 0.00. See [`get board.tccal`](CLI_CHEAT_SHEET.md#getter-quick-reference) to verify the current offset. + +--- + +### 13. How does temperature derating work? + +SOC% is **purely Coulomb-based** — it reflects the actual stored charge and does not change with temperature. Only real charge flow (measured by the INA228 coulomb counter) changes SOC%. + +However, the **extractable capacity** decreases at cold temperatures due to slower electrochemical kinetics and increased internal resistance during TX peaks (~100 mA). The firmware calculates a per-chemistry derating factor `f(T)` that is used for: +- **TTL calculation** — Trapped Charge model: extractable = max(0, remaining − capacity × (1−f(T))) +- **CLI display** — `get board.telem` shows the derated value in parentheses: `SOC:95.0% (78%)` = stored (extractable) + +The derating factor is visible in `get board.socdebug` (field `d=`). + +→ **Full details:** [POWER_MANAGEMENT.md — Temperature Derating](POWER_MANAGEMENT.md#5-time-to-live-ttl-prediction) + +--- + +### 14. What is TTL (Time-To-Live)? + +TTL is an estimated **remaining runtime** based on the current energy balance. It is shown in [`get board.stats`](CLI_CHEAT_SHEET.md#getters-query-status). See [POWER_MANAGEMENT.md — TTL Prediction](POWER_MANAGEMENT.md#5-time-to-live-ttl-prediction) for the algorithm. + +**How it works:** +- A 168-hour ring buffer (7 days) records hourly charge/discharge data from the INA228 coulomb counter. +- **Formula:** `TTL = extractable capacity / |7-day avg. daily net consumption| × 24h` — where extractable = SOC%-based remaining charge minus the temperature-locked share (see Cold weather below; identical to `SOC% × capacity / 100` at moderate temperatures) +- **Display format:** `T:12d0h` (12 days, 0 hours) or `T:12h` (< 24 hours) + +**TTL shows N/A or 0 when:** +- Less than 24 hours of data have been collected +- The board is running on solar surplus (no deficit) + +**Note:** If `set board.batcap` is not set, a rough chemistry default (1500–2000 mAh) is used — set the real capacity for a meaningful TTL. + +**Cold weather:** TTL uses the Trapped Charge model — cold temperatures lock the bottom of the discharge curve, so extractable capacity drops faster than SOC% at low charge levels. This is especially critical in winter: at 20% SOC, the extractable capacity may already be near zero. See [FAQ #13](FAQ.md#13-how-does-temperature-derating-work). + +--- + +**🔩 Hardware** + +### 15. Does the board have reverse polarity protection? + +**No.** The board has **no hardware reverse polarity protection** — neither on the battery nor on the solar input. A reverse-connected battery or solar panel can cause **immediate, irreversible damage** to the board. + +**Always verify polarity before plugging in any cable.** See [DATASHEET.md — Safety & Protection Features](DATASHEET.md#safety--protection-features). + +--- + +### 16. What does the "3.3V off" switch do, and when would I use it? + +The slide switch labeled **"3.3V off"** on the bottom-left of the PCB controls the EN pin of the TPS62840 buck converter (see [DATASHEET.md — Connectors, Buttons & LEDs](DATASHEET.md#connectors-buttons--leds--front-side)). + +> **⚠ Caution — Inverted logic:** +> - Switch position **"ON"** = EN pin low = board **powered off** +> - Switch position **"OFF"** = EN pin high = board **running** + +With the 3.3V rail disabled, the nRF52840, the RF frontend, and all 3.3V-powered components (INA228, RV-3028 RTC, BME280) are completely de-energized. Only the BQ25798 charger IC remains powered from VBAT (~15 µA quiescent current). **Charging is disabled** in this state — the firmware must be running to supervise the charger. Note: the RTC loses its time when the 3.3V rail is off — see [FAQ #23](#23-why-does-the-repeater-board-need-a-correct-time). + +**Use cases:** +- **Antenna swap:** Safely power down the RF frontend on a deployed board without disconnecting battery or solar. +- **Transport:** Switch off the board during shipping or relocation. +- **Short/medium-term storage:** ~15 µA total consumption. For longer storage (months), disconnect the battery entirely. + +--- + +### 17. What do the LEDs mean? + +The board has three LEDs: + +| LED | Location | Color | Meaning | +|-----|----------|-------|--------| +| **LED1** | Right side, top | Blue | Heartbeat (periodic blink during normal operation). Short flash during boot for each successfully initialized component (INA228, BQ25798, RTC). | +| **LED2** | Right side, bottom | Red | Hardware error indicator. Blinks permanently if a critical component (BQ25798, INA228, or RTC) was not found during initialization. | +| **Charge LED** | Bottom right, next to solar connector | Red | BQ25798 charge status output (hardware-controlled). Solid on = charging active. Off = not charging or charging done. Slow blinking = charger fault (see FAQ #9). | + +All three LEDs can be disabled with [`set board.leds off`](CLI_CHEAT_SHEET.md#setters-change-configuration). + +**Note:** The descriptions for LED1/LED2 apply only after the firmware has booted. The bootloader uses its own LED patterns (e.g., slow blue pulsing during OTA/UF2 updates). + +--- + +### 18. Can I operate the board without an antenna? + +**No.** Operating without an antenna risks **irreversible damage** to the RF frontend (SX1262 radio). Always connect both antennas (LoRa and BLE) before powering on. + +If you need to swap or install antennas on an already deployed board, use the **3.3V off switch** (see FAQ #16) to safely de-energize the RF frontend without disconnecting battery or solar. + +--- + +### 19. Why does the RTC have no backup battery? + +The RV-3028-C7 RTC has two main functions: +1. **Stable time base** with minimal drift for MeshCore. +2. **Wake-up timer** for low-voltage sleep (hourly wake-up for voltage check). + +As long as a battery is connected, the RTC is continuously powered — including during System Sleep. After a low-voltage sleep and reboot, the time is preserved. + +A backup battery (e.g., CR2032) was intentionally omitted. Its only additional benefit would be to preserve the time when the battery is disconnected. This does not justify the space required on the compact 45 × 40 mm form factor. See [FAQ #23](#23-why-does-the-repeater-board-need-a-correct-time) for why a correct clock matters. + +--- + +### 20. What are the dimensions of the mounting holes? + +The board has **4× M2.5 mounting holes** with a diameter of **2.5 mm** and a hole spacing of **35 × 40 mm**. The PCB itself measures 45 × 40 mm. + +--- + +### 21. Are interfaces (UART/I2C) exposed on the board? + +**Yes.** Two rows of castellated pads are available on the back side of the PCB: + +**Row 1 — UART / I2C:** + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | GND | Ground | +| 2 | RX | UART Receive | +| 3 | TX | UART Transmit | +| 4 | SDA | I2C Data | +| 5 | SCL | I2C Clock | +| 6 | 3.3V | 3.3 V output (max. 500 mA, shared with board consumption) | + +**Row 2 — SWD (Debug):** + +| Pin | Signal | Description | +|-----|--------|-------------| +| 1 | RESET | nRF52840 Reset | +| 2 | GND | Ground | +| 3 | SWCLK | SWD Clock | +| 4 | SWDIO | SWD Data | +| 5 | 3.3V | 3.3 V output (max. 500 mA, shared with board consumption) | + +The castellated pads can be soldered directly to a carrier board. See [DATASHEET.md — Headers & Pads](DATASHEET.md#headers--pads--back-side) for the complete pad layout. + +--- + +**⚙️ Firmware** + +### 22. Are my settings preserved during a firmware update? + +**Yes.** All board-specific settings are stored on the **LittleFS filesystem**, which is preserved during firmware updates. This includes: + +- Battery chemistry (`set board.bat`) +- Battery capacity (`set board.batcap`) +- Charge current (`set board.imax`) +- Frost protection (`set board.fmax`) +- MPPT, LED settings +- NTC calibration offset + +**Note:** Energy statistics (168h ring buffers for TTL) are held in RAM only and restart after any reboot or update. + +Settings are only lost on a full flash erase or filesystem corruption (rare). See [POWER_MANAGEMENT.md — Statistics Persistence](POWER_MANAGEMENT.md#11-statistics-persistence) for technical details. + +### 23. Why does the repeater board need a correct time? + +The firmware uses the RTC (Real-Time Clock) for several protection mechanisms. An incorrect clock does not cause a total outage — packets are still forwarded — but noticeable problems arise: + +- **Advertisements are rejected:** Every advertisement is cryptographically signed (Ed25519 over public key + timestamp + app data). Receivers compare the contained timestamp against the last stored value and discard timestamps that are equal or smaller as potential replay attacks. The existing entry on other nodes is preserved but no longer updated — name, position and "last seen" become increasingly stale. +- **Debug logs with incorrect timestamps:** `getLogDateTime()` shows wrong absolute times. Relative calculations like "last heard X seconds ago" remain correct as they use the same (wrong) clock source internally. + +Login, admin commands and the rate limiter are **not affected** — login/commands compare client-provided timestamps against each other only, and the rate limiter uses only relative time differences which remain correct as long as the clock ticks monotonically. A `clock sync` can therefore be run at any time after logging in. + +**How is the clock set?** +The Inhero MR2 has a hardware RTC that retains its time during a normal reboot. However, if the battery is disconnected or the 3.3V rail is switched off via the onboard switch, the RV-3028 loses its time and falls back to the POR default (January 2000). The clock can be set via CLI (`clock sync` or `time <epoch>`) from an admin client. The repeater is **not** automatically synchronized by clients. + +> **Recommendation:** After every battery swap or power-down of the board's 3.3V rail via the onboard switch, run `clock sync` via CLI as soon as possible. A normal reboot is not affected. + +--- + +### 24. Why can't the repeater clock be set backwards? + +`clock sync` and `time <epoch>` only allow setting the clock **forward** — setting it backwards is rejected with `ERR: clock cannot go backwards`. + +**Why?** The firmware uses increasing timestamps to protect against replay attacks. Both advertisements and admin commands are rejected if their timestamp is equal to or lower than the last stored value. Since other nodes in the mesh store the last (high) timestamp, a clock rollback would cause new advertisements to be rejected mesh-wide. + +**Solution: `clkreboot`** — resets the clock to a low value and reboots the board; the reboot resets the per-client replay timestamps (stored client entries are kept). Run `clock sync` afterwards to set the correct time. + +> **Note:** After `clkreboot`, advertisements will temporarily be rejected by nodes that still have the old timestamp stored. Visibility normalises once those entries expire. + +See also [FAQ #23](#23-why-does-the-repeater-board-need-a-correct-time). + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware datasheet, pinouts and specifications +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide diff --git a/variants/inhero_mr2/docs/POWER_MANAGEMENT.md b/variants/inhero_mr2/docs/POWER_MANAGEMENT.md new file mode 100644 index 0000000000..5d597e37d0 --- /dev/null +++ b/variants/inhero_mr2/docs/POWER_MANAGEMENT.md @@ -0,0 +1,1035 @@ +# Inhero MR-2 Power Management - Implementation Documentation (Rev 1.1) + +## Table of Contents + +- [Overview](#overview) +- [Hardware Architecture](#hardware-architecture) +- [1. Low-Voltage Detection (INA228 ALERT ISR)](#1-low-voltage-detection-ina228-alert-isr) +- [2. Coulomb Counter & SOC (State of Charge)](#2-coulomb-counter--soc-state-of-charge) +- [3. Daily Energy Balance](#3-daily-energy-balance) +- [4. Solar Power Management](#4-solar-power-management) + - [BQ25798 ADC at Low Battery Voltages](#bq25798-adc-at-low-battery-voltages) + - [JEITA WARM Zone & VBAT_OVP Prevention](#jeita-warm-zone--vbat_ovp-prevention) +- [5. Time-To-Live (TTL) Prediction](#5-time-to-live-ttl-prediction) +- [6. RTC Wakeup Management](#6-rtc-wakeup-management) +- [7. Power Management Flow](#7-power-management-flow) +- [8. INA228 ALERT Pin (Rev 1.1)](#8-ina228-alert-pin-rev-11) +- [9. SX1262 Power Control & PE4259 RF Switch](#9-sx1262-power-control--pe4259-rf-switch) +- [10. BQ25798 CE Pin Safety (Rev 1.1 — FET-inverted)](#10-bq25798-ce-pin-safety-rev-11--fet-inverted) +- [11. Statistics Persistence](#11-statistics-persistence) +- [12. CLI Commands](#12-cli-commands) +- [See Also](#see-also) + +> This documentation describes the power management implementation for the Inhero MR-2 board. +> Hardware Rev 1.1: INA228 ALERT on P1.02, TPS62840 EN via 3.3V_off switch, CE pin via DMN2004TK-7 FET (inverted). + +--- + +## Overview + +The system combines **INA228 ALERT-based low-voltage detection** + **System Sleep with GPIO latch** + **Coulomb Counter** + **daily energy balance** + **CE pin FET safety** for maximum energy efficiency: + +1. **INA228 ALERT ISR** (P1.02) - Low-voltage detection via hardware interrupt +2. **System Sleep with GPIO latch** (< 500µA) with RTC wake - Minimal power consumption during low-voltage +3. **CE Pin FET Safety** (DMN2004TK-7) - Inverted logic, solar charging possible in System Sleep +4. **Coulomb Counter** (INA228) - Real-time SOC tracking +5. **Daily Energy Balance** (7-day rolling) - Solar vs. battery +6. **RTC Wakeup Management** (RV-3028-C7) - Periodic recovery checks + +### Feature Matrix + +| Feature | Status | Notes | +|---------|--------|-------| +| INA228 ALERT → Low-Voltage System Sleep | Active | ISR on P1.02 → volatile flag → tickPeriodic() → System Sleep with GPIO latch + RTC Wake | +| RTC Wakeup (Low-Voltage Recovery) | Active | 60 min (periodic) | +| BQ CE Pin Safety (FET-inverted) | Active | GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low), Dual-Layer: GPIO + I2C | +| System Sleep with latched CE | Active | < 500µA, GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging possible | +| SOC via INA228 + manual battery capacity | Active | `set board.batcap` available | +| SOC→Li-Ion mV Mapping (workaround) | Active | Will be removed when MeshCore transmits SOC% natively | +| MPPT Recovery + Stuck-PGOOD Handling | Active | Cooldown logic active | + + +--- + +## Hardware Architecture + +### Components +| Component | Function | I2C | Pin | Details | +|-----------|----------|-----|-----|---------| +| **RAK4630** | Core Module | — | — | nRF52840 SoC + SX1262 LoRa transceiver | +| **INA228** | Power Monitor | 0x40 | ALERT→P1.02 (ISR) | 100mΩ shunt, 1.6A max, Coulomb Counter, BUVL Alert | +| **BME280** | Temp/Humidity/Pressure sensor | 0x76 | — | NTC calibration reference (`set board.tccal`), selftest | +| **RV-3028-C7** | RTC | 0x52 | INT→GPIO17 | Countdown timer, wake-up. See [FAQ #23](FAQ.md#23-why-does-the-repeater-board-need-a-correct-time) | +| **BQ25798** | Battery Charger | 0x6B | INT→GPIO21 | MPPT, JEITA, 15-bit ADC (IBUS ~±30mA error at low currents; ADC has VBAT-dependent thresholds, see [Section 4](#bq25798-adc-at-low-battery-voltages)) | +| **BQ CE Pin** | Charge Enable | — | GPIO4 (P0.04) | Via DMN2004TK-7 FET: GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low) | +| **TPS62840** | Buck Converter | - | EN via 3.3V_off switch | 750mA, 3.3V rail | +| **DMN2004TK-7** | CE FET | — | Gate←GPIO4 (ext. pull-down) | N-FET, Drain→CE, Source→GND. GPIO HIGH → FET ON → CE LOW → charging on. Pull-down defaults gate LOW when floating. | +| **Schottky diode** | USB→VBUS Diode | — | — | VBUS-USB → VBUS-BQ (solar input). USB-C CC1/CC2 via 4.7kΩ to GND (USB sink). **⚠ Solar short also shorts VBUS-USB.** | + +--- + +## 1. Low-Voltage Detection (INA228 ALERT ISR) + +### Implementation (Rev 1.1 — Flag/Tick Architecture) +- **Trigger**: INA228 BUVL (Bus Under-Voltage Limit) ALERT on P1.02 +- **ISR**: `BoardConfigContainer::lowVoltageAlertISR()` → sets `lowVoltageAlertFired = true` (flag only, no FreeRTOS call) +- **Processing**: `tickPeriodic()` checks flag in main loop context → `board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE)` +- **Arming**: `armLowVoltageAlert()` is called during battery configuration (sets BUVL threshold + enables ISR) + +### Low-Voltage Flow + +``` +INA228 BUVL Alert (P1.02, FALLING edge) + │ + ▼ +lowVoltageAlertISR() [ISR context] + │ Sets lowVoltageAlertFired = true (volatile flag) + ▼ +tickPeriodic() [Main loop context, next tick()] + │ Checks lowVoltageAlertFired == true + ▼ +board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE) + │ CE latched HIGH (GPIO latch preserved → FET ON → CE LOW → charging stays ON) + │ RTC wake configured (LOW_VOLTAGE_SLEEP_MINUTES = 60) + │ GPREGRET2 → LOW_VOLTAGE_SLEEP flag + ▼ +sd_power_system_off() → System Sleep with GPIO latch (< 500µA) +``` + +### Chemistry-Specific Thresholds (1-Level System, uniform 200mV hysteresis) + +| Chemistry | lowv_sleep_mv (ALERT) | lowv_wake_mv (0% SOC) | Hysteresis | +|-----------|----------------------|----------------------|------------| +| **Li-Ion 1S** | 3100 | 3300 | 200mV | +| **LiFePO4 1S** | 2700 | 2900 | 200mV | +| **LTO 2S** | 3900 | 4100 | 200mV | +| **Na-Ion 1S** | 2500 | 2700 | 200mV | + +**Implementation**: `BoardConfigContainer` — `battery_properties[]` lookup table +- `lowv_sleep_mv` → INA228 BUVL Alert threshold, triggers System Sleep +- `lowv_wake_mv` → RTC wake threshold (early boot checks VBAT, decides boot or sleep again) +- Static methods: `getLowVoltageSleepThreshold(type)`, `getLowVoltageWakeThreshold(type)` + +--- + +## 2. Coulomb Counter & SOC (State of Charge) + +### INA228 Integration +- **Driver**: `lib/Ina228Driver.cpp` +- **Init**: `BoardConfigContainer::begin()` + - 100mΩ shunt calibration + - CURRENT_LSB = 1.6384A / 524288 ≈ 3.125µA + - ADC Range ±163.84mV (ADCRANGE=0, optimal for 1A @ 100mΩ) + - **ADC Averaging**: 256 samples (filters TX voltage peaks) + - BUVL Alert configured to `lowv_sleep_mv` (chemistry-specific) + +### SOC Calculation +**Method**: `updateBatterySOC()` in `BoardConfigContainer.cpp` +- **Primary**: Coulomb Counting (INA228 CHARGE register) +- **Update interval**: every 60s via tickPeriodic(); no SOC updates while in low-voltage sleep (the RTC wake only checks VBAT and re-sleeps or boots) + +**Formula**: +``` +SOC_delta = charge_delta_mah / capacity_mah × 100% +SOC_new = SOC_old + SOC_delta +``` + +**Auto-Sync**: On BQ25798 "Charge Done", SOC is set to 100%. + +### Capacity Management + +#### Configuration Required +Battery capacity **must be set manually**, as it varies widely in practice: +- **Typical range**: 4000-24000mAh (4-24Ah) +- **CLI command**: `set board.batcap <mAh>` +- **Allowed range**: 100-100000mAh + +**Important**: Without correct capacity, SOC% and TTL calculations are inaccurate! + +#### Persistence Mechanism +**Storage Path**: `/inheromr2/batCap.txt` (LittleFS via SimplePreferences) +**Save Method**: `setBatteryCapacity()` in `BoardConfigContainer.cpp` (persists via SimplePreferences) +**Load Method**: `loadBatteryCapacity()` + +**Saved on**: +1. **Manual setting**: CLI command `set board.batcap <mAh>` + - Writes immediately to LittleFS + - Updates `batteryStats.capacity_mah` + +**Loaded on**: +- **Boot time**: `BoardConfigContainer::begin()` calls `loadBatteryCapacity()` +- **Fallback**: When no saved capacity exists +- **Validation**: Range check 100-100000mAh + +**Persistence properties**: +- ✅ **Survives** software shutdowns (System Sleep) +- ✅ **Survives** power cycles and low-voltage recovery +- ✅ **Survives** firmware updates (LittleFS preserved) +- ⚠️ **Lost** on: flash erase, `rm -rf /inheromr2/`, filesystem corruption + +--- + +## 3. Daily Energy Balance + +### Tracking (168-Hour Ring Buffer) +**Methods**: `updateHourlyStats()` + `calculateRollingStats()` in `BoardConfigContainer.cpp` +- **Called by**: tickPeriodic() (every 60 min) +- **Sampling**: On each hour boundary (RTC time truncated to full hours), the completed hour is written into the ring buffer + +**Data structure**: `BatterySOCStats.hours[168]` (7 days × 24 hours) +```cpp +typedef struct { + uint32_t timestamp; // start of hour, unix seconds + float charged_mah; // charged this hour + float discharged_mah; // discharged this hour + float solar_mah; // solar share this hour +} HourlyBatteryStats; +``` + +The per-hour values are accumulated from INA228 CHARGE register deltas in `updateBatterySOC()` (every 60s): a positive delta counts as charged (and solar), a negative delta as discharged. + +### Calculations +**Rolling sums** (`calculateRollingStats()`, after each completed hour): +``` +last_24h_net_mah = Σ(solar − discharged) over the last 24 hours +avg_3day_daily_net_mah = Σ(solar − discharged) over 72h / 3 (needs ≥ 24h of data) +avg_7day_daily_net_mah = Σ(solar − discharged) over 168h / 7 (needs ≥ 24h of data; used for TTL) +``` + +**Living Status**: +- `living_on_battery = true` when `last_24h_net_mah < 0` (net deficit over the last 24h) +- Solar surplus (SOL in `board.stats`) is simply `living_on_battery == false` + +--- + +## 4. Solar Power Management + +### Design Principle + +The BQ25798 decides **itself** via PowerGood (PG) whether an input is usable. +The charger runs in always-active mode (HIZ disabled). +The firmware monitors solar status and re-enables MPPT as needed. + +No INT pin interrupt — everything runs via polling in `runMpptCycle()` (60s interval). + +### Solar Checks + +`runMpptCycle()` performs two checks each cycle: +1. `checkAndFixSolarLogic()` — PG-stuck recovery + MPPT re-enablement +2. `updateMpptStats()` — Updates MPPT statistics for 7-day average + +### PFM Forward Mode + +- PFM forward mode is enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it +- PFM improves efficiency at low solar currents + +### MPPT Recovery + PG-Stuck + +`checkAndFixSolarLogic()` handles two scenarios: + +**PG=1**: MPPT re-enablement — BQ25798 automatically disables MPPT on faults. +Readback check: only write when actual change needed. + +**PG=0 + VBUS ≥ 4.5V**: PG-stuck recovery — panel delivers voltage, but BQ has not +qualified the input source (typical during slow sunrise). HIZ toggle forces +new input qualification. 5-minute cooldown prevents excessive toggling. +Constant: `PG_STUCK_VBUS_THRESHOLD_MV = 4500` in BoardConfigContainer.h + +### BQ25798 Interrupt Handling + +**BQ INT pin (GPIO 21)**: Not used as interrupt — `INPUT_PULLUP` against floating. +BQ status is checked via polling in `runMpptCycle()` every 60s. + +**Flag clearing on boot**: `BqDriver::clearInterruptFlags()` (called from `BoardConfigContainer::begin()`) +- Reads the CHARGER_FLAG/FAULT_FLAG registers 0x22–0x27 to de-assert the INT line +- Prevents stale faults from previous power cycle + +### Flag/Tick Architecture + +All I2C operations run in main loop context via `tickPeriodic()` (called by `InheroMr2Board::tick()`). There are no FreeRTOS tasks for I2C access — this eliminates mutex and race conditions. + +**I2C Bus Recovery** (in `InheroMr2Board::begin()`): After OTA/warm reset, an I2C slave may hold SDA low. Before `Wire.begin()`, up to 9 SCL pulses + STOP condition are generated to free the bus. + +**tickPeriodic()** dispatches periodic work via `millis()` timers: +``` +tickPeriodic() [called by tick(), main loop] + ├─ Check low-voltage alert flag → initiateShutdown() + ├─ Every 60s: runMpptCycle() + │ ├─ checkAndFixSolarLogic() — PG-stuck recovery (HIZ toggle) + MPPT recovery + │ └─ updateMpptStats() — Update MPPT statistics + ├─ Every 60s: updateBatterySOC() + └─ Every 60min: updateHourlyStats() +``` + +**Remaining FreeRTOS tasks** (GPIO only, no I2C): +- `heartbeatTask` — blue LED blink pattern +- `ErrorLED` lambda — red LED on missing components + +**Timing Summary**: +- **MPPT Cycle**: 60 seconds (via tickPeriodic) +- **SOC Update**: 60 seconds (via tickPeriodic) +- **Hourly Stats**: 60 minutes (via tickPeriodic) + +### BQ25798 ADC at Low Battery Voltages + +> **Reference:** BQ25798 Datasheet (TI SLUSE22), Section 9.3.16 — ADC + +#### Problem + +The 15-bit ADC in the BQ25798 has **voltage-dependent operating thresholds** that become relevant in battery-only operation (without solar). At low battery voltages, the ADC cannot complete its conversion — `ADC_EN` stays set and the firmware runs into a timeout. + +#### Datasheet Quote (Section 9.3.16) + +> *"The ADC is allowed to operate if either VBUS > 3.4V or VBAT > 2.9V is valid. +> At battery only condition, if the TS_ADC channel is enabled, the ADC only works +> when battery voltage is higher than 3.2V, otherwise, the ADC works when the +> battery voltage is higher than 2.9V."* + +#### Operating Scenarios + +| Condition | VBUS | VBAT | TS Channel | ADC | Temperature | +|-----------|------|------|------------|-----|-------------| +| Solar connected | > 3.4V | any | enabled | ✅ runs | ✅ available | +| Battery operation, normal | — | ≥ 3.2V | enabled | ✅ runs | ✅ available | +| Battery operation, low | — | 2.9–3.2V | **disabled** | ✅ runs | ❌ not available | +| Battery operation, critical | — | < 2.9V | disabled | ❌ timeout | ❌ not available | + +#### Firmware Solution: VBAT-dependent TS Channel Control + +The firmware reads the current battery voltage from the INA228 and passes it to `BqDriver::getTelemetryData(vbat_mv)`: + +- **VBAT ≥ 3.2V** (or unknown): TS channel enabled → ADC threshold 3.2V, temperature available +- **VBAT < 3.2V**: TS channel disabled → ADC threshold drops to 2.9V, temperature shown as "N/A" + +This allows the ADC to continue working in the 2.9–3.2V range for solar measurements (VBUS, IBUS), even when battery temperature cannot be read. + +#### ADC Channel Configuration (only required channels) + +On the MR-2, D+, D−, VAC1, VAC2 are not connected. The firmware enables only the actually used channels: + +| Register | Value (TS on) | Value (TS off) | Active Channels | +|----------|---------------|----------------|-----------------| +| 0x2F (ADC_FUNCTION_DISABLE_0) | `0x5A` | `0x5E` | IBUS, VBUS, (TS) | +| 0x30 (ADC_FUNCTION_DISABLE_1) | `0xF0` | `0xF0` | none (D+/D−/VAC disabled) | + +**Important:** In one-shot mode, `ADC_EN` is only cleared when **all enabled channels** have completed conversion. Unconnected channels can block this → therefore only required channels are enabled. + +#### Temperature Sentinel Values + +The firmware uses special return values for invalid temperatures: + +| Value | Meaning | Display | +|-------|---------|---------| +| −999.0 | I2C communication error | N/A | +| −888.0 | ADC not ready / TS disabled (low VBAT) | N/A | +| −99.0 | NTC open/not connected | N/A | +| +99.0 | NTC short circuit | N/A | +| −50…+90°C | Valid measurement | XX°C | + +**Display rule:** Values ≤ −100°C are shown as "N/A" in CLI and omitted from CayenneLPP packets. + +#### Code References +- `BqDriver::getTelemetryData(vbat_mv)` — Main function with VBAT-dependent TS control +- `BqDriver::startADCOneShot(ts_enabled)` — Configures ADC channels and starts conversion +- `BoardConfigContainer::getTelemetryData()` — Passes INA228 VBAT to BqDriver + +### JEITA WARM Zone & VBAT_OVP Prevention + +#### Problem: Default JEITA Configuration + Inhero Divider + +The Inhero MR2 uses a non-standard NTC voltage divider (RT1=5.6 kΩ pullup to REGN, RT2=27 kΩ parallel to GND) instead of the TI reference design (5.24 kΩ / 30.31 kΩ). This shifts TS thresholds lower by a **temperature-dependent** amount: ~5–6 °C in the cold range (where NTC resistance is large relative to RT2, amplifying the divider mismatch) and ~2–3 °C in the warm/hot range. + +With the BQ25798 POR defaults (`TS_WARM = 45°C`, `JEITA_VSET = VREG−400mV`, `EN_AUTO_IBATDIS = 1`), this caused a critical failure chain at moderate temperatures (~42 °C): + +``` +42°C ambient → TS = 44.65% REGN (below VT3_FALL = 44.8%) + → BQ enters WARM zone + → JEITA_VSET reduces VREG: 3.5V − 400mV = 3.1V (LiFePO4) + → Battery at 3.47V > 104% × 3.1V = 3.224V → VBAT_OVP triggers + → Converter stops, EN_AUTO_IBATDIS sinks IBAT_LOAD = 30mA from battery + → Total drain: −11mA (system) + −30mA (IBAT_LOAD) = −41mA + → Recovery requires VBAT < 102% × 3.1V = 3.162V → hours of battery drain +``` + +#### Fix: Three Register Settings in `configureBaseBQ()` + +| Setting | Register | Value | Effect | +|---------|----------|-------|--------| +| `setTsWarm(BQ25798_TS_WARM_55C)` | NTC Control 1 (0x18), bits 5:4 | 55 °C (37.7% REGN) | WARM zone starts at ~52 °C (Inhero), not ~42 °C | +| `setJeitaVSet(BQ25798_JEITA_VSET_UNCHANGED)` | NTC Control 0 (0x17), bits 7:5 | UNCHANGED | No VREG reduction in WARM — prevents VBAT_OVP | +| `JEITA_ISETH` (POR default retained) | NTC Control 0, bits 4:3 | 11b = ICHG unchanged | No charge current reduction in WARM | +| `setAutoIBATDIS(false)` | Charger Control 0, bit 7 | 0 | Disables 30 mA active battery discharge during OVP | + +> **Result:** With JEITA_VSET=UNCHANGED and JEITA_ISETH=ICHG unchanged, the WARM zone (T3–T5) is effectively neutralized. Charging continues at full voltage and full current until T-Hot (~58 °C), where charging is suspended entirely. + +#### TS Threshold Comparison + +| Zone Boundary | BQ Register | % REGN | TI Reference (°C) | Inhero MR2 (°C) | Shift | +|---------------|-------------|--------|--------------------|------------------|-------| +| VT1 (Cold) | — | 72.0% | +3.7 | −2.0 | −5.7 °C | +| VT2 (Cool) | — | 69.8% | +7.9 | +2.8 | −5.1 °C | +| VT3 (Warm) | TS_WARM=55°C | 37.7% | +54.5 | +52.2 | −2.3 °C | +| VT5 (Hot) | — | 34.2% | +59.9 | +57.7 | −2.2 °C | + +> NTC models: 103AT (B25/50=3435) for TI reference, NCP15XH103F03RC (B25/85=3380) for Inhero. Typical %REGN from BQ25798 datasheet. + +#### Code References +- `BoardConfigContainer::configureBaseBQ()` — Applies all three settings at startup +- `BqDriver::setTsWarm()` / `setJeitaVSet()` — Existing driver API +- `BqDriver::setAutoIBATDIS()` — Added to driver (Charger Control 0, bit 7) + +--- + +## 5. Time-To-Live (TTL) Prediction + +### Data Source and Time Base + +The TTL calculation is based on the **7-day moving average** of daily net energy consumption, calculated from a **168-hour ring buffer** (7 days) of hourly INA228 coulomb counter measurements. + +#### Data Flow + +``` +INA228 Hardware Coulomb Counter (24-bit ADC, ±0.1% accuracy) + │ + ▼ +updateHourlyStats() — every hour + │ Stores per hour: charged_mah, discharged_mah, solar_mah + │ in hours[168] ring buffer (BatterySOCStats.hours[]) + ▼ +calculateRollingStats() — after each hourly update + │ Sums last 168 hours → divides by 7 + │ → avg_7day_daily_net_mah (= solar − discharged per day) + │ Minimum requirement: ≥ 24 hours of valid data + ▼ +calculateTTL() — after calculateRollingStats() + │ extractable_mah / |deficit_per_day| × 24 = TTL hours + │ (extractable = remaining − trapped charge, see formula below) + ▼ +socStats.ttl_hours → getTTL_Hours() → board.stats / telemetry +``` + +### Calculation +**Method**: `calculateTTL()` in `BoardConfigContainer.cpp` +- **Called**: After `calculateRollingStats()` (hourly) +- **Time base**: 7-day moving average (`avg_7day_daily_net_mah`) from hourly samples + +**Prerequisites for TTL > 0**: +1. `living_on_battery == true` (24h net is negative, i.e. energy deficit) +2. `avg_7day_daily_net_mah < 0` (7-day average shows net discharge) +3. `capacity_mah > 0` (battery capacity known, via `set board.batcap`) +4. At least **24 hours** of valid data in the ring buffer + +**Formula** (Trapped-Charge model): +``` +remaining_capacity_mah = (SOC% / 100) × capacity_mah +trapped_mah = capacity_mah × (1 − f(T)) +extractable_mah = max(0, remaining_capacity_mah − trapped_mah) +daily_deficit_mah = -avg_7day_daily_net_mah (positive value) +TTL_hours = extractable_mah / daily_deficit_mah × 24 +``` +f(T) is the chemistry-specific cold-temperature derating factor (`temp_derating_factor`); f(T) = 1 at ≥ 25 °C, so at moderate temperatures nothing is trapped and the formula reduces to remaining/deficit. + +**TTL = 0 means**: +- Device is solar-powered (net surplus) → `living_on_battery == false` +- Less than 24h of data collected (cold start) +- Battery capacity unknown + +**Infinite TTL (telemetry)**: +- When `living_on_battery == false` and SOC valid → transmitted as 990 days (max value) + +**Example**: +- SOC: 60% = 1200mAh remaining (with 2000mAh capacity) +- Temperature ≥ 25 °C → f(T) = 1, no trapped charge → extractable = 1200mAh +- 7-day avg: -100 mAh/day (from 168h hourly samples) +- TTL: 1200 / 100 × 24 = 288 hours = 12 days + +**CLI output**: `board.stats` +``` ++150/+120/+90mAh C:200 D:50 3C:180 3D:60 7C:160 7D:70 SOL M:85% T:N/A ← Solar surplus +``` +or +``` +-80/-100/-110mAh C:10 D:90 3C:15 3D:115 7C:20 7D:130 BAT M:45% T:12d0h ← 12 days until empty +``` + +--- + +## 6. RTC Wakeup Management + +### RV-3028-C7 Integration +**Pin**: GPIO17 (WB_IO1) → RTC INT +**Init**: `InheroMr2Board::begin()` +- `attachInterrupt(RTC_INT_PIN, rtcInterruptHandler, FALLING)` +- Checks `GPREGRET2` for wake-up reason + +### Countdown Timer Configuration +**Method**: `configureRTCWake()` in `InheroMr2Board.cpp` +- **Tick Rate**: 1/60 Hz (1 minute per tick), configured via TD=11 in CTRL1 +- **Max Countdown**: 4095 minutes ≈ 2.8 days (12-bit timer register) +- **Low-Voltage Sleep Interval**: `LOW_VOLTAGE_SLEEP_MINUTES` = 60 min (1h) +- **Rationale**: Each wake is a System-ON reset with an early-boot fast path (minimal I2C: clear RTC TF, read VBAT, re-sleep) costing only ~0.03 mAh + +**Registers**: +```cpp +RV3028_CTRL1 (0x0F): TE=1, TD=11 (1/60 Hz), TRPT=0 (Single shot) +RV3028_CTRL2 (0x10): TIE=1 (Timer Interrupt Enable, bit 4) +RV3028_STATUS (0x0E): TF (Timer Flag, bit 3) — must be cleared after wake! +RV3028_TIMER_VALUE_0 (0x0A): Countdown value LSB +RV3028_TIMER_VALUE_1 (0x0B): Countdown value MSB (upper 4 bits) +``` + +### Interrupt Handler +**Method**: `rtcInterruptHandler()` — only sets `rtc_irq_pending = true`. + +The actual TF clear happens in main loop context in `tick()` via I2C (read-modify-write, clears only the TF bit): +```cpp +// In InheroMr2Board::tick() — main loop context: +if (rtc_irq_pending) { + rtc_irq_pending = false; + // Read RV3028_REG_STATUS ... + uint8_t status = Wire.read(); + status &= ~(1 << 3); // Clear TF bit only → INT pin goes HIGH via pull-up + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(status); // write back — other status flags stay untouched + Wire.endTransmission(); +} +``` + +**Why not in the ISR?** I2C (Wire) must not be called from an ISR context. +The ISR only sets the flag; `tick()` checks it in the main loop. + +--- + +## 7. Power Management Flow + +### Shutdown Sequence (Rev 1.1 — System Sleep with GPIO latch) +**Method**: `initiateShutdown()` in `InheroMr2Board.cpp` + +**On Low-Voltage → System Sleep with GPIO latch** (< 500µA, CE FET holds state): + +**Flow:** INA228 ALERT ISR → Flag → tickPeriodic() → `board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE)`: + +1. **Stop Background Tasks**: `BoardConfigContainer::stopBackgroundTasks()` + - Stops heartbeat task (only remaining FreeRTOS task with GPIO) + - Disarms INA228 low-voltage alert (detach ISR, disable BUVL) + +2. **INA228 to minimum current**: release the ALERT pin (`enableAlert(false, ...)`, `setUnderVoltageAlert(0)` — a latched-LOW ALERT would waste ~330µA through the pull-up), then `shutdown()` (ADC off, ~3.5µA) + +3. **SX1262 Sleep + PE4259 off**: `inhero::prepareRadioForSystemOff()` — first `radio.sleep(false)` (Cold Sleep via SPI, ~0.16µA), then `digitalWrite(SX126X_POWER_EN, LOW)` (PE4259 VDD off) + +4. **LEDs off**: PIN_LED1, PIN_LED2 LOW + +5. **Latch CE pin HIGH** (GPIO output latch preserved for P0.04): + - `digitalWrite(BQ_CE_PIN, HIGH)` → DMN2004TK-7 ON → CE LOW → charging active + - P0.04 is excluded from `disconnectLeakyPullups()` → GPIO latch stays HIGH in System Sleep + - Without latch: ext. pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** + +6. **INA228 + BQ25798 minimal current**: `inhero::prepareIcsForSystemOff()` (raw-I2C safety net, repeats the INA228 shutdown with readback) + +7. **BME280 to sleep**: forced Sleep mode via I2C (saves ~1–7µA; harmless NACK if not populated) + +8. **Configure RTC wake**: `configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)` (60 min) + +9. **Clear P0 LATCH for the RTC INT pin** (a stale latch would fire DETECT immediately → instant wake → boot loop) + +10. **Release I2C**: `Wire.end()`, then `inhero::disconnectLeakyPullups()` (each held-LOW pull-up wastes ~250µA) + +11. **Save shutdown reason**: `NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | reason` + +12. **System Sleep with GPIO latch**: `sd_power_system_off()` → nRF52840 System-Off (< 500µA total) + - GPIO4 latch preserved (excluded from disconnectLeakyPullups) → FET stays ON → CE LOW → **charging active** + - RAM contents are lost (168h statistics, SOC, etc.) + - RTC interrupt on GPIO17 wakes system after timer expires + +SOC is not written during shutdown — on the next successful recovery boot, `begin()` calls `setSOCManually(0.0)` (low-voltage recovery), so SOC restarts at 0%. + +**Why System Sleep with GPIO latch?** +- DMN2004TK-7 FET for CE pin → GPIO4 latch preserved HIGH → FET ON → CE LOW → charging active +- Total consumption: **< 500µA** (nRF52840 System-Off + RTC + quiescent currents of all components) + +**168h statistics are lost on System Sleep** — no persistence mechanism exists for the ring buffer data. After recovery, statistics start from zero. + +### Wake-up Check (Anti-Motorboating) +**Method**: `InheroMr2Board::begin()` + +The code checks `GPREGRET2` for shutdown reason and battery voltage for wake-up decisions. + +**2 Cases**: + +**Case 1: Wake from Low-Voltage Sleep** (`(GPREGRET2 & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE`) +```cpp +// InheroMr2Board::begin() — Early Boot Fast Path (simplified) +uint8_t shutdown_reason = NRF_POWER->GPREGRET2; +if ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE) { + Wire.begin(); + inhero::clearTimerFlag(); // wake was a reset — the ISR never saw the RTC event + uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR); + uint16_t wake_threshold = getLowVoltageWakeThreshold(); + + if (vbat_mv == 0 || vbat_mv < wake_threshold) { + // Voltage still too low → back to System Sleep. + // The wake reset cleared all PIN_CNF — the sleep-time GPIO latch does NOT + // survive it. CE must be re-driven OUTPUT HIGH or solar charging stops. + pinMode(BQ_CE_PIN, OUTPUT); + digitalWrite(BQ_CE_PIN, HIGH); + inhero::prepareIcsForSystemOff(); // INA228 + BQ25798 to minimal current + inhero::prepareRadioForSystemOff(false); // SX1262 back to Cold Sleep + configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES); + inhero::disconnectLeakyPullups(); + NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE; + sd_power_system_off(); // Stays in low-voltage sleep cycle + } + // Voltage OK → normal boot; low-voltage recovery marking + SOC=0% + // are applied after boardConfig.begin() + NRF_POWER->GPREGRET2 = SHUTDOWN_REASON_NONE; +} +``` + +**Case 2: Normal Cold Boot** (power-on, reset button, voltage OK) +```cpp +else { + // Continue normal boot + // INA228 and all other components are initialized +} +``` + +**Direct ADC Read** (boardConfig not yet ready): +```cpp +// Must read directly from INA228 ADC registers (24-bit, ±0.1% accuracy) +uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR); +``` + +**Voltage Thresholds** (Chemistry-Specific, 1-Level System): +| Chemistry | lowv_sleep_mv (ALERT) | lowv_wake_mv (Recovery) | Hysteresis | +|-----------|----------------------|------------------------|------------| +| Li-Ion 1S | 3100 | 3300 | 200mV | +| LiFePO4 1S | 2700 | 2900 | 200mV | +| LTO 2S | 3900 | 4100 | 200mV | +| Na-Ion 1S | 2500 | 2700 | 200mV | + +**Anti-Motorboating**: The early-boot check in `begin()` prevents the system from repeatedly booting and immediately crashing at marginal voltage. Only when VBAT is above `lowv_wake_mv` does it boot normally. + +**Power consumption in System Sleep with GPIO latch (Low-Voltage Sleep)**: +- **Total: < 500µA** (nRF52840 System-Off + RTC + quiescent currents of all components) +- CE FET: GPIO4 latch preserved HIGH → FET ON → CE LOW → **solar charging active** + +--- + +## 8. INA228 ALERT Pin (Rev 1.1) + +### Wiring +**Pin**: INA228 ALERT → P1.02 (nRF52840 GPIO, with ext. pull-up) +**TPS62840 EN**: Switched via 3.3V_off slide switch + +### Operation +The ALERT pin is used as a **software interrupt**: + +1. `armLowVoltageAlert()` configures INA228 BUVL (Bus Under-Voltage Limit) to `lowv_sleep_mv` +2. ALERT fires as FALLING edge interrupt on P1.02 +3. ISR (`lowVoltageAlertISR()`) sets `lowVoltageAlertFired = true` (flag only, no FreeRTOS call) +4. `tickPeriodic()` checks flag in the next main loop tick and calls `initiateShutdown()` → System Sleep + +**No latch problem**: Since ALERT does not go to TPS62840 EN, there is no latched-off behavior. +The system can boot normally after RTC wake and check voltage in `begin()`. + +--- + +## 9. SX1262 Power Control & PE4259 RF Switch + +### Hardware Architecture +- **SX1262**: LoRa transceiver (SPI bus), sleep mode via `SetSleep` SPI command +- **PE4259**: SPDT RF antenna switch in **single-pin mode**: + - **Pin 6 (VDD)**: GPIO 37 (P1.05, `SX126X_POWER_EN`) — power supply (must be HIGH for operation) + - **Pin 4 (CTRL)**: SX1262 DIO2 — TX/RX switching (automatic via `setDio2AsRfSwitch(true)`) + +### Shutdown Sequence (in `initiateShutdown()`) +The SX1262 is powered down in **two steps** — **order is critical**: + +```cpp +// Step 1: SX1262 to Cold Sleep via SPI (MUST be first!) +radio_driver.powerOff(); // → radio.sleep(false) → SPI SetSleep command +delay(10); + +// Step 2: PE4259 RF switch power off +digitalWrite(SX126X_POWER_EN, LOW); // VDD off → PE4259 off +``` + +**Why this order?** +- `radio.sleep(false)` sends an SPI command to the SX1262 → ensures clean radio shutdown +- PE4259 VDD (GPIO 37) powers the RF switch, NOT the SX1262 directly +- SPI is powered by the nRF52840 3.3V rail, not by PE4259 +- For safety: First put SX1262 to sleep, then power off PE4259 + +### Boot Sequence (in `begin()`) +```cpp +// PE4259 VDD on → RF switch ready +pinMode(SX126X_POWER_EN, OUTPUT); +digitalWrite(SX126X_POWER_EN, HIGH); +delay(10); // PE4259 power-on time + +// Later in radio_init() → target.cpp: +radio.std_init(&SPI); // → setDio2AsRfSwitch(true) → DIO2 controls TX/RX +``` + +**Important details**: +- **`SX126X_POWER_EN`** (GPIO 37 / P1.05) controls the **PE4259 VDD**, NOT the SX1262 power +- **`DIO2`** is controlled internally by the SX1262 (`setDio2AsRfSwitch(true)`) — no GPIO needed +- **Sleep current SX1262**: ~0.16µA (Cold Sleep) — datasheet value +- **Without `radio_driver.powerOff()`**: SX1262 remains in RX mode → ~5mA power consumption! + +--- + +## 10. BQ25798 CE Pin Safety (Rev 1.1 — FET-inverted) + +### Problem +The BQ25798 starts with default configuration (1S Li-Ion, 4.2V charge voltage). If a LiFePO4 battery (3.5V max) is connected and the RAK has not yet booted, the BQ25798 would overcharge the battery → **fire hazard**. + +### Hardware Design (Rev 1.1 — FET-inverted) +- **Pin**: `BQ_CE_PIN` = GPIO 4 (P0.04 / WB_IO4) +- **DMN2004TK-7 N-FET**: Gate ← GPIO4 (ext. pull-down), Drain → CE, Source → GND +- **External pull-down on Gate**: Defaults gate LOW when GPIO is floating → FET OFF +- **External pull-up on CE**: 10kΩ to VSYS → CE HIGH when FET OFF → **charging OFF** (BQ25798 CE active-low) +- **GPIO HIGH** → FET ON → CE pulled to GND (LOW) → **charging ON** +- **GPIO LOW** → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** +- **GPIO High-Z** (unpowered/reset) → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** + +**Key point Rev 1.1**: Charging is only active when GPIO4 is driven HIGH (by firmware or GPIO output latch in System Sleep). When the RAK is unpowered or unflashed, the external pull-down ensures FET OFF → CE HIGH → **charging disabled** — a deliberate safety feature. + +### 3-Layer Protection (Rev 1.1) + +| Layer | Location | Mechanism | When | +|---|---|---|---| +| **1. Hardware (passive)** | Pull-down + Pull-up | RAK unpowered → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** | Always (safety default) | +| **2. Early Boot** | `InheroMr2Board::begin()` | GPIO4 not yet driven → FET OFF → CE HIGH → **charging OFF** until firmware configures it | Before I2C init | +| **3. Chemistry Configuration** | `configureChemistry()` | GPIO HIGH → FET ON → CE LOW → **charging ON** + I2C register for known chemistry | After BQ25798 configuration | + +### Dual-Layer Safety (Hardware + Software) + +```cpp +// In configureChemistry() — after BQ25798 register configuration: +bq.setChargeEnable(props->charge_enable); // Software layer (I2C register) +#ifdef BQ_CE_PIN + pinMode(BQ_CE_PIN, OUTPUT); + // Rev 1.1 FET-inverted: HIGH → FET ON → CE LOW → charging active (BQ25798: CE active-low) + // FET OFF → pull-up on CE → CE HIGH → charging disabled (safety default) + digitalWrite(BQ_CE_PIN, props->charge_enable ? HIGH : LOW); // HIGH=FET ON=CE LOW=charge on +#endif +``` + +- `charge_enable` is part of the `BatteryProperties` table +- `BAT_UNKNOWN` → `charge_enable = false` → GPIO LOW → FET OFF → CE HIGH → **charging disabled** + register disabled +- Known chemistry → `charge_enable = true` → GPIO HIGH → FET ON → CE LOW → **charging enabled** + register enabled + +### Behavior in System Sleep with GPIO latch (Rev 1.1) + +In Rev 1.1, **System Sleep with GPIO latch** is used (via `initiateShutdown()`): +- `digitalWrite(BQ_CE_PIN, HIGH)` is called before entering System Sleep +- P0.04 is excluded from `disconnectLeakyPullups()` → GPIO output latch preserved at HIGH +- GPIO4 latched HIGH → DMN2004TK-7 FET ON → CE LOW → **charging active** +- BQ25798 MPPT/CC/CV runs autonomously in hardware → solar charging possible +- Power consumption: **< 500µA** (nRF52840 System-Off + RTC + quiescent currents of all components) + +| State | CE Pin | Charging | Solar Recovery | +|---|---|---|---| +| RAK unpowered (no battery) | HIGH (pull-up, FET OFF) | **Disabled** (safety default) | N/A | +| Early Boot | HIGH (pull-up, GPIO not driven) | **Disabled** (not yet configured) | No | +| BAT_UNKNOWN | HIGH (GPIO LOW → FET OFF) | **Disabled** (CE + I2C register) | No | +| Chemistry configured | LOW (GPIO HIGH → FET ON) | **Active** | **Yes** | +| System Sleep (Low-Voltage) | LOW (GPIO latch HIGH → FET ON) | **Active** | **Yes** | + +--- + +## 11. Statistics Persistence + +### Current State + +The 168h ring buffer statistics (coulomb counter, MPPT data, SOC state) are stored **in RAM only** and are lost on every reboot — whether System Sleep or cold boot. No persistence mechanism exists (neither `.noinit` section nor LittleFS snapshot). + +**Persistent data** (survives reboots via LittleFS): +- Battery type (`batType`) +- Battery capacity (`batCap`) +- NTC calibration (`tcCal`) +- MPPT setting (`mpptEn`) +- Frost behavior (`frost`) +- Max charge current (`maxChrg`) +- LED setting (`leds_en`) + +The INA228 calibration factor is **not** persisted — it lives in RAM only and resets to 1.0 on every boot. + +**Non-persistent data** (lost on reboot): +- 168h energy ring buffer (hourly charge/discharge/solar mAh) +- MPPT statistics (168h MPPT activity buffer) +- SOC percentage (set to 0% after recovery, synchronized to 100% on "Charging Done") +- TTL calculation (requires min. 24h data after each restart) +- Daily energy balance (7-day window rebuilds after restart) + +--- + +## 12. CLI Commands + +### Getters +```bash +board.bat # Query battery type + # Output: liion1s | lifepo1s | lto2s | naion1s | none + +board.fmax # Query frost charge behavior + # Output: 0% | 20% | 40% | 100% (LTO/Na-Ion: N/A) + +board.imax # Query maximum charge current + # Output: <current>mA (e.g. 500mA) + +board.mppt # Query MPPT status + # Output: MPPT=1 | MPPT=0 + +board.telem # Real-time telemetry with SOC + # Output: B:<V>V/<I>mA/<T>C SOC:<percent>% S:<V>V/<solar current> + # Example: B:3.85V/125.4mA/22C SOC:68.5% S:5.12V/385mA + # Example: B:3.85V/-8.2mA/N/A SOC:N/A S:0.00V/0mA + +board.stats # Energy statistics (balance + MPPT + TTL) + # Output: <24h>/<3d>/<7d>mAh C:<24h> D:<24h> 3C:<3d> 3D:<3d> 7C:<7d> 7D:<7d> <SOL|BAT> M:<mppt>% T:<ttl> + # Example: +125/+45/+38mAh C:200 D:75 3C:150 3D:105 7C:140 7D:102 SOL M:85% T:N/A + # Example: -30/-45/-40mAh C:10 D:40 3C:5 3D:50 7C:8 7D:48 BAT M:45% T:12d0h + # SOL = Solar surplus, BAT = Energy deficit + # T: Time To Live (N/A if solar surplus or <24h data) + +board.cinfo # Charger info + last PG-stuck HIZ toggle + # Output: "PG / CC HIZ:never" or "!PG / !CHG HIZ:3m ago" + +board.selftest # I²C hardware probe (all on-board devices) + # Output: "INA:OK BQ:OK RTC:OK BME:OK" + # Per-device states: OK | NACK | WR_FAIL (RTC only) + +board.conf # All configuration values + # Output: B:<bat> F:<fmax> M:<mppt> I:<imax> Vco:<V> V0:<V> + # Example: B:liion1s F:0% M:1 I:500mA Vco:4.10 V0:3.30 + +board.tccal # NTC temperature calibration offset + # Output: TC offset: +0.00 C (0.00=default) + +board.leds # LED enable status (Heartbeat + BQ Stat) + # Output: "LEDs: ON (Heartbeat + BQ Stat)" + +board.batcap # Battery capacity + # Output: 10000 mAh (set) or 2000 mAh (default; LiFePO4 defaults to 1500 mAh) +``` + +### Setters +```bash +set board.bat <type> # Set battery chemistry + # Options: liion1s | lifepo1s | lto2s | naion1s | none + +set board.fmax <value> # Set frost charge current reduction + # Options: 0% | 20% | 40% | 100% + # Limits charge current in T-Cool range (approx. -2 °C to +3 °C, see JEITA table in README) + # No effect on LTO / Na-Ion (JEITA disabled) + +set board.imax <mA> # Set maximum charge current + # Range: 50-1500 mA + +set board.mppt <0|1> # Enable/disable MPPT + +set board.batcap <mAh> # Set battery capacity + # Range: 100-100000 mAh + +set board.tccal # Calibrate NTC temperature (auto via BME280) +set board.tccal reset # Reset offset to 0.00 + +set board.leds <on|off> # Enable/disable LEDs (on/1, off/0) + +set board.soc <percent> # Manually set SOC (0-100, INA228 must be ready) +``` + +--- + +## File Overview + +### Main Implementation +| File | Description | +|------|-------------| +| **InheroMr2Board.h/cpp** | Board class, init, shutdown, RTC, CLI commands | +| **BoardConfigContainer.h/cpp** | Battery management, BQ25798, INA228, MPPT, SOC, daily balance | +| **lib/Ina228Driver.h/cpp** | INA228 I2C communication, calibration, coulomb counter | +| **lib/BqDriver.h/cpp** | BQ25798 I2C communication, MPPT, charging | + +### Key Methods +| Method | File | Function | +|--------|------|----------| +| `begin()` | InheroMr2Board.cpp | Board initialization, wake-up check, early-boot low-voltage check | +| `initiateShutdown()` | InheroMr2Board.cpp | System Sleep shutdown (called by tickPeriodic after ALERT) | +| `configureRTCWake()` | InheroMr2Board.cpp | RTC countdown timer | +| `rtcInterruptHandler()` | InheroMr2Board.cpp | RTC INT ISR (sets flag) | +| `queryBoardTelemetry()` | InheroMr2Board.cpp | CayenneLPP telemetry collection | +| `getLowVoltageSleepThreshold()` | InheroMr2Board.cpp | Chemistry-specific sleep voltage (INA228 ALERT) | +| `getLowVoltageWakeThreshold()` | InheroMr2Board.cpp | Chemistry-specific wake voltage (0% SOC) | +| `armLowVoltageAlert()` | BoardConfigContainer.cpp | Arm INA228 BUVL alert + register ISR | +| `disarmLowVoltageAlert()` | BoardConfigContainer.cpp | Disarm INA228 alert + detach ISR | +| `lowVoltageAlertISR()` | BoardConfigContainer.cpp | ISR: sets lowVoltageAlertFired flag (checked in tickPeriodic) | +| `tickPeriodic()` | BoardConfigContainer.cpp | Main loop dispatch: MPPT (60s), SOC (60s), hourly (60min), low-V check | +| `runMpptCycle()` | BoardConfigContainer.cpp | Single MPPT cycle (solar checks, MPPT recovery) | +| `updateBatterySOC()` | BoardConfigContainer.cpp | Coulomb counter SOC calculation | +| `updateHourlyStats()` | BoardConfigContainer.cpp | Hourly sampling into the 168h ring buffer | +| `calculateRollingStats()` | BoardConfigContainer.cpp | 24h/3d/7d rolling sums + living_on_battery | +| `calculateTTL()` | BoardConfigContainer.cpp | Time To Live forecast | +| `Ina228Driver::begin()` | lib/Ina228Driver.cpp | 100mΩ calibration, ADC config | +| `Ina228Driver::readVBATDirect()` | lib/Ina228Driver.cpp | Static early-boot VBAT read | + +--- + +## Code Fragments (Key Sections) + +### INA228 Shutdown Mode +```cpp +// Ina228Driver.cpp — returns bool: false if the INA228 stays in continuous mode +bool Ina228Driver::shutdown() { + // Set operating mode to Shutdown (MODE = 0x0) + // This disables all conversions and Coulomb Counter. + // Retries up to 3× with readback — I2C writes can fail silently. + uint16_t adc_config = 0x0000; // MODE = 0x0 (Shutdown) + // ... write + readback retry loop, checks MODE bits [15:12] ... +} +``` + +### INA228 Wake-up +```cpp +// Ina228Driver.cpp +void Ina228Driver::wakeup() { + // Re-enable continuous measurement mode with full ADC configuration + // Must restore conversion times from begin() - defaults are much shorter (50µs) + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples (TX peak filtering) + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); +} +``` + +### RTC Interrupt Handler +```cpp +// InheroMr2Board.cpp — ISR only sets flag, no I2C! +void InheroMr2Board::rtcInterruptHandler() { + rtc_irq_pending = true; +} +// TF clear happens in main loop context (tick()) +``` + +### INA228 Driver Access +```cpp +// Direct access to INA228 driver +if (boardConfig.getIna228Driver() != nullptr) { + // INA228 specific code +} +``` + +--- + +## Scenarios + +### Scenario A: Normal Discharge (Low-Voltage System Sleep) - Li-Ion +``` +t=0: VBAT = 3.7V → Normal (60s checks, coulomb counter running) + Daily balance: Today +150mAh SOLAR + +t=+1h: VBAT = 3.5V → Normal (INA228 ALERT not triggered) + SOC: 45% + +t=+2h: VBAT = 3.08V → INA228 ALERT fires (< 3100mV lowv_sleep_mv) + - lowVoltageAlertISR() → sets lowVoltageAlertFired flag + - tickPeriodic() detects flag in next tick() + - board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE) + - CE latched (GPIO4 latch HIGH → FET ON → CE LOW → charging active) + - RTC: Wake in 1h (LOW_VOLTAGE_SLEEP_MINUTES = 60) + - sd_power_system_off() → System Sleep with GPIO latch (< 500µA) + +t=+3h: RTC wakes → system boots → early boot check + - Ina228Driver::readVBATDirect() → VBAT = 3.15V + - VBAT < lowv_wake_mv (3300mV) → immediately back to sleep + - configureRTCWake(60) + sd_power_system_off() + +t=+4h: RTC wakes → system boots → early boot check + - VBAT = 3.20V → still below 3300mV → back to sleep + +t=+5h: RTC wakes → system boots → early boot check + - VBAT = 3.45V (solar recovery!) + - VBAT > lowv_wake_mv (3300mV) → normal boot + - Low-voltage recovery marked, SOC at 0% + - Coulomb counter restarts + - Daily balance rebuilds +``` + +### Scenario B: Critical Discharge (Rev 1.1 — no hardware UVLO) +``` +In Rev 1.1 there is no hardware UVLO (TPS62840 EN via 3.3V_off switch). +The INA228 ALERT on P1.02 serves as software interrupt for System Sleep. + +t=0: VBAT = 3.08V → INA228 ALERT fires + - tickPeriodic() → initiateShutdown() + - System Sleep with GPIO latch (< 500µA), CE latched LOW (charging active), RTC wake 1h + +t=+1h: RTC wake → early boot → VBAT = 3.05V (still below 3300mV) + - Immediately back to sleep (CE remains latched LOW → solar charging possible) + +t=+2h: RTC wake → VBAT = 2.95V (dropped further, no solar) + - Immediately back to sleep + - Board continues cycling at < 500µA + hourly boot (~0.03mAh) + +t=+∞: At < 500µA the battery can survive for months + - As soon as solar available → VBAT rises → normal boot at >3300mV + - NO latching: system can ALWAYS recover on its own +``` + +### Scenario C: Energy Balance Tracking - LiFePO4 +``` +Day 0: VBAT = 3.2V, SOC = 85% + 24 hourly entries land in hours[]: Σ charged +800mAh (solar), Σ discharged -450mAh + last_24h_net = +350mAh → SOLAR + +Day 1: VBAT = 3.15V, SOC = 72% + Charged: +650mAh, Discharged: -520mAh + last_24h_net = +130mAh → SOLAR + +Day 2: VBAT = 3.05V, SOC = 58% + Charged: +200mAh (heavy clouds), Discharged: -480mAh + last_24h_net = -280mAh → BAT (living_on_battery = true) + + 3-day avg: (350+130-280)/3 = +66.7 mAh/day + 7-day avg: (350+130-280)/7 = +28.6 mAh/day + (168h window still part-filled — the sum is always divided by 7) + → 7-day avg positive → TTL stays 0 (shown as N/A) + +Day 3: VBAT = 2.95V, SOC = 42% + Charged: +150mAh (heavy clouds), Discharged: -500mAh + last_24h_net = -350mAh → BAT + + 3-day avg: (130-280-350)/3 = -166.7 mAh/day + 7-day avg: (350+130-280-350)/7 = -21.4 mAh/day → negative → TTL is calculated + living_on_battery = true + + TTL calculation (7-day avg basis, ≥25 °C → f(T)=1, nothing trapped): + remaining = 42% × 1500mAh = 630mAh + deficit = |-21.4| = 21.4 mAh/day + TTL = (630 / 21.4) × 24 ≈ 706 hours ≈ 29.4 days + + CLI output: "-350/-167/-21mAh C:150 D:500 3C:.. 3D:.. 7C:.. 7D:.. BAT M:45% T:29d10h" +``` + +--- + +## See Also + +- [README.md](README.md) — User documentation and CLI reference +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) — Commissioning and configuration +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All CLI commands at a glance + +### Datasheets +- **INA228**: https://www.ti.com/product/INA228 +- **RV-3028-C7**: https://www.microcrystal.com/en/products/real-time-clock-rtc-modules/rv-3028-c7/ +- **BQ25798**: https://www.ti.com/product/BQ25798 +- **TPS62840**: https://www.ti.com/product/TPS62840 +- **nRF52840**: https://www.nordicsemi.com/products/nrf52840 diff --git a/variants/inhero_mr2/docs/QUICK_START.md b/variants/inhero_mr2/docs/QUICK_START.md new file mode 100644 index 0000000000..5c3ee7ce2d --- /dev/null +++ b/variants/inhero_mr2/docs/QUICK_START.md @@ -0,0 +1,206 @@ +# Inhero MR2 Quick-Start + +This guide walks you through commissioning and the most important CLI commands. + +## 1) Prepare Temperature Sensor (TS/NTC) +- Either use the 3-pin battery connector with TS/NTC, or close the onboard NTC solder bridge on the back side. +- Firmware NTC type: NCP15XH103F03RC (10k @ 25C, Beta 3380). +- Purpose: The charger uses the TS pin for JEITA/frost logic. +- → [FAQ #2 — Battery packs without NTC](FAQ.md#2-can-i-use-battery-packs-without-a-built-in-ntc) + +## 2) Connect Antennas +- Never operate without an antenna — risk of damage to the RF frontend. + +## 3) Connect Battery +- A charge level >90% is recommended so the battery can be fully charged via USB and the SOC calculation starts reliably. + +> **⚠ WARNING — No Reverse Polarity Protection:** The board has no hardware reverse polarity protection. Connecting the battery with reversed polarity will cause immediate, irreversible damage. Always verify correct polarity before plugging in. + +## 4) Configure Repeater via USB +- Connect the repeater to a computer via USB cable. +- Go to https://meshcore.io/flasher -> Repeater Setup to configure (LoRa settings, name, admin password, etc.). +- This sets the basic parameters on the device. + +## 5) Open CLI +- https://meshcore.io/flasher -> Console +- or MeshCore App -> Manage -> Command-Line +- Board-specific commands are set here. + +## 6) Set Battery Chemistry +- Command: + - set board.bat liion1s + - or set board.bat lifepo1s + - or set board.bat lto2s + - or set board.bat naion1s +- Defines charge parameters and low-voltage thresholds. +- → [FAQ #1](FAQ.md#1-which-battery-chemistry-should-i-choose) | [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Which battery chemistry should I choose? + +## 7) Set Battery Capacity +- Command: set board.batcap <mAh> +- Example: set board.batcap 10000 +- Important for accurate SOC calculation. +- → [FAQ #4 — What mAh value?](FAQ.md#4-what-mah-value-should-i-enter-for-set-boardbatcap) + +## 8) Set Maximum Charge Current +- Command: set board.imax <mA> +- Firmware range: 50 to 1500 mA (BQ25798 minimum: 50mA). +- Choose to match your solar setup so currents fit the PG check. +- Rule of thumb: panel power / panel voltage * 1.2 +- → [FAQ #5 — Why set imax?](FAQ.md#5-why-is-it-important-to-set-the-maximum-charge-current-with-set-boardimax) + +## 9) Set Frost Charge Current Reduction +- Command: set board.fmax <0%|20%|40%|100%> +- Limits the maximum charge current in the T-Cool range (approx. -2 °C to +3 °C, see JEITA table in README) to X% of board.imax. +- 0% = Charging blocked in T-Cool range. +- 20% = max. 20% of imax (e.g. 500mA → 100mA at approx. -2 °C to +3 °C). +- 40% = max. 40% of imax (e.g. 500mA → 200mA at approx. -2 °C to +3 °C). +- 100% = no reduction, full charge current even in cold conditions. +- Below approx. -2 °C (T-Cold): Charging always completely blocked by JEITA. +- Important: Only charging is restricted. With sufficient solar, the board continues to run on solar power — the battery is neither charged nor discharged. +- Note: For LTO and Na-Ion, JEITA is disabled (`set board.fmax` is rejected with an error, charging works even in frost). +- → [FAQ #6 — What does fmax control?](FAQ.md#6-what-does-set-boardfmax-control) + +## 10) Enable MPPT +- Command: set board.mppt <0|1> +- 1 = MPPT on, 0 = MPPT off. +- Typically enable for solar input. + +## 11) Enable/Disable LEDs +- Command: set board.leds <on|off> or set board.leds <1|0> +- Controls heartbeat LED and BQ status LED (bootloader LED patterns are unaffected). +- → [FAQ #17 — What do the LEDs mean?](FAQ.md#17-what-do-the-leds-mean) + +## 12) Fully Charge Battery (SOC Sync) +- Fully charge the battery once via USB so the SOC synchronizes cleanly. +- → [FAQ #11 — SOC shows 0% or N/A?](FAQ.md#11-why-does-the-soc-show-0-or-na) + +> **Cold weather note:** SOC% is purely Coulomb-based and does not change with temperature. However, `get board.telem` shows both the stored and extractable capacity when it's cold: `SOC:95.0% (78%)`. The firmware uses a Trapped Charge model — at low SOC and cold temperatures, the extractable value drops steeply (the bottom of the discharge curve is "locked"). See [FAQ #13](FAQ.md#13-how-does-temperature-derating-work) for details. + +## Additional Notes (Practical) +- After setting the battery chemistry, a quick check with `get board.bat` confirms the setting was saved. +- For solar operation, `set board.mppt 1` is recommended; for USB-only operation, MPPT can stay off. + +## Example Values per Battery Chemistry (Starting Point) +These values are safe starting points and should be adjusted to match battery, panel, and usage profile. + +The `imax` values below are derived from the rule of thumb from section 8: +**`imax ≈ panel power ÷ panel voltage × 1.2`** (e.g. 2 W ÷ 5 V × 1.2 ≈ 480 mA → round to 500). +`fmax` is given as a percentage of `imax` and only applies in the T-Cool zone (approx. -2 °C to +3 °C, see JEITA table in README). + +### Li-Ion 1S (3.7V nominal) +```bash +set board.bat liion1s # chemistry: 1S Li-Ion (sets charge profile + low-V thresholds) +set board.imax 500 # max charge current — ≈ 2 W panel @ 5 V (2 W ÷ 5 V × 1.2 ≈ 480 mA) +set board.fmax 20% # T-Cool (approx. -2…+3 °C): cap at 20 % × 500 mA = 100 mA +``` + +### LiFePO4 1S (3.2V nominal) +```bash +set board.bat lifepo1s # chemistry: 1S LiFePO4 (sets charge profile + low-V thresholds) +set board.imax 300 # max charge current — ≈ 1 W panel @ 5 V (1 W ÷ 5 V × 1.2 ≈ 240 mA, rounded up for headroom) +set board.fmax 40% # T-Cool (approx. -2…+3 °C): cap at 40 % × 300 mA = 120 mA +``` + +### LTO 2S (2x 2.3V nominal) +```bash +set board.bat lto2s # chemistry: 2S LTO (sets charge profile + low-V thresholds) +set board.imax 700 # max charge current — ≈ 3 W panel @ 5 V (3 W ÷ 5 V × 1.2 = 720 mA → 700) + # fmax is omitted: rejected for LTO (JEITA disabled — LTO charges even at frost) +``` + +### Na-Ion 1S (3.1V nominal) +```bash +set board.bat naion1s # chemistry: 1S Na-Ion (sets charge profile + low-V thresholds) +set board.imax 500 # max charge current — ≈ 2 W panel @ 5 V (2 W ÷ 5 V × 1.2 ≈ 480 mA) + # fmax is omitted: rejected for Na-Ion (JEITA disabled) +``` + +Note: `set board.fmax` is rejected with an error for LTO and Na-Ion (JEITA disabled); `get board.fmax` shows N/A. + +## Solar Panel Notes +- Maximum open-circuit voltage (Voc) for the input: 25V. +- Typical panels are 5V or 6V (MPP below that). +- The board has buck/boost and can charge higher battery voltages from lower panel voltages. +- 24V panels or series connections may exceed the 25V Voc limit and are not suitable. +- Wattage class: at least 1W, typically 2W. +- For 1W panels, a battery capacity of >7Ah is recommended. +- This applies only with south-facing, vertical mounting, and an unshaded location. +- In worse solar conditions, either use 2W or increase battery capacity for "winter survival". + +→ [FAQ #8 — Which solar panels?](FAQ.md#8-which-solar-panels-can-i-connect) + +## USB Charging +- The board can also be charged via USB-C (5V). +- USB-C VBUS is routed to the BQ25798 VBUS input via a **Schottky diode** — the same single input as the solar panel. The BQ25798 has only one VBUS input and does not distinguish between USB and solar. +- The Schottky diode prevents backflow from the solar panel to the USB bus. However, current **can** flow from USB-VBUS out through the solar connector. +- CC1/CC2 are pulled to GND via 4.7kΩ (USB sink, 5V default). +- **⚠ Warning:** Since VBUS-USB and VBUS-BQ share the same bus (via the Schottky diode), a **short circuit on the solar connector will also short VBUS-USB**. Never short-circuit the solar input while USB is connected. + +## Voltage Thresholds per Battery Chemistry +Thresholds are optimized for maximum lifespan and stable operation. + +| Battery Chemistry | lowv_sleep_mv (System Sleep) | lowv_wake_mv (0% SOC) | Hysteresis | +|---|---|---|---| +| Li-Ion 1S | 3100 | 3300 | 200mV | +| LiFePO4 1S | 2700 | 2900 | 200mV | +| LTO 2S | 3900 | 4100 | 200mV | +| Na-Ion 1S | 2500 | 2700 | 200mV | + +## Low-Voltage Behavior +- **Low-Voltage System Sleep:** When VBAT drops below `lowv_sleep_mv`, the INA228 ALERT interrupt fires (P1.02). The firmware latches CE HIGH (`digitalWrite(BQ_CE_PIN, HIGH)` → FET ON → CE LOW → charging active), configures the RTC wake timer, and enters System Sleep with GPIO latch (< 500µA). P0.04 is excluded from `disconnectLeakyPullups()` so the GPIO latch stays HIGH. Periodic RTC wakes (hourly) check voltage — only when recovery above `lowv_wake_mv` does it boot normally. +- **Solar Recovery:** In System Sleep, GPIO4 latch is preserved HIGH → DMN2004TK-7 FET ON → CE LOW → charging active. Solar charging continues autonomously until the battery charges above `lowv_wake_mv`. Without GPIO latch (RAK unpowered): ext. pull-down on gate → FET OFF → CE HIGH → charging OFF (safety default). + +## CLI Examples (Compact) +```bash +# Battery chemistry and capacity +set board.bat liion1s +set board.batcap 10000 + +# Charge parameters +set board.imax 500 +set board.fmax 20% +set board.mppt 1 + +# LEDs +set board.leds off + +# Status checks +get board.bat +get board.imax +get board.fmax +get board.mppt +get board.leds +get board.batcap +get board.telem +get board.stats +get board.cinfo +get board.selftest +get board.conf +``` + +## Getter Quick Reference (all relevant board getters) +- `get board.bat` - Current battery type (liion1s, lifepo1s, lto2s, naion1s, none). +- `get board.fmax` - Current frost charge behavior (0%/20%/40%/100%; N/A for LTO/Na-Ion). +- `get board.imax` - Maximum charge current in mA. +- `get board.mppt` - MPPT status (0/1). +- `get board.leds` - LED status (Heartbeat + BQ Stat). +- `get board.batcap` - Battery capacity in mAh (set/default). +- `get board.telem` - Real-time telemetry (Battery/Solar incl. SOC, V/I/T). See [TELEMETRY.md](TELEMETRY.md) for what the app displays. +- `get board.stats` - Energy balance (24h/3d/7d), charge/discharge breakdown and MPPT ratio. +- `get board.cinfo` - Charger status (Charger State + Flags). +- `get board.selftest` - I²C hardware probe (`INA:OK BQ:OK RTC:OK BME:OK`). RTC includes a write/readback verify (state `WR_FAIL` on mismatch). +- `get board.conf` - Summary of all configs (B, F, M, I, Vco, V0). +- `get board.tccal` - NTC temperature calibration offset in °C (0.00 = default). + - → [FAQ #12 — When should I run tccal?](FAQ.md#12-when-should-i-run-set-boardtccal) + +--- + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/README.md b/variants/inhero_mr2/docs/README.md new file mode 100644 index 0000000000..00dd8707b0 --- /dev/null +++ b/variants/inhero_mr2/docs/README.md @@ -0,0 +1,382 @@ +# Inhero MR-2 + +<img src="img/front.jpg" alt="Inhero MR2" width="400"> + +## Table of Contents + +- [Overview](#overview) +- [Current Feature Matrix](#current-feature-matrix) +- [Power Management Features](#power-management-features) +- [Firmware Build](#firmware-build) +- [CLI Commands](#cli-commands) +- [Diagnostics & Troubleshooting](#diagnostics--troubleshooting) +- [Regulatory Notes & CE Compliance](#regulatory-notes--ce-compliance-red-201453eu) +- [See Also](#see-also) + +## Overview + +The Inhero MR-2 is an application-specific hardware platform designed for autonomous, long-term operation of mesh infrastructure. Unlike conventional general-purpose solutions, it is optimized for maximum reliability at hard-to-reach locations. With an active idle consumption of only 6.0 mA at 4.2 V and 7.7 mA at 3.3 V (USB off, no radio TX), the board is exceptionally efficient for a full-featured repeater — enabling long runtimes even with compact batteries and small solar panels. A universal solar input with active MPPT maximizes energy harvesting, enabling compact, low-profile installations while avoiding costly over-dimensioning of peripherals. With native support for Li-Ion, LiFePO4, LTO and Na-Ion batteries, combined with autonomous recovery logic via RTC wakeup, a consistent "install & forget" approach is achieved even under extreme environmental conditions. The design minimizes long-term operating costs at sites where manual maintenance visits would be disproportionately expensive due to difficult accessibility. + +**Hardware Version:** Rev 1.1 +**Key Features:** +- **Core:** Based on RAK4630 (nRF52840 + SX1262). +- **Power Path:** BQ25798 Buck/Boost Charger. Enables energy harvesting even when solar voltage is below battery voltage (critical for low-light conditions). +- **High-Efficiency Rail:** 3.3V rail via TPS62840 for maximum efficiency. +- **Robust Monitoring:** INA228 Coulomb Counter for precise SOC tracking (essential for LiFePO4 chemistry) and long-term energy statistics. +- **Universal Solar Input:** 3.6V – 24V with autonomous MPPT tracking and integrated protection against "stuck states" (hardware watchdog logic). +- **Environmental Sensing & Timekeeping:** Integrated BME280 and RV-3028 RTC for autonomous wake-up management and precise time base. See [FAQ #23](FAQ.md#23-why-does-the-repeater-board-need-a-correct-time) for why a correct clock matters. +- **Form Factor:** Only 45 × 40 mm – optimized for low-profile enclosures and minimal mechanical stress. + +> **⚠ WARNING — No Reverse Polarity Protection:** The board has no hardware reverse polarity protection on the battery or solar input. Connecting with reversed polarity will cause immediate, irreversible damage. Always verify correct polarity before connecting any power source. + +## Current Feature Matrix + +| Feature | Status | Notes | +|---------|--------|-------| +| INA228 ALERT → Low-Voltage System Sleep | Active | ISR on P1.02 → volatile flag → tickPeriodic() → System Sleep with GPIO latch + RTC Wake | +| RTC Wakeup (Low-Voltage Recovery) | Active | 60 min (periodic) | +| BQ CE Pin Safety (FET-inverted) | Active | GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low), Dual-Layer: GPIO + I2C | +| System Sleep with latched CE | Active | < 500µA, GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging possible | +| SOC 0% after Low-Voltage Recovery | Active | SOC initialized to 0% on recovery, auto-sync on "Charging Done" | +| SOC via INA228 + manual battery capacity | Active | `set board.batcap` available | +| SOC→Li-Ion mV Mapping (workaround) | Active | Will be removed when MeshCore transmits SOC% natively | +| MPPT Recovery + Stuck-PGOOD Handling | Active | Cooldown logic active | +| PFM Forward Mode | Active (chip default) | Enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it. Improves efficiency at low solar currents | + +## Power Management Features + +### Low-Voltage Handling (Flag/Tick Architecture) + +1. **INA228 ALERT** fires at `lowv_sleep_mv` (hardware interrupt on P1.02) +2. **ISR** sets `lowVoltageAlertFired = true` (volatile flag only, no FreeRTOS call) +3. **`tickPeriodic()`** (main loop, next `tick()`) checks flag → shutdown: + - CE pin → HIGH (FET ON → CE LOW → charging active) + - P0.04 excluded from `disconnectLeakyPullups()` → GPIO latch preserved in sleep + - RTC wake configured (`LOW_VOLTAGE_SLEEP_MINUTES` = 60 min) + - `sd_power_system_off()` → **System Sleep with GPIO latch** (< 500µA) +4. **RTC Wake** (hourly) → system boots, early-boot checks VBAT: + - Below `lowv_wake_mv` → immediately back to System Sleep (CE remains latched LOW) + - Above `lowv_wake_mv` → normal boot, SOC starts at 0% + +> **Note**: All I2C operations (MPPT, SOC, Hourly Stats) run in main loop context +> via `tickPeriodic()` — no FreeRTOS tasks for I2C, no mutex needed. + +### BQ CE Pin (Rev 1.1 — FET-inverted) +- **DMN2004TK-7 N-FET**: Gate ← GPIO4 (ext. pull-down), Drain → CE, Source → GND +- **GPIO HIGH** → FET ON → CE LOW → **charging ON** (BQ25798 CE active-low) +- **GPIO LOW / High-Z** → ext. pull-down on gate → FET OFF → pull-up on CE → CE HIGH → **charging OFF** +- **System Sleep**: GPIO4 latch preserved HIGH (excluded from `disconnectLeakyPullups()`) → FET ON → CE LOW → **solar charging active** +- **Safety default**: RAK unpowered/unflashed → pull-down on gate → FET OFF → CE HIGH → **charging disabled** +- **Dual-Layer**: CE pin (hardware FET) + `setChargeEnable()` (I2C register) + +### Voltage Thresholds (all chemistries) + +| Chemistry | lowv_sleep_mv | lowv_wake_mv | Hysteresis | +|-----------|--------------|-------------|------------| +| Li-Ion 1S | 3100 | 3300 | 200mV | +| LiFePO4 1S | 2700 | 2900 | 200mV | +| LTO 2S | 3900 | 4100 | 200mV | +| Na-Ion 1S | 2500 | 2700 | 200mV | + +- **lowv_sleep_mv**: INA228 ALERT threshold → triggers System Sleep with GPIO latch +- **lowv_wake_mv**: RTC wake threshold → boot only when VBAT is above, also 0% SOC marker + +### System Sleep Power Consumption +- **< 500µA** total consumption (nRF52840 System-Off + RTC + quiescent currents of all components) +- GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging active + +### Active Idle Power Consumption +- **6.0 mA** @ VBAT 4.2 V (USB off, no radio TX) +- **7.7 mA** @ VBAT 3.3 V (USB off, no radio TX) +- **+0.8–1.0 mA** with USB peripheral enabled +- USB is auto-managed: enabled on VBUS detect, disabled on removal + +### Power Saving Measures +- **WFE Idle** (`board.sleep(0)`): CPU enters Wait-For-Event between loop iterations. Wakes on any interrupt (radio, SysTick, USB, I2C) — typically within 1 ms. Reduces nRF52840 CPU current from ~3 mA (busy-loop) to ~0.5–0.8 mA. +- **USB Auto-Disable**: nRF52840 USB peripheral is automatically disabled when no VBUS is detected, saving ~0.8–1.0 mA. Re-enabled automatically when USB cable is connected. + +### Coulomb Counter & SOC Tracking +- **Real-time SOC tracking** via INA228 (±0.1% accuracy) +- **100mΩ shunt resistor** (1.6A max current) +- **200mV uniform hysteresis** for all chemistries (lowv_sleep_mv → lowv_wake_mv) +- **Manual capacity:** `set board.batcap` for fixed capacity + +### SOC→Li-Ion mV Mapping (Workaround) +- **Problem**: MeshCore only transmits `getBattMilliVolts()`, not SOC%. The Companion App uses a Li-Ion curve for SOC calculation — incorrect display for LiFePO4/LTO. +- **Solution**: When valid coulomb-counting SOC is available, an equivalent Li-Ion 1S OCV (3000–4200 mV) is returned, so the app displays the correct SOC%. See [TELEMETRY.md](TELEMETRY.md) for details on how this affects the app display. +- This workaround will be removed once MeshCore supports native SOC% transmission. +- → [FAQ #11 — SOC shows 0% or N/A?](FAQ.md#11-why-does-the-soc-show-0-or-na) + +### Time-To-Live (TTL) Prediction +- **Time base:** 7-day moving average (`avg_7day_daily_net_mah`) of daily net energy consumption +- **Data source:** 168-hour ring buffer (7 days) with hourly INA228 coulomb counter samples (charged/discharged/solar mAh) +- **Formula:** `TTL_hours = max(0, SOC% × capacity_mah / 100 − capacity_mah × (1 − f(T))) / |avg_7day_daily_net_mah| × 24` +- **f(T):** Cold-temperature derating factor (trapped-charge model) — at low temperatures part of the stored charge is unusable and is subtracted first; f(T) = 1 at warm temperatures +- **Prerequisites:** `living_on_battery == true` (24h deficit), min. 24h data, capacity known +- **TTL = 0:** Solar surplus, no 24h data available, or capacity unknown +- **CLI:** TTL is shown in `get board.stats` (BAT mode only, e.g. `T:12d0h`) +- **Telemetry:** Transmitted as days via CayenneLPP Distance field (max. 990 days for "infinite"). See [TELEMETRY.md](TELEMETRY.md) for channel details. + +### Solar Power Management + +- **Solar current display:** The BQ25798 IBUS ADC is inaccurate at low currents (~±30mA error). Therefore solar current is displayed in steps: + - `0mA` — ADC reports exactly 0 (no solar current) + - `<50mA` — 1–49mA (ADC unreliable in this range) + - `~72mA` — 50–100mA with rounding symbol `~` (limited accuracy) + - `385mA` — >100mA without rounding symbol (sufficiently accurate) + - Always integer without decimal places (no pseudo-precision) +- **PFM Forward Mode:** PFM forward mode is enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it. Improves efficiency at low currents. +- **MPPT VOC_PCT 81.25%:** The BQ25798 MPPT is configured to VOC_PCT=81.25% (instead of the chip default 87.5%). This value matches the typical Vmp/Voc ratio of crystalline silicon solar cells (~80-83%). +- **MPPT Recovery:** Re-enables MPPT on PowerGood=1 (readback check: only on actual change) +- **BQ INT pin not used:** No interrupt — pure polling every 60s in `runMpptCycle()` +- **Error monitoring:** Diagnostic commands show FAULT_STATUS registers (0x20, 0x21) for detailed analysis incl. VBAT_OVP, VBUS_OVP and temperature conditions +- **VREG display:** Shows the actually configured battery regulation voltage in diagnostics for threshold verification + +### JEITA Temperature Zone Configuration + +The BQ25798 uses the TS pin (NTC thermistor) for JEITA-compliant temperature-dependent charge control. The Inhero MR2 uses a voltage divider (RT1=5.6 kΩ pullup to REGN, RT2=27 kΩ parallel to GND) that shifts TS thresholds lower than the TI reference design (5.24 kΩ / 30.31 kΩ). The shift is **temperature-dependent**: ~5–6 °C in the cold range, ~2–3 °C in the warm/hot range (because at low temperatures the NTC resistance is large relative to RT2, amplifying the divider mismatch). + +| JEITA Zone | BQ25798 Threshold | TI Reference | Inhero MR2 (actual) | Shift | Firmware Config | +|------------|-------------------|--------------|----------------------|-------|-----------------| +| T-Cold (charge suspend) | VT1 = 72.0% REGN | +3.7 °C | −2.0 °C | −5.7 °C | — (not configurable) | +| T-Cool (reduced current) | VT2 = 69.8% REGN | +7.9 °C | +2.8 °C | −5.1 °C | `set board.fmax` | +| T-Warm start | VT3 = 37.7% REGN | +54.5 °C | +52.2 °C | −2.3 °C | `TS_WARM = 55°C` register setting | +| T-Hot (charge suspend) | VT5 = 34.2% REGN | +59.9 °C | +57.7 °C | −2.2 °C | — (not configurable) | + +> Calculation based on: NTC 103AT (B25/50=3435) for TI reference, NCP15XH103F03RC (B25/85=3380) for Inhero. Typical %REGN values from BQ25798 datasheet. + +**Key firmware settings in `configureBaseBQ()`:** + +- **`TS_WARM = 55°C`** (BQ register value): Moves the WARM zone threshold from the default 45 °C setting (44.8% REGN, ~41.8 °C with Inhero divider) up to 37.7% REGN (~52.2 °C with Inhero divider). This prevents premature WARM zone entry at moderate temperatures. +- **`JEITA_VSET = UNCHANGED`**: No battery regulation voltage reduction in the WARM zone. The POR default (VREG−400 mV) would reduce VREG to 3.1 V for LiFePO4, causing VBAT_OVP at normal battery voltages (3.3–3.5 V). +- **`JEITA_ISETH = ICHG unchanged`** (POR default, retained): No charge current reduction in the WARM zone. Combined with JEITA_VSET=UNCHANGED, the WARM zone is effectively neutralized — charging continues at full voltage and full current. +- **`AUTO_IBATDIS = disabled`**: Disables the BQ25798's automatic 30 mA battery discharge during VBAT_OVP. The POR default actively drains the battery at ~30 mA (IBAT_LOAD) when OVP is triggered, which is counterproductive for solar-powered systems. + +> **Background:** With default BQ25798 settings, the combination of the Inhero divider offset and LiFePO4 chemistry caused a failure chain at ~42 °C: WARM zone entry → VREG reduced to 3.1 V → VBAT_OVP (battery at 3.47 V > 104% × 3.1 V) → active 30 mA discharge → net −45 mA drain despite solar input. The settings above prevent this entirely. The WARM zone (52–58 °C with Inhero divider) now has no effect on charging behavior. + +## Firmware Build + +```bash +# Repeater (default) +platformio run -e Inhero_MR2_repeater + +# Repeater with RS232 bridge (Serial2 on P0.19/P0.20) +platformio run -e Inhero_MR2_repeater_bridge_rs232 + +# Sensor +platformio run -e Inhero_MR2_sensor +``` + +## CLI Commands + +### Get Commands +```bash +get board.bat # Query current battery type + # Output: liion1s | lifepo1s | lto2s | naion1s | none + +get board.fmax # Query frost charge behavior + # Output: 0% | 20% | 40% | 100% + # Value = max charge current in T-Cool range + # (approx. -2 °C to +3 °C, see JEITA table in README), + # relative to board.imax + # 40% at imax=500mA → max. 200mA charge current in T-Cool range + # 0% = charging blocked in T-Cool range + # 100% = no reduction (full current even in cold) + # Below approx. -2 °C (T-Cold): charging always completely blocked (JEITA) + # Note: Only charging is restricted. With sufficient + # solar, the board continues to run on solar power — + # the battery is neither charged nor discharged. + # LTO / Na-Ion batteries: N/A (JEITA disabled, charges even in frost) + +get board.imax # Query maximum charge current + # Output: <current>mA (e.g. 200mA) + +get board.mppt # Query MPPT status + # Output: MPPT=1 (enabled) | MPPT=0 (disabled) + +get board.telem # Query real-time telemetry with SOC + # Output: B:<V>V/<I>mA/<T>C SOC:<percent>% S:<V>V/<solar current> + # Examples: + # B:3.85V/125.4mA/22C SOC:68.5% S:5.12V/385mA (>100mA: accurate) + # B:3.85V/-8.2mA/18C SOC:72.0% S:4.90V/~72mA (50-100mA: ~estimate) + # B:3.30V/-45.0mA/5C SOC:40.1% S:0.00V/<50mA (<50mA: ADC inaccurate) + # Output variants: + # - SOC:N/A — no valid coulomb-counting SOC available + # - SOC:68.5% (52%) — second value = cold-derated SOC, + # shown when the temperature derating factor is < 1 + # - <T>C becomes N/A when the NTC temperature is unavailable + # Components: + # - B: Battery (Voltage/Current/Temperature/SOC) + # - S: Solar (Voltage/Current — accuracy depends on BQ25798 IBUS ADC) + +get board.stats # Query energy statistics (balance + MPPT) + # Output: <24h>/<3d>/<7d>mAh C:<24h> D:<24h> 3C:<3d> 3D:<3d> 7C:<7d> 7D:<7d> <SOL|BAT> M:<mppt>% T:<ttl> + # Example: +125/+45/+38mAh C:200 D:75 3C:150 3D:105 7C:140 7D:102 SOL M:85% T:N/A + # Example: -30/-45/-40mAh C:10 D:40 3C:5 3D:50 7C:8 7D:48 BAT M:45% T:72h + # Components: + # - +125: Last 24h net balance (charge - discharge) in mAh + # - +45: 3-day average net balance in mAh + # - +38: 7-day average net balance in mAh + # - C/D: Charged/Discharged mAh (24h) + # - 3C/3D: 3-day average charged/discharged mAh + # - 7C/7D: 7-day average charged/discharged mAh + # - SOL: Running on solar (self-sufficient) + # - BAT: Living on battery (deficit mode) + # - M:85%: MPPT enabled percentage (7-day average) + # - T:72h: Time To Live (only shown if BAT mode, 7d-avg basis) + # Format: T:12d5h (≥24h) or T:72h (<24h) or T:N/A + +get board.cinfo # Charger info + last PG-stuck HIZ toggle + # Output: <state> + flags + # States: !CHG, PRE, CC, CV, TRICKLE, TOP, DONE + +get board.selftest # I²C hardware probe (all on-board devices) + # Output: INA:<state> BQ:<state> RTC:<state> BME:<state> + # States: OK | NACK | WR_FAIL (RTC only, write-verify mismatch) + +get board.conf # Query all configuration values + # Output: B:<bat> F:<fmax> M:<mppt> I:<imax> Vco:<voltage> V0:<0%SOC> + +get board.batcap # Query battery capacity + # Output: <capacity> mAh (set) or <capacity> mAh (default) + # Shows whether capacity was manually set or chemistry default + +get board.tccal # Query NTC temperature calibration offset + # Output: TC offset: <+/-offset> C (0.00=default) + +get board.leds # Query LED enable status + # Output: "LEDs: ON (Heartbeat + BQ Stat)" or "LEDs: OFF (Heartbeat + BQ Stat)" + # Shows whether heartbeat LED and BQ25798 stat LED are enabled +``` + +### Set Commands +```bash +set board.bat <type> # Set battery type + # Options: lto2s | lifepo1s | liion1s | naion1s | none + # none = no battery / unknown (charging disabled) + +set board.fmax <behavior> # Set frost charge behavior + # Options: 0% | 20% | 40% | 100% + # Limits charge current in T-Cool range + # (approx. -2 °C to +3 °C, see JEITA table in README) + # to X% of board.imax + # 0% = charging blocked in T-Cool range + # 20% = max. 20% of imax in T-Cool range + # 40% = max. 40% of imax in T-Cool range + # 100% = no reduction + # Below approx. -2 °C (T-Cold): charging always blocked (JEITA) + # Note: Only charging is restricted. With sufficient + # solar, the board continues to run on solar power — + # the battery is neither charged nor discharged. + # N/A for LTO / Na-Ion batteries (JEITA disabled) + +set board.imax <current> # Set maximum charge current in mA + # Range: 50-1500mA (BQ25798 minimum: 50mA) + +set board.mppt <1|0> # Enable/disable MPPT + # 1 = enabled, 0 = disabled + +set board.batcap <capacity> # Set battery capacity in mAh + # Range: 100-100000 mAh + # Used for accurate SOC calculation + +set board.tccal # Calibrate NTC temperature + # Two modes: + # 1) set board.tccal → auto-calibration via BME280 + # Output: TC auto-cal: BME=<temp> offset=<+/-offset> C + # 2) set board.tccal reset → reset offset to 0.00 + # Output: TC calibration reset to 0.00 (default) + +set board.leds <on|off> # Enable/disable heartbeat + BQ stat LED + # on/1 = enable, off/0 = disable + # Boot LEDs follow this setting; only the + # low-voltage recovery flash (3 blue blinks) + # is always active + +set board.soc <percent> # Manually set SOC + # Range: 0-100 + # Note: INA228 must be initialized +``` + +## Diagnostics & Troubleshooting + +### I²C Hardware Self-Test + +```bash +get board.selftest +``` + +Probes all I²C devices on the board and reports their status in one line: + +``` +INA:OK BQ:OK RTC:OK BME:OK +``` + +| Device | Address | Test | +|---|---|---| +| `INA` | `0x40` | INA228 power monitor — address ACK | +| `BQ` | `0x6B` | BQ25798 charger — address ACK | +| `RTC` | `0x52` | RV-3028 RTC — address ACK **plus** user-RAM (`0x1F`) write/readback verification with two patterns (`0xA5`, `0x5A`); original byte is restored | +| `BME` | `0x76` | BME280 environment sensor — address ACK | + +Possible per-device states: + +- **`OK`** — device responds (and, for the RTC, persists writes correctly) +- **`NACK`** — device does not acknowledge on the I²C bus +- **`WR_FAIL`** — *RTC only* — chip ACKs but write/readback mismatched. The same write-verify runs in `BoardConfigContainer::begin()` and triggers the slow red error LED on failure, so the board is flagged as faulty before deployment. + +### BQ25798 Register Verification +The diagnostic functions enable precise verification of BQ25798 registers against the datasheet: + +**Key Registers:** +- **0x0F (CHARGER_CONTROL_0)**: EN_CHG (Bit 5) +- **0x15 (MPPT_CONTROL)**: EN_MPPT (Bit 0), VOC_PCT (Bits 7-5), VOC_DLY (Bits 4-3), VOC_RATE (Bits 2-1) +- **0x1B (CHARGER_STATUS_0)**: PG_STAT (Bit 3), VINDPM (Bit 6), IINDPM (Bit 7) +- **0x1C (CHARGER_STATUS_1)**: CHG_STAT (Bits 7-5), VBUS_STAT (Bits 4-1) +- **0x1F (CHARGER_STATUS_4)**: Temperature status (Bits 3-0) + +**Known Issues:** +1. **MPPT disabled**: BQ25798 automatically sets MPPT=0 when PG=0 + - Solution: `checkAndFixSolarLogic()` re-enables MPPT on PG=1 +2. **PG stuck at sunrise**: VBUS rises slowly, BQ fails to qualify the source + - Solution: `checkAndFixSolarLogic()` toggles HIZ when VBUS ≥ 4.5V + PG=0 (5min cooldown) + +→ [FAQ #9 — Red LED blinks / battery not charging](FAQ.md#9-the-red-led-bq-status-led-blinks-slowly-and-the-battery-is-not-charging) + +## Regulatory Notes & CE Compliance (RED 2014/53/EU) + +The Inhero MR-2 is shipped as a hardware platform (development module) with a pre-installed bootloader. The hardware is **CE-marked and conforms to the European Radio Equipment Directive (RED 2014/53/EU)**; the corresponding tests were carried out by an accredited test laboratory and an EU Declaration of Conformity is on file. Radiated power certification was performed using the designated reference antennas (RAK FPCB antenna 863–870 MHz, MHF1 connector, antenna gain: 0.7 dBi). + +**Requirements for legally compliant operation of radio firmware:** +Since the final transmission characteristics (TX power, frequency, duty cycle) are largely determined by the software installed by the user (e.g. MeshCore) and the chosen antenna, the following European limits (per EN 300 220 and EN 300 328, ERC/REC 70-03 Annex 1) must be strictly observed: + +1. **Standard LoRa (868 MHz band):** + * Frequency range: 865.0 – 868.6 MHz + * Max. radiated TX power (ERP): 25 mW (14 dBm) + * Max. duty cycle: 1% (or LBT+AFA per EN 300 220) + * *Note: Different duty cycle requirements per ERC/REC 70-03 apply in the 863.0 – 865.0 MHz sub-band.* + +2. **High-Power LoRa (special band 869.5 MHz):** + * Frequency range: 869.40 – 869.65 MHz (MeshCore default channel) + * Max. radiated TX power (ERP): **500 mW (27 dBm)** + * Max. duty cycle: 10% + * *Hardware note:* The onboard LoRa transceiver (SX1262) provides a maximum conducted TX power of 22 dBm. To fully utilize the legal limit of 500 mW ERP (equivalent to 29.15 dBm EIRP), an antenna with a gain of approx. +7 dBi is required (minus any cable losses). With the included FPCB antenna (0.7 dBi), max. approx. 114 mW ERP is achieved. + +3. **Bluetooth Low Energy (2.4 GHz):** + * Max. radiated TX power (EIRP): 100 mW (20 dBm) + +**Antennas & operator responsibility (EIRP/ERP limit):** +The user is obligated to match the configured TX power in the chip with the antenna gain. If an antenna is used whose gain, in combination with the configured TX power, exceeds the legal EIRP/ERP limits stated above, the TX power must be reduced in software. + +**Disclaimer:** +The Inhero MR-2 is a module intended for professional developers and qualified users. If the legal parameters are operated outside EU norms due to the choice of firmware, antenna, or manual configuration, the CE compliance of the device is void. In this case, all legal responsibility for operation transfers to the integrator or end user. + +## See Also + +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [TELEMETRY.md](TELEMETRY.md) — Telemetry channels explained (what the app displays) +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/TELEMETRY.md b/variants/inhero_mr2/docs/TELEMETRY.md new file mode 100644 index 0000000000..988cae697b --- /dev/null +++ b/variants/inhero_mr2/docs/TELEMETRY.md @@ -0,0 +1,139 @@ +# Telemetry Channels + +The Inhero MR-2 transmits telemetry data in [CayenneLPP](https://docs.mydevices.com/docs/lorawan/cayenne-lpp) format across four channels. The companion app displays these as **Channel 1–4**. + +--- + +## Channel 1 — Device Status + +Base data from the node. + +| Field | Unit | Source | Description | +|-------|------|--------|-------------| +| Battery Level | % / V | INA228 | See note below on SOC workaround | +| Temperature | °C / °F | nRF52840 | MCU die temperature | + +### Battery Level & SOC Workaround + +MeshCore currently transmits only battery **voltage** on Channel 1 — there is no native SOC% field. The companion app converts this voltage back to a percentage using a hardcoded **Li-Ion discharge curve**. This works well for Li-Ion cells but produces wrong readings for LiFePO₄, LTO, or Na-Ion chemistries (which have a much flatter voltage curve). + +The MR-2 works around this limitation: + +| SOC State | What `getBattMilliVolts()` returns | App displays | +|-----------|------------------------------------|--------------| +| **SOC not yet valid** | Real battery voltage from INA228 | Percentage based on Li-Ion curve (may be inaccurate for non-Li-Ion) | +| **SOC valid** (coulomb counter calibrated) | Fake Li-Ion OCV reverse-mapped from true SOC% (`socToLiIonMilliVolts()`) | Correct percentage — the app's Li-Ion curve decodes back to the original SOC% | + +> **OCV** = Open Circuit Voltage — the battery's resting voltage without load. The OCV curve (voltage vs. SOC%) is characteristic for each battery chemistry and is used here as a lookup table to reverse-map SOC% back to a voltage the app can interpret. + +The SOC becomes valid as soon as a **reference point** exists — either manually via `set board.soc <percent>` or automatically on a "Charging Done" event (which sets SOC to 100%). + +Without `set board.batcap <mAh>` a chemistry-typical default capacity (1500–2000 mAh) is assumed. Setting the real capacity is therefore required for the displayed percentage and the TTL to be accurate — not for the SOC to become valid. + +The reverse mapping uses a piecewise-linear Li-Ion OCV table (3000 mV at 0% → 4200 mV at 100%). This ensures the app displays the correct coulomb-counted SOC regardless of the actual battery chemistry. + +--- + +## Channel 2 — Environment (BME280) + +Data from the BME280 environment sensor (always present on the MR-2). + +| Field | Unit | Source | Description | +|-------|------|--------|-------------| +| Temperature | °C / °F | BME280 | Ambient temperature | +| Relative Humidity | % | BME280 | Relative humidity | +| Barometric Pressure | hPa | BME280 | Barometric pressure | +| Altitude | m / ft | BME280 | Altitude derived from barometric pressure (reference: sea level) | + +> **Note:** The altitude calculation is based on standard sea level pressure (1013.25 hPa) and may deviate depending on weather conditions. + +--- + +## Channel 3 — Battery (INA228 / BQ25798) + +High-precision battery data from the INA228 coulomb counter and BQ25798 charge controller. + +| Field | LPP Type | Unit | Source | Description | +|-------|----------|------|--------|-------------| +| Voltage | Voltage | V | INA228 | Battery voltage (20-bit ADC, ±0.1% accuracy) | +| SOC | Percentage | % | INA228 | State of charge via coulomb counting — *optional, only when calibrated* | +| Current | Current | A | INA228 | Battery current. Negative = discharging, positive = charging | +| Temperature | Temperature | °C / °F | BQ25798 NTC | Battery temperature from NTC thermistor | +| TTL | Distance | days | calculated | Estimated time-to-live (remaining runtime) — *optional, only with valid SOC* | + +### SOC & TTL + +SOC and TTL only appear when the coulomb counter has a valid reference point — either a manual SOC set (`set board.soc`) or a "Charging Done" event. The percentage is based on the configured battery capacity (`set board.batcap`; a chemistry-typical default of 1500–2000 mAh is assumed otherwise). Until the SOC is valid, these fields are omitted. + +### TTL Encoding + +The TTL (Time-To-Live) is transmitted as a **CayenneLPP Distance value** in days, since CayenneLPP has no native "duration" type. The companion app displays it as a distance (e.g. "42 m"), but the value represents **days of remaining runtime**. + +| Condition | Transmitted Value | Meaning | +|-----------|-------------------|---------| +| Finite TTL | `ttlHours / 24.0` | Estimated remaining days on battery | +| Surplus (charging > consumption) | `990.0` (sentinel value) | Effectively infinite — device is gaining charge | +| Unknown (SOC not yet valid) | *not sent* | TTL cannot be calculated yet | + +### Temperature Sentinel Values + +Invalid temperature readings are indicated by sentinel values and not transmitted to the app: + +| Value | Meaning | +|-------|---------| +| −999 °C | I²C communication error | +| −888 °C | ADC not ready | +| −99 °C | NTC open (not connected) | +| +99 °C | NTC shorted | + +--- + +## Channel 4 — Solar (BQ25798) + +Solar input data from the BQ25798 charge controller. + +| Field | LPP Type | Unit | Source | Description | +|-------|----------|------|--------|-------------| +| Voltage | Voltage | V | BQ25798 | Solar input voltage (VBUS) | +| Current | Current | A | BQ25798 | Solar input current (IBUS) | +| MPPT 7-Day | Percentage | % | Firmware | MPPT activation over the last 7 days. Shows what percentage of time the MPPT regulator was actively harvesting solar energy. | + +> **Note — Solar current accuracy:** The BQ25798 IBUS ADC has a resolution of 1 mA (15-bit mode) but exhibits significant measurement error at low currents (~±30 mA). Values below approximately 150 mA should be treated as rough estimates. For precise current measurement, the battery side uses the INA228 instead. + +> **Note:** The MPPT percentage is a rolling 7-day average. A low value (e.g. 1%) means the panel rarely delivers enough power to activate the MPPT regulator — e.g. during overcast conditions or suboptimal panel angle. + +--- + +## Channel Assignment in Code + +Channels are assigned dynamically: + +1. **Channel 1** (`TELEM_CHANNEL_SELF`) is statically defined and contains the MeshCore base data (battery voltage and MCU die temperature). +2. `querySensors()` assigns each active sensor its own channel starting right after Channel 1 — the BME280 therefore lands on **Channel 2**. +3. The **battery channel** is determined by `queryBoardTelemetry()` as the next free channel (`findNextFreeLppChannel`). +4. The **solar channel** = battery channel + 1. + +`querySensors()` assigns the BME280 to Channel 2 before `queryBoardTelemetry()` runs, so battery data lands on Channel 3 and solar on Channel 4 in practice. + +``` +Order in CayenneLPP packet: +┌───────────────────────────────────────────────┐ +│ Channel 1: Voltage (INA228 / SOC fake) │ ← MyMesh.cpp (getBattMilliVolts) +│ Channel 2: Temp, Humidity, Pressure, Alt. │ ← BME280 (querySensors) +│ Channel 3: VBAT, [SOC], IBAT, TBAT, [TTL] │ ← queryBoardTelemetry() +│ Channel 4: VSOL, ISOL, MPPT% │ ← queryBoardTelemetry() +│ Channel 1: MCU die temperature │ ← MyMesh.cpp (getMCUTemperature) +└───────────────────────────────────────────────┘ +``` + +> **Permissions:** Channels 2–4 are only sent if the requesting client has the `TELEM_PERM_ENVIRONMENT` permission. Guests (Guest role) receive only Channel 1 with base voltage and MCU temperature. + +## See Also + +- [README.md](README.md) — Overview, feature matrix and diagnostics +- [DATASHEET.md](DATASHEET.md) — Hardware specifications and pinout +- [CLI_CHEAT_SHEET.md](CLI_CHEAT_SHEET.md) — All board-specific CLI commands at a glance +- [QUICK_START.md](QUICK_START.md) — Quick start for commissioning and CLI setup +- [BATTERY_GUIDE.md](BATTERY_GUIDE.md) — Battery chemistry comparison and deployment guide +- [FAQ.md](FAQ.md) — Frequently asked questions +- [POWER_MANAGEMENT.md](POWER_MANAGEMENT.md) — Complete technical documentation diff --git a/variants/inhero_mr2/docs/img/back-annotated_.png b/variants/inhero_mr2/docs/img/back-annotated_.png new file mode 100644 index 0000000000..2979eed2e4 Binary files /dev/null and b/variants/inhero_mr2/docs/img/back-annotated_.png differ diff --git a/variants/inhero_mr2/docs/img/back.jpg b/variants/inhero_mr2/docs/img/back.jpg new file mode 100644 index 0000000000..395b0f36f9 Binary files /dev/null and b/variants/inhero_mr2/docs/img/back.jpg differ diff --git a/variants/inhero_mr2/docs/img/front-annotated_.png b/variants/inhero_mr2/docs/img/front-annotated_.png new file mode 100644 index 0000000000..3f70e9fb3d Binary files /dev/null and b/variants/inhero_mr2/docs/img/front-annotated_.png differ diff --git a/variants/inhero_mr2/docs/img/front.jpg b/variants/inhero_mr2/docs/img/front.jpg new file mode 100644 index 0000000000..8de2c028ce Binary files /dev/null and b/variants/inhero_mr2/docs/img/front.jpg differ diff --git a/variants/inhero_mr2/helpers/BatteryOcvMapping.cpp b/variants/inhero_mr2/helpers/BatteryOcvMapping.cpp new file mode 100644 index 0000000000..ffab9a8244 --- /dev/null +++ b/variants/inhero_mr2/helpers/BatteryOcvMapping.cpp @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "BatteryOcvMapping.h" + +namespace inhero { + +uint16_t socToLiIonMilliVolts(float soc_percent) { + // Clamp input to valid range + if (soc_percent <= 0.0f) return 3000; + if (soc_percent >= 100.0f) return 4200; + + // Standard Li-Ion 1S OCV table (NMC/NCA, 10% steps). + // Index 0 = 0% SOC, Index 10 = 100% SOC. + static const uint16_t LI_ION_OCV_TABLE[] = { + 3000, // 0% + 3300, // 10% + 3450, // 20% + 3530, // 30% + 3600, // 40% + 3670, // 50% + 3740, // 60% + 3820, // 70% + 3920, // 80% + 4050, // 90% + 4200 // 100% + }; + + // Piecewise-linear interpolation between 10% steps. + float index_f = soc_percent / 10.0f; // 0.0 – 10.0 + uint8_t idx_lo = (uint8_t)index_f; + if (idx_lo >= 10) idx_lo = 9; // safety clamp + uint8_t idx_hi = idx_lo + 1; + + float frac = index_f - (float)idx_lo; + float mv = (float)LI_ION_OCV_TABLE[idx_lo] + + frac * (float)(LI_ION_OCV_TABLE[idx_hi] - LI_ION_OCV_TABLE[idx_lo]); + + return (uint16_t)(mv + 0.5f); // round to nearest mV +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BatteryOcvMapping.h b/variants/inhero_mr2/helpers/BatteryOcvMapping.h new file mode 100644 index 0000000000..6be420de29 --- /dev/null +++ b/variants/inhero_mr2/helpers/BatteryOcvMapping.h @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include <Arduino.h> + +namespace inhero { + +// Maps a SOC percentage (0-100%) to a fake Li-Ion 1S OCV in millivolts. +// Uses a standard Li-Ion NMC/NCA OCV lookup table with piecewise-linear +// interpolation. The companion app reverse-maps these voltages back to the +// same SOC%, giving a correct battery-level display regardless of the +// actual cell chemistry (Li-Ion, LiFePO4, LTO, Na-Ion). +uint16_t socToLiIonMilliVolts(float soc_percent); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp new file mode 100644 index 0000000000..2b552e6a80 --- /dev/null +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.cpp @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "BqLowPowerSetup.h" + +#include <Arduino.h> +#include <Wire.h> + +#include "../InheroMr2Board.h" +#include "../lib/BqDriver.h" + +namespace inhero { + +static constexpr uint8_t INA228_ADDR = 0x40; + +void prepareIcsForSystemOff() { + // INA228 -> shutdown mode (~3.5uA vs ~350uA continuous). + // I2C writes can fail silently -> retry with readback verification. + for (int retry = 0; retry < 3; retry++) { + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x01); // ADC_CONFIG + Wire.write(0x00); + Wire.write(0x00); + if (Wire.endTransmission() != 0) { + delay(10); + continue; + } + delay(2); + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x01); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)INA228_ADDR, (uint8_t)2); + uint16_t rb = 0; + if (Wire.available() >= 2) { + rb = (Wire.read() << 8) | Wire.read(); + } + if ((rb & 0xF000) == 0x0000) break; + delay(10); + } + + // INA228 -> release latched ALERT (under-voltage alert is ALATCH=1 -> ALERT stays LOW + // -> RAK4630 internal pull-up wastes ~330uA). Switch to transparent mode and + // zero the threshold so no condition can re-assert. + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x0B); // DIAG_ALRT + Wire.write(0x00); + Wire.write(0x00); + Wire.endTransmission(); + Wire.beginTransmission(INA228_ADDR); + Wire.write(0x08); // BUVL + Wire.write(0x00); + Wire.write(0x00); + Wire.endTransmission(); + + // BQ25798 -> low-power housekeeping. These static helpers use raw Wire and + // work even when the driver instance has not been constructed yet (LV-Wake). + BqDriver::disableAdc(); // ~500uA saving + BqDriver::maskAllInterrupts(); // prevent INT holding LOW + BqDriver::clearInterruptFlags(); // de-assert latched INT +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/BqLowPowerSetup.h b/variants/inhero_mr2/helpers/BqLowPowerSetup.h new file mode 100644 index 0000000000..7c4efbc68f --- /dev/null +++ b/variants/inhero_mr2/helpers/BqLowPowerSetup.h @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// Prepares INA228 and BQ25798 for nRF52 System Sleep so the analog ICs +// don't burn quiescent current while the MCU is off: +// - INA228: shutdown ADC (with readback retry), release latched ALERT +// - BQ25798: disable ADC, mask all interrupts and clear flags so INT goes high-Z +// Wire must be initialised before calling. +void prepareIcsForSystemOff(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.cpp b/variants/inhero_mr2/helpers/CliCommands.cpp new file mode 100644 index 0000000000..80b8a6247f --- /dev/null +++ b/variants/inhero_mr2/helpers/CliCommands.cpp @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "CliCommands.h" + +#include "../BoardConfigContainer.h" + +#include <CayenneLPP.h> +#include <ctype.h> +#include <math.h> +#include <stdio.h> +#include <string.h> + +namespace inhero { + +namespace { + +uint8_t getLPPDataLength(uint8_t type) { + switch (type) { + case LPP_DIGITAL_INPUT: + case LPP_DIGITAL_OUTPUT: + case LPP_PRESENCE: + case LPP_RELATIVE_HUMIDITY: + case LPP_PERCENTAGE: + case LPP_SWITCH: + return 1; + case LPP_ANALOG_INPUT: + case LPP_ANALOG_OUTPUT: + case LPP_LUMINOSITY: + case LPP_TEMPERATURE: + case LPP_BAROMETRIC_PRESSURE: + case LPP_VOLTAGE: + case LPP_CURRENT: + case LPP_ALTITUDE: + case LPP_POWER: + case LPP_DIRECTION: + case LPP_CONCENTRATION: + return 2; + case LPP_COLOUR: + return 3; + case LPP_GENERIC_SENSOR: + case LPP_FREQUENCY: + case LPP_DISTANCE: + case LPP_ENERGY: + case LPP_UNIXTIME: + return 4; + case LPP_ACCELEROMETER: + case LPP_GYROMETER: + return 6; + case LPP_GPS: + return 9; + case LPP_POLYLINE: + return 8; // minimum size + default: + return 0; + } +} + +} // namespace + +uint8_t findNextFreeLppChannel(CayenneLPP& lpp) { + uint8_t max_channel = 0; + uint8_t cursor = 0; + uint8_t* buffer = lpp.getBuffer(); + uint8_t size = lpp.getSize(); + + while (cursor < size) { + if (cursor + 1 >= size) break; + uint8_t channel = buffer[cursor]; + uint8_t type = buffer[cursor + 1]; + uint8_t data_len = getLPPDataLength(type); + if (data_len == 0) break; // unknown type, can't continue + if (channel > max_channel) max_channel = channel; + cursor += 2 + data_len; + } + return max_channel + 1; +} + +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& telemetry) { + const Telemetry* telemetryData = cfg.getTelemetryData(); + if (!telemetryData) return false; + + uint8_t batteryChannel = findNextFreeLppChannel(telemetry); + uint8_t solarChannel = batteryChannel + 1; + + const BatterySOCStats* socStats = cfg.getSOCStats(); + bool hasValidSoc = (socStats && socStats->soc_valid); + float socPercent = roundf(cfg.getStateOfCharge() * 10.0f) / 10.0f; + + uint16_t ttlHours = cfg.getTTL_Hours(); + bool isInfiniteTtl = (socStats && socStats->soc_valid && !socStats->living_on_battery); + constexpr float MAX_TTL_DAYS = 990.0f; // sentinel reported when TTL is effectively infinite + + // Battery: VBAT[V], SOC[%] (opt), IBAT[A], TBAT[°C], TTL[d] (opt) + telemetry.addVoltage(batteryChannel, telemetryData->battery.voltage / 1000.0f); + if (hasValidSoc) telemetry.addPercentage(batteryChannel, socPercent); + telemetry.addCurrent(batteryChannel, telemetryData->battery.current / 1000.0f); + if (telemetryData->battery.temperature > -100.0f) { + telemetry.addTemperature(batteryChannel, telemetryData->battery.temperature); + } + if (ttlHours > 0) { + telemetry.addDistance(batteryChannel, ttlHours / 24.0f); + } else if (isInfiniteTtl) { + telemetry.addDistance(batteryChannel, MAX_TTL_DAYS); + } + + // Solar: VSOL[V], ISOL[A], MPPT_7D[%] + telemetry.addVoltage(solarChannel, telemetryData->solar.voltage / 1000.0f); + telemetry.addCurrent(solarChannel, telemetryData->solar.current / 1000.0f); + telemetry.addPercentage(solarChannel, cfg.getMpptEnabledPercentage7Day()); + + return true; +} + +bool handleGet(BoardConfigContainer& cfg, const char* getCommand, char* reply, uint32_t maxlen) { + // Trim trailing whitespace from command + char trimmedCommand[100]; + strncpy(trimmedCommand, getCommand, sizeof(trimmedCommand) - 1); + trimmedCommand[sizeof(trimmedCommand) - 1] = '\0'; + char* cmd = BoardConfigContainer::trim(trimmedCommand); + + if (strcmp(cmd, "bat") == 0) { + snprintf(reply, maxlen, "%s", + BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType())); + return true; + } else if (strcmp(cmd, "fmax") == 0) { + const auto* props = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (props && props->ts_ignore) { + snprintf(reply, maxlen, "N/A"); + } else { + snprintf(reply, maxlen, "%s", + BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour())); + } + return true; + } else if (strcmp(cmd, "imax") == 0) { + snprintf(reply, maxlen, "%s", cfg.getChargeCurrentAsStr()); + return true; + } else if (strcmp(cmd, "mppt") == 0) { + snprintf(reply, maxlen, "MPPT=%s", cfg.getMPPTEnabled() ? "1" : "0"); + return true; + } else if (strcmp(cmd, "stats") == 0) { + const BatterySOCStats* socStats = cfg.getSOCStats(); + if (!socStats) { + snprintf(reply, maxlen, "N/A M:%.0f%%", cfg.getMpptEnabledPercentage7Day()); + return true; + } + + // Rolling windows incl. current-hour accumulators (visible before first hour boundary) + float last_24h_net = socStats->last_24h_net_mah + + socStats->current_hour_solar_mah + - socStats->current_hour_discharged_mah; + float last_24h_charged = socStats->last_24h_charged_mah + socStats->current_hour_charged_mah; + float last_24h_discharged = socStats->last_24h_discharged_mah + socStats->current_hour_discharged_mah; + const char* status = socStats->living_on_battery ? "BAT" : "SOL"; + uint16_t ttl = cfg.getTTL_Hours(); + float mppt_pct = cfg.getMpptEnabledPercentage7Day(); + + char ttlBuf[16]; + if (ttl >= 24) snprintf(ttlBuf, sizeof(ttlBuf), "%dd%dh", ttl / 24, ttl % 24); + else if (ttl > 0) snprintf(ttlBuf, sizeof(ttlBuf), "%dh", ttl); + else snprintf(ttlBuf, sizeof(ttlBuf), "N/A"); + + snprintf(reply, maxlen, + "%+.0f/%+.0f/%+.0fmAh C:%.0f D:%.0f 3C:%.0f 3D:%.0f 7C:%.0f 7D:%.0f %s M:%.0f%% T:%s", + last_24h_net, socStats->avg_3day_daily_net_mah, socStats->avg_7day_daily_net_mah, + last_24h_charged, last_24h_discharged, + socStats->avg_3day_daily_charged_mah, socStats->avg_3day_daily_discharged_mah, + socStats->avg_7day_daily_charged_mah, socStats->avg_7day_daily_discharged_mah, + status, mppt_pct, ttlBuf); + return true; + } else if (strcmp(cmd, "cinfo") == 0) { + char infoBuffer[100]; + cfg.getChargerInfo(infoBuffer, sizeof(infoBuffer)); + snprintf(reply, maxlen, "%s", infoBuffer); + return true; + } else if (strcmp(cmd, "bqdiag") == 0) { + char diagBuffer[100]; + cfg.getBqDiagnostics(diagBuffer, sizeof(diagBuffer)); + snprintf(reply, maxlen, "%s", diagBuffer); + return true; + } else if (strcmp(cmd, "selftest") == 0) { + char stBuffer[64]; + cfg.getSelfTest(stBuffer, sizeof(stBuffer)); + snprintf(reply, maxlen, "%s", stBuffer); + return true; + } else if (strcmp(cmd, "socdebug") == 0) { + Ina228Driver* ina = cfg.getIna228Driver(); + if (!ina) { + snprintf(reply, maxlen, "INA228 n/a"); + return true; + } + const BatterySOCStats* s = cfg.getSOCStats(); + uint16_t scal = ina->readShuntCalRegister(); + float chg = ina->readCharge_mAh(); + float cur = ina->readCurrent_mA_precise(); + uint32_t rtc = BoardConfigContainer::getRTCTimestamp(); + snprintf(reply, maxlen, + "S=%u I=%.1f C=%.1f hC%.1f hD%.1f n=%u t=%lu d=%.2f", + scal, cur, chg, + s->current_hour_charged_mah, s->current_hour_discharged_mah, + s->soc_update_count, (unsigned long)rtc, s->temp_derating_factor); + return true; + } else if (strcmp(cmd, "telem") == 0) { + const Telemetry* telemetry = cfg.getTelemetryData(); + if (!telemetry) { + snprintf(reply, maxlen, "Err: Telemetry unavailable"); + return true; + } + + float precise_current_ma = telemetry->battery.current; + float soc = cfg.getStateOfCharge(); + const BatterySOCStats* socStats = cfg.getSOCStats(); + + // INA228 returns signed: positive=charging, negative=discharging + char bat_current_str[16]; + snprintf(bat_current_str, sizeof(bat_current_str), "%.1fmA", precise_current_ma); + + char sol_current_str[16]; + int16_t sol_current = telemetry->solar.current; + if (sol_current == 0) snprintf(sol_current_str, sizeof(sol_current_str), "0mA"); + else if (sol_current < 50) snprintf(sol_current_str, sizeof(sol_current_str), "<50mA"); + else if (sol_current <= 100) snprintf(sol_current_str, sizeof(sol_current_str), "~%dmA", (int)sol_current); + else snprintf(sol_current_str, sizeof(sol_current_str), "%dmA", (int)sol_current); + + char temp_str[8]; + if (telemetry->battery.temperature <= -100.0f) { + snprintf(temp_str, sizeof(temp_str), "N/A"); + } else { + snprintf(temp_str, sizeof(temp_str), "%.0fC", telemetry->battery.temperature); + } + + if (socStats && socStats->soc_valid) { + // Trapped Charge model: cold locks the bottom of the discharge curve. + // trapped% = (1 - f(T)) * 100, extractable% = max(0, SOC% - trapped%) + if (socStats->temp_derating_factor < 0.999f && socStats->temp_derating_factor > 0.0f) { + float trapped_pct = (1.0f - socStats->temp_derating_factor) * 100.0f; + float derated_soc = soc - trapped_pct; + if (derated_soc < 0.0f) derated_soc = 0.0f; + if (derated_soc > 100.0f) derated_soc = 100.0f; + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% (%.0f%%) S:%.2fV/%s", + telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, + soc, derated_soc, telemetry->solar.voltage / 1000.0f, sol_current_str); + } else { + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:%.1f%% S:%.2fV/%s", + telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, + soc, telemetry->solar.voltage / 1000.0f, sol_current_str); + } + } else { + snprintf(reply, maxlen, "B:%.2fV/%s/%s SOC:N/A S:%.2fV/%s", + telemetry->battery.voltage / 1000.0f, bat_current_str, temp_str, + telemetry->solar.voltage / 1000.0f, sol_current_str); + } + return true; + } else if (strcmp(cmd, "conf") == 0) { + const char* batType = BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType()); + const auto* confProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + const char* frostBehaviour = (confProps && confProps->ts_ignore) + ? "N/A" + : BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour()); + + if (cfg.getBatteryType() == BoardConfigContainer::BAT_UNKNOWN) { + snprintf(reply, maxlen, "B:%s (no battery, charging disabled)", batType); + } else { + float chargeVoltage = cfg.getMaxChargeVoltage(); + float voltage0Soc = + BoardConfigContainer::getLowVoltageWakeThreshold(cfg.getBatteryType()) / 1000.0f; + const char* imax = cfg.getChargeCurrentAsStr(); + bool mpptEnabled = cfg.getMPPTEnabled(); + snprintf(reply, maxlen, "B:%s F:%s M:%s I:%s Vco:%.2f V0:%.2f", batType, frostBehaviour, + mpptEnabled ? "1" : "0", imax, chargeVoltage, voltage0Soc); + } + return true; + } else if (strcmp(cmd, "tccal") == 0) { + snprintf(reply, maxlen, "TC offset: %+.2f C (0.00=default)", cfg.getTcCalOffset()); + return true; + } else if (strcmp(cmd, "leds") == 0) { + snprintf(reply, maxlen, "LEDs: %s (Heartbeat + BQ Stat)", + cfg.getLEDsEnabled() ? "ON" : "OFF"); + return true; + } else if (strcmp(cmd, "batcap") == 0) { + float capacity_mah = cfg.getBatteryCapacity(); + bool explicitly_set = cfg.isBatteryCapacitySet(); + snprintf(reply, maxlen, "%.0f mAh (%s)", capacity_mah, explicitly_set ? "set" : "default"); + return true; + } + + snprintf(reply, maxlen, + "Err: bat|fmax|imax|mppt|telem|stats|cinfo|conf|tccal|leds|batcap"); + return true; +} + +const char* handleSet(BoardConfigContainer& cfg, const char* setCommand) { + static char ret[100]; + memset(ret, 0, sizeof(ret)); + + if (strncmp(setCommand, "bat ", 4) == 0) { + const char* value = BoardConfigContainer::trim(const_cast<char*>(&setCommand[4])); + BoardConfigContainer::BatteryType bt = BoardConfigContainer::getBatteryTypeFromCommandString(value); + if (bt != BoardConfigContainer::BatteryType::BAT_UNKNOWN || strcmp(value, "none") == 0) { + cfg.setBatteryType(bt); + snprintf(ret, sizeof(ret), "Bat set to %s", + BoardConfigContainer::getBatteryTypeCommandString(cfg.getBatteryType())); + } else { + snprintf(ret, sizeof(ret), "Err: Try one of: %s", + BoardConfigContainer::getAvailableBatOptions()); + } + return ret; + } else if (strncmp(setCommand, "fmax ", 5) == 0) { + const auto* fmaxProps = BoardConfigContainer::getBatteryProperties(cfg.getBatteryType()); + if (fmaxProps && fmaxProps->ts_ignore) { + snprintf(ret, sizeof(ret), "Err: Fmax setting N/A for this chemistry (JEITA disabled)"); + return ret; + } + const char* value = BoardConfigContainer::trim(const_cast<char*>(&setCommand[5])); + BoardConfigContainer::FrostChargeBehaviour fcb = + BoardConfigContainer::getFrostChargeBehaviourFromCommandString(value); + if (fcb != BoardConfigContainer::FrostChargeBehaviour::REDUCE_UNKNOWN) { + cfg.setFrostChargeBehaviour(fcb); + snprintf(ret, sizeof(ret), "Fmax charge current set to %s of imax", + BoardConfigContainer::getFrostChargeBehaviourCommandString(cfg.getFrostChargeBehaviour())); + } else { + snprintf(ret, sizeof(ret), "Err: Try one of: %s", + BoardConfigContainer::getAvailableFrostChargeBehaviourOptions()); + } + return ret; + } else if (strncmp(setCommand, "imax ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast<char*>(&setCommand[5])); + int ma = atoi(value); + if (ma >= 50 && ma <= 1500) { + cfg.setMaxChargeCurrent_mA(ma); + snprintf(ret, sizeof(ret), "Max charge current set to %s", cfg.getChargeCurrentAsStr()); + return ret; + } + return "Err: Try 50-1500"; + } else if (strncmp(setCommand, "mppt ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast<char*>(&setCommand[5])); + char lowerValue[20]; + strncpy(lowerValue, value, sizeof(lowerValue) - 1); + lowerValue[sizeof(lowerValue) - 1] = '\0'; + for (char* p = lowerValue; *p; ++p) *p = tolower(*p); + + if (strcmp(lowerValue, "true") == 0 || strcmp(lowerValue, "1") == 0) { + cfg.setMPPTEnable(true); + snprintf(ret, sizeof(ret), "MPPT enabled"); + return ret; + } else if (strcmp(lowerValue, "false") == 0 || strcmp(lowerValue, "0") == 0) { + cfg.setMPPTEnable(false); + snprintf(ret, sizeof(ret), "MPPT disabled"); + return ret; + } + return "Err: Try true|false or 1|0"; + } else if (strncmp(setCommand, "batcap ", 7) == 0) { + const char* value = BoardConfigContainer::trim(const_cast<char*>(&setCommand[7])); + float capacity_mah = atof(value); + if (cfg.setBatteryCapacity(capacity_mah)) { + snprintf(ret, sizeof(ret), "Battery capacity set to %.0f mAh", capacity_mah); + } else { + snprintf(ret, sizeof(ret), "Err: Invalid capacity (100-100000 mAh)"); + } + return ret; + } else if (strncmp(setCommand, "tccal", 5) == 0) { + // `set board.tccal` -> auto-read BME280 as reference + // `set board.tccal reset` -> reset to 0.00 + const char* rest = &setCommand[5]; + if (*rest == ' ') rest++; + const char* value = BoardConfigContainer::trim(const_cast<char*>(rest)); + + if (strcmp(value, "reset") == 0 || strcmp(value, "RESET") == 0) { + if (cfg.setTcCalOffset(0.0f)) { + snprintf(ret, sizeof(ret), "TC calibration reset to 0.00 (default)"); + } else { + snprintf(ret, sizeof(ret), "Err: Failed to reset TC calibration"); + } + return ret; + } + + float bme_avg = 0.0f; + float new_offset = cfg.performTcCalibration(&bme_avg); + if (new_offset > -900.0f) { + snprintf(ret, sizeof(ret), "TC auto-cal: BME=%.1f offset=%+.2f C", bme_avg, new_offset); + } else { + snprintf(ret, sizeof(ret), "Err: Auto-cal failed (BME280/NTC error?)"); + } + return ret; + } else if (strncmp(setCommand, "leds ", 5) == 0) { + const char* value = BoardConfigContainer::trim(const_cast<char*>(&setCommand[5])); + bool enabled = (strcmp(value, "1") == 0 || strcmp(value, "on") == 0 || strcmp(value, "ON") == 0); + bool disabled = (strcmp(value, "0") == 0 || strcmp(value, "off") == 0 || strcmp(value, "OFF") == 0); + if (enabled || disabled) { + cfg.setLEDsEnabled(enabled); + snprintf(ret, sizeof(ret), "LEDs %s (Heartbeat + BQ Stat)", + enabled ? "enabled" : "disabled"); + } else { + snprintf(ret, sizeof(ret), "Err: Use 'on/1' or 'off/0'"); + } + return ret; + } else if (strncmp(setCommand, "soc ", 4) == 0) { + const char* value = BoardConfigContainer::trim(const_cast<char*>(&setCommand[4])); + float soc_percent = atof(value); + if (BoardConfigContainer::setSOCManually(soc_percent)) { + snprintf(ret, sizeof(ret), "SOC set to %.1f%%", soc_percent); + } else { + snprintf(ret, sizeof(ret), "Err: Invalid SOC (0-100) or INA228 not ready"); + } + return ret; + } + + snprintf(ret, sizeof(ret), "Err: bat|imax|fmax|mppt|batcap|tccal|leds|soc"); + return ret; +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/CliCommands.h b/variants/inhero_mr2/helpers/CliCommands.h new file mode 100644 index 0000000000..979d6a06b2 --- /dev/null +++ b/variants/inhero_mr2/helpers/CliCommands.h @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include <Arduino.h> +#include <CayenneLPP.h> + +class BoardConfigContainer; + +namespace inhero { + +// Handles `get board.<cmd>` queries. Writes formatted result into `reply`. +// Returns true if the command was recognised (always true currently — falls +// through to a usage hint on unknown commands). +bool handleGet(BoardConfigContainer& cfg, const char* cmd, char* reply, uint32_t maxlen); + +// Handles `set board.<cmd> <value>` commands. Returns a pointer to a static +// reply buffer owned by the helper (caller must not free). +const char* handleSet(BoardConfigContainer& cfg, const char* setCommand); + +// Appends battery + solar telemetry to `lpp` starting at the next free channel. +// Returns false if telemetry data is unavailable (cfg.getTelemetryData() == nullptr). +bool appendBoardTelemetry(BoardConfigContainer& cfg, CayenneLPP& lpp); + +// Parses a CayenneLPP buffer and returns highest_used_channel + 1, or 1 if empty. +uint8_t findNextFreeLppChannel(CayenneLPP& lpp); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/I2cBusRecovery.cpp b/variants/inhero_mr2/helpers/I2cBusRecovery.cpp new file mode 100644 index 0000000000..8e8dcc1a24 --- /dev/null +++ b/variants/inhero_mr2/helpers/I2cBusRecovery.cpp @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "I2cBusRecovery.h" + +#include <Arduino.h> +#include <MeshCore.h> + +namespace inhero { + +void recoverI2cBus(uint8_t sda, uint8_t scl) { + pinMode(sda, INPUT_PULLUP); + pinMode(scl, OUTPUT); + digitalWrite(scl, HIGH); + + if (digitalRead(sda) == LOW) { + for (int i = 0; i < 9; i++) { + digitalWrite(scl, LOW); + delayMicroseconds(5); + digitalWrite(scl, HIGH); + delayMicroseconds(5); + if (digitalRead(sda) == HIGH) break; + } + // STOP condition: SDA LOW->HIGH while SCL is HIGH + pinMode(sda, OUTPUT); + digitalWrite(sda, LOW); + delayMicroseconds(5); + digitalWrite(scl, HIGH); + delayMicroseconds(5); + digitalWrite(sda, HIGH); + delayMicroseconds(5); + MESH_DEBUG_PRINTLN("I2C bus recovery performed (SDA was stuck LOW)"); + } + + pinMode(sda, INPUT); + pinMode(scl, INPUT); +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/I2cBusRecovery.h b/variants/inhero_mr2/helpers/I2cBusRecovery.h new file mode 100644 index 0000000000..c26907c6aa --- /dev/null +++ b/variants/inhero_mr2/helpers/I2cBusRecovery.h @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include <stdint.h> + +namespace inhero { + +// Manually toggles SCL (up to 9 clocks) to release a slave that holds SDA low +// after OTA/warm-reset. Generates a STOP after recovery. Wire.begin() cannot +// do this on its own. Pins are released back to INPUT before returning so the +// Wire library can take them over. +void recoverI2cBus(uint8_t sda, uint8_t scl); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Rv3028Wake.cpp b/variants/inhero_mr2/helpers/Rv3028Wake.cpp new file mode 100644 index 0000000000..d15c3c4e5d --- /dev/null +++ b/variants/inhero_mr2/helpers/Rv3028Wake.cpp @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "Rv3028Wake.h" + +#include "../InheroMr2Board.h" // RTC_I2C_ADDR + RV3028_REG_* + +#include <MeshCore.h> +#include <Wire.h> + +namespace inhero { + +void configurePeriodicWake(uint16_t minutes) { + uint16_t ticks = (minutes == 0) ? 1 : minutes; + if (ticks > 4095) ticks = 4095; // 12-bit register + + MESH_DEBUG_PRINTLN("PWRMGT: Configuring RTC wake in %u minutes", + static_cast<unsigned>(ticks)); + + // Per RV-3028 manual section 4.8.2: + // Step 1: Stop Timer and clear flags + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + Wire.write(0x00); // TE=0, TD=00 (stop timer) + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL2); + Wire.write(0x00); // TIE=0 + Wire.endTransmission(); + + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(0x00); // Clear TF + Wire.endTransmission(); + + // Step 2: Set Timer Value (ticks at 1/60 Hz) + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_TIMER_VALUE_0); + Wire.write(ticks & 0xFF); + Wire.write((ticks >> 8) & 0x0F); + Wire.endTransmission(); + + // Step 3: Enable timer (1/60 Hz, single shot) + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL1); + Wire.write(0x07); // TE=1, TD=11 (1/60 Hz), TRPT=0 (single shot) + Wire.endTransmission(); + + // Step 4: Enable timer interrupt + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_CTRL2); + Wire.write(0x10); // TIE=1 + Wire.endTransmission(); + + MESH_DEBUG_PRINTLN("PWRMGT: RTC countdown configured (%u ticks at 1/60 Hz)", ticks); +} + +void clearTimerFlag() { + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + if (Wire.endTransmission(false) != 0) return; + + Wire.requestFrom((uint8_t)RTC_I2C_ADDR, (uint8_t)1); + if (!Wire.available()) return; + + uint8_t status = Wire.read(); + if ((status & (1 << 3)) == 0) return; // TF already clear + + status &= ~(1 << 3); + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RV3028_REG_STATUS); + Wire.write(status); + Wire.endTransmission(); +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Rv3028Wake.h b/variants/inhero_mr2/helpers/Rv3028Wake.h new file mode 100644 index 0000000000..64028f9292 --- /dev/null +++ b/variants/inhero_mr2/helpers/Rv3028Wake.h @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +#include <Arduino.h> + +namespace inhero { + +// Configures the RV-3028-C7 periodic countdown timer to fire after `minutes` +// at 1/60 Hz, single-shot, with TIE=1 so the INT pin asserts on expiry. +// `minutes` is clamped to [1, 4095] (12-bit timer register). +void configurePeriodicWake(uint16_t minutes); + +// Clears the RV-3028 Timer Flag (TF, status bit 3) without touching other bits. +// Read-modify-write: required because System Sleep wake is a reset, so the +// FALLING-edge ISR never sees the RTC event and TF stays latched. +void clearTimerFlag(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/SystemSleepGpio.cpp b/variants/inhero_mr2/helpers/SystemSleepGpio.cpp new file mode 100644 index 0000000000..a834190f93 --- /dev/null +++ b/variants/inhero_mr2/helpers/SystemSleepGpio.cpp @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "SystemSleepGpio.h" + +#include <Arduino.h> +#include <MeshCore.h> +#include <nrf.h> + +#include "../InheroMr2Board.h" +#include "../target.h" + +namespace inhero { + +void prepareRadioForSystemOff(bool radioInitialized) { + if (radioInitialized) { + // Cold sleep via SPI while SPIM is still active. + radio_driver.powerOff(); + delay(10); + } else { + // Early Boot: SPI/RadioLib not initialised. SX1262 may be in POR Standby RC + // (~600uA) or Cold Sleep (~160nA). Send SetSleep via bit-banged SPI to be sure. + pinMode(P_LORA_NSS, OUTPUT); + digitalWrite(P_LORA_NSS, HIGH); + pinMode(P_LORA_SCLK, OUTPUT); + digitalWrite(P_LORA_SCLK, LOW); // CPOL=0 + pinMode(P_LORA_MOSI, OUTPUT); + digitalWrite(P_LORA_MOSI, LOW); + pinMode(P_LORA_BUSY, INPUT); + + // SX1262 §13.1.1: a wake-up NSS pulse is consumed; the actual command + // needs a SUBSEQUENT NSS falling edge. + digitalWrite(P_LORA_NSS, LOW); + delayMicroseconds(2); + uint32_t t0 = millis(); + while (digitalRead(P_LORA_BUSY) == HIGH && (millis() - t0) < 10) { + delayMicroseconds(100); + } + digitalWrite(P_LORA_NSS, HIGH); + delayMicroseconds(10); + + // SetSleep 0x84 0x00 (Cold Start, no retention, TCXO off). + static const uint8_t cmd[2] = { 0x84, 0x00 }; + + digitalWrite(P_LORA_NSS, LOW); + delayMicroseconds(2); + + for (int b = 0; b < 2; b++) { + uint8_t byte = cmd[b]; + for (int i = 7; i >= 0; i--) { + digitalWrite(P_LORA_MOSI, (byte >> i) & 1); + delayMicroseconds(1); + digitalWrite(P_LORA_SCLK, HIGH); + delayMicroseconds(1); + digitalWrite(P_LORA_SCLK, LOW); + delayMicroseconds(1); + } + } + + digitalWrite(P_LORA_MOSI, LOW); + delayMicroseconds(1); + digitalWrite(P_LORA_NSS, HIGH); + + delay(1); + } + + // PE4259 RF switch off + digitalWrite(SX126X_POWER_EN, LOW); + + // Latch SX1262 SPI pins at defined levels so floating CMOS inputs don't + // pull shoot-through current during System Sleep. + uint32_t pin_cfg_out = (GPIO_PIN_CNF_DIR_Output << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Disconnect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Disabled << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Disabled << GPIO_PIN_CNF_SENSE_Pos); + NRF_P1->OUTSET = (1UL << 10); // NSS HIGH + NRF_P1->OUTCLR = (1UL << 11) | (1UL << 12); // SCLK LOW, MOSI LOW + NRF_P1->PIN_CNF[10] = pin_cfg_out; + NRF_P1->PIN_CNF[11] = pin_cfg_out; + NRF_P1->PIN_CNF[12] = pin_cfg_out; +} + +void disconnectLeakyPullups() { + uint32_t pin_cfg_discon = (GPIO_PIN_CNF_DIR_Input << GPIO_PIN_CNF_DIR_Pos) | + (GPIO_PIN_CNF_INPUT_Disconnect << GPIO_PIN_CNF_INPUT_Pos) | + (GPIO_PIN_CNF_PULL_Disabled << GPIO_PIN_CNF_PULL_Pos) | + (GPIO_PIN_CNF_DRIVE_S0S1 << GPIO_PIN_CNF_DRIVE_Pos) | + (GPIO_PIN_CNF_SENSE_Disabled << GPIO_PIN_CNF_SENSE_Pos); + + // P0: skip BQ_CE_PIN (P0.04) and RTC_INT_PIN (P0.17) + for (uint8_t pin = 0; pin < 32; pin++) { + if (pin == 4 || pin == 17) continue; + NRF_P0->PIN_CNF[pin] = pin_cfg_discon; + } + // P1: skip SX1262 SPI pins latched by prepareRadioForSystemOff() + for (uint8_t pin = 0; pin < 16; pin++) { + if (pin == 10 || pin == 11 || pin == 12) continue; + NRF_P1->PIN_CNF[pin] = pin_cfg_discon; + } +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/SystemSleepGpio.h b/variants/inhero_mr2/helpers/SystemSleepGpio.h new file mode 100644 index 0000000000..be8e678378 --- /dev/null +++ b/variants/inhero_mr2/helpers/SystemSleepGpio.h @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// Puts the SX1262 into Cold Sleep and latches NSS/SCK/MOSI to defined levels +// before nRF52 System Sleep. +// radioInitialized=true -> uses RadioLib (radio_driver.powerOff()). +// radioInitialized=false -> bit-bangs SetSleep on P_LORA_* directly. +// SPI.end() must NOT be called: floating SCK during the disconnect window +// re-wakes the SX1262 (~600uA Standby RC). +void prepareRadioForSystemOff(bool radioInitialized = true); + +// Resets every GPIO to INPUT_DISCONNECT/PULL_DISABLED except the few pins +// the design must keep alive across System Sleep: +// P0.04 (BQ_CE_PIN) -> OUTPUT HIGH (charging stays enabled) +// P0.17 (RTC_INT_PIN) -> INPUT_PULLUP + SENSE_Low (wake source) +// P1.10/11/12 -> latched by prepareRadioForSystemOff() (NSS/SCK/MOSI) +// Must run after Wire.end() and after prepareRadioForSystemOff(). +void disconnectLeakyPullups(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/UsbAutoManagement.cpp b/variants/inhero_mr2/helpers/UsbAutoManagement.cpp new file mode 100644 index 0000000000..e4fb5ea603 --- /dev/null +++ b/variants/inhero_mr2/helpers/UsbAutoManagement.cpp @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "UsbAutoManagement.h" + +#include <Arduino.h> +#include <MeshCore.h> +#include <nrf.h> + +#include "../BoardConfigContainer.h" + +namespace inhero { + +// USB starts enabled (Serial.begin in main) +static bool s_usbActive = true; + +bool isUsbPowered() { + return (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0; +} + +void disableUsb() { + if (s_usbActive) { + Serial.end(); + NRF_USBD->ENABLE = 0; + s_usbActive = false; + BoardConfigContainer::setUsbConnected(false); + MESH_DEBUG_PRINTLN("USB disabled"); + } +} + +void enableUsb() { + if (!s_usbActive) { + NRF_USBD->ENABLE = 1; + Serial.begin(115200); + s_usbActive = true; + BoardConfigContainer::setUsbConnected(true); + MESH_DEBUG_PRINTLN("USB enabled"); + } +} + +void serviceUsbAutoManagement() { + // After Serial.end(), Serial.available() returns 0 and Serial.read() returns -1, + // so no serial guard is needed in the main loop. + if (!s_usbActive && isUsbPowered()) enableUsb(); + if (s_usbActive && !isUsbPowered()) disableUsb(); +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/UsbAutoManagement.h b/variants/inhero_mr2/helpers/UsbAutoManagement.h new file mode 100644 index 0000000000..e70303f03b --- /dev/null +++ b/variants/inhero_mr2/helpers/UsbAutoManagement.h @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// Manages the nRF52 USB peripheral based on VBUS presence so the device can +// safely run from battery without an enumerated USB host. Also keeps +// BoardConfigContainer's USB-connected state in sync (used for IINDPM). +bool isUsbPowered(); +void enableUsb(); +void disableUsb(); + +// Call from board tick(); enables/disables USB on VBUS edge. +void serviceUsbAutoManagement(); + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Watchdog.cpp b/variants/inhero_mr2/helpers/Watchdog.cpp new file mode 100644 index 0000000000..05c329d776 --- /dev/null +++ b/variants/inhero_mr2/helpers/Watchdog.cpp @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#include "Watchdog.h" + +#include <Arduino.h> +#include <MeshCore.h> +#include <nrf_wdt.h> + +namespace inhero { + +static bool s_wdtEnabled = false; + +void setupWatchdog(bool blinkLed) { +#ifndef DEBUG_MODE + NRF_WDT->CONFIG = (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos) | + (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos); + NRF_WDT->CRV = 32768 * 600; // 600 s @ 32.768 kHz - long enough for OTA + NRF_WDT->RREN = WDT_RREN_RR0_Enabled << WDT_RREN_RR0_Pos; + NRF_WDT->TASKS_START = 1; + s_wdtEnabled = true; + MESH_DEBUG_PRINTLN("Watchdog enabled: 600s timeout"); + +#ifdef LED_BLUE + if (blinkLed) { + for (int i = 0; i < 3; i++) { + digitalWrite(LED_BLUE, HIGH); + delay(100); + digitalWrite(LED_BLUE, LOW); + delay(100); + } + } +#else + (void)blinkLed; +#endif +#else + (void)blinkLed; + MESH_DEBUG_PRINTLN("Watchdog disabled (DEBUG_MODE)"); +#endif +} + +void feedWatchdog() { +#ifndef DEBUG_MODE + if (s_wdtEnabled) { + NRF_WDT->RR[0] = WDT_RR_RR_Reload; + } +#endif +} + +void disableWatchdog() { +#ifndef DEBUG_MODE + // nRF52 WDT cannot be stopped once started -- only stop feeding. + s_wdtEnabled = false; +#endif +} + +} // namespace inhero diff --git a/variants/inhero_mr2/helpers/Watchdog.h b/variants/inhero_mr2/helpers/Watchdog.h new file mode 100644 index 0000000000..fb032b2c4d --- /dev/null +++ b/variants/inhero_mr2/helpers/Watchdog.h @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * SPDX-License-Identifier: MIT + */ +#pragma once + +namespace inhero { + +// nRF52 hardware watchdog wrappers. The nRF52 WDT cannot be stopped once +// started; disable() only stops the feed loop so the next CRV expiry resets +// the chip. All three are no-ops when DEBUG_MODE is defined. +// +// setupWatchdog(true) blinks LED_BLUE three times as visual confirmation. +void setupWatchdog(bool blinkLed); +void feedWatchdog(); +void disableWatchdog(); + +} // namespace inhero diff --git a/variants/inhero_mr2/lib/BqDriver.cpp b/variants/inhero_mr2/lib/BqDriver.cpp new file mode 100644 index 0000000000..fd2fd573d5 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.cpp @@ -0,0 +1,563 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver Implementation + */ +#include "BqDriver.h" + +#include <MeshCore.h> + +BqDriver::BqDriver() {} + +BqDriver::~BqDriver() { + if (ih_i2c_dev) { + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + } +} + +// Initializes BQ25798 charger and creates dedicated I2C device for NTC access +bool BqDriver::begin(uint8_t i2c_addr, TwoWire* wire) { + if (!Adafruit_BQ25798::begin(i2c_addr, wire)) { + // Cleanup any existing device before returning + if (ih_i2c_dev) { + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + } + return false; + } + if (ih_i2c_dev) { + delete ih_i2c_dev; + } + ih_i2c_dev = new Adafruit_I2CDevice(i2c_addr, wire); + if (!ih_i2c_dev->begin()) { + // Cleanup on failure + delete ih_i2c_dev; + ih_i2c_dev = nullptr; + return false; + } + return true; +} + +// Reads Power Good status from charger — true if input power is sufficient for charging +bool BqDriver::getChargerStatusPowerGood() { + Adafruit_BusIO_Register chrg_stat_0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_0); + Adafruit_BusIO_RegisterBits chrg_stat_0_bits = Adafruit_BusIO_RegisterBits(&chrg_stat_0_reg, 1, 3); + + uint8_t reg_value = chrg_stat_0_bits.read(); + + return (bool)reg_value; +} + +// Reads current charging state from charger +bq25798_charging_status BqDriver::getChargingStatus() { + Adafruit_BusIO_Register chrg_stat_1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_STATUS_1); + Adafruit_BusIO_RegisterBits chrg_stat_1_bits = Adafruit_BusIO_RegisterBits(&chrg_stat_1_reg, 3, 5); + + uint8_t reg_value = chrg_stat_1_bits.read(); + + return (bq25798_charging_status)reg_value; +} + +// Reads solar and temperature telemetry via BQ25798 ADC one-shot +// +// BQ25798 ADC Operating Conditions (Datasheet SLUSE22, Section 9.3.16): +// "The ADC is allowed to operate if either VBUS > 3.4V or VBAT > 2.9V is valid. +// At battery only condition, if the TS_ADC channel is enabled, the ADC only +// works when battery voltage is higher than 3.2V, otherwise, the ADC works +// when the battery voltage is higher than 2.9V." +// +// This means: +// VBUS > 3.4V → ADC runs, all channels available +// VBAT >= 3.2V (no VBUS) → ADC runs, all channels including TS +// VBAT 2.9-3.2V (no VBUS) → ADC runs ONLY if TS channel is DISABLED +// VBAT < 2.9V (no VBUS) → ADC cannot run at all +// +// Strategy: +// 1. If VBAT < 3.2V: disable TS channel to lower threshold to 2.9V +// → Solar data (VBUS/IBUS) still readable, temperature returns N/A +// 2. If VBAT < 2.9V and no VBUS: ADC times out, all values zero/N/A +// 3. Only channels actually used on MR2 are enabled (IBUS, VBUS, TS) +// — unused channels (IBAT, VBAT, VSYS, TDIE, D+, D-, VAC1, VAC2) +// are disabled to prevent ADC_EN from hanging on unconnected pins. +// +// ADC_EN auto-clear behavior: +// In one-shot mode, ADC_EN resets to 0 only when ALL enabled channels +// have completed conversion. If any channel cannot complete (e.g. floating +// input), ADC_EN stays 1 indefinitely. This is why unused channels MUST +// be disabled via registers 0x2F/0x30. +// +// vbat_mv: battery voltage in mV from INA228 (0 = unknown, assume sufficient). +// Returns pointer to internal Telemetry struct (valid until next call). +const Telemetry* BqDriver::getTelemetryData(uint16_t vbat_mv) { + telemetryData = { 0 }; + + // Determine if TS channel can be enabled based on VBAT + // See datasheet quote above: TS enabled requires VBAT >= 3.2V (battery-only) + bool ts_enabled = true; + if (vbat_mv > 0 && vbat_mv < 3200) { + ts_enabled = false; // Disable TS → ADC threshold drops to 2.9V + } + + bool success = this->startADCOneShot(ts_enabled); + + if (!success) { + return &telemetryData; + } + + // Poll ADC_EN bit until it auto-clears (conversion complete) or timeout. + // Channels: IBUS + VBUS (+ TS if enabled) → ~48-72ms typical. + const uint32_t ADC_TIMEOUT_MS = 250; + uint32_t start = millis(); + bool conversion_done = false; + while ((millis() - start) < ADC_TIMEOUT_MS) { + if (!this->getADCEnabled()) { + conversion_done = true; + break; + } + delay(10); + } + + if (!conversion_done) { + this->setADCEnabled(false); + } + + if (conversion_done) { + telemetryData.solar.voltage = getVBUS(); + telemetryData.solar.current = getIBUS(); + if (telemetryData.solar.current < 0) { + telemetryData.solar.current = 0; + } + telemetryData.solar.power = ((int32_t)telemetryData.solar.voltage * telemetryData.solar.current) / 1000; + + if (ts_enabled) { + telemetryData.battery.temperature = this->calculateBatteryTemp(getTS()); + } else { + // TS disabled due to low VBAT — cannot read NTC + telemetryData.battery.temperature = -888.0f; + } + } else { + // ADC didn't complete — VBAT < 2.9V and no VBUS, or I2C issue + telemetryData.battery.temperature = -888.0f; + } + + telemetryData.solar.mppt = getMPPTenable(); + + return &telemetryData; +} + +// Calculates battery temperature in °C using Steinhart-Hart equation. +// Uses coefficients derived from Murata NCP15XH103F03RC datasheet R-T table. +// Max error vs. datasheet: ±0.36°C over -40..+125°C range. +// +// Per BQ25798 datasheet Figure 9-12: REGN → RT1 → TS → (RT2||NTC) → GND +// ts_pct: voltage at TS pin in percentage of REGN (e.g., 70.5 for 70.5%). +// Special input values: -1.0 = I2C error, -2.0 = ADC not ready/invalid. +// Returns temperature in °C, or error codes: +// -999.0 = I2C communication error +// -888.0 = ADC not ready (read 0 or 0xFFFF) +// -99.0 = NTC open/disconnected (k > 0.99) +// 99.0 = NTC short circuit (k < 0.01) +float BqDriver::calculateBatteryTemp(float ts_pct) { + // Check for I2C read error + if (ts_pct == -1.0f) return -999.0f; // I2C error + if (ts_pct == -2.0f) return -888.0f; // ADC not ready or invalid value + + // Convert TS percentage to ratio (0.0 to 1.0) + // TS% = 100 × R_bottom / (R_top + R_bottom) + // where R_bottom = RT2 || NTC + float k = ts_pct / 100.0f; + + // Plausibility check + if (k > 0.99f) return -99.0f; // NTC open/disconnected + if (k < 0.01f) return 99.0f; // NTC short circuit + + // Calculate total resistance of bottom network (RT2 || NTC) + // From: k = R_bottom / (RT1 + R_bottom) + // Rearranged: R_bottom = RT1 × k / (1 - k) + float r_bottom_total = R_PULLUP * (k / (1.0f - k)); + + // Extract NTC resistance from parallel combination with RT2 + // For parallel resistors: 1/R_total = 1/R_NTC + 1/RT2 + // Therefore: 1/R_NTC = 1/R_total - 1/RT2 + float g_total = 1.0f / r_bottom_total; + float g_rt2 = 1.0f / R_PARALLEL; + + if (g_total <= g_rt2) { + return -99.0f; // Invalid measurement + } + + float r_ntc = 1.0f / (g_total - g_rt2); + + // Apply Steinhart-Hart equation: 1/T = A + B·ln(R) + C·(ln(R))³ + float ln_r = logf(r_ntc); + float inv_T = SH_A + SH_B * ln_r + SH_C * ln_r * ln_r * ln_r; + + // Convert Kelvin to Celsius + return (1.0f / inv_T) - 273.15f; +} + +// Getter/Setter for NTC Control 0 (0x17) +// Gets JEITA voltage setting for warm/cool regions +bq25798_jeita_vset_t BqDriver::getJeitaVSet() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_vset_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 3, 5); + + uint8_t reg_value = jeita_vset_bits.read(); + + return (bq25798_jeita_vset_t)reg_value; +} + +// Sets JEITA voltage setting for warm/cool temperature regions +bool BqDriver::setJeitaVSet(bq25798_jeita_vset_t setting) { + if (setting > BQ25798_JEITA_VSET_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_vset_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 3, 5); + + jeita_vset_bits.write((uint8_t)setting); + + return true; +} + +// Gets JEITA current setting for hot region +bq25798_jeita_iseth_t BqDriver::getJeitaISetH() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_iseth_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 3); + + uint8_t reg_value = jeita_iseth_bits.read(); + + return (bq25798_jeita_iseth_t)reg_value; +} + +// Sets JEITA current setting for hot temperature region +bool BqDriver::setJeitaISetH(bq25798_jeita_iseth_t setting) { + if (setting > BQ25798_JEITA_ISETH_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_iseth_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 3); + + jeita_iseth_bits.write((uint8_t)setting); + + return true; +} + +// Gets JEITA current setting for cold region +bq25798_jeita_isetc_t BqDriver::getJeitaISetC() { + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_isetc_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 1); + + uint8_t reg_value = jeita_isetc_bits.read(); + + return (bq25798_jeita_isetc_t)reg_value; +} + +// Sets JEITA current setting for cold temperature region +bool BqDriver::setJeitaISetC(bq25798_jeita_isetc_t setting) { + if (setting > BQ25798_JEITA_ISETC_UNCHANGED) { + return false; + } + + Adafruit_BusIO_Register ntc0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_0); + Adafruit_BusIO_RegisterBits jeita_isetc_bits = Adafruit_BusIO_RegisterBits(&ntc0_reg, 2, 1); + + jeita_isetc_bits.write((uint8_t)setting); + + return true; +} + +// Gets TS Cool threshold (lower boundary of COOL region) +bq25798_ts_cool_t BqDriver::getTsCool() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_cool_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 6); + + uint8_t reg_value = ts_cool_bits.read(); + + return (bq25798_ts_cool_t)reg_value; +} + +// Sets TS Cool threshold (lower boundary of COOL region) +bool BqDriver::setTsCool(bq25798_ts_cool_t threshold) { + if (threshold > BQ25798_TS_COOL_20C) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_cool_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 6); + + ts_cool_bits.write((uint8_t)threshold); + + return true; +} + +// Gets TS Warm threshold (upper boundary of WARM region) +bq25798_ts_warm_t BqDriver::getTsWarm() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_warm_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 4); + + uint8_t reg_value = ts_warm_bits.read(); + + return (bq25798_ts_warm_t)reg_value; +} + +// Sets TS Warm threshold (upper boundary of WARM region) +bool BqDriver::setTsWarm(bq25798_ts_warm_t threshold) { + if (threshold > BQ25798_TS_WARM_55C) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_warm_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 4); + + ts_warm_bits.write((uint8_t)threshold); + + return true; +} + +// Gets BHOT threshold (upper limit for charging) +bq25798_bhot_t BqDriver::getBHot() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bhot_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 2); + + uint8_t reg_value = bhot_bits.read(); + + return (bq25798_bhot_t)reg_value; +} + +// Sets BHOT threshold (upper limit for charging) +bool BqDriver::setBHot(bq25798_bhot_t threshold) { + if (threshold > BQ25798_BHOT_DISABLE) { + return false; + } + + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bhot_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 2, 2); + + bhot_bits.write((uint8_t)threshold); + + return true; +} + +// Gets BCOLD threshold (lower limit for charging) +bq25798_bcold_t BqDriver::getBCold() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bcold_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 1); + + uint8_t reg_value = bcold_bits.read(); + + return (bq25798_bcold_t)reg_value; +} + +// Sets BCOLD threshold (lower limit for charging) +bool BqDriver::setBCold(bq25798_bcold_t threshold) { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits bcold_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 1); + + bcold_bits.write((uint8_t)threshold); + + return true; +} + +// Gets TS ignore status (disables all temperature monitoring) +bool BqDriver::getTsIgnore() { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_ignore_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 0); + + return (bool)ts_ignore_bits.read(); +} + +// Sets TS ignore status (disables all temperature monitoring) +bool BqDriver::setTsIgnore(bool ignore) { + Adafruit_BusIO_Register ntc1_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_NTC_CONTROL_1); + Adafruit_BusIO_RegisterBits ts_ignore_bits = Adafruit_BusIO_RegisterBits(&ntc1_reg, 1, 0); + + ts_ignore_bits.write((uint8_t)ignore); + + return true; +} + +// Starts ADC one-shot conversion for selected channels +// +// MR2 ADC Channel Map: +// Reg 0x2F (ADC_FUNCTION_DISABLE_0): bit=1 means DISABLED +// Bit 7: IBUS → ENABLED (solar current) +// Bit 6: IBAT → disabled (INA228 measures battery current) +// Bit 5: VBUS → ENABLED (solar voltage) +// Bit 4: VBAT → disabled (INA228 measures battery voltage) +// Bit 3: VSYS → disabled (not used) +// Bit 2: TS → ENABLED or disabled depending on VBAT level +// Bit 1: TDIE → disabled (not used) +// Bit 0: reserved +// +// Reg 0x30 (ADC_FUNCTION_DISABLE_1): all disabled on MR2 +// Bit 7: D+ → disabled (AutoDPinsDetection=false, pin not connected) +// Bit 6: D- → disabled (pin not connected) +// Bit 5: VAC2 → disabled (not routed on PCB) +// Bit 4: VAC1 → disabled (not routed on PCB) +// +// Why only needed channels: ADC_EN only auto-clears when ALL enabled channels +// complete. Enabling unconnected channels (D+, D-, VAC) causes ADC_EN to hang +// indefinitely, requiring a timeout and forced disable. +// +// ts_enabled: true = enable TS channel (requires VBAT >= 3.2V per datasheet). +// Returns true if the I2C writes succeeded. +bool BqDriver::startADCOneShot(bool ts_enabled) { + Adafruit_BusIO_Register disable_reg_0 = Adafruit_BusIO_Register(ih_i2c_dev, 0x2F); + Adafruit_BusIO_Register disable_reg_1 = Adafruit_BusIO_Register(ih_i2c_dev, 0x30); + + // Reg 0x2F bit map: IBUS(7) IBAT(6) VBUS(5) VBAT(4) VSYS(3) TS(2) TDIE(1) reserved(0) + // 1 = disabled, 0 = enabled + uint8_t disable0 = 0x5A; // Enable IBUS(7), VBUS(5), TS(2) — disable rest + if (!ts_enabled) { + disable0 |= 0x04; // Also disable TS(2) → 0x5E + } + if (!disable_reg_0.write(disable0)) { return false; } + + // Reg 0x30: Disable all — D+(7), D-(6), VAC2(5), VAC1(4) not connected on MR2 + if (!disable_reg_1.write(0xF0)) { return false; } + + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + bool ok = adc_ctrl_reg.write(0xC0); + return ok; +} + +// ADC Control register (0x2E) implementations +bool BqDriver::getADCEnabled() { + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + Adafruit_BusIO_RegisterBits adc_en_bits = Adafruit_BusIO_RegisterBits(&adc_ctrl_reg, 1, 7); + bool result = (bool)adc_en_bits.read(); + return result; +} + +bool BqDriver::setADCEnabled(bool enabled) { + Adafruit_BusIO_Register adc_ctrl_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_ADC_CONTROL); + Adafruit_BusIO_RegisterBits adc_en_bits = Adafruit_BusIO_RegisterBits(&adc_ctrl_reg, 1, 7); + bool ok = adc_en_bits.write((uint8_t)enabled); + return ok; +} + +// ADC Reading implementations +int16_t BqDriver::getIBUS() { + Adafruit_BusIO_Register ibus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_IBUS_ADC, 2, MSBFIRST); + uint16_t raw; + if (!ibus_reg.read(&raw)) { // MSB first + return 0; + } + int16_t val = (int16_t)raw; // 2's complement for signed + return val; // in mA +} + +uint16_t BqDriver::getVBUS() { + Adafruit_BusIO_Register vbus_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_VBUS_ADC, 2, MSBFIRST); + uint16_t val; + if (!vbus_reg.read(&val)) { + return 0; + } + return val; // in mV +} + +float BqDriver::getTS() { + Adafruit_BusIO_Register ts_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_TS_ADC, 2, MSBFIRST); + uint16_t val; + + // Try up to 3 times with small delays if we get invalid values + for (int retry = 0; retry < 3; retry++) { + if (!ts_reg.read(&val)) { + delay(20); + continue; // I2C read error, retry + } + // Check for invalid/uninitialized ADC value (0 or 0xFFFF) + if (val == 0 || val == 0xFFFF) { + if (retry < 2) { + delay(50); // Wait a bit longer for ADC to settle + continue; + } + return -2.0f; // ADC not ready / invalid value after retries + } + // Valid value + return val * 0.09765625f; // 0.09765625 %/LSB (exact: 1/1024) + } + + return -1.0f; // I2C read error after all retries +} + +bool BqDriver::setVOCpercent(bq25798_voc_pct_t pct) { + uint8_t reg15 = readReg(0x15); + reg15 = (reg15 & 0x1F) | ((uint8_t)pct << 5); // Bits [7:5] = VOC_PCT + return writeReg(0x15, reg15); +} + +bq25798_voc_pct_t BqDriver::getVOCpercent() { + uint8_t reg15 = readReg(0x15); + return (bq25798_voc_pct_t)((reg15 >> 5) & 0x07); +} + + +// Gets EN_AUTO_IBATDIS state (auto battery discharge during VBAT_OVP; POR default = enabled) +bool BqDriver::getAutoIBATDIS() { + Adafruit_BusIO_Register ctrl0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + Adafruit_BusIO_RegisterBits auto_ibatdis_bit = Adafruit_BusIO_RegisterBits(&ctrl0_reg, 1, 7); + return (bool)auto_ibatdis_bit.read(); +} + +// Sets EN_AUTO_IBATDIS (auto battery discharge during VBAT_OVP). +// enable: true = BQ sinks 30mA from BAT during OVP, false = no active discharge. +bool BqDriver::setAutoIBATDIS(bool enable) { + Adafruit_BusIO_Register ctrl0_reg = Adafruit_BusIO_Register(ih_i2c_dev, BQ25798_REG_CHARGER_CONTROL_0); + Adafruit_BusIO_RegisterBits auto_ibatdis_bit = Adafruit_BusIO_RegisterBits(&ctrl0_reg, 1, 7); + return auto_ibatdis_bit.write(enable ? 1 : 0); +} + +// Non-static register access methods (use instance I2C config) +bool BqDriver::writeReg(uint8_t reg, uint8_t val) { + if (!ih_i2c_dev) return false; + + uint8_t buffer[2] = {reg, val}; + bool ok = ih_i2c_dev->write(buffer, 2); + return ok; +} + +uint8_t BqDriver::readReg(uint8_t reg) { + if (!ih_i2c_dev) return 0; + + uint8_t buffer[1] = {reg}; + if (!ih_i2c_dev->write_then_read(buffer, 1, buffer, 1)) { + return 0; + } + return buffer[0]; +} + +// Static, raw-Wire helpers — safe pre-begin(). +void BqDriver::maskAllInterrupts(TwoWire& wire, uint8_t addr) { + static const uint8_t mask_regs[] = {0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D}; + for (uint8_t r : mask_regs) { + wire.beginTransmission(addr); + wire.write(r); + wire.write(0xFF); + wire.endTransmission(); + } +} + +void BqDriver::clearInterruptFlags(TwoWire& wire, uint8_t addr) { + static const uint8_t flag_regs[] = {0x22, 0x23, 0x24, 0x25, 0x26, 0x27}; + for (uint8_t r : flag_regs) { + wire.beginTransmission(addr); + wire.write(r); + wire.endTransmission(false); + wire.requestFrom(addr, (uint8_t)1); + while (wire.available()) wire.read(); + } +} + +void BqDriver::disableAdc(TwoWire& wire, uint8_t addr) { + wire.beginTransmission(addr); + wire.write(0x2E); // ADC_CONTROL + wire.write(0x00); + wire.endTransmission(); +} diff --git a/variants/inhero_mr2/lib/BqDriver.h b/variants/inhero_mr2/lib/BqDriver.h new file mode 100644 index 0000000000..5830704892 --- /dev/null +++ b/variants/inhero_mr2/lib/BqDriver.h @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * BQ25798 Charger Driver for Inhero MR-2 + * Extends Adafruit_BQ25798 library (BSD License). + */ + +#pragma once + +#include <Adafruit_BQ25798.h> +#include <Wire.h> +#include <math.h> + +#define R_PULLUP 5600.0f // Upper resistor RT1 in Ohms +#define R_PARALLEL 27000.0f // Lower parallel resistor RT2 in Ohms + +// Steinhart-Hart coefficients for NCP15XH103F03RC NTC (10kΩ, B=3380) +// Fitted from Murata datasheet R-T table at -20°C, 25°C, 85°C +// Max error vs. datasheet: ±0.36°C over -40..+125°C range +#define SH_A 8.7248136876e-04f // Steinhart-Hart coefficient A +#define SH_B 2.5405556775e-04f // Steinhart-Hart coefficient B +#define SH_C 1.8122847672e-07f // Steinhart-Hart coefficient C + +// Solar input telemetry data +// Solar current from BQ25798 IBUS ADC has significant error at low currents (~±30mA). +// Values are approximate - treat as estimates, not precise measurements. +typedef struct { + uint16_t voltage; // Solar voltage in mV + int16_t current; // Solar current in mA (approximate, see note above) + int32_t power; // Solar power in mW + bool mppt; // MPPT enabled status +} SolarData; + +// Battery telemetry data +typedef struct { + uint16_t voltage; // Battery voltage in mV + float current; // Battery current in mA (positive = charging, negative = discharging) + int32_t power; // Battery power in mW + float temperature; // Battery temperature in °C +} BattData; + +// System voltage telemetry +typedef struct { + uint16_t voltage; // System voltage in mV +} SysData; + +// Main telemetry container aggregating all data sources +typedef struct { + SysData system; // System voltage data + SolarData solar; // Solar input data + BattData battery; // Battery data +} Telemetry; + +// JEITA voltage setting for warm/cool regions (NTC Control 0 Register 0x17) +typedef enum { + BQ25798_JEITA_VSET_SUSPEND = 0x00, // Charge Suspend + BQ25798_JEITA_VSET_MINUS_800MV = 0x01, // Set VREG to VREG-800mV + BQ25798_JEITA_VSET_MINUS_600MV = 0x02, // Set VREG to VREG-600mV + BQ25798_JEITA_VSET_MINUS_400MV = 0x03, // Set VREG to VREG-400mV (default) + BQ25798_JEITA_VSET_MINUS_300MV = 0x04, // Set VREG to VREG-300mV + BQ25798_JEITA_VSET_MINUS_200MV = 0x05, // Set VREG to VREG-200mV + BQ25798_JEITA_VSET_MINUS_100MV = 0x06, // Set VREG to VREG-100mV + BQ25798_JEITA_VSET_UNCHANGED = 0x07 // VREG unchanged +} bq25798_jeita_vset_t; + +typedef enum { + BQ25798_JEITA_ISETH_SUSPEND = 0x00, // Charge Suspend + BQ25798_JEITA_ISETH_20_PERCENT = 0x01, // Set ICHG to 20% * ICHG + BQ25798_JEITA_ISETH_40_PERCENT = 0x02, // Set ICHG to 40% * ICHG + BQ25798_JEITA_ISETH_UNCHANGED = 0x03 // ICHG unchanged (default) +} bq25798_jeita_iseth_t; + +typedef enum { + BQ25798_JEITA_ISETC_SUSPEND = 0x00, // Charge Suspend + BQ25798_JEITA_ISETC_20_PERCENT = 0x01, // Set ICHG to 20% * ICHG (default) + BQ25798_JEITA_ISETC_40_PERCENT = 0x02, // Set ICHG to 40% * ICHG + BQ25798_JEITA_ISETC_UNCHANGED = 0x03 // ICHG unchanged +} bq25798_jeita_isetc_t; + +typedef enum { + BQ25798_CHARGER_STATE_NOT_CHARGING = 0x00, + BQ25798_CHARGER_STATE_TRICKLE_CHARGING = 0x01, + BQ25798_CHARGER_STATE_PRE_CHARGING = 0x02, + BQ25798_CHARGER_STATE_CC_CHARGING = 0x03, + BQ25798_CHARGER_STATE_CV_CHARGING = 0x04, + BQ25798_CHARGER_STATE_TOP_OF_TIMER_ACTIVE_CHARGING = 0x06, + BQ25798_CHARGER_STATE_DONE_CHARGING = 0x07 + +} bq25798_charging_status; + +// New enums for NTC Control 1 (Register 0x18) +typedef enum { + BQ25798_TS_COOL_5C = 0x00, // 71.1% of REGN (5°C) + BQ25798_TS_COOL_10C = 0x01, // 68.4% of REGN (10°C, default) + BQ25798_TS_COOL_15C = 0x02, // 65.5% of REGN (15°C) + BQ25798_TS_COOL_20C = 0x03 // 62.4% of REGN (20°C) +} bq25798_ts_cool_t; + +typedef enum { + BQ25798_TS_WARM_40C = 0x00, // 48.4% of REGN (40°C) + BQ25798_TS_WARM_45C = 0x01, // 44.8% of REGN (45°C, default) + BQ25798_TS_WARM_50C = 0x02, // 41.2% of REGN (50°C) + BQ25798_TS_WARM_55C = 0x03 // 37.7% of REGN (55°C) +} bq25798_ts_warm_t; + +// BHOT threshold - upper temperature limit for charging +typedef enum { + BQ25798_BHOT_55C = 0x00, // 55°C + BQ25798_BHOT_60C = 0x01, // 60°C (default) + BQ25798_BHOT_65C = 0x02, // 65°C + BQ25798_BHOT_DISABLE = 0x03 // Disable BHOT protection +} bq25798_bhot_t; + +// BCOLD threshold - lower temperature limit for charging +typedef enum { + BQ25798_BCOLD_MINUS_10C = 0x00, // -10°C (default) + BQ25798_BCOLD_MINUS_20C = 0x01 // -20°C +} bq25798_bcold_t; + +// ADC resolution setting +typedef enum { + BQ25798_ADC_SAMPLE_15BIT = 0b00, // 15-bit resolution (default, ~24ms conversion) + BQ25798_ADC_SAMPLE_14BIT = 0b01, // 14-bit resolution + BQ25798_ADC_SAMPLE_13BIT = 0b10, // 13-bit resolution + BQ25798_ADC_SAMPLE_12BIT = 0b11 // 12-bit resolution (not recommended) +} bq25798_adc_sample_t; + +// Extended BQ25798 driver with NTC support and comprehensive telemetry. +// Extends Adafruit_BQ25798 with: +// - JEITA temperature control (VSET, ISETH, ISETC) +// - NTC thermistor temperature calculation +// - Complete ADC telemetry (solar, battery, system) +// - One-shot ADC conversion management +class BqDriver : public Adafruit_BQ25798 { +public: + BqDriver(); + ~BqDriver(); + + bool begin(uint8_t i2c_addr = BQ25798_DEFAULT_ADDR, TwoWire* wire = &Wire); + + // Direct pass-through to parent — all I2C runs in tick() context (no concurrent access) + bool setHIZMode(bool enable) { return Adafruit_BQ25798::setHIZMode(enable); } + bool setChargeEnable(bool enable) { return Adafruit_BQ25798::setChargeEnable(enable); } + bool getChargeEnable() { return Adafruit_BQ25798::getChargeEnable(); } + bool getMPPTenable() { return Adafruit_BQ25798::getMPPTenable(); } + bool setMPPTenable(bool enable) { return Adafruit_BQ25798::setMPPTenable(enable); } + bool setStatPinEnable(bool enable) { return Adafruit_BQ25798::setStatPinEnable(enable); } + bool getStatPinEnable() { return Adafruit_BQ25798::getStatPinEnable(); } + + bq25798_jeita_vset_t getJeitaVSet(); + bool setJeitaVSet(bq25798_jeita_vset_t setting); + + bq25798_jeita_iseth_t getJeitaISetH(); + bool setJeitaISetH(bq25798_jeita_iseth_t setting); + + bq25798_jeita_isetc_t getJeitaISetC(); + bool setJeitaISetC(bq25798_jeita_isetc_t setting); + + bq25798_ts_cool_t getTsCool(); + bool setTsCool(bq25798_ts_cool_t threshold); + + bq25798_ts_warm_t getTsWarm(); + bool setTsWarm(bq25798_ts_warm_t threshold); + + bq25798_bhot_t getBHot(); + bool setBHot(bq25798_bhot_t threshold); + + bq25798_bcold_t getBCold(); + bool setBCold(bq25798_bcold_t threshold); + + bool getTsIgnore(); + bool setTsIgnore(bool ignore); + + // Read solar + temperature telemetry via BQ25798 ADC. + // vbat_mv: battery voltage from INA228 in mV, used to decide if the TS channel + // can be enabled (requires VBAT >= 3.2V without VBUS, per datasheet 9.3.16). + // Pass 0 if unknown (assumes sufficient voltage). + const Telemetry* getTelemetryData(uint16_t vbat_mv = 0); + + // Charger Status + bool getChargerStatusPowerGood(); + bq25798_charging_status getChargingStatus(); + + bool setVOCpercent(bq25798_voc_pct_t pct); + bq25798_voc_pct_t getVOCpercent(); + + bool getAutoIBATDIS(); + bool setAutoIBATDIS(bool enable); + + // Non-static register access methods (use instance I2C config) + bool writeReg(uint8_t reg, uint8_t val); + uint8_t readReg(uint8_t reg); + + // Low-level BQ25798 housekeeping via raw TwoWire. These are safe to call + // before begin() — used on the low-voltage wake path where the driver + // instance has not been constructed yet. + + // Mask every charger- and fault-interrupt source (MASK regs 0x28..0x2D) so + // the INT line stays HIGH when INT is not wired to an MCU IRQ. Otherwise a + // pull-up on INT wastes current (~254µA with RAK4630 INPUT_PULLUP). + static void maskAllInterrupts(TwoWire& wire = Wire, uint8_t addr = 0x6B); + + // Read flag regs 0x22..0x27 (read-to-clear) to de-assert the INT line. + // Status regs 0x1B/0x20/0x21 must NOT be touched here — they are read-only. + static void clearInterruptFlags(TwoWire& wire = Wire, uint8_t addr = 0x6B); + + // Disable the BQ25798 ADC (saves ~500µA continuous draw). Used when entering + // System Sleep; re-enabled on wake by the driver's normal startup path. + static void disableAdc(TwoWire& wire = Wire, uint8_t addr = 0x6B); + + // ADC status/result accessors + bool getADCEnabled(); + uint16_t getVBUS(); + +protected: + Adafruit_I2CDevice* ih_i2c_dev = nullptr; // Dedicated I2C device for NTC access + +private: + bool startADCOneShot(bool ts_enabled = true); + bool setADCEnabled(bool enabled); + int16_t getIBUS(); + float getTS(); // TS voltage in % of REGN + + float calculateBatteryTemp(float ts_pct); + Telemetry telemetryData = { 0 }; +}; \ No newline at end of file diff --git a/variants/inhero_mr2/lib/Ina228Driver.cpp b/variants/inhero_mr2/lib/Ina228Driver.cpp new file mode 100644 index 0000000000..532ef99b83 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.cpp @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver Implementation + */ + +#include "Ina228Driver.h" +#include <MeshCore.h> // for MESH_DEBUG_PRINTLN + +Ina228Driver::Ina228Driver(uint8_t i2c_addr) + : _i2c_addr(i2c_addr), _shunt_mohm(10.0f), _current_lsb(0.0f), _base_shunt_cal(0), _calibration_factor(1.0f) {} + +bool Ina228Driver::begin(float shunt_resistor_mohm) { + _shunt_mohm = shunt_resistor_mohm; + + // Check if device is present + if (!isConnected()) { + MESH_DEBUG_PRINTLN("INA228 begin() FAILED: isConnected() = false"); + return false; + } + MESH_DEBUG_PRINTLN("INA228 begin(): Device connected"); + // Do NOT reset device - Early Boot voltage check may have configured it + // Resetting causes timing issues where subsequent writes fail + // Just reconfigure registers directly + + // Configure ADC: Continuous mode, all channels, long conversion times, 256 samples averaging + // - Long conversion times (VSHCT=4120µs, VBUSCT=2074µs) reduce noise for accurate SOC tracking + // - AVG_256 filters TX voltage peaks (prevents false UVLO triggers during transmit) + // - Trade-off: ~1s per measurement (excellent accuracy, acceptable for 1h SOC updates) + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all = 0xF + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs for voltage accuracy + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs for current/SOC accuracy + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs (temp less critical) + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples + // Expected value: 0xFFCB + + // Write ADC_CONFIG with retry and verify + // Sometimes the first write after readVBATDirect() fails + bool adc_config_ok = false; + for (int retry = 0; retry < 5; retry++) { + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); + delay(10); + uint16_t readback = readRegister16(INA228_REG_ADC_CONFIG); + if (readback == adc_config) { + adc_config_ok = true; + break; + } + delay(20); // Wait longer before retry + } + + if (!adc_config_ok) { + MESH_DEBUG_PRINTLN("INA228 begin() ERROR: Failed to set ADC_CONFIG after 5 retries!"); + return false; + } + MESH_DEBUG_PRINTLN("INA228 begin(): ADC_CONFIG set to 0x%04X", adc_config); + + // Calculate current LSB: Max expected current / 2^19 (20-bit ADC) + // With 100mΩ shunt and ±163.84mV ADC range (ADCRANGE=0): Max = 163.84mV / 0.1Ω = 1.6384A + // Using 1.6384A, LSB = 1.6384A / 524288 ≈ 3.125 µA + // At 10mA standby: V_shunt = 1mV → SNR greatly improved vs. 20mΩ (200µV) + _current_lsb = 1.6384f / 524288.0f; // in Amperes (max ±1.6384A) + + // Calculate shunt calibration value + // SHUNT_CAL = 13107.2 × 10^6 × CURRENT_LSB × R_SHUNT + // R_SHUNT in Ohms, CURRENT_LSB in A + float shunt_ohm = _shunt_mohm / 1000.0f; + _base_shunt_cal = (uint16_t)(13107.2e6 * _current_lsb * shunt_ohm); + + // ADCRANGE = 0 (±163.84mV): No multiplier needed (×4 only required for ADCRANGE=1) + + // Apply calibration factor to SHUNT_CAL (if set) + uint16_t calibrated_shunt_cal = (uint16_t)(_base_shunt_cal * _calibration_factor); + writeRegister16(INA228_REG_SHUNT_CAL, calibrated_shunt_cal); + delay(5); + + // Configure INA228: ADCRANGE = 0 (±163.84mV, default) for 100mΩ shunt + // At 1A: V_shunt = 100mV (61% of full-scale) — sufficient headroom + // At 10mA: V_shunt = 1mV — 5× better SNR than 20mΩ (was 200µV) + uint16_t config = 0; // ADCRANGE=0 (±163.84mV range, bit 4 = 0) + writeRegister16(INA228_REG_CONFIG, config); + delay(5); + + return true; +} + +bool Ina228Driver::isConnected() { + // First check if device responds at all + Wire.beginTransmission(_i2c_addr); + uint8_t i2c_result = Wire.endTransmission(); + + if (i2c_result != 0) { + return false; // No ACK on bus + } + + // Read Manufacturer ID (should be 0x5449 = "TI") + uint16_t mfg_id = readRegister16(INA228_REG_MANUFACTURER); + + if (mfg_id == 0x0000 || mfg_id == 0xFFFF) { + return false; // Invalid MFG_ID (bus error) + } + + // Some INA228 clones may have different MFG_ID, skip strict check + // Just verify it's not a bus error value + + // Read Device ID (should be 0x228 in lower 12 bits) + uint16_t dev_id = readRegister16(INA228_REG_DEVICE_ID); + + if (dev_id == 0x0000 || dev_id == 0xFFFF) { + return false; // Invalid DEV_ID (bus error) + } + + // Accept any non-error DEV_ID — some INA228 clones report 0x2281 instead of 0x228. + + return true; // Accept device if MFG_ID was valid +} + +void Ina228Driver::reset() { + writeRegister16(INA228_REG_CONFIG, INA228_CONFIG_RST); +} + +uint16_t Ina228Driver::readVoltage_mV() { + int32_t vbus_raw = readRegister24(INA228_REG_VBUS); + // INA228 VBUS: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + vbus_raw >>= 4; + // VBUS LSB = 195.3125 µV + float vbus_v = vbus_raw * 195.3125e-6; + return (uint16_t)(vbus_v * 1000.0f); // Convert to mV +} + +int16_t Ina228Driver::readCurrent_mA() { + int32_t current_raw = readRegister24(INA228_REG_CURRENT); + // INA228 CURRENT: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + current_raw >>= 4; + // Current = raw × CURRENT_LSB + // Calibration is applied via SHUNT_CAL register (hardware calibration) + // Sign convention: INVERT because shunt is oriented for battery perspective + // Positive = charging (current into battery), Negative = discharging (current from battery) + float current_a = current_raw * _current_lsb; + float current_mA = -current_a * 1000.0f; // Convert to mA, inverted sign + return (int16_t)(current_mA); +} + +float Ina228Driver::readCurrent_mA_precise() { + int32_t current_raw = readRegister24(INA228_REG_CURRENT); + // INA228 CURRENT: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + current_raw >>= 4; + // Current = raw × CURRENT_LSB + // Calibration is applied via SHUNT_CAL register (hardware calibration) + // Sign convention: INVERT because shunt is oriented for battery perspective + // Positive = charging (current into battery), Negative = discharging (current from battery) + float current_a = current_raw * _current_lsb; + float current_mA = -current_a * 1000.0f; // Convert to mA with full precision, inverted sign + return current_mA; +} + +bool Ina228Driver::shutdown() { + // Set operating mode to Shutdown (MODE = 0x0) + // This disables all conversions and Coulomb Counter. + // Use retry+readback — I2C writes can fail silently (see setUnderVoltageAlert). + // If this fails, INA228 stays in continuous mode (~350µA wasted in System Sleep!). + uint16_t adc_config = 0x0000; // MODE = 0x0 (Shutdown) + + for (int retry = 0; retry < 3; retry++) { + if (!writeRegister16(INA228_REG_ADC_CONFIG, adc_config)) { + delay(10); + continue; + } + delay(2); + uint16_t readback = readRegister16(INA228_REG_ADC_CONFIG); + if ((readback & 0xF000) == 0x0000) { // Check MODE bits [15:12] + return true; + } + delay(10); + } + return false; +} + +void Ina228Driver::wakeup() { + // Re-enable continuous measurement mode with full ADC configuration + // Must restore conversion times from begin() - defaults are much shorter (50µs) + uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all = 0xF + (INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs for voltage accuracy + (INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs for current/SOC accuracy + (INA228_ADC_CT_540us << 3) | // VTCT: 540µs (temp less critical) + (INA228_ADC_AVG_256 << 0); // AVG: 256 samples + writeRegister16(INA228_REG_ADC_CONFIG, adc_config); +} + +uint16_t Ina228Driver::readVBATDirect(TwoWire* wire, uint8_t i2c_addr) { + // === Important: This is called BEFORE begin() in Early Boot Check === + // The INA228 may be in power-on reset state, so we need to be careful + + // First check if device responds + wire->beginTransmission(i2c_addr); + if (wire->endTransmission() != 0) { + return 0; // Device not present + } + + // === One-Shot ADC Trigger === + // Configure ADC for single-shot bus voltage measurement + // MODE = 0x1 (Single-shot bus voltage only) + uint16_t adc_config = (0x1 << 12); // MODE = 0x1, no averaging for speed + + wire->beginTransmission(i2c_addr); + wire->write(INA228_REG_ADC_CONFIG); + wire->write((adc_config >> 8) & 0xFF); + wire->write(adc_config & 0xFF); + if (wire->endTransmission() != 0) { + return 0; // I2C communication failed + } + + // Wait for conversion to complete (~200µs typical, use 2ms to be safe) + delay(2); + + // Read VBUS register (24-bit) + wire->beginTransmission(i2c_addr); + wire->write(INA228_REG_VBUS); + if (wire->endTransmission(false) != 0) { + return 0; + } + + wire->requestFrom(i2c_addr, (uint8_t)3); + if (wire->available() < 3) { + return 0; + } + + int32_t vbus_raw = wire->read() << 16; // MSB + vbus_raw |= wire->read() << 8; // Mid + vbus_raw |= wire->read(); // LSB + + // Sign-extend 24-bit to 32-bit + if (vbus_raw & 0x800000) { + vbus_raw |= 0xFF000000; + } + + // INA228 VBUS: 20-bit ADC left-aligned in 24-bit register + // Must right-shift by 4 bits to get actual 20-bit value + vbus_raw >>= 4; + + // VBUS LSB = 195.3125 µV + float vbus_v = vbus_raw * 195.3125e-6; + uint16_t vbus_mv = (uint16_t)(vbus_v * 1000.0f); + + return vbus_mv; +} + +int32_t Ina228Driver::readPower_mW() { + int32_t power_raw = readRegister24(INA228_REG_POWER); + // Power LSB = 3.2 × CURRENT_LSB + // Sign convention: INVERT to match current sign (positive = charging) + float power_w = power_raw * (3.2f * _current_lsb); + return (int32_t)(-power_w * 1000.0f); // Convert to mW, inverted sign +} + +int32_t Ina228Driver::readEnergy_mWh() { + int64_t energy_raw = readRegister40(INA228_REG_ENERGY); + // Energy LSB = 16 × 3.2 × CURRENT_LSB (in J) + // Convert to Wh: / 3600 + // NO inversion - shunt orientation gives correct battery perspective + // Positive = discharging (energy from battery), Negative = charging (energy into battery) + float energy_j = energy_raw * (16.0f * 3.2f * _current_lsb); + float energy_wh = energy_j / 3600.0f; + return (int32_t)(energy_wh * 1000.0f); // Convert to mWh, NO inversion +} + +float Ina228Driver::readCharge_mAh() { + int64_t charge_raw = readRegister40(INA228_REG_CHARGE); + // Charge LSB = CURRENT_LSB (in C = A·s) + // Convert to Ah: / 3600 + // INVERT: Hardware negative = charging, we want positive = charging (battery perspective) + float charge_c = charge_raw * _current_lsb; + float charge_ah = charge_c / 3600.0f; + return -charge_ah * 1000.0f; // Convert to mAh, INVERTED for battery perspective +} + +float Ina228Driver::readDieTemperature_C() { + // DIETEMP is a 16-bit register (not 24-bit like others!) + int16_t temp_raw = (int16_t)readRegister16(INA228_REG_DIETEMP); + // Temperature LSB = 7.8125 m°C + float temp_c = temp_raw * 7.8125e-3; + return temp_c; +} + +bool Ina228Driver::readAll(Ina228BatteryData* data) { + if (!isConnected()) { + return false; + } + + data->voltage_mv = readVoltage_mV(); + data->current_ma = readCurrent_mA(); + data->power_mw = readPower_mW(); + data->energy_mwh = readEnergy_mWh(); + data->charge_mah = readCharge_mAh(); + data->die_temp_c = readDieTemperature_C(); + + return true; +} + +void Ina228Driver::resetCoulombCounter() { + // Write RSTACC (bit 14) directly — no read-modify-write! + // CONFIG is always 0x0000 (set in begin()), so we can safely write 0x4000. + // RMW is dangerous: if readRegister16() returns garbage on I2C glitch, + // we could accidentally set RST (bit 15) and wipe SHUNT_CAL. + writeRegister16(INA228_REG_CONFIG, (1 << 14)); // RSTACC only +} + +uint16_t Ina228Driver::readShuntCalRegister() { + return readRegister16(INA228_REG_SHUNT_CAL); +} + +uint16_t Ina228Driver::readAdcConfigRegister() { + return readRegister16(INA228_REG_ADC_CONFIG); +} + +uint16_t Ina228Driver::readConfigRegister() { + return readRegister16(INA228_REG_CONFIG); +} + +bool Ina228Driver::validateAndRepairShuntCal() { + uint16_t expected = (uint16_t)(_base_shunt_cal * _calibration_factor); + if (expected == 0) return false; // Not initialized + + uint16_t actual = readShuntCalRegister(); + if (actual == expected) return true; // OK + + // SHUNT_CAL is wrong — repair it! + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL corrupted! Expected=%u, Got=%u - repairing", expected, actual); + writeRegister16(INA228_REG_SHUNT_CAL, expected); + delay(2); + + uint16_t verify = readShuntCalRegister(); + if (verify != expected) { + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repair FAILED! Wrote=%u, Read=%u", expected, verify); + return false; + } + MESH_DEBUG_PRINTLN("INA228: SHUNT_CAL repaired successfully"); + return true; +} + +bool Ina228Driver::setUnderVoltageAlert(uint16_t voltage_mv) { + // BUVL register: 3.125 mV/LSB (per datasheet Table 7-20) + // Non-zero BUVL enables bus under-voltage comparison → BUSUL flag + ALERT pin + // BUVL = 0 disables comparison (datasheet default) + uint16_t buvl_value = (uint16_t)(voltage_mv / 3.125f); + + // Write with retry and readback verification (I2C writes can fail silently) + for (int retry = 0; retry < 3; retry++) { + if (!writeRegister16(INA228_REG_BUVL, buvl_value)) { + delay(10); + continue; + } + delay(5); + uint16_t readback = readRegister16(INA228_REG_BUVL); + if (readback == buvl_value) { + return true; + } + MESH_DEBUG_PRINTLN("INA228: BUVL write mismatch (wrote=0x%04X, read=0x%04X), retry %d", + buvl_value, readback, retry); + delay(10); + } + MESH_DEBUG_PRINTLN("INA228: BUVL write FAILED after 3 retries!"); + return false; +} + +void Ina228Driver::enableAlert(bool enable_uvlo, bool active_high, bool latch_alert) { + // DIAG_ALRT register: Only bits [15:12] are R/W (config), bits [11:0] are read-only flags. + // Writing to this register clears all flag bits [11:0]. + // Note: BUSUL/BUSOL flags (bits 3-4) are READ-ONLY status flags, NOT enable bits. + // Bus under-voltage comparison is enabled by setting BUVL register to non-zero. + uint16_t diag_alrt = 0; + + if (latch_alert) { + diag_alrt |= INA228_DIAG_ALRT_ALATCH; // Latch mode: Alert stays active until DIAG_ALRT is read + } + + if (active_high) { + diag_alrt |= INA228_DIAG_ALRT_APOL; // Active-high polarity (default: active-low) + } + + // Write with retry and readback verification + // Only bits [15:12] are readable as config; bits [11:0] are flags (may change between write and read) + uint16_t expected_config = diag_alrt & 0xF000; // Only check config bits + for (int retry = 0; retry < 3; retry++) { + writeRegister16(INA228_REG_DIAG_ALRT, diag_alrt); + delay(5); + uint16_t readback = readRegister16(INA228_REG_DIAG_ALRT); + uint16_t readback_config = readback & 0xF000; + if (readback_config == expected_config) { + return; + } + MESH_DEBUG_PRINTLN("INA228: DIAG_ALRT config mismatch (wrote=0x%04X, read=0x%04X, config=0x%04X vs 0x%04X), retry %d", + diag_alrt, readback, expected_config, readback_config, retry); + delay(10); + } + MESH_DEBUG_PRINTLN("INA228: DIAG_ALRT write FAILED after 3 retries!"); +} + +bool Ina228Driver::isAlertActive() { + uint16_t diag_flags = getDiagnosticFlags(); + return (diag_flags & (INA228_DIAG_ALRT_BUSUL | INA228_DIAG_ALRT_BUSOL)) != 0; +} + +void Ina228Driver::clearAlert() { + // Read diagnostic register to clear latched alerts + getDiagnosticFlags(); +} + +uint16_t Ina228Driver::getDiagnosticFlags() { + return readRegister16(INA228_REG_DIAG_ALRT); +} + +uint16_t Ina228Driver::readBuvlRegister() { + return readRegister16(INA228_REG_BUVL); +} + +// ===== Private Methods ===== + +bool Ina228Driver::writeRegister16(uint8_t reg, uint16_t value) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.write((value >> 8) & 0xFF); // MSB + Wire.write(value & 0xFF); // LSB + bool ok = (Wire.endTransmission() == 0); + return ok; +} + +uint16_t Ina228Driver::readRegister16(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); // Repeated start + + Wire.requestFrom(_i2c_addr, (uint8_t)2); + if (Wire.available() < 2) { + return 0; + } + + uint16_t value = Wire.read() << 8; // MSB + value |= Wire.read(); // LSB + return value; +} + +int32_t Ina228Driver::readRegister24(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); + + Wire.requestFrom(_i2c_addr, (uint8_t)3); + if (Wire.available() < 3) { + return 0; + } + + int32_t value = Wire.read() << 16; // MSB + value |= Wire.read() << 8; // Mid + value |= Wire.read(); // LSB + + // Sign-extend 24-bit to 32-bit + if (value & 0x800000) { + value |= 0xFF000000; + } + + return value; +} + +int64_t Ina228Driver::readRegister40(uint8_t reg) { + Wire.beginTransmission(_i2c_addr); + Wire.write(reg); + Wire.endTransmission(false); + + Wire.requestFrom(_i2c_addr, (uint8_t)5); + if (Wire.available() < 5) { + return 0; + } + + int64_t value = (int64_t)Wire.read() << 32; // MSB + value |= (int64_t)Wire.read() << 24; + value |= (int64_t)Wire.read() << 16; + value |= (int64_t)Wire.read() << 8; + value |= (int64_t)Wire.read(); // LSB + + // Sign-extend 40-bit to 64-bit + if (value & 0x8000000000LL) { + value |= 0xFFFFFF0000000000LL; + } + + return value; +} + +// ===== Calibration Methods ===== + +float Ina228Driver::calibrateCurrent(float actual_current_ma) { + // Step 1: Reset calibration to 1.0 for accurate measurement + setCalibrationFactor(1.0f); + + // Wait for ADC to settle + delay(10); + + // Step 2: Read current measured value (uncalibrated) + int16_t measured_current_ma = readCurrent_mA(); + + // Avoid division by zero + if (measured_current_ma == 0) { + return 1.0f; // No correction possible + } + + // Step 3: Calculate correction factor (INVERSE ratio) + // If INA shows -9.4mA but actual is -10.4mA, we need SHUNT_CAL to be SMALLER + // so INA writes a LARGER value. Factor = measured/actual = 9.4/10.4 = 0.904 + float new_factor = (float)measured_current_ma / actual_current_ma; + + // Step 4: Apply new calibration factor to INA228 hardware + setCalibrationFactor(new_factor); + + return new_factor; +} + +void Ina228Driver::setCalibrationFactor(float factor) { + // Clamp to reasonable range (0.5x to 2.0x) + if (factor < 0.5f) factor = 0.5f; + if (factor > 2.0f) factor = 2.0f; + + _calibration_factor = factor; + + // Apply calibration factor to SHUNT_CAL register + // Lower SHUNT_CAL → INA writes larger values → higher current reading + // Higher SHUNT_CAL → INA writes smaller values → lower current reading + // CURRENT_LSB stays constant (per datasheet design) + if (_base_shunt_cal > 0) { + uint16_t calibrated_shunt_cal = (uint16_t)(_base_shunt_cal * factor); + writeRegister16(INA228_REG_SHUNT_CAL, calibrated_shunt_cal); + } +} + +float Ina228Driver::getCalibrationFactor() const { + return _calibration_factor; +} + + diff --git a/variants/inhero_mr2/lib/Ina228Driver.h b/variants/inhero_mr2/lib/Ina228Driver.h new file mode 100644 index 0000000000..414de3a953 --- /dev/null +++ b/variants/inhero_mr2/lib/Ina228Driver.h @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + * + * INA228 Power Monitor Driver for Inhero MR-2 + * + * Features: + * - Voltage, current, power monitoring + * - Coulomb counter (accumulated charge) + * - Alert pin for firmware-triggered low-voltage sleep (INA228 BUVL → ISR → System Sleep) + * - Chemistry-specific thresholds (Li-Ion, LiFePO4, LTO) + */ + +#pragma once + +#include <Wire.h> +#include <Arduino.h> + +// INA228 I2C Address +#define INA228_I2C_ADDR_DEFAULT 0x40 // A0=GND, A1=GND + +// INA228 Register Map +#define INA228_REG_CONFIG 0x00 // Configuration +#define INA228_REG_ADC_CONFIG 0x01 // ADC Configuration +#define INA228_REG_SHUNT_CAL 0x02 // Shunt Calibration +#define INA228_REG_SHUNT_TEMP 0x03 // Shunt Temperature Coefficient +#define INA228_REG_VSHUNT 0x04 // Shunt Voltage +#define INA228_REG_VBUS 0x05 // Bus Voltage +#define INA228_REG_DIETEMP 0x06 // Die Temperature +#define INA228_REG_CURRENT 0x07 // Current +#define INA228_REG_POWER 0x08 // Power +#define INA228_REG_ENERGY 0x09 // Energy (Coulomb Counter) +#define INA228_REG_CHARGE 0x0A // Charge (Coulomb Counter) +#define INA228_REG_DIAG_ALRT 0x0B // Diagnostic and Alert +#define INA228_REG_SOVL 0x0C // Shunt Over-Voltage Limit +#define INA228_REG_SUVL 0x0D // Shunt Under-Voltage Limit +#define INA228_REG_BOVL 0x0E // Bus Over-Voltage Limit +#define INA228_REG_BUVL 0x0F // Bus Under-Voltage Limit +#define INA228_REG_TEMP_LIMIT 0x10 // Temperature Limit +#define INA228_REG_PWR_LIMIT 0x11 // Power Limit +#define INA228_REG_MANUFACTURER 0x3E // Manufacturer ID (should be 0x5449 = "TI") +#define INA228_REG_DEVICE_ID 0x3F // Device ID (should be 0x228) + +// INA228 Configuration bits +#define INA228_CONFIG_RST (1 << 15) // Reset bit +#define INA228_CONFIG_ADCRANGE (1 << 4) // ADC Range (0=±163.84mV, 1=±40.96mV) + +// ADC Configuration - Mode +#define INA228_ADC_MODE_CONT_ALL 0x0F // Continuous conversion, all channels + +// ADC Configuration - Averaging +#define INA228_ADC_AVG_1 0x00 // No averaging +#define INA228_ADC_AVG_4 0x01 // 4 samples average +#define INA228_ADC_AVG_16 0x02 // 16 samples average +#define INA228_ADC_AVG_64 0x03 // 64 samples average +#define INA228_ADC_AVG_128 0x04 // 128 samples average +#define INA228_ADC_AVG_256 0x05 // 256 samples average +#define INA228_ADC_AVG_512 0x06 // 512 samples average +#define INA228_ADC_AVG_1024 0x07 // 1024 samples average + +// ADC Configuration - Conversion Time (VBUSCT, VSHCT, VTCT) +#define INA228_ADC_CT_50us 0x00 // 50 µs +#define INA228_ADC_CT_84us 0x01 // 84 µs +#define INA228_ADC_CT_150us 0x02 // 150 µs +#define INA228_ADC_CT_280us 0x03 // 280 µs +#define INA228_ADC_CT_540us 0x04 // 540 µs +#define INA228_ADC_CT_1052us 0x05 // 1052 µs (default) +#define INA228_ADC_CT_2074us 0x06 // 2074 µs +#define INA228_ADC_CT_4120us 0x07 // 4120 µs (maximum accuracy) + +// Alert Configuration +#define INA228_DIAG_ALRT_ALATCH (1 << 15) // Alert Latch Enable +#define INA228_DIAG_ALRT_CNVR (1 << 14) // Conversion Ready +#define INA228_DIAG_ALRT_SLOWALERT (1 << 13) // Slow Alert (for averaging) +#define INA228_DIAG_ALRT_APOL (1 << 12) // Alert Polarity (1=active high) +#define INA228_DIAG_ALRT_ENERGYOF (1 << 11) // Energy Overflow +#define INA228_DIAG_ALRT_CHARGEOF (1 << 10) // Charge Overflow +#define INA228_DIAG_ALRT_MATHOF (1 << 9) // Math Overflow +#define INA228_DIAG_ALRT_TMPOL (1 << 7) // Temperature Over-Limit +#define INA228_DIAG_ALRT_SHNTOL (1 << 6) // Shunt Over-Voltage +#define INA228_DIAG_ALRT_SHNTUL (1 << 5) // Shunt Under-Voltage +#define INA228_DIAG_ALRT_BUSOL (1 << 4) // Bus Over-Voltage +#define INA228_DIAG_ALRT_BUSUL (1 << 3) // Bus Under-Voltage (UVLO) +#define INA228_DIAG_ALRT_POL (1 << 2) // Power Over-Limit +#define INA228_DIAG_ALRT_CNVRF (1 << 1) // Conversion Ready Flag +#define INA228_DIAG_ALRT_MEMSTAT (1 << 0) // Memory Status + +// Battery telemetry from INA228 +typedef struct { + uint16_t voltage_mv; // Battery voltage in mV + int16_t current_ma; // Battery current in mA (+ = charging, - = discharging) + int32_t power_mw; // Battery power in mW + int32_t energy_mwh; // Accumulated energy in mWh (since last reset) + float charge_mah; // Accumulated charge in mAh (since last reset) + float die_temp_c; // Die temperature in °C +} Ina228BatteryData; + +class Ina228Driver { +public: + // i2c_addr default is for A0=A1=GND + Ina228Driver(uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + + // Initialize INA228 with default configuration. + // shunt_resistor_mohm is in milliohms (e.g., 100 for 0.1Ω). + bool begin(float shunt_resistor_mohm = 10.0f); + + // Check if INA228 is present and responsive + bool isConnected(); + + // Reset INA228 to default values + void reset(); + + // Read battery voltage in mV + uint16_t readVoltage_mV(); + + // Read battery current in mA (+ = charging, - = discharging) + int16_t readCurrent_mA(); + + // Read battery current in mA with full float precision (+ = charging, - = discharging). + // ±1 LSB ≈ 3.125 µA. + float readCurrent_mA_precise(); + + // Read battery power in mW + int32_t readPower_mW(); + + // Read accumulated energy in mWh (Coulomb Counter) + int32_t readEnergy_mWh(); + + // Read accumulated charge in mAh (Coulomb Counter) + float readCharge_mAh(); + + // Read die temperature in °C + float readDieTemperature_C(); + + // Get all battery data in one call + bool readAll(Ina228BatteryData* data); + + // Reset Coulomb Counter (energy and charge accumulators) + void resetCoulombCounter(); + + // Read back SHUNT_CAL register value (diagnostic) + uint16_t readShuntCalRegister(); + + // Read back ADC_CONFIG register value (diagnostic) + uint16_t readAdcConfigRegister(); + + // Read back CONFIG register value (diagnostic) + uint16_t readConfigRegister(); + + // Validate SHUNT_CAL and repair if corrupted. + // Returns true if SHUNT_CAL is correct (or was repaired), false if repair failed. + bool validateAndRepairShuntCal(); + + // Set bus under-voltage alert threshold in mV (for UVLO; e.g., 3200 for Li-Ion) + bool setUnderVoltageAlert(uint16_t voltage_mv); + + // Set bus over-voltage alert threshold in mV + bool setOverVoltageAlert(uint16_t voltage_mv); + + // Enable alert output on ALERT pin. + // latch_alert: true to latch the alert until DIAG_ALRT is read. + void enableAlert(bool enable_uvlo = true, bool active_high = false, bool latch_alert = false); + + // Check if alert condition is active + bool isAlertActive(); + + // Clear alert flags + void clearAlert(); + + // Get diagnostic and alert register value. + // WARNING: In LATCH mode, reading DIAG_ALRT clears latched alert flags and de-asserts ALERT pin! + uint16_t getDiagnosticFlags(); + + // Read back raw BUVL threshold register value (multiply by 3.125 for mV) + uint16_t readBuvlRegister(); + + // Put INA228 into shutdown mode — disables all measurements and Coulomb Counter to save power. + // Returns true if shutdown confirmed via readback, false if write failed. + bool shutdown(); + + // Wake INA228 from shutdown mode (re-enables continuous measurement mode) + void wakeup(); + + // Calibrate current measurement against the actual measured battery current (in mA). + // Returns the calculated calibration factor (multiplier for future readings) — store it + // persistently and apply it via setCalibrationFactor(). + float calibrateCurrent(float actual_current_ma); + + // Set persistent current calibration factor (1.0 = no correction, >1.0 = increase readings, + // <1.0 = decrease). Written directly to the INA228 SHUNT_CAL register (hardware calibration), + // so all measurements (current, power, energy, charge) are corrected automatically. + // Call this at startup with the value loaded from persistent storage. + void setCalibrationFactor(float factor); + + // Get current calibration factor (1.0 = no calibration) + float getCalibrationFactor() const; + + + + // Read battery voltage in mV directly via I2C, without requiring driver initialization — + // for early boot use before the INA228 is initialized. Returns 0 if the read fails. + // Triggers a One-Shot ADC conversion; uses the high-precision 24-bit ADC (±0.1% accuracy). + static uint16_t readVBATDirect(TwoWire* wire = &Wire, uint8_t i2c_addr = INA228_I2C_ADDR_DEFAULT); + +private: + uint8_t _i2c_addr; + float _shunt_mohm; + float _current_lsb; // Current LSB in A (constant per datasheet) + uint16_t _base_shunt_cal; // Original SHUNT_CAL value (before calibration) + float _calibration_factor; // Current calibration factor (1.0 = no correction) + + bool writeRegister16(uint8_t reg, uint16_t value); + uint16_t readRegister16(uint8_t reg); + + // Read 24-bit register (sign-extended to 32-bit) + int32_t readRegister24(uint8_t reg); + + // Read 40-bit register (for energy/charge) + int64_t readRegister40(uint8_t reg); +}; diff --git a/variants/inhero_mr2/lib/SimplePreferences.h b/variants/inhero_mr2/lib/SimplePreferences.h new file mode 100644 index 0000000000..f3c5cfaf8b --- /dev/null +++ b/variants/inhero_mr2/lib/SimplePreferences.h @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 Inhero GmbH + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include <Adafruit_LittleFS.h> +#include <Arduino.h> +#include <InternalFileSystem.h> + +using namespace Adafruit_LittleFS_Namespace; + +// Mini preferences library compatible with Arduino Preferences API +// Provides simple file-based key-value storage using LittleFS backend +class SimplePreferences { +private: + String _namespace; + bool _started = false; + + // Builds filename "/namespace/key.txt" + String getFilePath(const char* key) { + String path = "/" + _namespace; + // Create namespace folder if it doesn't exist + InternalFS.mkdir(path.c_str()); + path += "/"; + path += key; + path += ".txt"; + return path; + } + +public: + SimplePreferences() {} + + bool begin(const char* name) { + _namespace = name; + _started = true; + return InternalFS.begin(); + } + + void end() { _started = false; } + + // Store string value + size_t putString(const char* key, const char* value) { + if (!_started || value == nullptr) return 0; + + String path = getFilePath(key); + + // Remove existing file (clean overwrite) + InternalFS.remove(path.c_str()); + + File file = InternalFS.open(path.c_str(), FILE_O_WRITE); + if (!file) return 0; + + // file.print accepts const char* directly — no extra copy + size_t len = file.print(value); + + file.close(); + return len; + } + + size_t putInt(const char* key, const uint16_t val) { + char buffer[10]; + snprintf(buffer, sizeof(buffer), "%u", val); + return putString(key, buffer); + } + + // Read string value into the caller-provided buffer + size_t getString(const char* key, char* buffer, size_t maxLen, const char* defaultValue = "") { + if (!_started) { + // Copy fallback + strncpy(buffer, defaultValue, maxLen); + buffer[maxLen - 1] = '\0'; // Safety + return strlen(buffer); + } + + String path = getFilePath(key); + + if (!InternalFS.exists(path.c_str())) { + strncpy(buffer, defaultValue, maxLen); + buffer[maxLen - 1] = '\0'; + return strlen(buffer); + } + + File file = InternalFS.open(path.c_str(), FILE_O_READ); + if (!file) { + strncpy(buffer, defaultValue, maxLen); + return strlen(buffer); + } + + size_t bytesRead = file.readBytes(buffer, maxLen - 1); + buffer[bytesRead] = '\0'; // Set null-terminator manually + + // Trim trailing whitespace and newline characters + while (bytesRead > 0 && + (buffer[bytesRead - 1] == '\r' || buffer[bytesRead - 1] == '\n' || buffer[bytesRead - 1] == ' ')) { + buffer[bytesRead - 1] = '\0'; + bytesRead--; + } + + file.close(); + return bytesRead; + } + + bool containsKey(const char* key) { + if (!_started) return false; + String path = getFilePath(key); + return InternalFS.exists(path.c_str()); + } +}; diff --git a/variants/inhero_mr2/platformio.ini b/variants/inhero_mr2/platformio.ini new file mode 100644 index 0000000000..b182f0bf95 --- /dev/null +++ b/variants/inhero_mr2/platformio.ini @@ -0,0 +1,70 @@ +[inhero_mr2] +extends = nrf52_base +board = inhero_mr2 +board_check = true +board_build.ldscript = boards/nrf52840_s140_v6.ld +build_flags = ${nrf52_base.build_flags} + -D ENV_INCLUDE_BME280=1 + -I variants/inhero_mr2 + -D INHERO_MR2 + -D PIN_BOARD_SCL=14 + -D PIN_BOARD_SDA=13 + -D PIN_GPS_TX=PIN_SERIAL1_RX + -D PIN_GPS_RX=PIN_SERIAL1_TX + -D PIN_GPS_EN=-1 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/inhero_mr2> + +<helpers/sensors> +lib_deps = + ${nrf52_base.lib_deps} + adafruit/Adafruit BME280 Library @ ^2.3.0 + https://github.com/adafruit/Adafruit_bq25798.git#01e8dc09 + sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 +upload_protocol = nrfutil +debug_tool = cmsis-dap + + +[env:Inhero_MR2_repeater] +extends = inhero_mr2 +build_flags = + ${inhero_mr2.build_flags} + -D ADVERT_NAME='"Inhero_MR2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${inhero_mr2.build_src_filter} + +<../examples/simple_repeater> + +[env:Inhero_MR2_repeater_bridge_rs232] +extends = inhero_mr2 +build_flags = + ${inhero_mr2.build_flags} + -D ADVERT_NAME='"Inhero_MR2 RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL2_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL2_TX +build_src_filter = ${inhero_mr2.build_src_filter} + +<helpers/bridges/RS232Bridge.cpp> + +<../examples/simple_repeater> + +[env:Inhero_MR2_sensor] +extends = inhero_mr2 +build_flags = + ${inhero_mr2.build_flags} + -D ADVERT_NAME='"Inhero_MR2 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' +build_src_filter = ${inhero_mr2.build_src_filter} + +<../examples/simple_sensor> diff --git a/variants/inhero_mr2/target.cpp b/variants/inhero_mr2/target.cpp new file mode 100644 index 0000000000..fc4d99e651 --- /dev/null +++ b/variants/inhero_mr2/target.cpp @@ -0,0 +1,55 @@ +#include <Arduino.h> +#include "target.h" +#include <helpers/ArduinoHelpers.h> + +InheroMr2Board board; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +WRAPPER_CLASS radio_driver(radio, board); +VolatileRTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifndef PIN_USER_BTN + #define PIN_USER_BTN (-1) +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); + + #if defined(PIN_USER_BTN_ANA) + MomentaryButton analog_btn(PIN_USER_BTN_ANA, 1000, 20); + #endif +#endif + +#if ENV_INCLUDE_GPS + #include <helpers/sensors/MicroNMEALocationProvider.h> + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + return radio.std_init(&SPI); +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); +} + +void radio_set_tx_power(uint8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/inhero_mr2/target.h b/variants/inhero_mr2/target.h new file mode 100644 index 0000000000..7d5b473737 --- /dev/null +++ b/variants/inhero_mr2/target.h @@ -0,0 +1,30 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <InheroMr2Board.h> +#include <helpers/radiolib/CustomSX1262Wrapper.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/sensors/EnvironmentSensorManager.h> + +#ifdef DISPLAY_CLASS + #include <helpers/ui/SSD1306Display.h> + extern DISPLAY_CLASS display; + #include <helpers/ui/MomentaryButton.h> + extern MomentaryButton user_btn; + #if defined(PIN_USER_BTN_ANA) + extern MomentaryButton analog_btn; + #endif +#endif + +extern InheroMr2Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(uint8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/inhero_mr2/variant.cpp b/variants/inhero_mr2/variant.cpp new file mode 100644 index 0000000000..fed9cc7c6a --- /dev/null +++ b/variants/inhero_mr2/variant.cpp @@ -0,0 +1,48 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + Modified (c) 2026, Inhero GmbH + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "wiring_constants.h" +#include "wiring_digital.h" +#include "nrf.h" + +const uint32_t g_ADigitalPinMap[] = +{ + // P0 + 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , + 8 , 9 , 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, + 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47 +}; + +void initVariant() +{ + // LED1 & LED2 + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); + + pinMode(PIN_LED2, OUTPUT); + ledOff(PIN_LED2); +} diff --git a/variants/inhero_mr2/variant.h b/variants/inhero_mr2/variant.h new file mode 100644 index 0000000000..2e4ea252d1 --- /dev/null +++ b/variants/inhero_mr2/variant.h @@ -0,0 +1,177 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + Modified (c) 2026, Inhero GmbH + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef _VARIANT_INHERO_MR2_ +#define _VARIANT_INHERO_MR2_ + +#define INHERO_MR2 + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF +// define USE_LFRC // Board uses RC for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/* + * Inhero MR-2 GPIO definitions + */ +static const uint8_t WB_IO1 = 17; // SLOT_A SLOT_B +static const uint8_t WB_IO2 = 34; // SLOT_A SLOT_B +static const uint8_t WB_IO3 = 21; // SLOT_C +static const uint8_t WB_IO4 = 4; // SLOT_C +static const uint8_t WB_IO5 = 9; // SLOT_D +static const uint8_t WB_IO6 = 10; // SLOT_D +static const uint8_t WB_SW1 = 33; // IO_SLOT +static const uint8_t WB_A0 = 5; // IO_SLOT +static const uint8_t WB_A1 = 31; // IO_SLOT +static const uint8_t WB_I2C1_SDA = 13; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C1_SCL = 14; // SENSOR_SLOT IO_SLOT +static const uint8_t WB_I2C2_SDA = 24; // IO_SLOT +static const uint8_t WB_I2C2_SCL = 25; // IO_SLOT +static const uint8_t WB_SPI_CS = 26; // IO_SLOT +static const uint8_t WB_SPI_CLK = 3; // IO_SLOT +static const uint8_t WB_SPI_MISO = 29; // IO_SLOT +static const uint8_t WB_SPI_MOSI = 30; // IO_SLOT + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (35) +#define PIN_LED2 (36) +#define BQ_INT_PIN (21) +// BQ25798 CE via DMN2004TK-7 FET — inverted: HIGH = charge enable, LOW = charge disable +#define BQ_CE_PIN (4) // P0.04 (WB_IO4) +// INA228 ALERT (active-low, open-drain) for low-voltage sleep trigger +#define INA_ALERT_PIN (34) // P1.02 (WB_IO2) + +#define LED_BUILTIN PIN_LED1 +#define LED_CONN PIN_LED2 + +#define LED_BLUE PIN_LED1 // P1.03 +#define LED_RED PIN_LED2 // P1.04 + +#define LED_STATE_ON 1 // State when LED is litted + +/* + * Buttons + */ +// No user buttons on Inhero MR-2 + +/* + * Analog pins + */ +#define PIN_A0 (5) //(3) +#define PIN_A1 (31) //(4) +#define PIN_A2 (28) +#define PIN_A3 (29) +#define PIN_A4 (30) +#define PIN_A5 (31) +#define PIN_A6 (0xff) +#define PIN_A7 (0xff) + +static const uint8_t A0 = PIN_A0; +static const uint8_t A1 = PIN_A1; +static const uint8_t A2 = PIN_A2; +static const uint8_t A3 = PIN_A3; +static const uint8_t A4 = PIN_A4; +static const uint8_t A5 = PIN_A5; +static const uint8_t A6 = PIN_A6; +static const uint8_t A7 = PIN_A7; +#define ADC_RESOLUTION 14 + +// Other pins +#define PIN_AREF (2) +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +static const uint8_t AREF = PIN_AREF; + +/* + * Serial interfaces + */ +// TXD1 RXD1 on Base Board +#define PIN_SERIAL1_RX (15) +#define PIN_SERIAL1_TX (16) + +// TXD0 RXD0 on Base Board +#define PIN_SERIAL2_RX (19) +#define PIN_SERIAL2_TX (20) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 1 + +#define PIN_SPI_MISO (29) +#define PIN_SPI_MOSI (30) +#define PIN_SPI_SCK (3) + +static const uint8_t SS = 26; +static const uint8_t MOSI = PIN_SPI_MOSI; +static const uint8_t MISO = PIN_SPI_MISO; +static const uint8_t SCK = PIN_SPI_SCK; + +/* + * Wire Interfaces + */ +#define WIRE_INTERFACES_COUNT 2 + +#define PIN_WIRE_SDA (13) +#define PIN_WIRE_SCL (14) + +#define PIN_WIRE1_SDA (24) +#define PIN_WIRE1_SCL (25) + +// QSPI Pins +// QSPI occupied by GPIO's +#define PIN_QSPI_SCK 3 // 19 +#define PIN_QSPI_CS 26 // 17 +#define PIN_QSPI_IO0 30 // 20 +#define PIN_QSPI_IO1 29 // 21 +#define PIN_QSPI_IO2 28 // 22 +#define PIN_QSPI_IO3 2 // 23 + +// On-board QSPI Flash +// No onboard flash +#define EXTERNAL_FLASH_DEVICES IS25LP080D +#define EXTERNAL_FLASH_USE_QSPI + +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif diff --git a/variants/keepteen_lt1/KeepteenLT1Board.h b/variants/keepteen_lt1/KeepteenLT1Board.h index 752b27e752..25d4d86762 100644 --- a/variants/keepteen_lt1/KeepteenLT1Board.h +++ b/variants/keepteen_lt1/KeepteenLT1Board.h @@ -37,8 +37,4 @@ class KeepteenLT1Board : public NRF52Board { digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off } #endif - - void powerOff() override { - sd_power_system_off(); - } }; diff --git a/variants/keepteen_lt1/platformio.ini b/variants/keepteen_lt1/platformio.ini index 27cf809e08..11dc214b09 100644 --- a/variants/keepteen_lt1/platformio.ini +++ b/variants/keepteen_lt1/platformio.ini @@ -66,6 +66,7 @@ build_flags = ${KeepteenLT1.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${KeepteenLT1.build_src_filter} diff --git a/variants/lilygo_t3s3/platformio.ini b/variants/lilygo_t3s3/platformio.ini index 54990117cc..966235c661 100644 --- a/variants/lilygo_t3s3/platformio.ini +++ b/variants/lilygo_t3s3/platformio.ini @@ -140,6 +140,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${LilyGo_T3S3_sx1262.build_src_filter} diff --git a/variants/lilygo_t3s3_sx1276/platformio.ini b/variants/lilygo_t3s3_sx1276/platformio.ini index 5df632020e..c6497dcb13 100644 --- a/variants/lilygo_t3s3_sx1276/platformio.ini +++ b/variants/lilygo_t3s3_sx1276/platformio.ini @@ -21,8 +21,8 @@ build_flags = -D RADIO_CLASS=CustomSX1276 -D WRAPPER_CLASS=CustomSX1276Wrapper -D SX127X_CURRENT_LIMIT=120 - -D SX176X_RXEN=21 - -D SX176X_TXEN=10 + -D SX127X_RXEN=21 + -D SX127X_TXEN=10 -D LORA_TX_POWER=20 build_src_filter = ${esp32_base.build_src_filter} +<../variants/lilygo_t3s3_sx1276> @@ -138,6 +138,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_T3S3_sx1276.build_src_filter} +<helpers/ui/SSD1306Display.cpp> +<helpers/ui/MomentaryButton.cpp> diff --git a/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h b/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h index ea1782cf7a..30bbc3a5cf 100644 --- a/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h +++ b/variants/lilygo_t_impulse_plus/TImpulsePlusBoard.h @@ -47,13 +47,11 @@ class TImpulsePlusBoard : public NRF52BoardDCDC { return "LilyGo T-Impulse-Plus"; } - void powerOff() override { + void shutdownPeripherals() override { + // power off system + NRF52Board::shutdownPeripherals(); // turn off 3.3v digitalWrite(RT9080_EN, LOW); - - // power off system - sd_power_system_off(); - } }; diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini index c7a595520e..0f604fac40 100644 --- a/variants/lilygo_tbeam_1w/platformio.ini +++ b/variants/lilygo_tbeam_1w/platformio.ini @@ -114,6 +114,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TBeam_1W.build_src_filter} diff --git a/variants/lilygo_tbeam_SX1276/platformio.ini b/variants/lilygo_tbeam_SX1276/platformio.ini index cb25903ce9..f5f490479c 100644 --- a/variants/lilygo_tbeam_SX1276/platformio.ini +++ b/variants/lilygo_tbeam_SX1276/platformio.ini @@ -45,7 +45,7 @@ build_flags = ${LilyGo_TBeam_SX1276.build_flags} -I examples/companion_radio/ui-new -D MAX_CONTACTS=160 - -D MAX_GROUP_CHANNELS=8 + -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=128 diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index 0294615651..8bfc4093ac 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -18,6 +18,7 @@ build_flags = -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper -D DISPLAY_CLASS=SH1106Display + -D DISPLAY_ADDRESS=0x3D -D LORA_TX_POWER=22 -D P_LORA_TX_LED=6 -D PIN_BOARD_SDA=17 diff --git a/variants/lilygo_tdeck/TDeckBoard.h b/variants/lilygo_tdeck/TDeckBoard.h index 7ed007af9c..e2844360df 100644 --- a/variants/lilygo_tdeck/TDeckBoard.h +++ b/variants/lilygo_tdeck/TDeckBoard.h @@ -3,7 +3,6 @@ #include <Wire.h> #include <Arduino.h> #include "helpers/ESP32Board.h" -#include <driver/rtc_io.h> #define PIN_VBAT_READ 4 #define BATTERY_SAMPLES 8 @@ -23,29 +22,6 @@ class TDeckBoard : public ESP32Board { } #endif - void enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - uint16_t getBattMilliVolts() { #if defined(PIN_VBAT_READ) && defined(ADC_MULTIPLIER) analogReadResolution(12); diff --git a/variants/lilygo_tdeck/platformio.ini b/variants/lilygo_tdeck/platformio.ini index 745d8ff53d..00571db93b 100644 --- a/variants/lilygo_tdeck/platformio.ini +++ b/variants/lilygo_tdeck/platformio.ini @@ -71,6 +71,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_TDeck.build_src_filter} +<helpers/esp32/*.cpp> +<helpers/ui/MomentaryButton.cpp> diff --git a/variants/lilygo_techo/TechoBoard.h b/variants/lilygo_techo/TechoBoard.h index e560cd14b4..867fc24cf9 100644 --- a/variants/lilygo_techo/TechoBoard.h +++ b/variants/lilygo_techo/TechoBoard.h @@ -23,7 +23,8 @@ class TechoBoard : public NRF52BoardDCDC { return "LilyGo T-Echo"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef LED_RED digitalWrite(LED_RED, HIGH); #endif @@ -39,6 +40,5 @@ class TechoBoard : public NRF52BoardDCDC { #ifdef PIN_PWR_EN digitalWrite(PIN_PWR_EN, LOW); #endif - sd_power_system_off(); } }; diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index 5df77f95cb..81da2e48c3 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -120,6 +120,7 @@ build_flags = -D UI_SENSORS_PAGE=1 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_T-Echo.build_src_filter} +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> diff --git a/variants/lilygo_techo_card/TechoCardBoard.cpp b/variants/lilygo_techo_card/TechoCardBoard.cpp index f0dcef312a..8a3a54b160 100644 --- a/variants/lilygo_techo_card/TechoCardBoard.cpp +++ b/variants/lilygo_techo_card/TechoCardBoard.cpp @@ -87,11 +87,11 @@ void TechoCardBoard::turnOffLeds() { } } -void TechoCardBoard::powerOff() { +void TechoCardBoard::shutdownPeripherals() { nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); turnOffLeds(); digitalWrite(PIN_PWR_EN, LOW); - sd_power_system_off(); + NRF52Board::shutdownPeripherals(); } #endif diff --git a/variants/lilygo_techo_card/TechoCardBoard.h b/variants/lilygo_techo_card/TechoCardBoard.h index 8a2913a63b..a2ee3ea1e8 100644 --- a/variants/lilygo_techo_card/TechoCardBoard.h +++ b/variants/lilygo_techo_card/TechoCardBoard.h @@ -28,7 +28,7 @@ class TechoCardBoard : public NRF52BoardDCDC { return "LilyGo T-Echo Card"; } - void powerOff() override; + void shutdownPeripherals() override; void toggleTorch(); void turnOffLeds(); diff --git a/variants/lilygo_techo_card/platformio.ini b/variants/lilygo_techo_card/platformio.ini index 07bcebfe41..4ebf43da9d 100644 --- a/variants/lilygo_techo_card/platformio.ini +++ b/variants/lilygo_techo_card/platformio.ini @@ -23,7 +23,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<helpers/*.cpp> +<TechoCardBoard.cpp> +<helpers/sensors/EnvironmentSensorManager.cpp> - +<helpers/ui/U8g2Display.h> + +<helpers/ui/U8g2Display.cpp> +<helpers/ui/MomentaryButton.cpp> +<../variants/lilygo_techo_card> lib_deps = @@ -110,6 +110,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE build_src_filter = ${LilyGo_T-Echo_Card.build_src_filter} +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-tiny/*.cpp> diff --git a/variants/lilygo_techo_lite/TechoBoard.cpp b/variants/lilygo_techo_lite/TechoBoard.cpp index a11d31b27c..e0b69f9857 100644 --- a/variants/lilygo_techo_lite/TechoBoard.cpp +++ b/variants/lilygo_techo_lite/TechoBoard.cpp @@ -6,7 +6,7 @@ #ifdef LILYGO_TECHO void TechoBoard::begin() { - NRF52Board::begin(); + NRF52BoardDCDC::begin(); // Configure battery measurement control BEFORE Wire.begin() // to ensure P0.02 is not claimed by another peripheral diff --git a/variants/lilygo_techo_lite/TechoBoard.h b/variants/lilygo_techo_lite/TechoBoard.h index 7a43fd8383..1e5651e78b 100644 --- a/variants/lilygo_techo_lite/TechoBoard.h +++ b/variants/lilygo_techo_lite/TechoBoard.h @@ -21,7 +21,9 @@ class TechoBoard : public NRF52BoardDCDC { return "LilyGo T-Echo Lite"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); + digitalWrite(PIN_VBAT_MEAS_EN, LOW); #ifdef LED_RED digitalWrite(LED_RED, LOW); @@ -38,6 +40,5 @@ class TechoBoard : public NRF52BoardDCDC { #ifdef PIN_PWR_EN digitalWrite(PIN_PWR_EN, LOW); #endif - sd_power_system_off(); } }; \ No newline at end of file diff --git a/variants/lilygo_techo_lite/platformio.ini b/variants/lilygo_techo_lite/platformio.ini index 4b5edf6918..9ec73d59a1 100644 --- a/variants/lilygo_techo_lite/platformio.ini +++ b/variants/lilygo_techo_lite/platformio.ini @@ -15,6 +15,7 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_DIO3_TCXO_VOLTAGE=1.8 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 + -D SX126X_USE_REGULATOR_LDO=1 -D P_LORA_TX_LED=LED_GREEN -D DISABLE_DIAGNOSTIC_OUTPUT -D ENV_INCLUDE_GPS=1 @@ -173,6 +174,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D UI_RECENT_LIST_SIZE=9 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${nrf52_base.build_src_filter} diff --git a/variants/lilygo_teth_elite/platformio.ini b/variants/lilygo_teth_elite/platformio.ini index 97728f8b4c..ee1b987953 100644 --- a/variants/lilygo_teth_elite/platformio.ini +++ b/variants/lilygo_teth_elite/platformio.ini @@ -72,6 +72,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TETH_Elite_sx1262.build_src_filter} diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index 3673166861..1aea74d285 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -77,6 +77,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter} diff --git a/variants/m5stack_unit_c6l/platformio.ini b/variants/m5stack_unit_c6l/platformio.ini index 94083eb486..ea4d852d65 100644 --- a/variants/m5stack_unit_c6l/platformio.ini +++ b/variants/m5stack_unit_c6l/platformio.ini @@ -2,6 +2,7 @@ extends = esp32c6_base board = esp32-c6-devkitm-1 board_build.partitions = min_spiffs.csv ; get around 4mb flash limit +board_build.flash_mode = dio ; board manifest defaults to qio, which causes a boot crash-loop on this module build_flags = ${esp32c6_base.build_flags} ${sensor_base.build_flags} @@ -97,6 +98,7 @@ build_flags = ${M5Stack_Unit_C6L.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D ARDUINO_USB_CDC_ON_BOOT=1 -D ARDUINO_USB_MODE=1 + -D ENABLE_USB_INTERFACE build_src_filter = ${M5Stack_Unit_C6L.build_src_filter} +<helpers/esp32/*.cpp> -<helpers/esp32/ESPNOWRadio.cpp> diff --git a/variants/mesh_pocket/MeshPocket.h b/variants/mesh_pocket/MeshPocket.h index 478bd56d71..e215eb7768 100644 --- a/variants/mesh_pocket/MeshPocket.h +++ b/variants/mesh_pocket/MeshPocket.h @@ -32,8 +32,4 @@ class HeltecMeshPocket : public NRF52BoardDCDC { const char* getManufacturerName() const override { return "Heltec MeshPocket"; } - - void powerOff() override { - sd_power_system_off(); - } }; diff --git a/variants/mesh_pocket/platformio.ini b/variants/mesh_pocket/platformio.ini index 52a0d83523..a6f823dbf7 100644 --- a/variants/mesh_pocket/platformio.ini +++ b/variants/mesh_pocket/platformio.ini @@ -1,7 +1,6 @@ [Mesh_pocket] extends = nrf52_base board = heltec_mesh_pocket -platform_packages = framework-arduinoadafruitnrf52 board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} -I src/helpers/nrf52 @@ -32,7 +31,6 @@ lib_deps = stevemarple/MicroNMEA @ ^2.0.6 zinggjm/GxEPD2 @ 1.6.2 bakercp/CRC32 @ ^2.0.0 - debug_tool = jlink upload_protocol = nrfutil @@ -40,7 +38,6 @@ upload_protocol = nrfutil extends = Mesh_pocket build_src_filter = ${Mesh_pocket.build_src_filter} +<../examples/simple_repeater> - build_flags = ${Mesh_pocket.build_flags} -D ADVERT_NAME='"Heltec_Mesh_Pocket Repeater"' @@ -99,6 +96,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D AUTO_OFF_MILLIS=0 + -D ENABLE_USB_INTERFACE ; -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/meshadventurer/platformio.ini b/variants/meshadventurer/platformio.ini index f85be23885..4af0f7f705 100644 --- a/variants/meshadventurer/platformio.ini +++ b/variants/meshadventurer/platformio.ini @@ -186,6 +186,7 @@ build_flags = -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=128 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = @@ -209,8 +210,8 @@ build_flags = -D BLE_PIN_CODE=123456 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=128 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 lib_deps = ${Meshadventurer.lib_deps} densaugeo/base64 @ ~1.4.0 @@ -266,6 +267,7 @@ build_flags = -D MAX_CONTACTS=160 -D OFFLINE_QUEUE_SIZE=128 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = @@ -289,8 +291,8 @@ build_flags = -D BLE_PIN_CODE=123456 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=128 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 lib_deps = ${Meshadventurer.lib_deps} densaugeo/base64 @ ~1.4.0 diff --git a/variants/meshnology_w12/MeshnologyW12Board.cpp b/variants/meshnology_w12/MeshnologyW12Board.cpp new file mode 100644 index 0000000000..25c0d4f1a8 --- /dev/null +++ b/variants/meshnology_w12/MeshnologyW12Board.cpp @@ -0,0 +1,83 @@ +#include "MeshnologyW12Board.h" + +void MeshnologyW12Board::begin() { + ESP32Board::begin(); + + pinMode(PIN_ADC_CTRL, OUTPUT); + digitalWrite(PIN_ADC_CTRL, LOW); // Disable battery sense until required + + pinMode(P_LORA_LF_PA_POWER, OUTPUT); + digitalWrite(P_LORA_LF_PA_POWER, HIGH); // PA_EN_M — power the GC1109 FEM + + pinMode(P_LORA_HF_PA_POWER, OUTPUT); + digitalWrite(P_LORA_HF_PA_POWER, LOW); // PA_EN_G — disable the 2.4GHz FEM + + delay(100); // give the GC1109 some time to start + + periph_power.begin(); + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep) + startup_reason = BD_STARTUP_RX_PACKET; + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } + } + + void MeshnologyW12Board::onBeforeTransmit(void) { + neopixelWrite(NEOPIXEL_LED, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS, NEOPIXEL_BRIGHTNESS); // turn TX neopixel on (White) + } + + void MeshnologyW12Board::onAfterTransmit(void) { + neopixelWrite(NEOPIXEL_LED, 0, 0, 0); // turn TX neopixel off + } + + void MeshnologyW12Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + + // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep + rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); + + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + + if (pin_wake_btn < 0) { + esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet + } else { + esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000); + } + + // Finally set ESP32 into sleep + esp_deep_sleep_start(); // CPU halts here and never returns! + } + + void MeshnologyW12Board::powerOff() { + enterDeepSleep(0); + } + + uint16_t MeshnologyW12Board::getBattMilliVolts() { + analogReadResolution(12); + analogSetAttenuation(ADC_11db); + digitalWrite(PIN_ADC_CTRL, HIGH); + delay(10); + uint32_t raw = 0; + for (int i = 0; i < 8; i++) { + raw += analogRead(PIN_VBAT_READ); + } + raw = raw / 8; + + digitalWrite(PIN_ADC_CTRL, LOW); + + return (adc_mult * (3.3 / 4096.0) * raw) * 1000; + } + + const char* MeshnologyW12Board::getManufacturerName() const { + return "Meshnology W12"; + } \ No newline at end of file diff --git a/variants/meshnology_w12/MeshnologyW12Board.h b/variants/meshnology_w12/MeshnologyW12Board.h new file mode 100644 index 0000000000..85bec99041 --- /dev/null +++ b/variants/meshnology_w12/MeshnologyW12Board.h @@ -0,0 +1,37 @@ +#pragma once + +#include <Arduino.h> +#include <helpers/RefCountedDigitalPin.h> +#include <helpers/ESP32Board.h> +#include <driver/rtc_io.h> + +#ifndef ADC_MULTIPLIER + #define ADC_MULTIPLIER 5.42 +#endif + +class MeshnologyW12Board : public ESP32Board { + +protected: + float adc_mult = ADC_MULTIPLIER; + +public: + RefCountedDigitalPin periph_power; + MeshnologyW12Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } + + void begin(); + void onBeforeTransmit(void) override; + void onAfterTransmit(void) override; + void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); + void powerOff() override; + uint16_t getBattMilliVolts() override; + bool setAdcMultiplier(float multiplier) override { + if (multiplier == 0.0f) { + adc_mult = ADC_MULTIPLIER; + } else { + adc_mult = multiplier; + } + return true; + } + float getAdcMultiplier() const override { return adc_mult; } + const char* getManufacturerName() const override; +}; diff --git a/variants/meshnology_w12/pins_arduino.h b/variants/meshnology_w12/pins_arduino.h new file mode 100644 index 0000000000..5cfb36970b --- /dev/null +++ b/variants/meshnology_w12/pins_arduino.h @@ -0,0 +1,69 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include <stdint.h> + +static const uint8_t LED_BUILTIN = -1; +#define BUILTIN_LED LED_BUILTIN // backward compatibility +#define LED_BUILTIN LED_BUILTIN // allow testing #ifdef LED_BUILTIN + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 3; +static const uint8_t SCL = 4; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +static const uint8_t Vext = 45; +static const uint8_t LED = -1; +static const uint8_t RST_OLED = 21; +static const uint8_t SCL_OLED = 18; +static const uint8_t SDA_OLED = 17; + +static const uint8_t RST_LoRa = 12; +static const uint8_t BUSY_LoRa = 13; +static const uint8_t DIO0 = 14; + + + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini new file mode 100644 index 0000000000..1168f10a0f --- /dev/null +++ b/variants/meshnology_w12/platformio.ini @@ -0,0 +1,216 @@ +[meshnology_w12] +extends = esp32_base +board = meshnology_w12 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/meshnology_w12 + -D MESHNOLOGY_W12 + -D ESP32_CPU_FREQ=80 + -D RADIO_CLASS=CustomLR2021 + -D WRAPPER_CLASS=CustomLR2021Wrapper + -D USE_LR2021 + -D NEOPIXEL_LED=46 + -D NEOPIXEL_BRIGHTNESS=64 + -D P_LORA_DIO_1=14 + -D LR2021_IRQ_DIO=8 ; GPIO14 is connected to LR2021 DIO8 + -D P_LORA_NSS=8 + -D P_LORA_SCLK=9 + -D P_LORA_MOSI=10 + -D P_LORA_MISO=11 + -D P_LORA_RESET=12 + -D P_LORA_BUSY=13 + -D LR2021_TCXO_VOLTAGE=0.0f ; this board has no tcxo + -D RF_SWITCH_TABLE ; used below in commented code for the RF switch table. + -D P_LORA_LF_PA_POWER=4 ; PA_EN_M - power enable for the GC1109 868/915 PA + -D P_LORA_HF_PA_POWER=3 ; PA_EN_G - power enable for RFX2402E 2.4G PA + -D PIN_USER_BTN=0 + -D PIN_VEXT_EN=45 + -D PIN_VEXT_EN_ACTIVE=HIGH + -D LORA_TX_POWER=4 + -D MAX_LORA_TX_POWER=4 ; GC1109 datasheet says max input at TX port is +5dbm, confirmed full saturation seems to occur at around 3-4dbm + -D PIN_GPS_RX=38 + -D PIN_GPS_TX=39 + -D PIN_GPS_RESET=42 + -D PIN_GPS_RESET_ACTIVE=LOW + -D PIN_GPS_EN=48 + -D PIN_GPS_EN_ACTIVE=LOW + ; -D ENV_INCLUDE_GPS=1 + -D PIN_VBAT_READ=1 + -D PIN_ADC_CTRL=2 + -D PIN_BOARD_SDA=17 + -D PIN_BOARD_SCL=18 + -D PIN_RESET=47 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/meshnology_w12> + +<helpers/sensors> +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + +[env:meshnology_w12_repeater] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Meshnology W12 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_repeater> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:meshnology_w12_repeater_bridge_espnow] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 + ; -D BRIDGE_DEBUG=1 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<helpers/bridges/ESPNowBridge.cpp> + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_repeater> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + +[env:meshnology_w12_room_server] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Meshnology W12 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_room_server> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + +[env:meshnology_w12_terminal_chat] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_companion_radio_usb] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_companion_radio_ble] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 ; dynamic, random PIN + ; -D AUTO_SHUTDOWN_MILLIVOLTS=3400 ; disabled by default, otherwise reading bounce causes shutdown when there's no battery connected. + ; -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_companion_radio_wifi] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=SSD1306Display + ; -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${meshnology_w12.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:meshnology_w12_sensor] +extends = meshnology_w12 +build_flags = + ${meshnology_w12.build_flags} + -D ADVERT_NAME='"Meshnology W12 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=3 + -D ENV_PIN_SCL=4 + -D DISPLAY_CLASS=SSD1306Display + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${meshnology_w12.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_sensor> +lib_deps = + ${meshnology_w12.lib_deps} + ${esp32_ota.lib_deps} + +[env:meshnology_w12_kiss_modem] +extends = meshnology_w12 +build_src_filter = ${meshnology_w12.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/meshnology_w12/target.cpp b/variants/meshnology_w12/target.cpp new file mode 100644 index 0000000000..bf04ba3647 --- /dev/null +++ b/variants/meshnology_w12/target.cpp @@ -0,0 +1,73 @@ +#include <Arduino.h> +#include "target.h" + +MeshnologyW12Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi; + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include <helpers/sensors/MicroNMEALocationProvider.h> + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display(NULL); + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +const uint32_t rfswitch_dios[] = { + RADIOLIB_LR2021_DIO5, // RFX2402E 2.4G_TX_EN + RADIOLIB_LR2021_DIO6, // RFX2402E 2.4G_RX_EN + RADIOLIB_LR2021_DIO9, // GC1109 CTX (Transmit mode) + RADIOLIB_LR2021_DIO10, // GC1109 CPS (Bypass mode) + RADIOLIB_LR2021_DIO11, // GC1109 CSD (Shutdown) +}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // DIO5 DIO6 DIO9 DIO10 DIO11 + { LR2021::MODE_STBY, { LOW, LOW, LOW, LOW, LOW } }, // everything off + { LR2021::MODE_RX, { LOW, LOW, LOW, LOW, HIGH } }, // rx_lf: GC1109 LNA + { LR2021::MODE_TX, { LOW, LOW, HIGH, HIGH, HIGH } }, // tx_lf: GC1109 full PA + { LR2021::MODE_RX_HF, { LOW, HIGH, LOW, LOW, LOW } }, // rx_hf: 2G4 LNA, GC1109 off + { LR2021::MODE_TX_HF, { HIGH, LOW, LOW, LOW, LOW } }, // tx_hf: 2G4 PA, GC1109 off + END_OF_MODE_TABLE, +}; + + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + int err = radio.std_init(&spi); + if (err != 1) return err; +#else + int err = radio.std_init(); + if (err != 1) return err; +#endif + +#ifdef RF_SWITCH_TABLE + radio.setRfSwitchTable(rfswitch_dios, rfswitch_table); +#endif + + return true; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + diff --git a/variants/meshnology_w12/target.h b/variants/meshnology_w12/target.h new file mode 100644 index 0000000000..1bf8876b28 --- /dev/null +++ b/variants/meshnology_w12/target.h @@ -0,0 +1,28 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <MeshnologyW12Board.h> +#include <helpers/radiolib/CustomLR2021Wrapper.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/SensorManager.h> +#include <helpers/sensors/EnvironmentSensorManager.h> +#ifdef DISPLAY_CLASS + #include <helpers/ui/SSD1306Display.h> + #include <helpers/ui/MomentaryButton.h> +#endif + +extern MeshnologyW12Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + diff --git a/variants/meshtiny/MeshtinyBoard.h b/variants/meshtiny/MeshtinyBoard.h index b69c0e419c..67a521fe97 100644 --- a/variants/meshtiny/MeshtinyBoard.h +++ b/variants/meshtiny/MeshtinyBoard.h @@ -60,7 +60,7 @@ class MeshtinyBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(g_ADigitalPinMap[PIN_USER_BTN], NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/meshtiny/platformio.ini b/variants/meshtiny/platformio.ini index c5439c88f4..c0a3c5f94e 100644 --- a/variants/meshtiny/platformio.ini +++ b/variants/meshtiny/platformio.ini @@ -36,6 +36,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Meshtiny.build_src_filter} diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.cpp b/variants/meshtracker_x1/MeshTrackerX1Board.cpp new file mode 100644 index 0000000000..56e537a23e --- /dev/null +++ b/variants/meshtracker_x1/MeshTrackerX1Board.cpp @@ -0,0 +1,23 @@ +#include <Arduino.h> +#include <Wire.h> + +#include "MeshTrackerX1Board.h" + +void MeshTrackerX1Board::begin() { + NRF52BoardDCDC::begin(); + btn_prev_state = LOW; // button is active HIGH + +#ifdef BUTTON_PIN + pinMode(BATTERY_PIN, INPUT); + pinMode(BUTTON_PIN, INPUT_PULLDOWN); + pinMode(LED_PIN, OUTPUT); +#endif + +#if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); +#endif + + Wire.begin(); + + delay(10); // give lr2021 some time to power up +} diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h new file mode 100644 index 0000000000..51f022e0b8 --- /dev/null +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -0,0 +1,87 @@ +#pragma once + +#include <MeshCore.h> +#include <Arduino.h> +#include <helpers/NRF52Board.h> + +class MeshTrackerX1Board : public NRF52BoardDCDC { +protected: + uint8_t btn_prev_state; + +public: + MeshTrackerX1Board() : NRF52Board("X1_OTA") {} + void begin(); + + uint16_t getBattMilliVolts() override { + #ifdef BATTERY_PIN + #ifdef PIN_BAT_ADC_EN + digitalWrite(PIN_BAT_ADC_EN, HIGH); + #endif + analogReference(AR_INTERNAL_3_0); + analogReadResolution(12); + delay(10); + float volts = (analogRead(BATTERY_PIN) * ADC_MULTIPLIER * AREF_VOLTAGE) / 4096; + + analogReference(AR_DEFAULT); // put back to default + analogReadResolution(10); + + return volts * 1000; + #else + return 0; + #endif + } + + const char* getManufacturerName() const override { + return "Seeed SenseCAP MeshTracker X1"; + } + + int buttonStateChanged() { + #ifdef BUTTON_PIN + uint8_t v = digitalRead(BUTTON_PIN); + if (v != btn_prev_state) { + btn_prev_state = v; + return (v == USER_BTN_PRESSED) ? 1 : -1; + } + #endif + return 0; + } + + void powerOff() override { + #ifdef HAS_GPS + digitalWrite(GPS_VRTC_EN, LOW); + digitalWrite(GPS_RESET, LOW); + digitalWrite(GPS_SLEEP_INT, LOW); + digitalWrite(GPS_RTC_INT, LOW); + digitalWrite(GPS_EN, LOW); + #endif + + #ifdef PIN_DRV_EN + digitalWrite(PIN_DRV_EN, LOW); + #endif + + #ifdef PIN_BAT_ADC_EN + digitalWrite(PIN_BAT_ADC_EN, LOW); + #endif + + #ifdef PIN_3V3_EN + digitalWrite(PIN_3V3_EN, LOW); + #endif + + // set led on and wait for button release before poweroff + #ifdef LED_PIN + digitalWrite(LED_PIN, HIGH); + #endif + #ifdef BUTTON_PIN + while(digitalRead(BUTTON_PIN)); + #endif + #ifdef LED_PIN + digitalWrite(LED_PIN, LOW); + #endif + + #ifdef BUTTON_PIN + nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_PULLDOWN, NRF_GPIO_PIN_SENSE_HIGH); + #endif + + sd_power_system_off(); + } +}; diff --git a/variants/meshtracker_x1/platformio.ini b/variants/meshtracker_x1/platformio.ini new file mode 100644 index 0000000000..1ce8bf17ad --- /dev/null +++ b/variants/meshtracker_x1/platformio.ini @@ -0,0 +1,132 @@ +[MeshTracker_X1] +extends = nrf52_base +board = seeed-mesh-tracker-x1 +board_build.ldscript = boards/nrf52840_s140_v7.ld +build_flags = ${nrf52_base.build_flags} + -I src/helpers/nrf52 + -I lib/nrf52/s140_nrf52_7.3.0_API/include + -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 + -I variants/meshtracker_x1 + -I src/helpers/ui + -D MESH_TRACKER_X1 + -D PIN_USER_BTN=6 + -D USER_BTN_PRESSED=HIGH + -D PIN_STATUS_LED=24 + -D PIN_STATUS_LED_R=3 ; P0.3 red + -D PIN_STATUS_LED_G=24 ; P0.24 green + -D PIN_STATUS_LED_B=28 ; P0.28 blue + -D RADIO_CLASS=CustomLR2021 + -D USE_LR2021 + -D WRAPPER_CLASS=CustomLR2021Wrapper + -D LORA_TX_POWER=22 + -D P_LORA_BUSY=7 ; P0.7 + -D P_LORA_SCLK=11 ; P0.11 + -D P_LORA_NSS=12 ; P0.12 + -D P_LORA_DIO_1=33 ; P1.1 + -D P_LORA_MISO=40 ; P1.8 + -D P_LORA_MOSI=41 ; P1.9 + -D P_LORA_RESET=42 ; P1.10 + -D LR2021_IRQ_DIO=8 ; P1.1 is connected to LR2021 DIO8 + -D LR2021_TCXO_VOLTAGE=1.6f + -D ENV_INCLUDE_GPS=1 +build_src_filter = ${nrf52_base.build_src_filter} + +<helpers/*.cpp> + +<../variants/meshtracker_x1> +lib_deps = ${nrf52_base.lib_deps} + https://github.com/adafruit/Adafruit_SPA06_003/archive/refs/tags/1.0.2.zip +debug_tool = jlink +upload_protocol = nrfutil + +[env:MeshTracker_X1_repeater] +extends = MeshTracker_X1 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D ADVERT_NAME='"MeshTracker X1 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MeshTracker_X1.build_src_filter} + +<../examples/simple_repeater> +lib_deps = ${MeshTracker_X1.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + +[env:MeshTracker_X1_room_server] +extends = MeshTracker_X1 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D ADVERT_NAME='"MeshTracker X1 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MeshTracker_X1.build_src_filter} + +<../examples/simple_room_server> +lib_deps = ${MeshTracker_X1.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + +[env:MeshTracker_X1_companion_radio_usb] +extends = MeshTracker_X1 +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D QSPIFLASH=1 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=NullDisplayDriver + -D PIN_BUZZER=25 + -D HAS_DRV2605=1 + -D DRV2605_USE_LRA=1 + -D ENABLE_USB_INTERFACE +build_src_filter = ${MeshTracker_X1.build_src_filter} + +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> + +<helpers/ui/DRV2605Vibration.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = ${MeshTracker_X1.lib_deps} + densaugeo/base64 @ ~1.4.0 + stevemarple/MicroNMEA @ ^2.0.6 + end2endzone/NonBlockingRTTTL@^1.3.0 + adafruit/Adafruit DRV2605 Library @ ^1.2.4 + +[env:MeshTracker_X1_companion_radio_ble] +extends = MeshTracker_X1 +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 +build_flags = ${MeshTracker_X1.build_flags} + -I examples/companion_radio/ui-orig + -D QSPIFLASH=1 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D BLE_TX_POWER=0 +; -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=NullDisplayDriver + -D PIN_BUZZER=25 + -D HAS_DRV2605=1 + -D DRV2605_USE_LRA=1 + -D ADVERT_NAME='"@@MAC"' +build_src_filter = ${MeshTracker_X1.build_src_filter} + +<helpers/nrf52/SerialBLEInterface.cpp> + +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> + +<helpers/ui/DRV2605Vibration.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = ${MeshTracker_X1.lib_deps} + densaugeo/base64 @ ~1.4.0 + stevemarple/MicroNMEA @ ^2.0.6 + end2endzone/NonBlockingRTTTL@^1.3.0 + adafruit/Adafruit DRV2605 Library @ ^1.2.4 diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp new file mode 100644 index 0000000000..d6d548b511 --- /dev/null +++ b/variants/meshtracker_x1/target.cpp @@ -0,0 +1,132 @@ +#include <Arduino.h> +#include "target.h" +#include <helpers/sensors/MicroNMEALocationProvider.h> + +MeshTrackerX1Board board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); + +WRAPPER_CLASS radio_driver(radio, board); + +VolatileRTCClock rtc_clock; +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +MeshTrackerX1SensorManager sensors = MeshTrackerX1SensorManager(nmea); + +#ifdef DISPLAY_CLASS + NullDisplayDriver display; +#endif + +bool radio_init() { + return radio.std_init(&SPI); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + +void MeshTrackerX1SensorManager::start_gps() { + gps_active = true; + // this init sequence comes from seeed examples and deals with all gps pins + pinMode(GPS_EN, OUTPUT); + digitalWrite(GPS_EN, HIGH); + delay(10); + pinMode(GPS_VRTC_EN, OUTPUT); + digitalWrite(GPS_VRTC_EN, HIGH); + delay(10); + + pinMode(GPS_RESET, OUTPUT); + digitalWrite(GPS_RESET, HIGH); + delay(10); + digitalWrite(GPS_RESET, LOW); + + pinMode(GPS_SLEEP_INT, OUTPUT); + digitalWrite(GPS_SLEEP_INT, HIGH); + pinMode(GPS_RTC_INT, OUTPUT); + digitalWrite(GPS_RTC_INT, LOW); +} + +void MeshTrackerX1SensorManager::sleep_gps() { + gps_active = false; + digitalWrite(GPS_VRTC_EN, HIGH); // keep RTC alive for faster fix on wake + digitalWrite(GPS_EN, LOW); + digitalWrite(GPS_RESET, LOW); + digitalWrite(GPS_SLEEP_INT, HIGH); + digitalWrite(GPS_RTC_INT, LOW); +} + +void MeshTrackerX1SensorManager::stop_gps() { + gps_active = false; + digitalWrite(GPS_VRTC_EN, LOW); + digitalWrite(GPS_EN, LOW); + digitalWrite(GPS_RESET, LOW); + digitalWrite(GPS_SLEEP_INT, HIGH); + digitalWrite(GPS_RTC_INT, LOW); +} + +bool MeshTrackerX1SensorManager::begin() { + // init GPS + Serial1.begin(GPS_BAUD_RATE); + + // init SPA06-003 barometer + baro_ok = spa06.begin(SPA06_003_DEFAULT_ADDR, &Wire) || spa06.begin(0x76, &Wire); + if (baro_ok) { + spa06.setPressureOversampling(SPA06_003_OVERSAMPLE_8); + spa06.setTemperatureOversampling(SPA06_003_OVERSAMPLE_8); + // 1 Hz continuous keeps reads non-blocking at minimal power cost + spa06.setPressureMeasureRate(SPA06_003_RATE_1); + spa06.setTemperatureMeasureRate(SPA06_003_RATE_1); + spa06.setMeasurementMode(SPA06_003_MEAS_CONTINUOUS_BOTH); + } + return true; +} + +bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { + if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission? + telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); + } + if (requester_permissions & TELEM_PERM_ENVIRONMENT && baro_ok) { + telemetry.addTemperature(TELEM_CHANNEL_SELF, spa06.readTemperature()); + telemetry.addBarometricPressure(TELEM_CHANNEL_SELF, spa06.readPressure()); + } + return true; +} + +void MeshTrackerX1SensorManager::loop() { + static long next_gps_update = 0; + + + _nmea->loop(); + + if (millis() > next_gps_update) { + if (gps_active && _nmea->isValid()) { + node_lat = ((double)_nmea->getLatitude())/1000000.; + node_lon = ((double)_nmea->getLongitude())/1000000.; + node_altitude = ((double)_nmea->getAltitude()) / 1000.0; + } + next_gps_update = millis() + 1000; + } +} + +int MeshTrackerX1SensorManager::getNumSettings() const { return 1; } // just one supported: "gps" (power switch) + +const char* MeshTrackerX1SensorManager::getSettingName(int i) const { + return i == 0 ? "gps" : NULL; +} +const char* MeshTrackerX1SensorManager::getSettingValue(int i) const { + if (i == 0) { + return gps_active ? "1" : "0"; + } + return NULL; +} +bool MeshTrackerX1SensorManager::setSettingValue(const char* name, const char* value) { + if (strcmp(name, "gps") == 0) { + if (strcmp(value, "0") == 0) { + sleep_gps(); // sleep for faster fix ! + } else { + start_gps(); + } + return true; + } + return false; // not supported +} diff --git a/variants/meshtracker_x1/target.h b/variants/meshtracker_x1/target.h new file mode 100644 index 0000000000..9741efb6af --- /dev/null +++ b/variants/meshtracker_x1/target.h @@ -0,0 +1,47 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include "MeshTrackerX1Board.h" +#include <helpers/radiolib/CustomLR2021Wrapper.h> +#include <helpers/ArduinoHelpers.h> +#include <helpers/SensorManager.h> +#include <helpers/sensors/LocationProvider.h> +#include <Adafruit_SPA06_003.h> +#ifdef DISPLAY_CLASS + #include "NullDisplayDriver.h" +#endif + +class MeshTrackerX1SensorManager: public SensorManager { + bool gps_active = false; + bool baro_ok = false; + Adafruit_SPA06_003 spa06; + LocationProvider * _nmea; + + void start_gps(); + void sleep_gps(); + void stop_gps(); +public: + MeshTrackerX1SensorManager(LocationProvider &nmea): _nmea(&nmea) { } + bool begin() override; + bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + void loop() override; + int getNumSettings() const override; + const char* getSettingName(int i) const override; + const char* getSettingValue(int i) const override; + bool setSettingValue(const char* name, const char* value) override; + LocationProvider* getLocationProvider() { return _nmea; } +}; + +#ifdef DISPLAY_CLASS + extern NullDisplayDriver display; +#endif + +extern MeshTrackerX1Board board; +extern WRAPPER_CLASS radio_driver; +extern VolatileRTCClock rtc_clock; +extern MeshTrackerX1SensorManager sensors; + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/meshtracker_x1/variant.cpp b/variants/meshtracker_x1/variant.cpp new file mode 100644 index 0000000000..3f38a604d6 --- /dev/null +++ b/variants/meshtracker_x1/variant.cpp @@ -0,0 +1,109 @@ +/* + * variant.cpp - Seeed SenseCAP MeshTracker X1 (nRF52840 + Semtech LR2021) + */ + +#include "variant.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[PINS_COUNT + 1] = +{ + 0, // P0.00 + 1, // P0.01 + 2, // P0.02, AIN0 BATTERY_PIN + 3, // P0.03, LED_RED + 4, // P0.04, AIN2 VCC_ADC + 5, // P0.05, AIN3 EXT_PWR_DETECT + 6, // P0.06, PIN_BUTTON1 + 7, // P0.07, LORA_BUSY + 8, // P0.08, GPS_RESET + 9, // P0.09, PIN_RTC_EN + 10, // P0.10 + 11, // P0.11, PIN_SPI_SCK + 12, // P0.12, PIN_SPI_NSS + 13, // P0.13, PIN_SERIAL1_TX + 14, // P0.14, PIN_SERIAL1_RX + 15, // P0.15 + 16, // P0.16, PIN_SERIAL2_TX + 17, // P0.17, PIN_SERIAL2_RX + 18, // P0.18 + 19, // P0.19 + 20, // P0.20 + 21, // P0.21 + 22, // P0.22 + 23, // P0.23 + 24, // P0.24, LED_GREEN + 25, // P0.25, BUZZER_PIN + 26, // P0.26 + 27, // P0.27 + 28, // P0.28, LED_BLUE + 29, // P0.29, GPS_RTC_INT + 30, // P0.30, GPS_SLEEP_INT + 31, // P0.31 + 32, // P1.00 + 33, // P1.01, LORA_DIO_1 (LR2021 DIO8 IRQ) + 34, // P1.02 + 35, // P1.03, EXT_CHRG_DETECT + 36, // P1.04, CHARGE_DONE + 37, // P1.05, PIN_DRV_EN + 38, // P1.06, PIN_BAT_ADC_EN + 39, // P1.07, PIN_3V3_EN + 40, // P1.08, PIN_SPI_MISO + 41, // P1.09, PIN_SPI_MOSI + 42, // P1.10, LORA_RESET + 43, // P1.11, GPS_EN + 44, // P1.12 + 45, // P1.13, GPS_VRTC_EN + 46, // P1.14, PIN_WIRE_SCL + 47, // P1.15, PIN_WIRE_SDA + 255, // NRFX_SPIM_PIN_NOT_USED +}; + +void initVariant() +{ + pinMode(BATTERY_PIN, INPUT); + pinMode(EXT_CHRG_DETECT, INPUT_PULLUP); + pinMode(EXT_PWR_DETECT, INPUT); + pinMode(PIN_BUTTON1, INPUT_PULLDOWN); + + pinMode(PIN_3V3_EN, OUTPUT); + digitalWrite(PIN_3V3_EN, HIGH); + + pinMode(PIN_FLASH_EN, OUTPUT); + digitalWrite(PIN_FLASH_EN, HIGH); + + pinMode(PIN_BAT_ADC_EN, OUTPUT); + digitalWrite(PIN_BAT_ADC_EN, HIGH); + + pinMode(PIN_RTC_EN, OUTPUT); + digitalWrite(PIN_RTC_EN, LOW); + + pinMode(PIN_DRV_EN, OUTPUT); + digitalWrite(PIN_DRV_EN, LOW); + + pinMode(LED_RED, OUTPUT); + digitalWrite(LED_RED, LOW); + + pinMode(LED_GREEN, OUTPUT); + digitalWrite(LED_GREEN, LOW); + + pinMode(LED_BLUE, OUTPUT); + digitalWrite(LED_BLUE, LOW); + + pinMode(GPS_EN, OUTPUT); + digitalWrite(GPS_EN, LOW); + + pinMode(GPS_VRTC_EN, OUTPUT); + digitalWrite(GPS_VRTC_EN, HIGH); + + pinMode(GPS_RESET, OUTPUT); + digitalWrite(GPS_RESET, LOW); + + pinMode(GPS_SLEEP_INT, OUTPUT); + digitalWrite(GPS_SLEEP_INT, HIGH); + + pinMode(GPS_RTC_INT, OUTPUT); + digitalWrite(GPS_RTC_INT, LOW); + + pinMode(BUZZER_PIN, INPUT_PULLDOWN); +} diff --git a/variants/meshtracker_x1/variant.h b/variants/meshtracker_x1/variant.h new file mode 100644 index 0000000000..7f2b246b50 --- /dev/null +++ b/variants/meshtracker_x1/variant.h @@ -0,0 +1,139 @@ +/* + * variant.h - Seeed SenseCAP MeshTracker X1 (nRF52840 + Semtech LR2021) + */ + +#pragma once + +#include "WVariant.h" + +//////////////////////////////////////////////////////////////////////////////// +// Low frequency clock source + +#define USE_LFXO // 32.768 kHz crystal oscillator +#define VARIANT_MCK (64000000ul) + +//////////////////////////////////////////////////////////////////////////////// +// Power + +#define NRF_APM // detect usb power +#define PIN_3V3_EN (39) // P1.7 Power to sensors +#define PIN_BAT_ADC_EN (38) // P1.6 Power to battery ADC divider +#define PIN_RTC_EN (9) // P0.9 Power to RTC + +#define BATTERY_PIN (2) // P0.2/AIN0 +#define BATTERY_IMMUTABLE +#define ADC_MULTIPLIER (2.0F) + +#define EXT_CHRG_DETECT (35) // P1.3, LOW while charging +// P1.4 is the charger's second status line, but it reads LOW regardless of +// charge state on this board, so it is not used (Meshtastic leaves it out too) +#define EXT_PWR_DETECT (5) // P0.5 + +#define ADC_RESOLUTION (14) +#define BATTERY_SENSE_RES (12) + +#define AREF_VOLTAGE (3.0) + +//////////////////////////////////////////////////////////////////////////////// +// Number of pins + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (6) +#define NUM_ANALOG_OUTPUTS (0) + +//////////////////////////////////////////////////////////////////////////////// +// UART pin definition + +#define PIN_SERIAL1_RX (14) // P0.14 (GPS) +#define PIN_SERIAL1_TX (13) // P0.13 (GPS) + +#define PIN_SERIAL2_RX (17) // P0.17 +#define PIN_SERIAL2_TX (16) // P0.16 + +//////////////////////////////////////////////////////////////////////////////// +// I2C pin definition + +#define HAS_WIRE (1) +#define WIRE_INTERFACES_COUNT (1) + +#define PIN_WIRE_SDA (47) // P1.15 +#define PIN_WIRE_SCL (46) // P1.14 +#define I2C_NO_RESCAN + +//////////////////////////////////////////////////////////////////////////////// +// SPI pin definition + +#define SPI_INTERFACES_COUNT (1) + +#define PIN_SPI_MISO (40) // P1.8 +#define PIN_SPI_MOSI (41) // P1.9 +#define PIN_SPI_SCK (11) // P0.11 +#define PIN_SPI_NSS (12) // P0.12 + +//////////////////////////////////////////////////////////////////////////////// +// Builtin LEDs (RGB) + +#define LED_BUILTIN (-1) +#define LED_RED (3) // P0.3 +#define LED_GREEN (24) // P0.24 +#define LED_BLUE (28) // P0.28 +#define LED_PIN LED_GREEN + +#define LED_STATE_ON HIGH + +//////////////////////////////////////////////////////////////////////////////// +// Builtin buttons + +#define PIN_BUTTON1 (6) // P0.6, active HIGH (internal pull-down) +#define BUTTON_PIN PIN_BUTTON1 + +//////////////////////////////////////////////////////////////////////////////// +// LR2021 + +#define LORA_DIO_1 (33) // P1.1, IRQ line (LR2021 DIO8) +#define LORA_NSS (PIN_SPI_NSS) // P0.12 +#define LORA_RESET (42) // P1.10 +#define LORA_BUSY (7) // P0.7 +#define LORA_SCLK (PIN_SPI_SCK) // P0.11 +#define LORA_MISO (PIN_SPI_MISO) // P1.8 +#define LORA_MOSI (PIN_SPI_MOSI) // P1.9 + +//////////////////////////////////////////////////////////////////////////////// +// GPS (Airoha, dual-band L1+L5) + +#define HAS_GPS 1 +#define GPS_RX_PIN PIN_SERIAL1_RX +#define GPS_TX_PIN PIN_SERIAL1_TX +#define GPS_BAUD_RATE (115200) + +#define GPS_EN (43) // P1.11, active HIGH +#define GPS_RESET (8) // P0.8, active HIGH + +#define GPS_VRTC_EN (45) // P1.13 +#define GPS_SLEEP_INT (30) // P0.30 +#define GPS_RTC_INT (29) // P0.29 + +//////////////////////////////////////////////////////////////////////////////// +// Haptic driver (DRV2605 on I2C) + +#define PIN_DRV_EN (37) // P1.5, power to haptic driver + +//////////////////////////////////////////////////////////////////////////////// +// Buzzer + +#define BUZZER_PIN (25) // P0.25, pwm output + +//////////////////////////////////////////////////////////////////////////////// +// QSPI Flash + +#define PIN_FLASH_EN (15) // P0.15 (Flash power enable) + +#define PIN_QSPI_SCK (19) // P0.19 +#define PIN_QSPI_CS (20) // P0.20 +#define PIN_QSPI_IO0 (21) // P0.21 +#define PIN_QSPI_IO1 (22) // P0.22 +#define PIN_QSPI_IO2 (23) // P0.23 +#define PIN_QSPI_IO3 (32) // P1.00 + +#define EXTERNAL_FLASH_DEVICES GD25Q64C \ No newline at end of file diff --git a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h index 4fa5cd41ea..450a3d13b0 100644 --- a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h +++ b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h @@ -65,7 +65,7 @@ class MinewsemiME25LS01Board : public NRF52BoardDCDC { #ifdef BUTTON_PIN nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } #if defined(P_LORA_TX_LED) diff --git a/variants/minewsemi_me25ls01/NullDisplayDriver.h b/variants/minewsemi_me25ls01/NullDisplayDriver.h deleted file mode 100644 index 38bf93f153..0000000000 --- a/variants/minewsemi_me25ls01/NullDisplayDriver.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include <helpers/ui/DisplayDriver.h> - -class NullDisplayDriver : public DisplayDriver { -public: - NullDisplayDriver() : DisplayDriver(128, 64) { } - bool begin() { return false; } // not present - - bool isOn() override { return false; } - void turnOn() override { } - void turnOff() override { } - void clear() override { } - void startFrame(Color bkg = DARK) override { } - void setTextSize(int sz) override { } - void setColor(Color c) override { } - void setCursor(int x, int y) override { } - void print(const char* str) override { } - void fillRect(int x, int y, int w, int h) override { } - void drawRect(int x, int y, int w, int h) override { } - void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { } - uint16_t getTextWidth(const char* str) override { return 0; } - void endFrame() { } -}; diff --git a/variants/minewsemi_me25ls01/platformio.ini b/variants/minewsemi_me25ls01/platformio.ini index 39d4252deb..d115a1f1a8 100644 --- a/variants/minewsemi_me25ls01/platformio.ini +++ b/variants/minewsemi_me25ls01/platformio.ini @@ -46,8 +46,8 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE @@ -55,6 +55,7 @@ build_flags = ${me25ls01.build_flags} ;-D PIN_BUZZER=25 ;-D PIN_BUZZER_EN=37 build_src_filter = ${me25ls01.build_src_filter} + +<helpers/ui/NullDisplayDriver.cpp> +<helpers/nrf52/SerialBLEInterface.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> @@ -66,8 +67,8 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=8 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE @@ -79,6 +80,7 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_repeater> + +<helpers/ui/NullDisplayDriver.cpp> [env:Minewsemi_me25ls01_room_server] extends = me25ls01 @@ -101,6 +103,7 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_room_server> + +<helpers/ui/NullDisplayDriver.cpp> [env:Minewsemi_me25ls01_terminal_chat] extends = me25ls01 @@ -109,8 +112,8 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=8 -D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE @@ -123,6 +126,7 @@ build_flags = ${me25ls01.build_flags} -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${me25ls01.build_src_filter} +<../examples/simple_secure_chat/main.cpp> + +<helpers/ui/NullDisplayDriver.cpp> [env:Minewsemi_me25ls01_companion_radio_usb] extends = me25ls01 @@ -134,14 +138,16 @@ build_flags = ${me25ls01.build_flags} -D MAX_GROUP_CHANNELS=40 ;-D BLE_PIN_CODE=123456 ; -D BLE_DEBUG_LOGGING=1 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 -D OFFLINE_QUEUE_SIZE=256 -D RX_BOOSTED_GAIN=true -D RF_SWITCH_TABLE -D DISPLAY_CLASS=NullDisplayDriver + -D ENABLE_USB_INTERFACE build_src_filter = ${me25ls01.build_src_filter} +<helpers/nrf52/*.cpp> + +<helpers/ui/NullDisplayDriver.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> diff --git a/variants/minewsemi_me25ls01/target.h b/variants/minewsemi_me25ls01/target.h index f8d42863b2..978e616b96 100644 --- a/variants/minewsemi_me25ls01/target.h +++ b/variants/minewsemi_me25ls01/target.h @@ -10,7 +10,7 @@ #include <helpers/sensors/LocationProvider.h> #include <helpers/sensors/EnvironmentSensorManager.h> #ifdef DISPLAY_CLASS - #include "NullDisplayDriver.h" + #include <helpers/ui/NullDisplayDriver.h> #endif #ifdef DISPLAY_CLASS diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index 3dbecf1e84..52dc3e38fe 100644 --- a/variants/muziworks_r1_neo/platformio.ini +++ b/variants/muziworks_r1_neo/platformio.ini @@ -66,6 +66,7 @@ build_flags = -I examples/companion_radio/ui-orig -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${R1Neo.build_src_filter} @@ -127,7 +128,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${R1Neo.build_src_filter} +<../examples/simple_sensor> diff --git a/variants/nano_g2_ultra/nano-g2.h b/variants/nano_g2_ultra/nano-g2.h index cf771efe7d..bf9543b039 100644 --- a/variants/nano_g2_ultra/nano-g2.h +++ b/variants/nano_g2_ultra/nano-g2.h @@ -52,6 +52,6 @@ class NanoG2Ultra : public NRF52Board { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(PIN_USER_BTN), NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/nano_g2_ultra/platformio.ini b/variants/nano_g2_ultra/platformio.ini index 3cdc29ffb1..b817b3e991 100644 --- a/variants/nano_g2_ultra/platformio.ini +++ b/variants/nano_g2_ultra/platformio.ini @@ -99,6 +99,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=SH1106Display -D PIN_BUZZER=4 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Nano_G2_Ultra.build_src_filter} diff --git a/variants/nibble_screen_connect/platformio.ini b/variants/nibble_screen_connect/platformio.ini index 20f7dc2438..112181df2d 100644 --- a/variants/nibble_screen_connect/platformio.ini +++ b/variants/nibble_screen_connect/platformio.ini @@ -34,7 +34,7 @@ lib_deps = adafruit/Adafruit SSD1306 @ ^2.5.13 adafruit/Adafruit NeoPixel @ ^1.12.3 -[env:nibble_screen_connect_repeater] +[env:nibble_screen_connect_repeater_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -51,7 +51,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} ${esp32_ota.lib_deps} -[env:nibble_screen_connect_repeater_bridge_espnow] +[env:nibble_screen_connect_repeater_bridge_espnow_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -70,7 +70,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} ${esp32_ota.lib_deps} -[env:nibble_screen_connect_terminal_chat] +[env:nibble_screen_connect_terminal_chat_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -82,7 +82,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_room_server] +[env:nibble_screen_connect_room_server_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -99,7 +99,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} ${esp32_ota.lib_deps} -[env:nibble_screen_connect_companion_radio_usb] +[env:nibble_screen_connect_companion_radio_usb_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -107,6 +107,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=300 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE build_src_filter = ${nibble_screen_connect_base.build_src_filter} +<helpers/ui/SSD1306Display.cpp> +<helpers/ui/MomentaryButton.cpp> @@ -116,7 +117,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_companion_radio_ble] +[env:nibble_screen_connect_companion_radio_ble_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -137,7 +138,7 @@ lib_deps = ${nibble_screen_connect_base.lib_deps} densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_companion_radio_wifi] +[env:nibble_screen_connect_companion_radio_wifi_] extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} @@ -160,7 +161,7 @@ lib_deps = densaugeo/base64 @ ~1.4.0 -[env:nibble_screen_connect_kiss_modem] +[env:nibble_screen_connect_kiss_modem_] extends = nibble_screen_connect_base build_src_filter = ${nibble_screen_connect_base.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/nibble_zero_connect/platformio.ini b/variants/nibble_zero_connect/platformio.ini new file mode 100644 index 0000000000..1161743eab --- /dev/null +++ b/variants/nibble_zero_connect/platformio.ini @@ -0,0 +1,159 @@ +[nibble_zero_connect_base] +extends = esp32_base +board = esp32-s3-zero +build_flags = + ${esp32_base.build_flags} + -I variants/nibble_zero_connect + -D NIBBLE_ZERO_CONNECT + -D P_LORA_DIO_1=4 + -D P_LORA_NSS=10 + -D P_LORA_RESET=6 + -D P_LORA_BUSY=5 + -D P_LORA_SCLK=12 + -D P_LORA_MISO=13 + -D P_LORA_MOSI=11 + -D PIN_USER_BTN=1 + -D PIN_BOARD_SDA=8 + -D PIN_BOARD_SCL=7 + -D PIN_STATUS_LED=39 + -D P_LORA_TX_LED=39 + -D DISPLAY_ROTATION=0 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_RX_BOOSTED_GAIN=1 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/nibble_zero_connect> +lib_deps = + ${esp32_base.lib_deps} + adafruit/Adafruit SSD1306 @ ^2.5.13 + +[env:nibble_zero_connect_repeater_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Nibble Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_repeater> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:nibble_zero_connect_repeater_bridge_espnow_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<helpers/bridges/ESPNowBridge.cpp> + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_repeater> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:nibble_zero_connect_terminal_chat_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=1 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:nibble_zero_connect_room_server_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Nibble Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_room_server> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + ${esp32_ota.lib_deps} + +[env:nibble_zero_connect_companion_radio_usb_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:nibble_zero_connect_companion_radio_ble_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=8 + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:nibble_zero_connect_companion_radio_wifi_] +extends = nibble_zero_connect_base +build_flags = + ${nibble_zero_connect_base.build_flags} + -I examples/companion_radio/ui-new + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=300 + -D MAX_GROUP_CHANNELS=8 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +build_src_filter = ${nibble_zero_connect_base.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/esp32/*.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${nibble_zero_connect_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + + diff --git a/variants/nibble_zero_connect/target.cpp b/variants/nibble_zero_connect/target.cpp new file mode 100644 index 0000000000..d53b6a1c3d --- /dev/null +++ b/variants/nibble_zero_connect/target.cpp @@ -0,0 +1,35 @@ +#include <Arduino.h> +#include "target.h" + +ESP32Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +SensorManager sensors; + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + return radio.std_init(&spi); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} + + diff --git a/variants/nibble_zero_connect/target.h b/variants/nibble_zero_connect/target.h new file mode 100644 index 0000000000..87c55f0f2e --- /dev/null +++ b/variants/nibble_zero_connect/target.h @@ -0,0 +1,28 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <helpers/ESP32Board.h> +#include <helpers/radiolib/CustomSX1262Wrapper.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/SensorManager.h> +#ifdef DISPLAY_CLASS + #include <helpers/ui/SSD1306Display.h> + #include <helpers/ui/MomentaryButton.h> +#endif + +extern ESP32Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern SensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + + diff --git a/variants/promicro/PromicroBoard.h b/variants/promicro/PromicroBoard.h index 7b6afb1b3d..b190c47c81 100644 --- a/variants/promicro/PromicroBoard.h +++ b/variants/promicro/PromicroBoard.h @@ -72,8 +72,4 @@ class PromicroBoard : public NRF52BoardDCDC { #endif return 0; } - - void powerOff() override { - sd_power_system_off(); - } }; diff --git a/variants/promicro/platformio.ini b/variants/promicro/platformio.ini index 5415e15861..90cb475f5b 100644 --- a/variants/promicro/platformio.ini +++ b/variants/promicro/platformio.ini @@ -119,6 +119,7 @@ build_flags = ${Promicro.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Promicro.build_src_filter} @@ -143,7 +144,7 @@ build_flags = ${Promicro.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=SSD1306Display ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${Promicro.build_src_filter} +<helpers/nrf52/SerialBLEInterface.cpp> +<helpers/ui/SSD1306Display.cpp> diff --git a/variants/rak11310/platformio.ini b/variants/rak11310/platformio.ini index ab820cf6a2..c8526317ab 100644 --- a/variants/rak11310/platformio.ini +++ b/variants/rak11310/platformio.ini @@ -85,6 +85,7 @@ extends = rak11310 build_flags = ${rak11310.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak11310.build_src_filter} diff --git a/variants/rak3112/RAK3112Board.h b/variants/rak3112/RAK3112Board.h index 8ba3197cf6..704162b8c8 100644 --- a/variants/rak3112/RAK3112Board.h +++ b/variants/rak3112/RAK3112Board.h @@ -16,8 +16,6 @@ #define ADC_MULTIPLIER (3 * 1.73 * 1.187 * 1000) #define BATTERY_SAMPLES 8 -#include <driver/rtc_io.h> - class RAK3112Board : public ESP32Board { private: bool adc_active_state; @@ -51,33 +49,6 @@ class RAK3112Board : public ESP32Board { } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - - void powerOff() override { - enterDeepSleep(0); - } - uint16_t getBattMilliVolts() override { analogReadResolution(12); diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index 9cd32c4b4d..8bd3c59771 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -136,6 +136,7 @@ build_flags = -I examples/companion_radio/ui-orig -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak3112.build_src_filter} diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 20a8a548b9..48e7192266 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -67,6 +67,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak3401.build_src_filter} diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index f966786087..c23019c803 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -37,6 +37,7 @@ build_flags = ${rak3x72.build_flags} ; -D FORMAT_FS=true -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE build_src_filter = ${rak3x72.build_src_filter} +<../examples/companion_radio/*.cpp> lib_deps = ${rak3x72.lib_deps} diff --git a/variants/rak4631/RAK4631Board.cpp b/variants/rak4631/RAK4631Board.cpp index 9fb47b432e..1b5698d0eb 100644 --- a/variants/rak4631/RAK4631Board.cpp +++ b/variants/rak4631/RAK4631Board.cpp @@ -1,8 +1,21 @@ #include <Arduino.h> #include <Wire.h> +#include "nrf_gpio.h" #include "RAK4631Board.h" +#ifdef ETHERNET_ENABLED +// Drive WB_IO2 HIGH as early as possible using direct register access. +// WB_IO2 (P1.02, Arduino pin 34) controls the WisBlock slot power switch. +// With POE through RAK13800, this must be enabled before the framework +// initializes or the board will brownout from insufficient power delivery. +// Priority 102 runs just after SystemInit. +static void __attribute__((constructor(102))) rak4631_early_poe_power() { + nrf_gpio_cfg_output(NRF_GPIO_PIN_MAP(1, 2)); // WB_IO2 = P1.02 + nrf_gpio_pin_set(NRF_GPIO_PIN_MAP(1, 2)); +} +#endif + #ifdef NRF52_POWER_MANAGEMENT // Static configuration for power management // Values set in variant.h defines @@ -36,6 +49,7 @@ void RAK4631Board::begin() { pinMode(PIN_USER_BTN_ANA, INPUT_PULLUP); #endif + #if defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) Wire.setPins(PIN_BOARD_SDA, PIN_BOARD_SCL); #endif diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 2bbba31463..0dfbf79775 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -53,6 +53,25 @@ build_src_filter = ${rak4631.build_src_filter} +<helpers/ui/SSD1306Display.cpp> +<../examples/simple_repeater> +[env:RAK_4631_repeater_ethernet] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D ETHERNET_ENABLED=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_repeater> +lib_deps = + ${rak4631.lib_deps} + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + [env:RAK_4631_repeater_bridge_rs232_serial1] extends = rak4631 build_flags = @@ -115,6 +134,25 @@ build_src_filter = ${rak4631.build_src_filter} +<helpers/ui/SSD1306Display.cpp> +<../examples/simple_room_server> +[env:RAK_4631_room_server_ethernet] +extends = rak4631 +build_flags = + ${rak4631.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RAK4631 Room Server"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D ETHERNET_ENABLED=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rak4631.build_src_filter} + +<helpers/ui/SSD1306Display.cpp> + +<../examples/simple_room_server> +lib_deps = + ${rak4631.lib_deps} + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + [env:RAK_4631_companion_radio_usb] extends = rak4631 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld @@ -127,6 +165,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} @@ -136,6 +175,38 @@ lib_deps = ${rak4631.lib_deps} densaugeo/base64 @ ~1.4.0 + +[env:RAK_4631_companion_radio_ethernet] +extends = rak4631 +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 712704 +build_unflags = + -D EXTRAFS=1 +build_flags = + ${rak4631.build_flags} + -I examples/companion_radio/ui-new + -D PIN_USER_BTN=9 + -D PIN_USER_BTN_ANA=31 + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ETHERNET_ENABLED=1 + -D ETHERNET_USE_RAK13800 + -D ETHERNET_CLASS=RAK13800EthernetInterface +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +; -D ETHERNET_DEBUG_LOGGING=1 +build_src_filter = ${rak4631.build_src_filter} + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + +<helpers/ethernet/*.cpp> + +<helpers/ethernet/RAK13800/> +lib_deps = + ${rak4631.lib_deps} + densaugeo/base64 @ ~1.4.0 + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + + [env:RAK_4631_companion_radio_ble] extends = rak4631 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld @@ -187,7 +258,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} +<helpers/ui/SSD1306Display.cpp> +<../examples/simple_sensor> @@ -199,4 +270,4 @@ build_flags = build_src_filter = ${rak4631.build_src_filter} +<../examples/kiss_modem/> lib_deps = - ${rak4631.lib_deps} \ No newline at end of file + ${rak4631.lib_deps} diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h index cc5aa06f59..daa90d746a 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h @@ -69,6 +69,6 @@ class RAKWismeshTagBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index e9cddb74dd..d614018717 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -71,6 +71,7 @@ build_flags = -I examples/companion_radio/ui-orig -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak_wismesh_tag.build_src_filter} diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index 66c7b9f890..0fe8c43696 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -58,6 +58,7 @@ extends = rpi_picow build_flags = ${rpi_picow.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rpi_picow.build_src_filter} diff --git a/variants/sensecap_solar/SenseCapSolarBoard.h b/variants/sensecap_solar/SenseCapSolarBoard.h index 6799a5e921..5a8861c298 100644 --- a/variants/sensecap_solar/SenseCapSolarBoard.h +++ b/variants/sensecap_solar/SenseCapSolarBoard.h @@ -54,7 +54,7 @@ class SenseCapSolarBoard : public NRF52BoardDCDC { #ifdef NRF52_POWER_MANAGEMENT initiateShutdown(SHUTDOWN_REASON_USER); #else - sd_power_system_off(); + NRF52Board::powerOff(); #endif } }; diff --git a/variants/sensecap_solar/platformio.ini b/variants/sensecap_solar/platformio.ini index effef38ccd..6e0eadbcb0 100644 --- a/variants/sensecap_solar/platformio.ini +++ b/variants/sensecap_solar/platformio.ini @@ -92,6 +92,7 @@ build_flags = ${SenseCap_Solar.build_flags} -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${SenseCap_Solar.build_src_filter} diff --git a/variants/station_g2/StationG2Board.h b/variants/station_g2/StationG2Board.h index a905682c8d..d1989ee0ff 100644 --- a/variants/station_g2/StationG2Board.h +++ b/variants/station_g2/StationG2Board.h @@ -2,7 +2,6 @@ #include <Arduino.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> class StationG2Board : public ESP32Board { public: @@ -21,29 +20,6 @@ class StationG2Board : public ESP32Board { } } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - uint16_t getBattMilliVolts() override { return 0; } diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index 6432b52386..753aee7ab9 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -110,7 +110,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 -D SX126X_RX_BOOSTED_GAIN=1 ; https://wiki.uniteng.com/en/meshtastic/station-g2#impact-of-lora-node-dense-areashigh-noise-environments-on-rf-performance ; -D MESH_DEBUG=1 @@ -152,7 +152,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 -D SX126X_RX_BOOSTED_GAIN=1 -D WITH_ESPNOW_BRIDGE=1 ; -D BRIDGE_DEBUG=1 @@ -188,6 +188,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Station_G2.build_src_filter} @@ -241,5 +242,10 @@ lib_deps = [env:Station_G2_kiss_modem] extends = Station_G2 +build_unflags = + -DARDUINO_USB_MODE=0 +build_flags = + ${Station_G2.build_flags} + -DARDUINO_USB_MODE=1 build_src_filter = ${Station_G2.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp new file mode 100644 index 0000000000..4a49831107 --- /dev/null +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -0,0 +1,15 @@ +#include "StationG3Board.h" + +void StationG3Board::powerOff() { +#ifdef P_PA1_EN + setPAModeHigh(false); + rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); +#endif + +#ifdef P_PRIMARY_LNA_EN + setPrimaryLNAControl(true); + rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); +#endif + + ESP32Board::powerOff(); +} diff --git a/variants/station_g3_esp32/StationG3Board.h b/variants/station_g3_esp32/StationG3Board.h index dc440d95f5..4b1fb81c10 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -73,36 +73,7 @@ class StationG3Board : public ESP32Board { setPrimaryLNAControl(true); } - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - -#ifdef P_PA1_EN - setPAModeHigh(false); - rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); -#endif - -#ifdef P_PRIMARY_LNA_EN - setPrimaryLNAControl(true); - rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); -#endif - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); - } else { - esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - esp_deep_sleep_start(); - } + void powerOff() override; uint16_t getBattMilliVolts() override { return 0; diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index b29388ef7e..e4a66a18ee 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -71,7 +71,7 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Station_G3_ESP32.build_src_filter} +<../examples/simple_repeater> @@ -103,6 +103,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Station_G3_ESP32.build_src_filter} diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index e7653fb2f4..1111d3c408 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -88,6 +88,6 @@ class T1000eBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_HIGH); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 43a3d93f61..8456dc9151 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -82,8 +82,10 @@ build_flags = ${t1000-e.build_flags} -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=25 -D PIN_BUZZER_EN=37 ; P1/5 - required for T1000-E + -D ENABLE_USB_INTERFACE build_src_filter = ${t1000-e.build_src_filter} +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${t1000-e.lib_deps} @@ -112,6 +114,7 @@ build_flags = ${t1000-e.build_flags} build_src_filter = ${t1000-e.build_src_filter} +<helpers/nrf52/SerialBLEInterface.cpp> +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${t1000-e.lib_deps} diff --git a/variants/tenstar_c3/target.h b/variants/tenstar_c3/target.h index b3ee6d177f..7839fa973a 100644 --- a/variants/tenstar_c3/target.h +++ b/variants/tenstar_c3/target.h @@ -3,7 +3,7 @@ #define RADIOLIB_STATIC_ONLY 1 #include <RadioLib.h> #include <helpers/radiolib/RadioLibWrappers.h> -#include <helpers/XiaoC3Board.h> +#include <../variants/xiao_c3/XiaoC3Board.h> #include <helpers/radiolib/CustomSX1262Wrapper.h> #include <helpers/radiolib/CustomSX1268Wrapper.h> #include <helpers/AutoDiscoverRTCClock.h> diff --git a/variants/thinknode_m1/ThinkNodeM1Board.h b/variants/thinknode_m1/ThinkNodeM1Board.h index ebc46e6e6f..94224d9c2b 100644 --- a/variants/thinknode_m1/ThinkNodeM1Board.h +++ b/variants/thinknode_m1/ThinkNodeM1Board.h @@ -40,7 +40,6 @@ class ThinkNodeM1Board : public NRF52Board { #endif // power off board - sd_power_system_off(); - + NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index 356edfee69..89c48403bf 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -58,6 +58,7 @@ build_flags = -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ThinkNode_M1.build_src_filter} @@ -116,6 +117,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=6 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M1.build_src_filter} +<helpers/ui/GxEPDDisplay.cpp> +<helpers/ui/buzzer.cpp> diff --git a/variants/thinknode_m2/ThinknodeM2Board.cpp b/variants/thinknode_m2/ThinknodeM2Board.cpp index 0596510361..8d68006d2c 100644 --- a/variants/thinknode_m2/ThinknodeM2Board.cpp +++ b/variants/thinknode_m2/ThinknodeM2Board.cpp @@ -1,40 +1,30 @@ #include "ThinknodeM2Board.h" - - void ThinknodeM2Board::begin() { - pinMode(PIN_VEXT_EN, OUTPUT); - digitalWrite(PIN_VEXT_EN, !PIN_VEXT_EN_ACTIVE); // force power cycle - delay(20); // allow power rail to discharge - digitalWrite(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE); // turn backlight back on - delay(120); // give display time to bias on cold boot - ESP32Board::begin(); - pinMode(PIN_STATUS_LED, OUTPUT); // init power led - } + pinMode(PIN_VEXT_EN, OUTPUT); + digitalWrite(PIN_VEXT_EN, !PIN_VEXT_EN_ACTIVE); // force power cycle + delay(20); // allow power rail to discharge + digitalWrite(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE); // turn backlight back on + delay(120); // give display time to bias on cold boot + ESP32Board::begin(); + pinMode(PIN_STATUS_LED, OUTPUT); // init power led +} - void ThinknodeM2Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_deep_sleep_start(); - } +uint16_t ThinknodeM2Board::getBattMilliVolts() { + analogReadResolution(12); + analogSetPinAttenuation(PIN_VBAT_READ, ADC_11db); - void ThinknodeM2Board::powerOff() { - enterDeepSleep(0); + uint32_t mv = 0; + for (int i = 0; i < 8; ++i) { + mv += analogReadMilliVolts(PIN_VBAT_READ); + delayMicroseconds(200); } + mv /= 8; - uint16_t ThinknodeM2Board::getBattMilliVolts() { - analogReadResolution(12); - analogSetPinAttenuation(PIN_VBAT_READ, ADC_11db); - - uint32_t mv = 0; - for (int i = 0; i < 8; ++i) { - mv += analogReadMilliVolts(PIN_VBAT_READ); - delayMicroseconds(200); - } - mv /= 8; - - analogReadResolution(10); - return static_cast<uint16_t>(mv * ADC_MULTIPLIER ); + analogReadResolution(10); + return static_cast<uint16_t>(mv * ADC_MULTIPLIER); } - const char* ThinknodeM2Board::getManufacturerName() const { - return "Elecrow ThinkNode M2"; - } +const char *ThinknodeM2Board::getManufacturerName() const { + return "Elecrow ThinkNode M2"; +} diff --git a/variants/thinknode_m2/ThinknodeM2Board.h b/variants/thinknode_m2/ThinknodeM2Board.h index 8011fae67d..0255677784 100644 --- a/variants/thinknode_m2/ThinknodeM2Board.h +++ b/variants/thinknode_m2/ThinknodeM2Board.h @@ -3,15 +3,11 @@ #include <Arduino.h> #include <helpers/RefCountedDigitalPin.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> class ThinknodeM2Board : public ESP32Board { public: - void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/thinknode_m2/platformio.ini b/variants/thinknode_m2/platformio.ini index aae83324e3..583f913c99 100644 --- a/variants/thinknode_m2/platformio.ini +++ b/variants/thinknode_m2/platformio.ini @@ -30,7 +30,7 @@ build_flags = ${esp32_base.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_RX_BOOSTED_GAIN=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 build_src_filter = ${esp32_base.build_src_filter} +<helpers/ui/SH1106Display.cpp> +<helpers/ui/MomentaryButton.cpp> @@ -159,6 +159,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M2.build_src_filter} +<helpers/esp32/*.cpp> +<helpers/ui/MomentaryButton.cpp> diff --git a/variants/thinknode_m3/ThinkNodeM3Board.h b/variants/thinknode_m3/ThinkNodeM3Board.h index 1435d31d47..9e8f49894d 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.h +++ b/variants/thinknode_m3/ThinkNodeM3Board.h @@ -18,12 +18,12 @@ class ThinkNodeM3Board : public NRF52BoardDCDC { void begin(); uint16_t getBattMilliVolts() override; -#if defined(P_LORA_TX_LED) +#ifdef P_LORA_TX_LED void onBeforeTransmit() override { - digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + digitalWrite(P_LORA_TX_LED, LED_STATE_ON); // turn TX LED on } void onAfterTransmit() override { - digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off + digitalWrite(P_LORA_TX_LED, !LED_STATE_ON); // turn TX LED off } #endif @@ -44,11 +44,11 @@ class ThinkNodeM3Board : public NRF52BoardDCDC { void powerOff() override { // turn off all leds, sd_power_system_off will not do this for us - #ifdef P_LORA_TX_LED - digitalWrite(P_LORA_TX_LED, LOW); - #endif + digitalWrite(PIN_LED_BLUE, !LED_STATE_ON); + digitalWrite(PIN_LED_GREEN, !LED_STATE_ON); + digitalWrite(PIN_LED_RED, !LED_STATE_ON); // power off board - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m3/platformio.ini b/variants/thinknode_m3/platformio.ini index 0a3d4eda92..ce1e7e59e4 100644 --- a/variants/thinknode_m3/platformio.ini +++ b/variants/thinknode_m3/platformio.ini @@ -24,10 +24,9 @@ build_flags = ${nrf52_base.build_flags} -D P_LORA_MOSI=46 -D P_LORA_RESET=42 -D P_LORA_TX_LED=PIN_LED_BLUE - -D P_LORA_TX_LED_ON=LOW -D LR11X0_DIO_AS_RF_SWITCH=true -D LR11X0_DIO3_TCXO_VOLTAGE=3.3 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 -D ENV_INCLUDE_GPS=1 build_src_filter = ${nrf52_base.build_src_filter} +<helpers/*.cpp> @@ -84,8 +83,10 @@ build_flags = ${ThinkNode_M3.build_flags} -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=23 -D PIN_BUZZER_EN=36 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M3.build_src_filter} +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${ThinkNode_M3.lib_deps} @@ -105,7 +106,6 @@ build_flags = ${ThinkNode_M3.build_flags} -D BLE_TX_POWER=0 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 - -D GPS_NMEA_DEBUG -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=NullDisplayDriver -D PIN_BUZZER=23 @@ -113,6 +113,7 @@ build_flags = ${ThinkNode_M3.build_flags} build_src_filter = ${ThinkNode_M3.build_src_filter} +<helpers/nrf52/SerialBLEInterface.cpp> +<helpers/ui/buzzer.cpp> + +<helpers/ui/NullDisplayDriver.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${ThinkNode_M3.lib_deps} diff --git a/variants/thinknode_m3/variant.cpp b/variants/thinknode_m3/variant.cpp index dad0f3f550..b47b83543d 100644 --- a/variants/thinknode_m3/variant.cpp +++ b/variants/thinknode_m3/variant.cpp @@ -80,16 +80,18 @@ void initVariant() digitalWrite(LED_POWER, HIGH); pinMode(PIN_LED_BLUE, OUTPUT); + digitalWrite(PIN_LED_BLUE, !LED_STATE_ON); pinMode(PIN_LED_GREEN, OUTPUT); + digitalWrite(PIN_LED_GREEN, !LED_STATE_ON); pinMode(PIN_LED_RED, OUTPUT); + digitalWrite(PIN_LED_RED, !LED_STATE_ON); pinMode(BUTTON_PIN, INPUT_PULLUP); pinMode(PIN_GPS_POWER, OUTPUT); pinMode(PIN_GPS_EN, OUTPUT); - pinMode(PIN_GPS_RESET, OUTPUT); // Power on gps but in standby - digitalWrite(PIN_GPS_EN, LOW); - digitalWrite(PIN_GPS_POWER, HIGH); + digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE); + digitalWrite(PIN_GPS_POWER, GPS_POWER_ACTIVE); } diff --git a/variants/thinknode_m3/variant.h b/variants/thinknode_m3/variant.h index 02ed78a84f..78dfab85d9 100644 --- a/variants/thinknode_m3/variant.h +++ b/variants/thinknode_m3/variant.h @@ -32,7 +32,7 @@ #define EXT_CHRG_DETECT (32) // P1.3 #define EXT_PWR_DETECT (31) // P0.5 -#define PIN_VBAT_READ (5) +#define PIN_VBAT_READ (5) #define AREF_VOLTAGE (2.4f) #define ADC_MULTIPLIER (2.0) //(1.75f) // 2.0 gives more coherent value, 4.2V when charged, needs tweaking @@ -92,18 +92,19 @@ // GPS #define HAS_GPS 1 -#define PIN_GPS_RX (22) +#define PIN_GPS_RX (22) #define PIN_GPS_TX (20) #define PIN_GPS_POWER (14) #define PIN_GPS_EN (21) // STANDBY #define PIN_GPS_RESET (25) // REINIT -#define GPS_RESET_ACTIVE LOW +#define GPS_POWER_ACTIVE HIGH #define GPS_EN_ACTIVE HIGH +#define GPS_RESET (-1) #define GPS_BAUDRATE 9600 //////////////////////////////////////////////////////////////////////////////// // Buzzer #define BUZZER_EN (37) // P1.5 -#define BUZZER_PIN (25) // P0.25 \ No newline at end of file +#define BUZZER_PIN (25) // P0.25 diff --git a/variants/thinknode_m5/ThinknodeM5Board.cpp b/variants/thinknode_m5/ThinknodeM5Board.cpp index c4de538c56..2cb138e639 100644 --- a/variants/thinknode_m5/ThinknodeM5Board.cpp +++ b/variants/thinknode_m5/ThinknodeM5Board.cpp @@ -19,14 +19,6 @@ void ThinknodeM5Board::begin() { ESP32Board::begin(); } - void ThinknodeM5Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_deep_sleep_start(); - } - - void ThinknodeM5Board::powerOff() { - enterDeepSleep(0); - } - uint16_t ThinknodeM5Board::getBattMilliVolts() { analogReadResolution(12); analogSetPinAttenuation(PIN_VBAT_READ, ADC_11db); diff --git a/variants/thinknode_m5/ThinknodeM5Board.h b/variants/thinknode_m5/ThinknodeM5Board.h index 3c120027b7..57ff0d00eb 100644 --- a/variants/thinknode_m5/ThinknodeM5Board.h +++ b/variants/thinknode_m5/ThinknodeM5Board.h @@ -3,7 +3,6 @@ #include <Arduino.h> #include <helpers/RefCountedDigitalPin.h> #include <helpers/ESP32Board.h> -#include <driver/rtc_io.h> #include <PCA9557.h> extern PCA9557 expander; @@ -13,8 +12,6 @@ class ThinknodeM5Board : public ESP32Board { public: void begin(); - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index 8572d1ebe0..5e85b64981 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -37,7 +37,7 @@ build_flags = ${esp32_base.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_RX_BOOSTED_GAIN=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 -D ENV_INCLUDE_GPS=1 -D PERSISTANT_GPS=1 -D ENV_SKIP_GPS_DETECT=1 @@ -173,6 +173,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M5.build_src_filter} +<helpers/esp32/*.cpp> +<helpers/ui/MomentaryButton.cpp> diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 32baa2a0a2..78815e2c9c 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -44,6 +44,6 @@ class ThinkNodeM6Board : public NRF52BoardDCDC { #endif // power off board - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index 6fe9043668..ad7e6902cf 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -48,8 +48,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 - -D GPS_NMEA_DEBUG=1 +; -D MESH_DEBUG=1 +; -D GPS_NMEA_DEBUG=1 build_src_filter = ${ThinkNode_M6.build_src_filter} +<../examples/simple_repeater/*.cpp> lib_deps = @@ -63,6 +63,7 @@ build_flags = -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ThinkNode_M6.build_src_filter} @@ -84,7 +85,6 @@ build_flags = -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 - -D QSPIFLASH=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${ThinkNode_M6.build_src_filter} @@ -106,9 +106,9 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 - -D QSPIFLASH=1 -D OFFLINE_QUEUE_SIZE=256 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 + -D ENABLE_USB_INTERFACE build_src_filter = ${ThinkNode_M6.build_src_filter} +<helpers/ui/buzzer.cpp> +<helpers/ui/MomentaryButton.cpp> diff --git a/variants/thinknode_m6/variant.cpp b/variants/thinknode_m6/variant.cpp index c88f387db6..79930e2d05 100644 --- a/variants/thinknode_m6/variant.cpp +++ b/variants/thinknode_m6/variant.cpp @@ -30,6 +30,8 @@ void initVariant() { digitalWrite(PIN_GPS_STANDBY, HIGH); pinMode(PIN_GPS_EN, OUTPUT); digitalWrite(PIN_GPS_EN, HIGH); - pinMode(PIN_GPS_RESET, OUTPUT); - digitalWrite(PIN_GPS_RESET, HIGH); + // PIN_GPS_RESET (pin 29 / REINIT) is intentionally left floating (input). + // Driving it HIGH holds the L76K silent, so it never streams NMEA and the + // firmware reports "no GPS". Letting it float lets the module run, matching + // the Meshtastic M6 variant. See GPS_RESET (-1) in variant.h. } diff --git a/variants/thinknode_m6/variant.h b/variants/thinknode_m6/variant.h index 70fd65062c..0a4025f3bd 100644 --- a/variants/thinknode_m6/variant.h +++ b/variants/thinknode_m6/variant.h @@ -102,7 +102,12 @@ #define PIN_GPS_RX (2) #define PIN_GPS_TX (3) #define PIN_GPS_EN (6) // EN -#define PIN_GPS_RESET (29) +#define PIN_GPS_RESET (29) // REINIT - must FLOAT; driving it (esp. HIGH) silences the L76K +// The M6's L76K streams NMEA on its own and must not have its REINIT pin driven. +// Tell the location provider there is no reset pin so it never touches pin 29 +// (driving it HIGH holds the module silent). Matches Meshtastic, which leaves +// this pin as an input. See variant.cpp (pin 29 is intentionally not configured). +#define GPS_RESET (-1) #define PIN_GPS_STANDBY (30) // STANDBY #define PIN_GPS_PPS (31) #define GPS_BAUD_RATE 9600 diff --git a/variants/thinknode_m7/ThinkNodeM7Board.cpp b/variants/thinknode_m7/ThinkNodeM7Board.cpp new file mode 100644 index 0000000000..6b1bf86029 --- /dev/null +++ b/variants/thinknode_m7/ThinkNodeM7Board.cpp @@ -0,0 +1,17 @@ +#include "ThinkNodeM7Board.h" + +void ThinkNodeM7Board::begin() { + ESP32Board::begin(); +} + +void ThinkNodeM7Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_deep_sleep_start(); +} + +void ThinkNodeM7Board::powerOff() { + enterDeepSleep(0); +} + +const char* ThinkNodeM7Board::getManufacturerName() const { + return "Elecrow ThinkNode M7"; +} diff --git a/variants/thinknode_m7/ThinkNodeM7Board.h b/variants/thinknode_m7/ThinkNodeM7Board.h new file mode 100644 index 0000000000..f591b5accc --- /dev/null +++ b/variants/thinknode_m7/ThinkNodeM7Board.h @@ -0,0 +1,23 @@ +#pragma once + +#include <Arduino.h> +#include <helpers/RefCountedDigitalPin.h> +#include <helpers/ESP32Board.h> +#include "variant.h" +#include "NullDisplayDriver.h" +#include "MomentaryButton.h" + +class ThinkNodeM7Board : public ESP32Board { + +public: + void begin(); + void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); + void powerOff() override; + const char* getManufacturerName() const override; + void onBeforeTransmit() override { + digitalWrite(P_LORA_TX_LED, LOW); + } + void onAfterTransmit() override { + digitalWrite(P_LORA_TX_LED, HIGH); + } +}; diff --git a/variants/thinknode_m7/pins_arduino.h b/variants/thinknode_m7/pins_arduino.h new file mode 100644 index 0000000000..845a8db1d5 --- /dev/null +++ b/variants/thinknode_m7/pins_arduino.h @@ -0,0 +1,19 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include <stdint.h> + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +// Default SPI is the LR1110 radio bus +static const uint8_t SS = 12; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 9; +static const uint8_t SCK = 11; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini new file mode 100644 index 0000000000..7d9892e5fb --- /dev/null +++ b/variants/thinknode_m7/platformio.ini @@ -0,0 +1,179 @@ +[ThinkNode_M7] +extends = esp32_base +board = thinknode_m7 +board_upload.flash_size = 8MB +board_build.partitions = default_8MB.csv +build_flags = ${esp32_base.build_flags} + -I src/helpers/esp32 + -I variants/thinknode_m7 + -I src/helpers/sensors + -I src/helpers/ui + -D THINKNODE_M7 + -D PIN_USER_BTN_ANA=4 + -D PIN_STATUS_LED=3 ; green + -D LED_STATE_ON=LOW + -D RADIO_CLASS=CustomLR1110 + -D WRAPPER_CLASS=CustomLR1110Wrapper + -D USE_LR1110 + -D LORA_TX_POWER=22 + -D RF_SWITCH_TABLE + -D RX_BOOSTED_GAIN=true + -D P_LORA_BUSY=13 + -D P_LORA_SCLK=11 + -D P_LORA_NSS=12 + -D P_LORA_DIO_1=38 + -D P_LORA_MISO=9 + -D P_LORA_MOSI=10 + -D P_LORA_RESET=39 + -D P_LORA_TX_LED=46 ; blue + -D LR11X0_DIO_AS_RF_SWITCH=true + -D LR11X0_DIO3_TCXO_VOLTAGE=1.8 +build_src_filter = ${esp32_base.build_src_filter} + +<helpers/*.cpp> + +<helpers/sensors/EnvironmentSensorManager.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../variants/thinknode_m7> +lib_deps = ${esp32_base.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + +[ThinkNode_M7_ethernet] +build_flags = + -D ETHERNET_ENABLED + -D ETHERNET_USE_CH390 + -D ETHERNET_CLASS=CH390EthernetInterface + -D ETH_MISO_PIN=14 + -D ETH_MOSI_PIN=48 + -D ETH_SCLK_PIN=47 + -D ETH_CS_PIN=21 + -D ETH_INT_PIN=45 + -D ETHERNET_DEBUG_LOGGING=1 +build_src_filter = + +<helpers/ethernet/*.cpp> + +<helpers/ethernet/ch390/> +lib_deps = + https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d + +[env:ThinkNode_M7_repeater] +extends = ThinkNode_M7 +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<../examples/simple_repeater/*.cpp> +build_flags = + ${ThinkNode_M7.build_flags} + -D ADVERT_NAME='"ThinkNode M7 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${ThinkNode_M7.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M7_room_server] +extends = ThinkNode_M7 +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<../examples/simple_room_server> +build_flags = + ${ThinkNode_M7.build_flags} + -D ADVERT_NAME='"ThinkNode M7 Room Server"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${ThinkNode_M7.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M7_companion_radio_ble] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ethernet.build_flags} + -I examples/companion_radio/ui-orig + -D DISPLAY_CLASS=NullDisplayDriver + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + ; -D BLE_DEBUG_LOGGING=1 + ; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M7.build_src_filter} + ${ThinkNode_M7_ethernet.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/ui/NullDisplayDriver.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = + ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ethernet.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_companion_radio_usb] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + -I examples/companion_radio/ui-orig + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = + ${ThinkNode_M7.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_companion_radio_wifi] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + -I examples/companion_radio/ui-orig + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=NullDisplayDriver + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<helpers/ui/NullDisplayDriver.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = + ${ThinkNode_M7.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_companion_radio_ethernet] +extends = ThinkNode_M7 +build_flags = + ${ThinkNode_M7.build_flags} + ${ThinkNode_M7_ethernet.build_flags} + -I examples/companion_radio/ui-orig + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=NullDisplayDriver + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${ThinkNode_M7.build_src_filter} + ${ThinkNode_M7_ethernet.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-orig/*.cpp> +lib_deps = ${ThinkNode_M7.lib_deps} + ${ThinkNode_M7_ethernet.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:ThinkNode_M7_kiss_modem] +extends = ThinkNode_M7 +build_src_filter = ${ThinkNode_M7.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/thinknode_m7/target.cpp b/variants/thinknode_m7/target.cpp new file mode 100644 index 0000000000..f4b898d8e9 --- /dev/null +++ b/variants/thinknode_m7/target.cpp @@ -0,0 +1,84 @@ +#include <Arduino.h> +#include "target.h" +#include <helpers/sensors/MicroNMEALocationProvider.h> + +ThinkNodeM7Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifdef ENV_INCLUDE_GPS +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else +EnvironmentSensorManager sensors = EnvironmentSensorManager(); +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; +#endif + +#ifdef RF_SWITCH_TABLE +static const uint32_t rfswitch_dios[Module::RFSWITCH_MAX_PINS] = { + RADIOLIB_LR11X0_DIO5, + RADIOLIB_LR11X0_DIO6, + RADIOLIB_NC, + RADIOLIB_NC, + RADIOLIB_NC +}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}}, + {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}}, + {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE, + END_OF_MODE_TABLE, +}; +#endif + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + +#ifdef LR11X0_DIO3_TCXO_VOLTAGE + float tcxo = LR11X0_DIO3_TCXO_VOLTAGE; +#else + float tcxo = 1.6f; +#endif + + spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS); + + int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + radio.setCRC(2); + radio.explicitHeader(); + +#ifdef RF_SWITCH_TABLE + radio.setRfSwitchTable(rfswitch_dios, rfswitch_table); +#endif +#ifdef RX_BOOSTED_GAIN + radio.setRxBoostedGainMode(RX_BOOSTED_GAIN); +#endif + + return true; // success +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + + diff --git a/variants/thinknode_m7/target.h b/variants/thinknode_m7/target.h new file mode 100644 index 0000000000..34213f57ca --- /dev/null +++ b/variants/thinknode_m7/target.h @@ -0,0 +1,29 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <ThinkNodeM7Board.h> +#include <helpers/radiolib/CustomLR1110Wrapper.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/SensorManager.h> +#include <helpers/sensors/EnvironmentSensorManager.h> +#include <helpers/sensors/LocationProvider.h> +#ifdef DISPLAY_CLASS + #include "NullDisplayDriver.h" +#endif + +extern ThinkNodeM7Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern NullDisplayDriver display; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + + + \ No newline at end of file diff --git a/variants/thinknode_m7/variant.cpp b/variants/thinknode_m7/variant.cpp new file mode 100644 index 0000000000..e056ab647a --- /dev/null +++ b/variants/thinknode_m7/variant.cpp @@ -0,0 +1,8 @@ +#include "variant.h" +#include "Arduino.h" + +void initVariant() +{ + pinMode(P_LORA_TX_LED, OUTPUT); + digitalWrite(P_LORA_TX_LED, HIGH); +} \ No newline at end of file diff --git a/variants/thinknode_m7/variant.h b/variants/thinknode_m7/variant.h new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/variants/thinknode_m7/variant.h @@ -0,0 +1 @@ + diff --git a/variants/thinknode_m9/ThinkNodeM9Board.cpp b/variants/thinknode_m9/ThinkNodeM9Board.cpp new file mode 100644 index 0000000000..bf558639dd --- /dev/null +++ b/variants/thinknode_m9/ThinkNodeM9Board.cpp @@ -0,0 +1,23 @@ +#include "ThinkNodeM9Board.h" + +void ThinkNodeM9Board::begin() { + + // power on screen + pinMode(VEXT_ENABLE, OUTPUT); + digitalWrite(VEXT_ENABLE, VEXT_ON_VALUE); + + ESP32Board::begin(); + +} + +void ThinkNodeM9Board::powerOff() { + enterDeepSleep(0); +} + +uint32_t ThinkNodeM9Board::getIRQGpio() { + return LORA_DIO0; +} + +const char* ThinkNodeM9Board::getManufacturerName() const { + return "Elecrow ThinkNode M9"; +} diff --git a/variants/thinknode_m9/ThinkNodeM9Board.h b/variants/thinknode_m9/ThinkNodeM9Board.h new file mode 100644 index 0000000000..d5f68e0556 --- /dev/null +++ b/variants/thinknode_m9/ThinkNodeM9Board.h @@ -0,0 +1,13 @@ +#pragma once + +#include <Arduino.h> +#include <helpers/ESP32Board.h> + +class ThinkNodeM9Board : public ESP32Board { + +public: + void begin(); + void powerOff() override; + const char* getManufacturerName() const override; + uint32_t getIRQGpio() override; +}; diff --git a/variants/thinknode_m9/pins_arduino.h b/variants/thinknode_m9/pins_arduino.h new file mode 100755 index 0000000000..1886eea031 --- /dev/null +++ b/variants/thinknode_m9/pins_arduino.h @@ -0,0 +1,64 @@ +// Need this file for ESP32-S3 +// No need to modify this file, changes to pins imported from variant.h +// Most is similar to https://github.com/espressif/arduino-esp32/blob/master/variants/esp32s3/pins_arduino.h + +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include <stdint.h> +#include <variant.h> + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// Serial +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// Default SPI will be mapped to Radio +static const uint8_t SS = LORA_CS; +static const uint8_t SCK = SPI_SCK; +static const uint8_t MOSI = SPI_MOSI; +static const uint8_t MISO = SPI_MISO; + +// The default Wire will be mapped to PMU and RTC +static const uint8_t SCL = I2C_SCL; +static const uint8_t SDA = I2C_SDA; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ diff --git a/variants/thinknode_m9/platformio.ini b/variants/thinknode_m9/platformio.ini new file mode 100755 index 0000000000..09b1391d67 --- /dev/null +++ b/variants/thinknode_m9/platformio.ini @@ -0,0 +1,161 @@ +[ThinkNode_M9] +extends = esp32_base +board = thinknode_m9 +board_check = true +board_build.partitions = default_16MB.csv +upload_protocol = esptool +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/thinknode_m9 + -I src/helpers/esp32 + -I src/helpers/sensors + -D THINKNODE_M9 + -D PIN_BUZZER=9 + -D PIN_BOARD_SCL=6 + -D PIN_BOARD_SDA=7 + -D P_LORA_NSS=39 + -D P_LORA_RESET=45 + -D P_LORA_BUSY=41 + -D P_LORA_SCLK=40 + -D P_LORA_MISO=38 + -D P_LORA_MOSI=47 + -D P_LORA_DIO_1=42 + -D USE_LR1110 + -D RF_SWITCH_TABLE + -D RADIO_CLASS=CustomLR1110 + -D WRAPPER_CLASS=CustomLR1110Wrapper + -D LR11X0_DIO_AS_RF_SWITCH=true + -D LR11X0_DIO3_TCXO_VOLTAGE=3.3 + -D LORA_TX_POWER=22 + -D DISPLAY_CLASS=ST7789Display + -D DISPLAY_FLIP_VERTICALLY=1 + -D DISPLAY_SCALE_X=2.5f ; 320 / 128 + -D DISPLAY_SCALE_Y=3.75f ; 240 / 64 + -D ST7789 + -D PIN_TFT_VDD_CTL=-1 + -D PIN_TFT_LEDA_CTL=17 + -D PIN_TFT_RST=14 + -D PIN_GPS_RX=3 + -D PIN_GPS_TX=2 + -D PIN_GPS_EN=11 + -D PIN_GPS_EN_ACTIVE=LOW + -D PIN_GPS_RESET=5 + -D PIN_GPS_RESET_ACTIVE=HIGH + -D GPS_BAUD_RATE=115200 + -D ENV_INCLUDE_GPS=1 + -D PIN_VBAT_READ=13 +build_src_filter = ${esp32_base.build_src_filter} + +<helpers/sensors/EnvironmentSensorManager.cpp> + +<helpers/ui/ST7789Display.cpp> + +<helpers/ui/OLEDDisplay.cpp> + +<helpers/ui/OLEDDisplayFonts.cpp> + +<helpers/*.cpp> + +<../variants/thinknode_m9> +lib_deps = ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + adafruit/Adafruit GFX Library @ ^1.12.1 + +[env:ThinkNode_M9_repeater_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -D ADVERT_NAME='"ThinkNode M9 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + ;-D MESH_PACKET_LOGGING=1 + ;-D MESH_DEBUG=1 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<../examples/simple_repeater/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M9_room_server_] +extends = ThinkNode_M9 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<../examples/simple_room_server> +build_flags = + ${ThinkNode_M9.build_flags} + -D ADVERT_NAME='"ThinkNode M9 Room Server"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = + ${ThinkNode_M9.lib_deps} + ${esp32_ota.lib_deps} + +[env:ThinkNode_M9_companion_radio_ble_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + ; -D BLE_DEBUG_LOGGING=1 + ; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/buzzer.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:ThinkNode_M9_companion_radio_usb_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/buzzer.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:ThinkNode_M9_companion_radio_wifi_] +extends = ThinkNode_M9 +build_flags = + ${ThinkNode_M9.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 + ; -D MESH_PACKET_LOGGING=1 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<helpers/esp32/*.cpp> + +<helpers/ui/buzzer.cpp> + +<helpers/ui/MomentaryButton.cpp> + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${ThinkNode_M9.lib_deps} + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:ThinkNode_M9_kiss_modem] +extends = ThinkNode_M9 +build_src_filter = ${ThinkNode_M9.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/thinknode_m9/target.cpp b/variants/thinknode_m9/target.cpp new file mode 100644 index 0000000000..3cbd7d758a --- /dev/null +++ b/variants/thinknode_m9/target.cpp @@ -0,0 +1,78 @@ +#include <Arduino.h> +#include "target.h" + +ThinkNodeM9Board board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifdef ENV_INCLUDE_GPS +#include <helpers/sensors/MicroNMEALocationProvider.h> +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else +EnvironmentSensorManager sensors = EnvironmentSensorManager(); +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; +#endif + +#ifdef RF_SWITCH_TABLE +static const uint32_t rfswitch_dios[Module::RFSWITCH_MAX_PINS] = { + RADIOLIB_LR11X0_DIO5, + RADIOLIB_LR11X0_DIO6, + RADIOLIB_NC, + RADIOLIB_NC, + RADIOLIB_NC +}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}}, + {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}}, + {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE, +}; +#endif + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + +#ifdef LR11X0_DIO3_TCXO_VOLTAGE + float tcxo = LR11X0_DIO3_TCXO_VOLTAGE; +#else + float tcxo = 1.6f; +#endif + + int status = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR, RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + radio.setCRC(2); + radio.explicitHeader(); + +#ifdef RF_SWITCH_TABLE + radio.setRfSwitchTable(rfswitch_dios, rfswitch_table); +#endif +#ifdef RX_BOOSTED_GAIN + radio.setRxBoostedGainMode(RX_BOOSTED_GAIN); +#endif + + return true; // success +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} \ No newline at end of file diff --git a/variants/thinknode_m9/target.h b/variants/thinknode_m9/target.h new file mode 100644 index 0000000000..f083a9f1b3 --- /dev/null +++ b/variants/thinknode_m9/target.h @@ -0,0 +1,29 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include <RadioLib.h> +#include <helpers/radiolib/RadioLibWrappers.h> +#include <ThinkNodeM9Board.h> +#include <helpers/radiolib/CustomLR1110Wrapper.h> +#include <helpers/AutoDiscoverRTCClock.h> +#include <helpers/SensorManager.h> +#include <helpers/sensors/EnvironmentSensorManager.h> +#include <helpers/sensors/LocationProvider.h> +#ifdef DISPLAY_CLASS + #include <helpers/ui/ST7789Display.h> + #include <helpers/ui/MomentaryButton.h> +#endif + +extern ThinkNodeM9Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + diff --git a/variants/thinknode_m9/variant.cpp b/variants/thinknode_m9/variant.cpp new file mode 100644 index 0000000000..4ac6a78276 --- /dev/null +++ b/variants/thinknode_m9/variant.cpp @@ -0,0 +1,7 @@ +#include "variant.h" +#include <Arduino.h> + + +void initVariant() { + +} diff --git a/variants/thinknode_m9/variant.h b/variants/thinknode_m9/variant.h new file mode 100755 index 0000000000..37f8b07630 --- /dev/null +++ b/variants/thinknode_m9/variant.h @@ -0,0 +1,114 @@ +/*Power*/ +#define VEXT_ENABLE 18 +#define VEXT_ON_VALUE LOW +#define PIN_GPS_EN 11 +#define GPS_EN_ACTIVE LOW + +/*Wire Interface*/ +#define WIRE_INTERFACES_COUNT 2 +// I2C keybuttons +#define I2C_SCL1 21 +#define I2C_SDA1 20 +#define KB_INT 12 +// I2C peripheral` +#define I2C_SCL 6 +#define I2C_SDA 7 + +/*LED*/ +#define PIN_LED 13 +#define KB_LED 46 + +/*BUZZER*/ +#define PIN_BUZZER 9 + +/*CHARGE_CHECK*/ +#define DONE 8 +#define EXT_PWR_DETECT 1 +#define EXT_CHRG_DETECT 1 +#define EXT_CHRG_DETECT_VALUE LOW +/*GPS*/ +#define GPS_L76K +#define GPS_BAUDRATE 9600 +#define PIN_GPS_RESET 5 +#define PIN_GPS_PPS 4 +#define PIN_GPS_STANDBY 10 // An output to wake GPS, low means allow sleep, high means force wake +#define GPS_TX_PIN 3 +#define GPS_RX_PIN 2 +#define GPS_THREAD_INTERVAL 50 + +/*SPI*/ +#define SPI_MOSI 47 +#define SPI_SCK 40 +#define SPI_MISO 38 + +/*SD Card*/ +#define SDCARD_CS 48 +#define SD_SPI_FREQUENCY 80000000U + +/*Screen*/ +// #define USE_ST7789 1 +#define ST7789_CS 16 +#define ST7789_RS 15 +#define ST7789_TE 19 +#define ST7789_SDA SPI_MOSI // MOSI +#define ST7789_SCK SPI_SCK +#define ST7789_RESET 14 +#define ST7789_MISO SPI_MISO +#define ST7789_BUSY -1 +#define ST7789_BL 17 +#define ST7789_SPI_HOST SPI2_HOST +#define SPI_FREQUENCY 80000000 +#define SPI_READ_FREQUENCY 16000000 + +#define USE_TFTDISPLAY 1 +#define TFT_CS ST7789_CS +#define TFT_BL ST7789_BL +#define TFT_HEIGHT 320 +#define TFT_WIDTH 240 +#define TFT_OFFSET_X 0 +#define TFT_OFFSET_Y 0 +#define TFT_OFFSET_ROTATION 0 +#define TFT_PWM_FREQ 44000 +#define TFT_PWM_CHANNEL 7 +#define TFT_INVERT_LIGHT true +#define TFT_BACKLIGHT_ON LOW +#define SCREEN_ROTATE +#define SCREEN_TRANSITION_FRAMERATE 5 +#define BRIGHTNESS_DEFAULT 128 + +#define LGFX_PANEL ST7789 +#define DISPLAY_SIZE 320x240 +#define LGFX_ROTATION 0 +#define LGFX_CFG_HOST SPI2_HOST +#define LGFX_PIN_SCK ST7789_SCK +#define LGFX_PIN_MOSI ST7789_SDA +#define LGFX_PIN_DC ST7789_RS +#define LGFX_PIN_CS ST7789_CS +#define LGFX_PIN_BL ST7789_BL +#define LGFX_SCREEN_WIDTH TFT_WIDTH +#define LGFX_SCREEN_HEIGHT TFT_HEIGHT +#define LGFX_INVERT_LIGHT true +#define LGFX_PWM_FREQ 44000 +#define LGFX_PWM_CHANNEL 7 + +/*Lora radio*/ +#define LORA_SCK SPI_SCK +#define LORA_MISO SPI_MISO +#define LORA_MOSI SPI_MOSI +#define LORA_CS 39 +#define LORA_RESET 45 +#define LORA_DIO0 41 + +#define USE_LR1110 +#define LR1110_IRQ_PIN 42 +#define LR1110_NRESET_PIN LORA_RESET +#define LR1110_BUSY_PIN LORA_DIO0 +#define LR1110_SPI_NSS_PIN LORA_CS +#define LR1110_SPI_SCK_PIN LORA_SCK +#define LR1110_SPI_MOSI_PIN LORA_MOSI +#define LR1110_SPI_MISO_PIN LORA_MISO +#define LR11X0_DIO3_TCXO_VOLTAGE 3.3 +#define LR11X0_DIO_AS_RF_SWITCH + +/*RTC*/ +#define PCF8563_RTC 0x51 diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index 82cb251fdb..13c2ec1cb7 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -44,6 +44,7 @@ build_flags = ${Tiny_Relay.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -D MAX_LORA_TX_POWER=22 + -D ENABLE_USB_INTERFACE build_src_filter = ${Tiny_Relay.build_src_filter} +<../examples/companion_radio/*.cpp> lib_deps = ${Tiny_Relay.lib_deps} diff --git a/variants/waveshare_rp2040_lora/platformio.ini b/variants/waveshare_rp2040_lora/platformio.ini index 7dfe14012d..db1ec6038d 100644 --- a/variants/waveshare_rp2040_lora/platformio.ini +++ b/variants/waveshare_rp2040_lora/platformio.ini @@ -84,6 +84,7 @@ extends = waveshare_rp2040_lora build_flags = ${waveshare_rp2040_lora.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${waveshare_rp2040_lora.build_src_filter} diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 22bdc3c837..82b3781e34 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -46,6 +46,7 @@ build_flags = ${lora_e5.build_flags} -D LORA_TX_POWER=22 -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE build_src_filter = ${lora_e5.build_src_filter} +<../examples/companion_radio/*.cpp> lib_deps = ${lora_e5.lib_deps} diff --git a/variants/wio-e5-mini/NullDisplayDriver.h b/variants/wio-e5-mini/NullDisplayDriver.h deleted file mode 100644 index 2a9670bd24..0000000000 --- a/variants/wio-e5-mini/NullDisplayDriver.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include <helpers/ui/DisplayDriver.h> - -class NullDisplayDriver : public DisplayDriver { -public: - NullDisplayDriver() : DisplayDriver(128, 64) { } - bool begin() { return false; } // not present - - bool isOn() override { return false; } - void turnOn() override { } - void turnOff() override { } - void clear() override { } - void startFrame(Color bkg = DARK) override { } - void setTextSize(int sz) override { } - void setColor(Color c) override { } - void setCursor(int x, int y) override { } - void print(const char* str) override { } - void fillRect(int x, int y, int w, int h) override { } - void drawRect(int x, int y, int w, int h) override { } - void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { } - uint16_t getTextWidth(const char* str) override { return 0; } - void endFrame() { } -}; diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 82f01331bb..021c7db20a 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -44,7 +44,9 @@ build_flags = ${lora_e5_mini.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -D DISPLAY_CLASS=NullDisplayDriver + -D ENABLE_USB_INTERFACE build_src_filter = ${lora_e5_mini.build_src_filter} + +<helpers/ui/NullDisplayDriver.cpp> +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${lora_e5_mini.lib_deps} diff --git a/variants/wio-e5-mini/target.h b/variants/wio-e5-mini/target.h index 4807e0f791..f4d6a08846 100644 --- a/variants/wio-e5-mini/target.h +++ b/variants/wio-e5-mini/target.h @@ -8,7 +8,7 @@ #include <helpers/ArduinoHelpers.h> #include <helpers/SensorManager.h> #ifdef DISPLAY_CLASS - #include "NullDisplayDriver.h" + #include <helpers/ui/NullDisplayDriver.h> #endif #include <BME280I2C.h> diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 052238e66f..e376e0666d 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -35,6 +35,6 @@ class WioTrackerL1Board : public NRF52BoardDCDC { } void powerOff() override { - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 7bb175bb9a..fc958ea2d2 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -68,6 +68,7 @@ build_flags = ${WioTrackerL1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=12 -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} diff --git a/variants/xiao_c3/XiaoC3Board.h b/variants/xiao_c3/XiaoC3Board.h index 6ea1c15ffc..c5700c6884 100644 --- a/variants/xiao_c3/XiaoC3Board.h +++ b/variants/xiao_c3/XiaoC3Board.h @@ -3,7 +3,6 @@ #include <helpers/ESP32Board.h> #include <Arduino.h> -#include <driver/rtc_io.h> #include <driver/uart.h> class XiaoC3Board : public ESP32Board { @@ -40,37 +39,6 @@ class XiaoC3Board : public ESP32Board { #endif } - void enterDeepSleep(uint32_t secs, int8_t wake_pin = -1) { - gpio_set_direction(gpio_num_t(P_LORA_DIO_1), GPIO_MODE_INPUT); - if (wake_pin >= 0) { - gpio_set_direction((gpio_num_t)wake_pin, GPIO_MODE_INPUT); - } - - //hold disable, isolate and power domain config functions may be unnecessary - //gpio_deep_sleep_hold_dis(); - //esp_sleep_config_gpio_isolate(); - gpio_deep_sleep_hold_en(); - -#if defined(LORA_TX_BOOST_PIN) - gpio_hold_en((gpio_num_t) LORA_TX_BOOST_PIN); - gpio_deep_sleep_hold_en(); -#endif - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - if (wake_pin >= 0) { - esp_deep_sleep_enable_gpio_wakeup((1 << P_LORA_DIO_1) | (1 << wake_pin), ESP_GPIO_WAKEUP_GPIO_HIGH); - } else { - esp_deep_sleep_enable_gpio_wakeup(1 << P_LORA_DIO_1, ESP_GPIO_WAKEUP_GPIO_HIGH); - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! - } - #if defined(LORA_TX_BOOST_PIN) || defined(P_LORA_TX_LED) void onBeforeTransmit() override { #if defined(P_LORA_TX_LED) diff --git a/variants/xiao_c3/platformio.ini b/variants/xiao_c3/platformio.ini index c0e8458d0e..587c5c273f 100644 --- a/variants/xiao_c3/platformio.ini +++ b/variants/xiao_c3/platformio.ini @@ -100,6 +100,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 diff --git a/variants/xiao_c6/platformio.ini b/variants/xiao_c6/platformio.ini index 9f504b8ed0..0d8c2a79d3 100644 --- a/variants/xiao_c6/platformio.ini +++ b/variants/xiao_c6/platformio.ini @@ -1,6 +1,7 @@ [Xiao_C6] extends = esp32c6_base board = esp32-c6-devkitm-1 +board_build.flash_mode = dio board_build.partitions = min_spiffs.csv ; get around 4mb flash limit build_flags = ${esp32c6_base.build_flags} diff --git a/variants/xiao_nrf52/XiaoNrf52Board.h b/variants/xiao_nrf52/XiaoNrf52Board.h index 2790dbad90..e71e7f6517 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.h +++ b/variants/xiao_nrf52/XiaoNrf52Board.h @@ -47,10 +47,10 @@ class XiaoNrf52Board : public NRF52BoardDCDC { #ifdef PIN_USER_BTN // configure button press to wake up when in powered off state - nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_USER_BTN]), NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); + nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_USER_BTN]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - sd_power_system_off(); + NRF52Board::powerOff(); } }; diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index a085433688..1076bd8de7 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -53,7 +53,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_DEBUG=1 -D QSPIFLASH=1 build_src_filter = ${Xiao_nrf52.build_src_filter} +<helpers/nrf52/SerialBLEInterface.cpp> @@ -74,6 +74,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 -D QSPIFLASH=1 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Xiao_nrf52.build_src_filter} diff --git a/variants/xiao_nrf52/target.cpp b/variants/xiao_nrf52/target.cpp index ab6fe279ac..def1dbbeaa 100644 --- a/variants/xiao_nrf52/target.cpp +++ b/variants/xiao_nrf52/target.cpp @@ -4,6 +4,7 @@ #ifdef DISPLAY_CLASS DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif XiaoNrf52Board board; diff --git a/variants/xiao_nrf52/target.h b/variants/xiao_nrf52/target.h index bb3d2a81ff..20093904aa 100644 --- a/variants/xiao_nrf52/target.h +++ b/variants/xiao_nrf52/target.h @@ -11,7 +11,9 @@ #ifdef DISPLAY_CLASS #include <helpers/ui/NullDisplayDriver.h> + #include <helpers/ui/MomentaryButton.h> extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; #endif extern XiaoNrf52Board board; diff --git a/variants/xiao_rp2040/platformio.ini b/variants/xiao_rp2040/platformio.ini index ca00e38b57..cbc50a51db 100644 --- a/variants/xiao_rp2040/platformio.ini +++ b/variants/xiao_rp2040/platformio.ini @@ -38,8 +38,8 @@ build_flags = ${Xiao_rp2040.build_flags} -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - -D MESH_PACKET_LOGGING=1 - -D MESH_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 build_src_filter = ${Xiao_rp2040.build_src_filter} +<../examples/simple_repeater> @@ -61,6 +61,7 @@ extends = Xiao_rp2040 build_flags = ${Xiao_rp2040.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Xiao_rp2040.build_src_filter} diff --git a/variants/xiao_s3/platformio.ini b/variants/xiao_s3/platformio.ini index 22464e7d80..1632d11201 100644 --- a/variants/xiao_s3/platformio.ini +++ b/variants/xiao_s3/platformio.ini @@ -123,6 +123,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D BLE_DEBUG_LOGGING=1 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index db8c5a9486..293a13c152 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -136,6 +136,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${Xiao_S3_WIO.build_src_filter}