Merge pull request #13 from PhosphorosVR/main

Current monitoring / External LED error mirroring / QoL improvements
This commit is contained in:
Lorow
2025-10-18 19:03:43 +02:00
committed by GitHub
42 changed files with 1214 additions and 2825 deletions

105
README.md
View File

@@ -1,5 +1,5 @@
| Supported Targets | ESP32-S3 |
| ----------------- | -------- |
| Supported Targets | ESP32-S3 · Project Babble · FaceFocusVR |
| ----------------- | --------------------------------------- |
## OpenIris-ESPIDF
@@ -12,6 +12,12 @@ Firmware and tools for OpenIris — WiFi, UVC streaming, and a Python setup C
- Python tools for setup over USB serial:
- `tools/switchBoardType.py` — choose a board profile (builds the right sdkconfig)
- `tools/openiris_setup.py` — interactive CLI for WiFi, MDNS/Name, Mode, LED PWM, Logs, and a Settings Summary
- Composite USB (UVC + CDC) when UVC mode is enabled (`GENERAL_INCLUDE_UVC_MODE`) for simultaneous video streaming and command channel
- LED current monitoring (if enabled via `MONITORING_LED_CURRENT`) with filtered mA readings
- Configurable debug LED + external IR LED control with optional error mirroring (`LED_DEBUG_ENABLE`, `LED_EXTERNAL_AS_DEBUG`)
- Autodiscovered perboard configuration overlays under `boards/`
- Command framework (JSON over serial / CDC / REST) for mode switching, WiFi config, OTA credentials, LED brightness, info & monitoring
- Single source advertised name (`CONFIG_GENERAL_ADVERTISED_NAME`) used for both UVC device name and mDNS hostname (unless overridden at runtime)
---
@@ -50,20 +56,29 @@ After this, youre ready for the Quick start below.
## Quick start
### 1) Pick your board (loads the default configuration)
Boards are autodiscovered from the `boards/` directory. First list them, then pick one:
Windows (cmd):
```cmd
python .\tools\switchBoardType.py --board xiao-esp32s3 --diff
python .\tools\switchBoardType.py --list
python .\tools\switchBoardType.py --board seed_studio_xiao_esp32s3 --diff
```
macOS/Linux (bash):
```bash
python3 ./tools/switchBoardType.py --board xiao-esp32s3 --diff
python3 ./tools/switchBoardType.py --list
python3 ./tools/switchBoardType.py --board seed_studio_xiao_esp32s3 --diff
```
- Set `--board` to your target board
- `--diff` shows what changed in the config
Notes:
- Use `--list` to see all detected board keys.
- Board key = relative path under `boards/` with `/` replaced by `_` (and duplicate tail segments collapsed, e.g. `project_babble/project_babble` -> `project_babble`).
- `--diff` shows what will change vs the current `sdkconfig`.
- You can also pass partial or pathlike inputs (e.g. `facefocusvr/eye_L`), the tool normalizes them.
### 2) Build & flash
- Set the target (e.g., ESP32S3).
- Build, flash, and open the serial monitor.
- (Optional) For UVC mode ensure `GENERAL_INCLUDE_UVC_MODE=y`. If you want device to boot directly into UVC: also set `START_IN_UVC_MODE=y`.
- Disable WiFi services for pure wired builds: `GENERAL_ENABLE_WIRELESS=n`.
### 3) Use the Python setup CLI (recommended)
Configure the device over USB serial.
@@ -84,7 +99,7 @@ Examples:
What the CLI can do:
- WiFi menu: automatic (scan → pick → password → connect → wait for IP) or manual (scan, show, configure, connect, status)
- Set MDNS/Device name (also used for the UVC device name)
- Switch mode (WiFi / UVC / Auto)
- Switch mode (WiFi / UVC / Setup)
- Adjust LED PWM
- Show a Settings Summary (MAC, WiFi status, mode, PWM, …)
- View logs
@@ -96,12 +111,23 @@ What the CLI can do:
- The CLI displays the MAC by default (clearer); its the value used as the serial number.
- The UVC device name is based on the MDNS hostname.
## Advertised Name (UVC + mDNS)
`CONFIG_GENERAL_ADVERTISED_NAME` (Kconfig) defines the base name announced over:
- USB UVC descriptor (appears in OS camera list)
- mDNS hostname / service name
Runtime override: If the setup CLI (or a JSON command) provides a new device name, that value supersedes the compile-time default until next flash/reset of settings.
---
## Common workflows
- Fast WiFi setup: in the CLI, go to “WiFi settings” → “Automatic setup”, then check “status”.
- Change name/MDNS: set the device name in the CLI, then replug USB — UVC will show the new name.
- Adjust brightness/LED: set LED PWM in the CLI.
- Switch to UVC mode over commands (CDC/serial):
`{"commands":[{"command":"switch_mode","data":{"mode":"uvc"}}]}` then reboot.
- Read filtered LED current (if enabled):
`{"commands":[{"command":"get_led_current"}]}`
---
@@ -114,10 +140,73 @@ If you want to dig deeper: commands are mapped via the `CommandManager` under `c
---
## Troubleshooting
### USB Composite (UVC + CDC)
When UVC support is compiled in the device enumerates as a composite USB device:
- UVC interface: video streaming (JPEG frames)
- CDC (virtual COM): command channel accepting newlineterminated JSON objects
Example newlineterminated JSON commands over CDC (one per line):
```
{"commands":[{"command":"ping"}]}
{"commands":[{"command":"get_who_am_i"}]}
{"commands":[{"command":"switch_mode","data":{"mode":"wifi"}}]}
```
Chained commands in a single request (processed in order):
```
{"commands":[
{"command":"set_mdns","data":{"hostname":"tracker"}},
{"command":"set_wifi","data":{"name":"main","ssid":"your_network","password":"password","channel":0,"power":0}}
]}
```
Responses are JSON blobs flushed immediately.
---
### Monitoring (LED Current)
Enabled with `MONITORING_LED_CURRENT=y` plus shunt/gain settings. The task samples every `CONFIG_MONITORING_LED_INTERVAL_MS` ms and maintains a filtered moving average over `CONFIG_MONITORING_LED_SAMPLES` samples. Use `get_led_current` command to query.
### Debug & External LED Configuration
| Kconfig | Effect |
|---------|--------|
| LED_DEBUG_ENABLE | Enables/disables discrete status LED GPIO init & drive |
| LED_EXTERNAL_CONTROL | Enables PWM control for IR / external LED |
| LED_EXTERNAL_PWM_DUTY_CYCLE | Default duty % applied at boot (0100) |
| LED_EXTERNAL_AS_DEBUG | Mirrors only error patterns onto external LED (0%/50%) when debug LED absent or also for redundancy |
### Board Profiles
Each file under `boards/` overlays `sdkconfig.base_defaults`. The merge order: base → board file → (optional) dynamic WiFi overrides via `switchBoardType.py` flags. Duplicate trailing segment directories collapse to unique keys.
- UVC doesnt appear on the host?
- Switch mode to UVC via CLI tool, replug USB and wait 20s.
### Adding a new board configuration
1. Create a new config file under `boards/` (you can nest folders): for example `boards/my_family/my_variant`.
2. Populate it with only the `CONFIG_...` lines that differ from the shared defaults. Shared baseline lives in `boards/sdkconfig.base_defaults` and is always merged first.
3. The board key the script accepts will be the relative path with `/` turned into `_` (example: `boards/my_family/my_variant` -> `my_family_my_variant`).
4. Run `python tools/switchBoardType.py --list` to verify its detected, then switch using `-b my_family_my_variant`.
5. If you accidentally create two files that collapse to the same key the last one found wins—rename to keep keys unique.
Tips:
- Use `--diff` after adding a board to sanitycheck only the intended keys change.
- For WiFi overrides on first flash: add none—pass `--ssid` / `--password` when switching if needed.
---
## Troubleshooting
### LED Status / Error Patterns
The firmware uses a small set of LED patterns to indicate status and blocking errors. When `LED_DEBUG_ENABLE` is disabled and `LED_EXTERNAL_AS_DEBUG` is enabled the external IR LED mirrors ONLY error patterns (0%/50% duty). Nonerror patterns are not mirrored.
| State | Visual | Category | Timing Pattern (ms) | Meaning |
|-------|--------|----------|---------------------|---------|
| LedStateNone | ![idle](docs/led_patterns/idle.svg) | idle | (off) | No activity / heartbeat window waiting |
| LedStateStreaming | ![stream](docs/led_patterns/streaming.svg) | active | steady on | Streaming running (UVC or WiFi) |
| LedStateStoppedStreaming | ![stopped](docs/led_patterns/stopped.svg) | inactive | steady off | Streaming intentionally stopped |
| CameraError | ![camera error](docs/led_patterns/camera_error.svg) | error | 300/300 300/700 (loop) | Camera init/runtime failure (check sensor, ribbon, power) |
| WiFiStateConnecting | ![wifi connecting](docs/led_patterns/wifi_connecting.svg) | transitional | 400/400 (loop) | WiFi associating / DHCP pending |
| WiFiStateConnected | ![wifi connected](docs/led_patterns/wifi_connected.svg) | notification | 150/150×3 then 600 off | WiFi connected successfully |
| WiFiStateError | ![wifi error](docs/led_patterns/wifi_error.svg) | error | 200/100 500/300 (loop) | WiFi failed (auth timeout or no AP) |
---
Feedback, issues, and PRs are welcome.

View File

@@ -1,7 +1,4 @@
CONFIG_BLINK_LED_GPIO=y
CONFIG_BLINK_GPIO=21
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
CONFIG_SPIRAM_MODE_OCT=y
# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set
@@ -57,7 +54,18 @@ CONFIG_SPIRAM_SPEED_80M=y
CONFIG_LED_EXTERNAL_CONTROL=y
CONFIG_LED_EXTERNAL_GPIO=9
CONFIG_LED_EXTERNAL_PWM_FREQ=20000
CONFIG_LED_EXTERNAL_PWM_DUTY_CYCLE=100
CONFIG_LED_EXTERNAL_PWM_DUTY_CYCLE=45
CONFIG_CAMERA_USB_XCLK_FREQ=23000000
CONFIG_GENERAL_DEFAULT_WIRED_MODE=y
CONFIG_START_IN_UVC_MODE=y
CONFIG_GENERAL_INCLUDE_UVC_MODE=y
CONFIG_START_IN_UVC_MODE=y
CONFIG_MONITORING_LED_CURRENT=y
CONFIG_MONITORING_LED_ADC_GPIO=3
CONFIG_MONITORING_LED_GAIN=11
CONFIG_MONITORING_LED_SHUNT_MILLIOHM=22000
CONFIG_MONITORING_LED_SAMPLES=10
CONFIG_MONITORING_LED_INTERVAL_MS=500
CONFIG_GENERAL_BOARD="facefocusvr_eye_l"
# CONFIG_GENERAL_ENABLE_WIRELESS is not set
# CONFIG_LED_DEBUG_ENABLE is not set
CONFIG_LED_EXTERNAL_AS_DEBUG=y
CONFIG_GENERAL_ADVERTISED_NAME="FFVR Eye L"

71
boards/facefocusvr/eye_R Normal file
View File

@@ -0,0 +1,71 @@
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
CONFIG_SPIRAM_MODE_OCT=y
# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set
# CONFIG_ESPTOOLPY_FLASHSIZE_2MB is not set
# CONFIG_ESPTOOLPY_FLASHSIZE_4MB is not set
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
# CONFIG_ESPTOOLPY_FLASHSIZE_16MB is not set
# CONFIG_ESPTOOLPY_FLASHSIZE_32MB is not set
# CONFIG_ESPTOOLPY_FLASHSIZE_64MB is not set
# CONFIG_ESPTOOLPY_FLASHSIZE_128MB is not set
CONFIG_ESPTOOLPY_FLASHSIZE="8MB"
# Camera sensor pinout configuration
CONFIG_CAMERA_MODULE_NAME="FaceFocusVR_Face"
CONFIG_PWDN_GPIO_NUM=-1
CONFIG_RESET_GPIO_NUM=-1
CONFIG_XCLK_GPIO_NUM=10
CONFIG_SIOD_GPIO_NUM=40
CONFIG_SIOC_GPIO_NUM=39
CONFIG_Y9_GPIO_NUM=48
CONFIG_Y8_GPIO_NUM=11
CONFIG_Y7_GPIO_NUM=12
CONFIG_Y6_GPIO_NUM=14
CONFIG_Y5_GPIO_NUM=16
CONFIG_Y4_GPIO_NUM=18
CONFIG_Y3_GPIO_NUM=17
CONFIG_Y2_GPIO_NUM=15
CONFIG_VSYNC_GPIO_NUM=38
CONFIG_HREF_GPIO_NUM=47
CONFIG_PCLK_GPIO_NUM=13
# end of Camera sensor pinout configuration
# CONFIG_FLASHMODE_QIO is not set
# CONFIG_FLASHMODE_QOUT is not set
CONFIG_FLASHMODE_DIO=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_TYPE_AUTO=y
# CONFIG_SPIRAM_TYPE_ESPPSRAM16 is not set
# CONFIG_SPIRAM_TYPE_ESPPSRAM32 is not set
# CONFIG_SPIRAM_TYPE_ESPPSRAM64 is not set
CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y
CONFIG_SPIRAM_CLK_IO=30
CONFIG_SPIRAM_CS_IO=26
# CONFIG_SPIRAM_XIP_FROM_PSRAM is not set
# CONFIG_SPIRAM_FETCH_INSTRUCTIONS is not set
# CONFIG_SPIRAM_RODATA is not set
CONFIG_SPIRAM_SPEED_80M=y
# CONFIG_SPIRAM_SPEED_40M is not set
# CONFIG_SPIRAM_XIP_FROM_PSRAM is not set
# CONFIG_SPIRAM_FETCH_INSTRUCTIONS is not set
# CONFIG_SPIRAM_RODATA is not set
# CONFIG_SPIRAM_SPEED_120M is not set
CONFIG_SPIRAM_SPEED=80
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_LED_EXTERNAL_CONTROL=y
CONFIG_LED_EXTERNAL_GPIO=9
CONFIG_LED_EXTERNAL_PWM_FREQ=20000
CONFIG_LED_EXTERNAL_PWM_DUTY_CYCLE=45
CONFIG_CAMERA_USB_XCLK_FREQ=23000000
CONFIG_GENERAL_INCLUDE_UVC_MODE=y
CONFIG_START_IN_UVC_MODE=y
CONFIG_MONITORING_LED_CURRENT=y
CONFIG_MONITORING_LED_ADC_GPIO=3
CONFIG_MONITORING_LED_GAIN=11
CONFIG_MONITORING_LED_SHUNT_MILLIOHM=22000
CONFIG_MONITORING_LED_SAMPLES=10
CONFIG_MONITORING_LED_INTERVAL_MS=500
CONFIG_GENERAL_BOARD="facefocusvr_eye_r"
# CONFIG_GENERAL_ENABLE_WIRELESS is not set
# CONFIG_LED_DEBUG_ENABLE is not set
CONFIG_LED_EXTERNAL_AS_DEBUG=y
CONFIG_GENERAL_ADVERTISED_NAME="FFVR Eye R"

View File

@@ -1,7 +1,4 @@
CONFIG_BLINK_LED_GPIO=y
CONFIG_BLINK_GPIO=21
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
CONFIG_SPIRAM_MODE_OCT=y
# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set
@@ -57,7 +54,18 @@ CONFIG_SPIRAM_SPEED_80M=y
CONFIG_LED_EXTERNAL_CONTROL=y
CONFIG_LED_EXTERNAL_GPIO=9
CONFIG_LED_EXTERNAL_PWM_FREQ=20000
CONFIG_LED_EXTERNAL_PWM_DUTY_CYCLE=50
CONFIG_LED_EXTERNAL_PWM_DUTY_CYCLE=85
CONFIG_CAMERA_USB_XCLK_FREQ=23000000
CONFIG_GENERAL_DEFAULT_WIRED_MODE=y
CONFIG_START_IN_UVC_MODE=y
CONFIG_GENERAL_INCLUDE_UVC_MODE=y
CONFIG_START_IN_UVC_MODE=y
CONFIG_MONITORING_LED_CURRENT=y
CONFIG_MONITORING_LED_ADC_GPIO=3
CONFIG_MONITORING_LED_GAIN=11
CONFIG_MONITORING_LED_SHUNT_MILLIOHM=22000
CONFIG_MONITORING_LED_SAMPLES=10
CONFIG_MONITORING_LED_INTERVAL_MS=500
CONFIG_GENERAL_BOARD="facefocusvr_face"
# CONFIG_GENERAL_ENABLE_WIRELESS is not set
# CONFIG_LED_DEBUG_ENABLE is not set
CONFIG_LED_EXTERNAL_AS_DEBUG=y
CONFIG_GENERAL_ADVERTISED_NAME="FFVR Face"

View File

@@ -1,4 +1,4 @@
CONFIG_BLINK_GPIO=38
CONFIG_LED_DEBUG_GPIO=38
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
# CONFIG_ESPTOOLPY_FLASHSIZE_1MB is not set
@@ -51,5 +51,8 @@ CONFIG_LED_EXTERNAL_PWM_FREQ=5000
CONFIG_LED_EXTERNAL_PWM_DUTY_CYCLE=100
CONFIG_LED_EXTERNAL_GPIO=1
CONFIG_CAMERA_USB_XCLK_FREQ=23000000
CONFIG_GENERAL_DEFAULT_WIRED_MODE=y
# CONFIG_START_IN_UVC_MODE is not set
CONFIG_GENERAL_INCLUDE_UVC_MODE=y
# CONFIG_START_IN_UVC_MODE is not set
# CONFIG_MONITORING_LED_CURRENT is not set
CONFIG_GENERAL_BOARD="project_babble"
CONFIG_GENERAL_ENABLE_WIRELESS=y

View File

@@ -570,9 +570,10 @@ CONFIG_ENV_GPIO_OUT_RANGE_MAX=48
#
# OpenIris: General Configuration
#
# CONFIG_GENERAL_DEFAULT_WIRED_MODE is not set
# CONFIG_GENERAL_INCLUDE_UVC_MODE is not set
# CONFIG_START_IN_UVC_MODE is not set
# CONFIG_GENERAL_UVC_DELAY is not set
# CONFIG_GENERAL_STARTUP_DELAY is not set
CONFIG_GENERAL_VERSION="0.0.1"
# end of OpenIris: General Configuration
#
@@ -595,7 +596,7 @@ CONFIG_WIFI_AP_PASSWORD="12345678"
#
# OpenIris: LED Configuration
#
CONFIG_LED_BLINK_GPIO=8
CONFIG_LED_DEBUG_GPIO=8
CONFIG_LED_EXTERNAL_GPIO=1
CONFIG_LED_EXTERNAL_CONTROL=y
CONFIG_LED_EXTERNAL_PWM_FREQ=5000
@@ -2566,4 +2567,4 @@ CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ABORTS=y
CONFIG_SUPPRESS_SELECT_DEBUG_OUTPUT=y
CONFIG_SUPPORT_TERMIOS=y
CONFIG_SEMIHOSTFS_MAX_MOUNT_POINTS=1
# End of deprecated options
# End of deprecated options

View File

@@ -1,5 +1,4 @@
CONFIG_BLINK_LED_GPIO=y
CONFIG_BLINK_GPIO=21
CONFIG_LED_DEBUG_GPIO=21
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
@@ -56,5 +55,8 @@ CONFIG_SPIRAM_SPEED=80
CONFIG_SPIRAM_SPEED_80M=y
# CONFIG_LED_EXTERNAL_CONTROL is not set
CONFIG_CAMERA_USB_XCLK_FREQ=23000000
CONFIG_GENERAL_DEFAULT_WIRED_MODE=y
# CONFIG_START_IN_UVC_MODE is not set
CONFIG_GENERAL_INCLUDE_UVC_MODE=y
# CONFIG_START_IN_UVC_MODE is not set
# CONFIG_MONITORING_LED_CURRENT is not set
CONFIG_GENERAL_BOARD="xiao_esp32s3"
CONFIG_GENERAL_ENABLE_WIRELESS=y

View File

@@ -1,6 +1,6 @@
// source: https://github.com/espressif/esp-iot-solution/blob/4730d91db70df7e6e0a3191d725ab1c5f98ff9ce/examples/usb/device/usb_webcam/bootloader_components/boot_hooks/boot_hooks.c
#ifdef CONFIG_GENERAL_DEFAULT_WIRED_MODE
#ifdef CONFIG_GENERAL_INCLUDE_UVC_MODE
#include "esp_log.h"
#include "soc/rtc_cntl_struct.h"
#include "soc/usb_serial_jtag_reg.h"

View File

@@ -48,7 +48,7 @@ void CameraManager::setupCameraPinout()
ESP_LOGI(CAMERA_MANAGER_TAG, "CAM_BOARD");
#endif
#if CONFIG_GENERAL_DEFAULT_WIRED_MODE
#if CONFIG_GENERAL_INCLUDE_UVC_MODE
xclk_freq_hz = CONFIG_CAMERA_USB_XCLK_FREQ;
#endif
@@ -79,10 +79,10 @@ void CameraManager::setupCameraPinout()
.pixel_format = PIXFORMAT_JPEG, // YUV422,GRAYSCALE,RGB565,JPEG
.frame_size = FRAMESIZE_240X240, // QQVGA-UXGA, For ESP32, do not use sizes above QVGA when not JPEG. The performance of the ESP32-S series has improved a lot, but JPEG mode always gives better frame rates.
.jpeg_quality = 7, // 0-63, for OV series camera sensors, lower number means higher quality // Below 6 stability problems
.fb_count = 2, // When jpeg mode is used, if fb_count more than one, the driver will work in continuous mode.
.fb_location = CAMERA_FB_IN_DRAM,
.grab_mode = CAMERA_GRAB_LATEST, // CAMERA_GRAB_WHEN_EMPTY
.jpeg_quality = 8, // 0-63, for OV series camera sensors, lower number means higher quality // Below 6 stability problems
.fb_count = 2, // When jpeg mode is used, if fb_count more than one, the driver will work in continuous mode.
.fb_location = CAMERA_FB_IN_DRAM,
.grab_mode = CAMERA_GRAB_WHEN_EMPTY, // was CAMERA_GRAB_LATEST; new mode reduces frame skips at cost of minor latency
};
}
@@ -196,7 +196,7 @@ bool CameraManager::setupCamera()
return false;
}
#if CONFIG_GENERAL_DEFAULT_WIRED_MODE
#if CONFIG_GENERAL_INCLUDE_UVC_MODE
const auto temp_sensor = esp_camera_sensor_get();
// Thanks to lick_it, we discovered that OV5640 likes to overheat when

View File

@@ -11,5 +11,5 @@ idf_component_register(
INCLUDE_DIRS
"CommandManager"
"CommandManager/commands"
REQUIRES ProjectConfig cJSON CameraManager OpenIrisTasks wifiManager Helpers LEDManager
REQUIRES ProjectConfig cJSON CameraManager OpenIrisTasks wifiManager Helpers LEDManager Monitoring
)

View File

@@ -26,6 +26,8 @@ std::unordered_map<std::string, CommandType> commandTypeMap = {
{"set_led_duty_cycle", CommandType::SET_LED_DUTY_CYCLE},
{"get_led_duty_cycle", CommandType::GET_LED_DUTY_CYCLE},
{"get_serial", CommandType::GET_SERIAL},
{"get_led_current", CommandType::GET_LED_CURRENT},
{"get_who_am_i", CommandType::GET_WHO_AM_I},
};
std::function<CommandResult()> CommandManager::createCommand(const CommandType type, std::string_view json) const
@@ -103,6 +105,12 @@ std::function<CommandResult()> CommandManager::createCommand(const CommandType t
case CommandType::GET_SERIAL:
return [this]
{ return getSerialNumberCommand(this->registry); };
case CommandType::GET_LED_CURRENT:
return [this]
{ return getLEDCurrentCommand(this->registry); };
case CommandType::GET_WHO_AM_I:
return [this]
{ return getInfoCommand(this->registry); };
default:
return nullptr;
}

View File

@@ -47,6 +47,8 @@ enum class CommandType
SET_LED_DUTY_CYCLE,
GET_LED_DUTY_CYCLE,
GET_SERIAL,
GET_LED_CURRENT,
GET_WHO_AM_I,
};
class CommandManager

View File

@@ -9,7 +9,8 @@ enum class DependencyType
project_config,
camera_manager,
wifi_manager,
led_manager
led_manager,
monitoring_manager
};
class DependencyRegistry

View File

@@ -1,5 +1,6 @@
#include "device_commands.hpp"
#include "LEDManager.hpp"
#include "MonitoringManager.hpp"
#include "esp_mac.h"
#include <cstdio>
@@ -171,9 +172,9 @@ CommandResult switchModeCommand(std::shared_ptr<DependencyRegistry> registry, st
{
newMode = StreamingMode::WIFI;
}
else if (strcmp(modeStr, "auto") == 0)
else if (strcmp(modeStr, "setup") == 0 || strcmp(modeStr, "auto") == 0)
{
newMode = StreamingMode::AUTO;
newMode = StreamingMode::SETUP;
}
else
{
@@ -203,8 +204,8 @@ CommandResult getDeviceModeCommand(std::shared_ptr<DependencyRegistry> registry)
case StreamingMode::WIFI:
modeStr = "WiFi";
break;
case StreamingMode::AUTO:
modeStr = "Auto";
case StreamingMode::SETUP:
modeStr = "Setup";
break;
}
@@ -231,3 +232,30 @@ CommandResult getSerialNumberCommand(std::shared_ptr<DependencyRegistry> /*regis
auto result = std::format("{{ \"serial\": \"{}\", \"mac\": \"{}\" }}", serial_no_sep, mac_colon);
return CommandResult::getSuccessResult(result);
}
CommandResult getLEDCurrentCommand(std::shared_ptr<DependencyRegistry> registry)
{
#if CONFIG_MONITORING_LED_CURRENT
auto mon = registry->resolve<MonitoringManager>(DependencyType::monitoring_manager);
if (!mon)
{
return CommandResult::getErrorResult("MonitoringManager unavailable");
}
float ma = mon->getCurrentMilliAmps();
auto result = std::format("{{ \"led_current_ma\": {:.3f} }}", static_cast<double>(ma));
return CommandResult::getSuccessResult(result);
#else
return CommandResult::getErrorResult("Monitoring disabled");
#endif
}
CommandResult getInfoCommand(std::shared_ptr<DependencyRegistry> /*registry*/)
{
const char* who = CONFIG_GENERAL_BOARD;
const char* ver = CONFIG_GENERAL_VERSION;
// Ensure non-null strings
if (!who) who = "";
if (!ver) ver = "";
auto result = std::format("{{ \"who_am_i\": \"{}\", \"version\": \"{}\" }}", who, ver);
return CommandResult::getSuccessResult(result);
}

View File

@@ -22,4 +22,10 @@ CommandResult switchModeCommand(std::shared_ptr<DependencyRegistry> registry, st
CommandResult getDeviceModeCommand(std::shared_ptr<DependencyRegistry> registry);
CommandResult getSerialNumberCommand(std::shared_ptr<DependencyRegistry> registry);
CommandResult getSerialNumberCommand(std::shared_ptr<DependencyRegistry> registry);
// Monitoring
CommandResult getLEDCurrentCommand(std::shared_ptr<DependencyRegistry> registry);
// General info
CommandResult getInfoCommand(std::shared_ptr<DependencyRegistry> registry);

View File

@@ -1,11 +1,15 @@
#include "scan_commands.hpp"
#include "sdkconfig.h"
CommandResult scanNetworksCommand(std::shared_ptr<DependencyRegistry> registry)
{
#if !CONFIG_GENERAL_ENABLE_WIRELESS
return CommandResult::getErrorResult("Not supported by current firmware");
#endif
auto wifiManager = registry->resolve<WiFiManager>(DependencyType::wifi_manager);
if (!wifiManager)
{
return CommandResult::getErrorResult("WiFiManager not available");
return CommandResult::getErrorResult("Not supported by current firmware");
}
auto networks = wifiManager->ScanNetworks();

View File

@@ -1,5 +1,6 @@
#include "wifi_commands.hpp"
#include "esp_netif.h"
#include "sdkconfig.h"
std::optional<WifiPayload> parseSetWiFiCommandPayload(std::string_view jsonPayload)
{
@@ -143,6 +144,9 @@ std::optional<UpdateAPWiFiPayload> parseUpdateAPWiFiCommandPayload(const std::st
CommandResult setWiFiCommand(std::shared_ptr<DependencyRegistry> registry, std::string_view jsonPayload)
{
#if !CONFIG_GENERAL_ENABLE_WIRELESS
return CommandResult::getErrorResult("Not supported by current firmware");
#endif
const auto payload = parseSetWiFiCommandPayload(jsonPayload);
if (!payload.has_value())
@@ -164,6 +168,9 @@ CommandResult setWiFiCommand(std::shared_ptr<DependencyRegistry> registry, std::
CommandResult deleteWiFiCommand(std::shared_ptr<DependencyRegistry> registry, std::string_view jsonPayload)
{
#if !CONFIG_GENERAL_ENABLE_WIRELESS
return CommandResult::getErrorResult("Not supported by current firmware");
#endif
const auto payload = parseDeleteWifiCommandPayload(jsonPayload);
if (!payload.has_value())
return CommandResult::getErrorResult("Invalid payload");
@@ -176,6 +183,9 @@ CommandResult deleteWiFiCommand(std::shared_ptr<DependencyRegistry> registry, st
CommandResult updateWiFiCommand(std::shared_ptr<DependencyRegistry> registry, std::string_view jsonPayload)
{
#if !CONFIG_GENERAL_ENABLE_WIRELESS
return CommandResult::getErrorResult("Not supported by current firmware");
#endif
const auto payload = parseUpdateWifiCommandPayload(jsonPayload);
if (!payload.has_value())
{
@@ -207,6 +217,9 @@ CommandResult updateWiFiCommand(std::shared_ptr<DependencyRegistry> registry, st
CommandResult updateAPWiFiCommand(std::shared_ptr<DependencyRegistry> registry, std::string_view jsonPayload)
{
#if !CONFIG_GENERAL_ENABLE_WIRELESS
return CommandResult::getErrorResult("Not supported by current firmware");
#endif
const auto payload = parseUpdateAPWiFiCommandPayload(jsonPayload);
if (!payload.has_value())
@@ -226,7 +239,13 @@ CommandResult updateAPWiFiCommand(std::shared_ptr<DependencyRegistry> registry,
}
CommandResult getWiFiStatusCommand(std::shared_ptr<DependencyRegistry> registry) {
auto wifiManager = registry->resolve<WiFiManager>(DependencyType::wifi_manager);
#if !CONFIG_GENERAL_ENABLE_WIRELESS
return CommandResult::getErrorResult("Not supported by current firmware");
#endif
auto wifiManager = registry->resolve<WiFiManager>(DependencyType::wifi_manager);
if (!wifiManager) {
return CommandResult::getErrorResult("Not supported by current firmware");
}
auto projectConfig = registry->resolve<ProjectConfig>(DependencyType::project_config);
// Get current WiFi state
@@ -287,7 +306,13 @@ CommandResult getWiFiStatusCommand(std::shared_ptr<DependencyRegistry> registry)
}
CommandResult connectWiFiCommand(std::shared_ptr<DependencyRegistry> registry) {
auto wifiManager = registry->resolve<WiFiManager>(DependencyType::wifi_manager);
#if !CONFIG_GENERAL_ENABLE_WIRELESS
return CommandResult::getErrorResult("Not supported by current firmware");
#endif
auto wifiManager = registry->resolve<WiFiManager>(DependencyType::wifi_manager);
if (!wifiManager) {
return CommandResult::getErrorResult("Not supported by current firmware");
}
auto projectConfig = registry->resolve<ProjectConfig>(DependencyType::project_config);
auto networks = projectConfig->getWifiConfigs();

View File

@@ -2,63 +2,23 @@
const char *LED_MANAGER_TAG = "[LED_MANAGER]";
// Pattern design rules:
// - Error states: isError=true, repeat indefinitely, easily distinguishable (avoid overlap).
// - Non-error repeating: show continuous activity (e.g. streaming ON steady, connecting blink).
// - Non-repeating notification (e.g. Connected) gives user a brief confirmation burst then turns off.
// Durations in ms.
ledStateMap_t LEDManager::ledStateMap = {
{
LEDStates_e::LedStateNone,
{
false,
false,
{{LED_OFF, 1000}},
},
},
{
LEDStates_e::LedStateStreaming,
{
false,
true,
{{LED_ON, 1000}},
},
},
{
LEDStates_e::LedStateStoppedStreaming,
{
false,
true,
{{LED_OFF, 1000}},
},
},
{
LEDStates_e::CameraError,
{
true,
true,
{{{LED_ON, 300}, {LED_OFF, 300}, {LED_ON, 300}, {LED_OFF, 300}}},
},
},
{
LEDStates_e::WiFiStateConnecting,
{
false,
true,
{{LED_ON, 400}, {LED_OFF, 400}},
},
},
{
LEDStates_e::WiFiStateConnected,
{
false,
false,
{{LED_ON, 200}, {LED_OFF, 200}, {LED_ON, 200}, {LED_OFF, 200}, {LED_ON, 200}, {LED_OFF, 200}, {LED_ON, 200}, {LED_OFF, 200}, {LED_ON, 200}, {LED_OFF, 200}},
},
},
{
LEDStates_e::WiFiStateError,
{
true,
true,
{{LED_ON, 200}, {LED_OFF, 100}, {LED_ON, 500}, {LED_OFF, 100}, {LED_ON, 200}},
},
},
{ LEDStates_e::LedStateNone, { /*isError*/false, /*repeat*/false, {{LED_OFF, 1000}} } },
{ LEDStates_e::LedStateStreaming, { false, /*repeat steady*/true, {{LED_ON, 1000}} } },
{ LEDStates_e::LedStateStoppedStreaming, { false, true, {{LED_OFF, 1000}} } },
// CameraError: double blink pattern repeating
{ LEDStates_e::CameraError, { true, true, {{ {LED_ON,300}, {LED_OFF,300}, {LED_ON,300}, {LED_OFF,700} }} } },
// WiFiStateConnecting: balanced slow blink 400/400
{ LEDStates_e::WiFiStateConnecting, { false, true, {{ {LED_ON,400}, {LED_OFF,400} }} } },
// WiFiStateConnected: short 3 quick flashes then done (was long noisy burst before)
{ LEDStates_e::WiFiStateConnected, { false, false, {{ {LED_ON,150}, {LED_OFF,150}, {LED_ON,150}, {LED_OFF,150}, {LED_ON,150}, {LED_OFF,600} }} } },
// WiFiStateError: asymmetric attention pattern (fast, pause, long, pause, fast)
{ LEDStates_e::WiFiStateError, { true, true, {{ {LED_ON,200}, {LED_OFF,100}, {LED_ON,500}, {LED_OFF,300} }} } },
};
LEDManager::LEDManager(gpio_num_t pin, gpio_num_t illumninator_led_pin,
@@ -73,10 +33,13 @@ LEDManager::LEDManager(gpio_num_t pin, gpio_num_t illumninator_led_pin,
void LEDManager::setup()
{
ESP_LOGI(LED_MANAGER_TAG, "Setting up status led.");
#ifdef CONFIG_LED_DEBUG_ENABLE
gpio_reset_pin(blink_led_pin);
/* Set the GPIO as a push/pull output */
gpio_set_direction(blink_led_pin, GPIO_MODE_OUTPUT);
this->toggleLED(LED_OFF);
#else
ESP_LOGI(LED_MANAGER_TAG, "Debug LED disabled via Kconfig (LED_DEBUG_ENABLE=n)");
#endif
#ifdef CONFIG_LED_EXTERNAL_CONTROL
ESP_LOGI(LED_MANAGER_TAG, "Setting up illuminator led.");
@@ -168,6 +131,31 @@ void LEDManager::updateState(const LEDStates_e newState)
if (newState == this->currentState)
return;
// Handle external LED mirroring transitions (store/restore duty)
#if defined(CONFIG_LED_EXTERNAL_CONTROL) && defined(CONFIG_LED_EXTERNAL_AS_DEBUG)
bool wasError = ledStateMap[this->currentState].isError;
bool willBeError = ledStateMap[newState].isError;
if (!wasError && willBeError)
{
// store current duty once
if (!hasStoredExternalDuty)
{
storedExternalDuty = ledc_get_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
hasStoredExternalDuty = true;
}
}
else if (wasError && !willBeError)
{
// restore duty
if (hasStoredExternalDuty)
{
ESP_ERROR_CHECK_WITHOUT_ABORT(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, storedExternalDuty));
ESP_ERROR_CHECK_WITHOUT_ABORT(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0));
hasStoredExternalDuty = false;
}
}
#endif
this->currentState = newState;
this->currentPatternIndex = 0;
this->finishedPattern = false;
@@ -175,7 +163,20 @@ void LEDManager::updateState(const LEDStates_e newState)
void LEDManager::toggleLED(const bool state) const
{
#ifdef CONFIG_LED_DEBUG_ENABLE
gpio_set_level(blink_led_pin, state);
#endif
#if defined(CONFIG_LED_EXTERNAL_CONTROL) && defined(CONFIG_LED_EXTERNAL_AS_DEBUG)
// Mirror only for error states
if (ledStateMap.contains(this->currentState) && ledStateMap.at(this->currentState).isError)
{
// For pattern ON use 50%, OFF use 0%
uint32_t duty = (state == LED_ON) ? ((50 * 255) / 100) : 0;
ESP_ERROR_CHECK_WITHOUT_ABORT(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty));
ESP_ERROR_CHECK_WITHOUT_ABORT(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0));
}
#endif
}
void LEDManager::setExternalLEDDutyCycle(uint8_t dutyPercent)

View File

@@ -70,6 +70,11 @@ private:
size_t currentPatternIndex = 0;
size_t timeToDelayFor = 100;
bool finishedPattern = false;
#if defined(CONFIG_LED_EXTERNAL_CONTROL) && defined(CONFIG_LED_EXTERNAL_AS_DEBUG)
bool hasStoredExternalDuty = false;
uint32_t storedExternalDuty = 0; // raw 0-255
#endif
};
void HandleLEDDisplayTask(void *pvParameter);

View File

@@ -0,0 +1,6 @@
idf_component_register(SRCS
"Monitoring/CurrentMonitor.cpp"
"Monitoring/MonitoringManager.cpp"
INCLUDE_DIRS "Monitoring"
REQUIRES driver esp_adc Helpers
)

View File

@@ -0,0 +1,179 @@
#include "CurrentMonitor.hpp"
#include <esp_log.h>
#include <cmath>
#if CONFIG_MONITORING_LED_CURRENT
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#endif
static const char *TAG_CM = "[CurrentMonitor]";
CurrentMonitor::CurrentMonitor()
{
#if CONFIG_MONITORING_LED_CURRENT
samples_.assign(CONFIG_MONITORING_LED_SAMPLES, 0);
#endif
}
void CurrentMonitor::setup()
{
#if CONFIG_MONITORING_LED_CURRENT
init_adc();
#else
ESP_LOGI(TAG_CM, "LED current monitoring disabled");
#endif
}
float CurrentMonitor::getCurrentMilliAmps() const
{
#if CONFIG_MONITORING_LED_CURRENT
const int shunt_milliohm = CONFIG_MONITORING_LED_SHUNT_MILLIOHM; // mΩ
if (shunt_milliohm <= 0)
return 0.0f;
// Physically correct scaling:
// I[mA] = 1000 * Vshunt[mV] / R[mΩ]
return (1000.0f * static_cast<float>(filtered_mv_)) / static_cast<float>(shunt_milliohm);
#else
return 0.0f;
#endif
}
float CurrentMonitor::pollAndGetMilliAmps()
{
sampleOnce();
return getCurrentMilliAmps();
}
void CurrentMonitor::sampleOnce()
{
#if CONFIG_MONITORING_LED_CURRENT
int mv = read_mv_once();
// Divide by analog gain/divider factor to get shunt voltage
if (CONFIG_MONITORING_LED_GAIN > 0)
mv = mv / CONFIG_MONITORING_LED_GAIN;
// Moving average over N samples
if (samples_.empty())
{
samples_.assign(CONFIG_MONITORING_LED_SAMPLES, 0);
sample_sum_ = 0;
sample_idx_ = 0;
sample_count_ = 0;
}
sample_sum_ -= samples_[sample_idx_];
samples_[sample_idx_] = mv;
sample_sum_ += mv;
sample_idx_ = (sample_idx_ + 1) % samples_.size();
if (sample_count_ < samples_.size())
sample_count_++;
filtered_mv_ = sample_sum_ / static_cast<int>(sample_count_ > 0 ? sample_count_ : 1);
#else
(void)0;
#endif
}
#if CONFIG_MONITORING_LED_CURRENT
static adc_oneshot_unit_handle_t s_adc_handle = nullptr;
static adc_cali_handle_t s_cali_handle = nullptr;
static bool s_cali_inited = false;
static adc_channel_t s_channel;
static adc_unit_t s_unit;
void CurrentMonitor::init_adc()
{
// Derive ADC unit/channel from GPIO
int gpio = CONFIG_MONITORING_LED_ADC_GPIO;
esp_err_t err;
adc_oneshot_unit_init_cfg_t unit_cfg = {
.unit_id = ADC_UNIT_1,
};
err = adc_oneshot_new_unit(&unit_cfg, &s_adc_handle);
if (err != ESP_OK)
{
ESP_LOGE(TAG_CM, "adc_oneshot_new_unit failed: %s", esp_err_to_name(err));
return;
}
// Try to map GPIO to ADC channel automatically if helper exists; otherwise guess for ESP32-S3 ADC1
#ifdef ADC1_GPIO1_CHANNEL
(void)0; // placeholder for potential future macros
#endif
// Use IO-to-channel helper where available
#ifdef CONFIG_IDF_TARGET_ESP32S3
// ESP32-S3: ADC1 channels on GPIO1..GPIO10 map to CH0..CH9
if (gpio >= 1 && gpio <= 10)
{
s_unit = ADC_UNIT_1;
s_channel = static_cast<adc_channel_t>(gpio - 1);
}
else
{
ESP_LOGW(TAG_CM, "Configured GPIO %d may not be ADC-capable on ESP32-S3", gpio);
s_unit = ADC_UNIT_1;
s_channel = ADC_CHANNEL_0;
}
#else
// Fallback: assume ADC1 CH0
s_unit = ADC_UNIT_1;
s_channel = ADC_CHANNEL_0;
#endif
adc_oneshot_chan_cfg_t chan_cfg = {
.atten = ADC_ATTEN_DB_11,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
err = adc_oneshot_config_channel(s_adc_handle, s_channel, &chan_cfg);
if (err != ESP_OK)
{
ESP_LOGE(TAG_CM, "adc_oneshot_config_channel failed: %s", esp_err_to_name(err));
}
// Calibration using curve fitting if available
adc_cali_curve_fitting_config_t cal_cfg = {
.unit_id = s_unit,
.atten = chan_cfg.atten,
.bitwidth = chan_cfg.bitwidth,
};
if (adc_cali_create_scheme_curve_fitting(&cal_cfg, &s_cali_handle) == ESP_OK)
{
s_cali_inited = true;
ESP_LOGI(TAG_CM, "ADC calibration initialized (curve fitting)");
}
else
{
s_cali_inited = false;
ESP_LOGW(TAG_CM, "ADC calibration not available; using raw-to-mV approximation");
}
}
int CurrentMonitor::read_mv_once()
{
if (!s_adc_handle)
return 0;
int raw = 0;
if (adc_oneshot_read(s_adc_handle, s_channel, &raw) != ESP_OK)
return 0;
int mv = 0;
if (s_cali_inited)
{
if (adc_cali_raw_to_voltage(s_cali_handle, raw, &mv) != ESP_OK)
mv = 0;
}
else
{
// Very rough fallback for 11dB attenuation
// Typical full-scale ~2450mV at raw max 4095 (12-bit). IDF defaults may vary.
mv = (raw * 2450) / 4095;
}
return mv;
}
#endif // CONFIG_MONITORING_LED_CURRENT

View File

@@ -0,0 +1,49 @@
#ifndef CURRENT_MONITOR_HPP
#define CURRENT_MONITOR_HPP
#pragma once
#include <cstdint>
#include <memory>
#include <vector>
#include "sdkconfig.h"
class CurrentMonitor {
public:
CurrentMonitor();
~CurrentMonitor() = default;
void setup();
void sampleOnce();
// Returns filtered voltage in millivolts at shunt (after dividing by gain)
int getFilteredMillivolts() const { return filtered_mv_; }
// Returns current in milliamps computed as Vshunt[mV] / R[mΩ]
float getCurrentMilliAmps() const;
// convenience: combined sampling and compute; returns mA
float pollAndGetMilliAmps();
// Whether monitoring is enabled by Kconfig
static constexpr bool isEnabled()
{
#ifdef CONFIG_MONITORING_LED_CURRENT
return true;
#else
return false;
#endif
}
private:
#if CONFIG_MONITORING_LED_CURRENT
void init_adc();
int read_mv_once();
int gpio_to_adc_channel(int gpio);
#endif
int filtered_mv_ = 0;
int sample_sum_ = 0;
std::vector<int> samples_;
size_t sample_idx_ = 0;
size_t sample_count_ = 0;
};
#endif

View File

@@ -0,0 +1,58 @@
#include "MonitoringManager.hpp"
#include <esp_log.h>
#include "sdkconfig.h"
static const char* TAG_MM = "[MonitoringManager]";
void MonitoringManager::setup()
{
#if CONFIG_MONITORING_LED_CURRENT
cm_.setup();
ESP_LOGI(TAG_MM, "Monitoring enabled. Interval=%dms, Samples=%d, Gain=%d, R=%dmΩ",
CONFIG_MONITORING_LED_INTERVAL_MS,
CONFIG_MONITORING_LED_SAMPLES,
CONFIG_MONITORING_LED_GAIN,
CONFIG_MONITORING_LED_SHUNT_MILLIOHM);
#else
ESP_LOGI(TAG_MM, "Monitoring disabled by Kconfig");
#endif
}
void MonitoringManager::start()
{
#if CONFIG_MONITORING_LED_CURRENT
if (task_ == nullptr)
{
xTaskCreate(&MonitoringManager::taskEntry, "MonitoringTask", 2048, this, 1, &task_);
}
#endif
}
void MonitoringManager::stop()
{
if (task_)
{
TaskHandle_t toDelete = task_;
task_ = nullptr;
vTaskDelete(toDelete);
}
}
void MonitoringManager::taskEntry(void* arg)
{
static_cast<MonitoringManager*>(arg)->run();
}
void MonitoringManager::run()
{
#if CONFIG_MONITORING_LED_CURRENT
while (true)
{
float ma = cm_.pollAndGetMilliAmps();
last_current_ma_.store(ma);
vTaskDelay(pdMS_TO_TICKS(CONFIG_MONITORING_LED_INTERVAL_MS));
}
#else
vTaskDelete(nullptr);
#endif
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <atomic>
#include "CurrentMonitor.hpp"
class MonitoringManager {
public:
void setup();
void start();
void stop();
// Latest filtered current in mA
float getCurrentMilliAmps() const { return last_current_ma_.load(); }
private:
static void taskEntry(void* arg);
void run();
TaskHandle_t task_{nullptr};
std::atomic<float> last_current_ma_{0.0f};
CurrentMonitor cm_;
};

View File

@@ -23,7 +23,7 @@ struct BaseConfigModel
enum class StreamingMode
{
AUTO,
SETUP,
UVC,
WIFI,
};
@@ -31,18 +31,18 @@ enum class StreamingMode
struct DeviceMode_t : BaseConfigModel
{
StreamingMode mode;
explicit DeviceMode_t(Preferences *pref) : BaseConfigModel(pref), mode(StreamingMode::AUTO) {}
explicit DeviceMode_t(Preferences *pref) : BaseConfigModel(pref), mode(StreamingMode::SETUP) {}
void load()
{
// Default mode can be controlled via sdkconfig:
// - If CONFIG_START_IN_UVC_MODE is enabled, default to UVC
// - Otherwise default to AUTO
// Default mode can be controlled via sdkconfig:
// - If CONFIG_START_IN_UVC_MODE is enabled, default to UVC
// - Otherwise default to SETUP
int default_mode =
#if CONFIG_START_IN_UVC_MODE
static_cast<int>(StreamingMode::UVC);
#else
static_cast<int>(StreamingMode::AUTO);
static_cast<int>(StreamingMode::SETUP);
#endif
int stored_mode = this->pref->getInt("mode", default_mode);
@@ -103,9 +103,13 @@ struct MDNSConfig_t : BaseConfigModel
void load()
{
// by default, this will be openiris
// but we can override it at compile time
std::string default_hostname = CONFIG_WIFI_MDNS_HOSTNAME;
// Default hostname comes from GENERAL_ADVERTISED_NAME (unified advertised name)
std::string default_hostname =
#ifdef CONFIG_GENERAL_ADVERTISED_NAME
CONFIG_GENERAL_ADVERTISED_NAME;
#else
"openiristracker";
#endif
if (default_hostname.empty())
{

View File

@@ -115,7 +115,7 @@ void ProjectConfig::setMDNSConfig(const std::string &hostname)
{
ESP_LOGD(CONFIGURATION_TAG, "Updating MDNS config");
this->config.mdns.hostname.assign(hostname);
this->config.device.save();
this->config.mdns.save();
}
void ProjectConfig::setCameraConfig(const uint8_t vflip,

View File

@@ -6,15 +6,17 @@
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
// LED status categories
// Naming kept stable for existing queues; documented meanings added.
enum class LEDStates_e
{
LedStateNone,
LedStateStreaming,
LedStateStoppedStreaming,
CameraError,
WiFiStateError,
WiFiStateConnecting,
WiFiStateConnected
LedStateNone, // Idle / no indication (LED off)
LedStateStreaming, // Active streaming (UVC or WiFi) steady ON
LedStateStoppedStreaming, // Streaming stopped intentionally steady OFF (could differentiate later)
CameraError, // Camera init / runtime failure double blink pattern
WiFiStateError, // WiFi connection error distinctive blink sequence
WiFiStateConnecting, // WiFi association / DHCP pending slow blink
WiFiStateConnected // WiFi connected (momentary confirmation burst)
};
enum class WiFiState_e

View File

@@ -6,6 +6,10 @@
static const char *UVC_STREAM_TAG = "[UVC DEVICE]";
// Tracks whether a frame has been handed to TinyUSB and not yet returned.
// File scope so both get_cb and return_cb can access it safely.
static bool s_frame_inflight = false;
extern "C"
{
static char serial_number_str[13];
@@ -85,55 +89,57 @@ static void UVCStreamHelpers::camera_stop_cb(void *cb_ctx)
static uvc_fb_t *UVCStreamHelpers::camera_fb_get_cb(void *cb_ctx)
{
auto *mgr = static_cast<UVCStreamManager *>(cb_ctx);
s_fb.cam_fb_p = esp_camera_fb_get();
if (!s_fb.cam_fb_p)
{
return nullptr;
}
// Guard against requesting a new frame while previous is still in flight.
// This was causing intermittent corruption/glitches because the pointer
// to the underlying camera buffer was overwritten before TinyUSB returned it.
//--------------------------------------------------------------------------------------------------------------
// Pace frames to exactly 60 fps (drop extras). Uses fixed-point accumulator
// to achieve an exact average of 60.000 fps without drifting.
static int64_t next_deadline_us = 0;
static int rem_acc = 0; // remainder accumulator for 1e6 % fps distribution
constexpr int target_fps = 60;
constexpr int64_t us_per_sec = 1000000LL;
constexpr int base_interval_us = us_per_sec / target_fps; // 16666
constexpr int rem_us = us_per_sec % target_fps; // 40
// --- Frame pacing BEFORE grabbing a new camera frame ---
static int64_t next_deadline_us = 0; // next permitted capture time
static int rem_acc = 0; // fractional remainder accumulator
static const int target_fps = 60; // desired FPS
static const int64_t us_per_sec = 1000000; // 1e6 microseconds
static const int base_interval_us = us_per_sec / target_fps; // 16666
static const int rem_us = us_per_sec % target_fps; // 40 (distributed)
const int64_t now_us = esp_timer_get_time();
if (next_deadline_us == 0)
{
// First frame: allow immediately and schedule next slot from now
// First allowed capture immediately
next_deadline_us = now_us;
}
if (now_us < next_deadline_us)
// If a frame is still being transmitted or we are too early, just signal no frame
if (s_frame_inflight || now_us < next_deadline_us)
{
return nullptr; // host will poll again
}
// Acquire a fresh frame only when allowed and no frame in flight
camera_fb_t *cam_fb = esp_camera_fb_get();
if (!cam_fb)
{
// Too early for next frame: drop this camera buffer
esp_camera_fb_return(s_fb.cam_fb_p);
s_fb.cam_fb_p = nullptr;
return nullptr;
}
//--------------------------------------------------------------------------------------------------------------
s_fb.uvc_fb.buf = s_fb.cam_fb_p->buf;
s_fb.uvc_fb.len = s_fb.cam_fb_p->len;
s_fb.uvc_fb.width = s_fb.cam_fb_p->width;
s_fb.uvc_fb.height = s_fb.cam_fb_p->height;
s_fb.uvc_fb.format = UVC_FORMAT_JPEG; // we gotta make sure we're ALWAYS using JPEG
s_fb.uvc_fb.timestamp = s_fb.cam_fb_p->timestamp;
s_fb.cam_fb_p = cam_fb;
s_fb.uvc_fb.buf = cam_fb->buf;
s_fb.uvc_fb.len = cam_fb->len;
s_fb.uvc_fb.width = cam_fb->width;
s_fb.uvc_fb.height = cam_fb->height;
s_fb.uvc_fb.format = UVC_FORMAT_JPEG;
s_fb.uvc_fb.timestamp = cam_fb->timestamp;
// Ensure frame fits into configured UVC transfer buffer
// Validate size fits into transfer buffer
if (mgr && s_fb.uvc_fb.len > mgr->getUvcBufferSize())
{
ESP_LOGE(UVC_STREAM_TAG, "Frame size %d exceeds UVC buffer size %u", (int)s_fb.uvc_fb.len, (unsigned)mgr->getUvcBufferSize());
esp_camera_fb_return(s_fb.cam_fb_p);
esp_camera_fb_return(cam_fb);
s_fb.cam_fb_p = nullptr;
return nullptr;
}
//--------------------------------------------------------------------------------------------------------------
// Schedule the next allowed frame time: base interval plus distributed remainder
// Schedule next frame time (distribute remainder for exact longterm 60.000 fps)
rem_acc += rem_us;
int extra_us = 0;
if (rem_acc >= target_fps)
@@ -141,11 +147,10 @@ static uvc_fb_t *UVCStreamHelpers::camera_fb_get_cb(void *cb_ctx)
rem_acc -= target_fps;
extra_us = 1;
}
// Accumulate from the previous deadline to avoid drift; if we are badly late, catch up from now
const int64_t base_next = next_deadline_us + base_interval_us + extra_us;
next_deadline_us = (base_next < now_us) ? now_us : base_next;
//--------------------------------------------------------------------------------------------------------------
const int64_t candidate_next = next_deadline_us + base_interval_us + extra_us;
next_deadline_us = (candidate_next < now_us) ? now_us : candidate_next;
s_frame_inflight = true;
return &s_fb.uvc_fb;
}
@@ -153,13 +158,18 @@ static void UVCStreamHelpers::camera_fb_return_cb(uvc_fb_t *fb, void *cb_ctx)
{
(void)cb_ctx;
assert(fb == &s_fb.uvc_fb);
esp_camera_fb_return(s_fb.cam_fb_p);
if (s_fb.cam_fb_p)
{
esp_camera_fb_return(s_fb.cam_fb_p);
s_fb.cam_fb_p = nullptr;
}
s_frame_inflight = false;
}
esp_err_t UVCStreamManager::setup()
{
#ifndef CONFIG_GENERAL_DEFAULT_WIRED_MODE
#ifndef CONFIG_GENERAL_INCLUDE_UVC_MODE
ESP_LOGE(UVC_STREAM_TAG, "The board does not support UVC, please, setup WiFi connection.");
return ESP_FAIL;
#endif

View File

@@ -70,6 +70,8 @@ uint8_t const *tud_descriptor_device_cb(void)
#define STRID_PRODUCT 2
#define STRID_SERIAL 3
#define STRID_UVC_CAM1 4
// CDC interface string index used by TUD_CDC_DESCRIPTOR below
#define STRID_CDC 6
// Endpoint numbers for CDC
#define EPNUM_CDC_NOTIF 0x81
@@ -124,7 +126,8 @@ static uint8_t const desc_fs_configuration[] = {
// TUD_CONFIG_DESCRIPTOR(config_number, interface_count, string_index,
// total_length, attributes, power_mA)
// attributes: 0 = bus-powered (default). Add TUSB_DESC_CONFIG_ATT_SELF_POWERED or _REMOTE_WAKEUP if needed.
TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0, 500),
// Advertise max bus power consumption: 200 mA
TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0, 200),
TUD_CDC_DESCRIPTOR(ITF_NUM_CDC, 6, EPNUM_CDC_NOTIF, 8, EPNUM_CDC_OUT, EPNUM_CDC_IN, 64),
// IAD for Video Control
#if CFG_TUD_CAM1_VIDEO_STREAMING_BULK
@@ -200,12 +203,15 @@ uint8_t const *tud_descriptor_configuration_cb(uint8_t index)
//--------------------------------------------------------------------+
// Array of pointers to string literals. Indices must match STRID_* above.
// NOTE: Indices must be contiguous up to the highest used index (STRID_CDC = 6)
char const *string_desc_arr[] = {
(const char[]){0x09, 0x04}, // 0: Supported language: English (0x0409)
CONFIG_TUSB_MANUFACTURER, // 1: Manufacturer
CONFIG_TUSB_PRODUCT, // 2: Product
CONFIG_TUSB_PRODUCT, // 2: Product (overridden by advertised name)
CONFIG_TUSB_SERIAL_NUM, // 3: Serial (overridden by get_serial_number())
"UVC CAM1", // 4: UVC Interface name for Cam1 (overridden by get_uvc_device_name())
"UVC CAM1", // 4: UVC Interface name for Cam1 (overridden by get_uvc_device_name())
"CDC", // 5: placeholder (unused)
"CDC Interface", // 6: CDC Interface name (overridden to advertised name)
};
static uint16_t _desc_str[32];
@@ -249,7 +255,8 @@ uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
// Allow dynamic overrides for specific indices
if (index == STRID_SERIAL)
str = get_serial_number();
if (index == STRID_UVC_CAM1)
// Unify all user-visible names (Product, UVC interface, CDC interface) to advertised name
if (index == STRID_UVC_CAM1 || index == STRID_PRODUCT || index == STRID_CDC)
str = get_uvc_device_name();
if (str == NULL)
str = string_desc_arr[index];

View File

@@ -0,0 +1,7 @@
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="30" fill="#ff2d55">
<!-- Pattern: ON300 OFF300 ON300 OFF700 (1600ms) -->
<animate attributeName="fill-opacity" dur="1.6s" repeatCount="indefinite" calcMode="discrete"
values="1;0;1;0;0" keyTimes="0;0.1875;0.375;0.5625;1" />
</circle>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,3 @@
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="30" fill="#444"/>
</svg>

After

Width:  |  Height:  |  Size: 139 B

View File

@@ -0,0 +1,3 @@
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="30" fill="#222"/>
</svg>

After

Width:  |  Height:  |  Size: 139 B

View File

@@ -0,0 +1,3 @@
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="30" fill="#ffd60a"/>
</svg>

After

Width:  |  Height:  |  Size: 142 B

View File

@@ -0,0 +1,7 @@
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="30" fill="#34c759">
<!-- Pattern: (ON150 OFF150)x3 then OFF600 (total 1350ms) -->
<animate attributeName="fill-opacity" dur="1.35s" repeatCount="indefinite" calcMode="discrete"
values="1;0;1;0;1;0" keyTimes="0;0.1111;0.2222;0.3333;0.4444;1" />
</circle>
</svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1,5 @@
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle id="dot" cx="50" cy="50" r="30" fill="#007aff">
<animate attributeName="fill-opacity" values="1;0;1" dur="0.8s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@@ -0,0 +1,7 @@
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="30" fill="#ff9500">
<!-- Pattern: ON200 OFF100 ON500 OFF300 (1100ms) -->
<animate attributeName="fill-opacity" dur="1.1s" repeatCount="indefinite" calcMode="discrete"
values="1;0;1;0;0" keyTimes="0;0.1818;0.2727;0.7273;1" />
</circle>
</svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@@ -7,28 +7,64 @@ endmenu
menu "OpenIris: General Configuration"
config START_IN_UVC_MODE
bool "Start in UVC Mode"
bool "Default initial streaming mode = UVC"
default false
help
Enables UVC (wired) support in the firmware by default.
To be used when a board is designed to be used primarily with wired headsets.
When enabled, the default device streaming mode will be UVC unless overridden by a
saved preference. When disabled, the default mode is AUTO.
Sets the poweron default streaming mode (before any user preference is stored).
If enabled AND UVC support is compiled in (GENERAL_INCLUDE_UVC_MODE), the device
will default to UVC mode on first boot. If disabled it defaults to SETUP mode,
waiting for a user choice or commands. This option does NOT compile UVC support in;
it only changes the initial preference used when no saved mode exists.
config GENERAL_DEFAULT_WIRED_MODE
bool "Wired mode"
config GENERAL_INCLUDE_UVC_MODE
bool "Include UVC (USB Video Class) support"
default false
help
Enables UVC (wired) support in the firmware. When enabled, the
default device streaming mode will be UVC unless overridden by a
saved preference. When disabled, the default mode is AUTO.
Compiles in UVC (USB Video Class) streaming support (camera + CDC bridge).
Disable this on boards that are WiFi only or where USB bandwidth / memory
should be conserved. If disabled any attempt to switch to UVC mode will log
an error and fall back to WiFi (if wireless is enabled). Combine with
START_IN_UVC_MODE only when the hardware supports UVC.
config GENERAL_UVC_DELAY
int "UVC delay (s)"
default 30
config GENERAL_STARTUP_DELAY
int "Setup grace period (s)"
default 20
range 10 10000
help
Delay in seconds before the ESP reports itself as a UVC device.
Number of seconds the device remains in SETUP / heartbeat mode on boot (when the
current streaming mode resolves to SETUP) before automatically launching the
selected streaming backend (UVC or WiFi). During this window host commands can
change mode or other settings. After the timer expires, streaming starts
automatically unless a command was received or startup was paused.
config GENERAL_ENABLE_WIRELESS
bool "Enable wireless (WiFi/Bluetooth)"
default y
help
When disabled, the firmware will not start WiFi or related services (mDNS/REST),
and any Bluetooth memory (if present on the SoC) should be left released. This can
reduce power consumption when operating solely in UVC mode or without networking.
config GENERAL_BOARD
string "Board / device identifier"
default "OpenIris"
help
A human-readable board or device identifier exposed via the get_info command.
config GENERAL_VERSION
string "Firmware version"
default "0.0.1"
help
A firmware version string exposed via the get_info command.
config GENERAL_ADVERTISED_NAME
string "Advertised device name (UVC + mDNS)"
default "openiristracker"
help
Human-readable device name advertised uniformly across interfaces.
Used as the default mDNS hostname and (indirectly) the UVC USB
device name via get_uvc_device_name(). Users can still override
the runtime hostname through preferences.
endmenu
@@ -51,10 +87,7 @@ menu "OpenIris: Camera Configuration"
endmenu
menu "OpenIris: WiFi Configuration"
config WIFI_MDNS_HOSTNAME
string "mDNS hostname"
default "openiristracker"
# mDNS hostname now derives from GENERAL_ADVERTISED_NAME (no separate Kconfig)
config WIFI_SSID
string "WiFi network name (SSID)"
@@ -76,12 +109,20 @@ endmenu
menu "OpenIris: LED Configuration"
config LED_BLINK_GPIO
int "Blink GPIO number"
config LED_DEBUG_ENABLE
bool "Enable debug/status LED"
default y
help
When disabled the firmware will not drive the dedicated debug/status GPIO.
Useful on boards without a discrete status LED. Error/state patterns can
optionally be mirrored onto the external IR LED if LED_EXTERNAL_AS_DEBUG is set.
config LED_DEBUG_GPIO
int "Debug LED GPIO number"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 8
help
GPIO number (IOxx) used to blink an onboard LED.
GPIO number (IOxx) used to drive an onboard debug/status LED.
Some GPIOs are reserved for other functions (e.g. flash) and cannot be used.
config LED_EXTERNAL_GPIO
@@ -97,6 +138,17 @@ menu "OpenIris: LED Configuration"
help
Enable this if your board can control external IR LEDs.
config LED_EXTERNAL_AS_DEBUG
bool "Mirror error pattern on external LED"
depends on LED_EXTERNAL_CONTROL
default n
help
When enabled and an error LED pattern is active, the external IR LED PWM output
will blink (0% / 50% duty) to replicate the debug/status LED pattern. If
LED_DEBUG_ENABLE is disabled this provides visual error feedback using only
the external LED. Normal configured PWM brightness is restored when leaving
the error pattern.
config LED_EXTERNAL_PWM_FREQ
int "External LED PWM frequency (Hz)"
default 5000
@@ -114,4 +166,54 @@ menu "OpenIris: LED Configuration"
Duty cycle of the PWM signal for external IR LEDs, in percent.
0 means always off, 100 means always on.
endmenu
menu "OpenIris: Monitoring"
config MONITORING_LED_CURRENT
bool "Enable LED current monitoring"
default y
help
Enable sampling LED current via ADC and report it over commands.
config MONITORING_LED_ADC_GPIO
int "ADC GPIO for LED current sense"
depends on MONITORING_LED_CURRENT
range 0 48
default 3
help
GPIO connected to the current sense input (ADC1 on ESP32-S3: 1..10 supported).
config MONITORING_LED_GAIN
int "Analog front-end gain/divider"
depends on MONITORING_LED_CURRENT
range 1 1024
default 11
help
Divider or amplifier gain between shunt and ADC. The measured mV are divided by this value.
config MONITORING_LED_SHUNT_MILLIOHM
int "Shunt resistance (milli-ohms)"
depends on MONITORING_LED_CURRENT
range 1 1000000
default 22000
help
Shunt resistor value in milli-ohms. Current[mA] = 1000 * Vshunt[mV] / R[mΩ].
config MONITORING_LED_SAMPLES
int "Filter window size (samples)"
depends on MONITORING_LED_CURRENT
range 1 200
default 10
help
Moving-average window length for voltage filtering.
config MONITORING_LED_INTERVAL_MS
int "Sampling interval (ms)"
depends on MONITORING_LED_CURRENT
range 10 60000
default 500
help
Period between samples when background monitoring is active.
endmenu

View File

@@ -5,6 +5,7 @@
#include "freertos/queue.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "sdkconfig.h"
#include "nvs_flash.h"
@@ -21,12 +22,18 @@
#include <SerialManager.hpp>
#include <RestAPI.hpp>
#include <main_globals.hpp>
#include <MonitoringManager.hpp>
#ifdef CONFIG_GENERAL_DEFAULT_WIRED_MODE
#ifdef CONFIG_GENERAL_INCLUDE_UVC_MODE
#include <UVCStream.hpp>
#endif
#define BLINK_GPIO (gpio_num_t) CONFIG_LED_BLINK_GPIO
#ifdef CONFIG_LED_DEBUG_ENABLE
#define BLINK_GPIO (gpio_num_t) CONFIG_LED_DEBUG_GPIO
#else
// Use an invalid / unused GPIO when debug LED disabled to avoid accidental toggles
#define BLINK_GPIO (gpio_num_t) -1
#endif
#define CONFIG_LED_C_PIN_GPIO (gpio_num_t) CONFIG_LED_EXTERNAL_GPIO
TaskHandle_t serialManagerHandle;
@@ -52,12 +59,13 @@ StreamServer streamServer(80, stateManager);
auto *restAPI = new RestAPI("http://0.0.0.0:81", commandManager);
#ifdef CONFIG_GENERAL_DEFAULT_WIRED_MODE
#ifdef CONFIG_GENERAL_INCLUDE_UVC_MODE
UVCStreamManager uvcStream;
#endif
auto ledManager = std::make_shared<LEDManager>(BLINK_GPIO, CONFIG_LED_C_PIN_GPIO, ledStateQueue, deviceConfig);
auto *serialManager = new SerialManager(commandManager, &timerHandle, deviceConfig);
std::shared_ptr<MonitoringManager> monitoringManager = std::make_shared<MonitoringManager>();
void startWiFiMode(bool shouldCloseSerialManager);
void startWiredMode(bool shouldCloseSerialManager);
@@ -81,14 +89,14 @@ int websocket_logger(const char *format, va_list args)
void launch_streaming()
{
// Note, when switching and later right away activating UVC mode when we were previously in WiFi or Auto mode, the WiFi
// Note, when switching and later right away activating UVC mode when we were previously in WiFi or Setup mode, the WiFi
// utilities will still be running since we've launched them with startAutoMode() -> startWiFiMode()
// we could add detection of this case, but it's probably not worth it since the next start of the device literally won't launch them
// and we're telling folks to just reboot the device anyway
// same case goes for when switching from UVC to WiFi
StreamingMode deviceMode = deviceConfig->getDeviceMode();
// if we've changed the mode from auto to something else, we can clean up serial manager
// if we've changed the mode from setup to something else, we can clean up serial manager
// either the API endpoints or CDC will take care of further configuration
if (deviceMode == StreamingMode::WIFI)
{
@@ -98,10 +106,10 @@ void launch_streaming()
{
startWiredMode(true);
}
else if (deviceMode == StreamingMode::AUTO)
else if (deviceMode == StreamingMode::SETUP)
{
// we're still in auto, the user didn't select anything yet, let's give a bit of time for them to make a choice
ESP_LOGI("[MAIN]", "No mode was selected, staying in AUTO mode. WiFi streaming will be enabled still. \nPlease select another mode if you'd like.");
// we're still in setup, the user didn't select anything yet, let's give a bit of time for them to make a choice
ESP_LOGI("[MAIN]", "No mode was selected, staying in SETUP mode. WiFi streaming will be enabled still. \nPlease select another mode if you'd like.");
}
else
{
@@ -150,7 +158,7 @@ void force_activate_streaming()
void startWiredMode(bool shouldCloseSerialManager)
{
#ifndef CONFIG_GENERAL_DEFAULT_WIRED_MODE
#ifndef CONFIG_GENERAL_INCLUDE_UVC_MODE
ESP_LOGE("[MAIN]", "UVC mode selected but the board likely does not support it.");
ESP_LOGI("[MAIN]", "Falling back to WiFi mode if credentials available");
deviceMode = StreamingMode::WIFI;
@@ -211,7 +219,7 @@ void startWiFiMode(bool shouldCloseSerialManager)
ESP_LOGI("[MAIN]", "We're still connected to serial. Serial manager task will remain running.");
}
}
#ifdef CONFIG_GENERAL_ENABLE_WIRELESS
wifiManager->Begin();
mdnsManager.start();
restAPI->begin();
@@ -223,16 +231,20 @@ void startWiFiMode(bool shouldCloseSerialManager)
restAPI,
1, // it's the rest API, we only serve commands over it so we don't really need a higher priority
nullptr);
#else
ESP_LOGW("[MAIN]", "Wireless is disabled by configuration; skipping WiFi/mDNS/REST startup.");
#endif
}
void startSetupMode()
{
// If we're in an auto mode - Device starts with a 20-second delay before deciding on what to do
// If we're in SETUP mode - Device starts with a 20-second delay before deciding on what to do
// during this time we await any commands
const uint64_t startup_delay_s = CONFIG_GENERAL_STARTUP_DELAY;
ESP_LOGI("[MAIN]", "=====================================");
ESP_LOGI("[MAIN]", "STARTUP: 20-SECOND DELAY MODE ACTIVE");
ESP_LOGI("[MAIN]", "STARTUP: %llu-SECOND DELAY MODE ACTIVE", (unsigned long long)startup_delay_s);
ESP_LOGI("[MAIN]", "=====================================");
ESP_LOGI("[MAIN]", "Device will wait 20 seconds for commands...");
ESP_LOGI("[MAIN]", "Device will wait %llu seconds for commands...", (unsigned long long)startup_delay_s);
// Create a one-shot timer for 20 seconds
const esp_timer_create_args_t startup_timer_args = {
@@ -243,17 +255,21 @@ void startSetupMode()
.skip_unhandled_events = false};
ESP_ERROR_CHECK(esp_timer_create(&startup_timer_args, &timerHandle));
ESP_ERROR_CHECK(esp_timer_start_once(timerHandle, CONFIG_GENERAL_UVC_DELAY * 1000000));
ESP_LOGI("[MAIN]", "Started 20-second startup timer");
ESP_LOGI("[MAIN]", "Send any command within 20 seconds to enter heartbeat mode");
ESP_ERROR_CHECK(esp_timer_start_once(timerHandle, startup_delay_s * 1000000));
ESP_LOGI("[MAIN]", "Started %llu-second startup timer", (unsigned long long)startup_delay_s);
ESP_LOGI("[MAIN]", "Send any command within %llu seconds to enter heartbeat mode", (unsigned long long)startup_delay_s);
}
extern "C" void app_main(void)
{
dependencyRegistry->registerService<ProjectConfig>(DependencyType::project_config, deviceConfig);
dependencyRegistry->registerService<CameraManager>(DependencyType::camera_manager, cameraHandler);
// Register WiFiManager only when wireless is enabled to avoid exposing WiFi commands in no-wireless builds
#ifdef CONFIG_GENERAL_ENABLE_WIRELESS
dependencyRegistry->registerService<WiFiManager>(DependencyType::wifi_manager, wifiManager);
#endif
dependencyRegistry->registerService<LEDManager>(DependencyType::led_manager, ledManager);
dependencyRegistry->registerService<MonitoringManager>(DependencyType::monitoring_manager, monitoringManager);
// add endpoint to check firmware version
// add firmware version somewhere
@@ -266,6 +282,8 @@ extern "C" void app_main(void)
initNVSStorage();
deviceConfig->load();
ledManager->setup();
monitoringManager->setup();
monitoringManager->start();
xTaskCreate(
HandleStateManagerTask,
@@ -316,7 +334,8 @@ extern "C" void app_main(void)
{
// since we're in setup mode, we have to have wireless functionality on,
// so we can do wifi scanning, test connection etc
startWiFiMode(false);
// if wireless is disabled by configuration, we will not start WiFi services here
startWiFiMode(false);
startSetupMode();
}
}

View File

@@ -571,8 +571,12 @@ CONFIG_ENV_GPIO_OUT_RANGE_MAX=48
# OpenIris: General Configuration
#
# CONFIG_START_IN_UVC_MODE is not set
CONFIG_GENERAL_DEFAULT_WIRED_MODE=y
CONFIG_GENERAL_UVC_DELAY=30
CONFIG_GENERAL_INCLUDE_UVC_MODE=y
CONFIG_GENERAL_STARTUP_DELAY=20
CONFIG_GENERAL_ENABLE_WIRELESS=y
CONFIG_GENERAL_BOARD="project_babble"
CONFIG_GENERAL_VERSION="0.0.1"
CONFIG_GENERAL_ADVERTISED_NAME="openiristracker"
# end of OpenIris: General Configuration
#
@@ -585,7 +589,6 @@ CONFIG_CAMERA_WIFI_XCLK_FREQ=16500000
#
# OpenIris: WiFi Configuration
#
CONFIG_WIFI_MDNS_HOSTNAME="openiristracker"
CONFIG_WIFI_SSID=""
CONFIG_WIFI_PASSWORD=""
CONFIG_WIFI_AP_SSID="EyeTrackVR"
@@ -595,13 +598,21 @@ CONFIG_WIFI_AP_PASSWORD="12345678"
#
# OpenIris: LED Configuration
#
CONFIG_LED_BLINK_GPIO=8
CONFIG_LED_DEBUG_ENABLE=y
CONFIG_LED_DEBUG_GPIO=38
CONFIG_LED_EXTERNAL_GPIO=1
CONFIG_LED_EXTERNAL_CONTROL=y
# CONFIG_LED_EXTERNAL_AS_DEBUG is not set
CONFIG_LED_EXTERNAL_PWM_FREQ=5000
CONFIG_LED_EXTERNAL_PWM_DUTY_CYCLE=100
# end of OpenIris: LED Configuration
#
# OpenIris: Monitoring
#
# CONFIG_MONITORING_LED_CURRENT is not set
# end of OpenIris: Monitoring
#
# Camera sensor pinout configuration
#

File diff suppressed because it is too large Load Diff

View File

@@ -378,7 +378,7 @@ class OpenIrisDevice:
return True
def switch_mode(self, mode: str) -> bool:
"""Switch device mode between WiFi, UVC, and Auto"""
"""Switch device mode between WiFi, UVC, and Setup"""
print(f"🔄 Switching device mode to '{mode}'...")
params = {"mode": mode}
@@ -707,11 +707,9 @@ def configure_wifi(device: OpenIrisDevice, args = None):
def configure_mdns(device: OpenIrisDevice, args = None):
current_name = device.get_mdns_name()
print(f"\n📍 Current device name: {current_name} \n")
print("💡 Please enter your preferred device name, your board will be accessible under http://<name>.local/")
print("💡 Please avoid spaces and special characters")
print(" To back out, enter `back`")
print("\n Note, this will also modify the name of the UVC device")
print(f"\n📍 Current advertised name: {current_name} \n")
print("💡 This single name is used for both: mDNS (http://<name>.local/) and USB UVC device descriptor.")
print("💡 Avoid spaces / special chars. Enter 'back' to cancel.")
while True:
name_choice = input("\nDevice name: ").strip()
@@ -906,7 +904,7 @@ def switch_device_mode(device: OpenIrisDevice, args = None):
print("\n🔄 Select new device mode:")
print("1. WiFi - Stream over WiFi connection")
print("2. UVC - Stream as USB webcam")
print("3. Auto - Automatic mode selection")
print("3. Setup - Configuration mode")
mode_choice = input("\nSelect mode (1-3): ").strip()
@@ -915,7 +913,7 @@ def switch_device_mode(device: OpenIrisDevice, args = None):
elif mode_choice == "2":
device.switch_mode("uvc")
elif mode_choice == "3":
device.switch_mode("auto")
device.switch_mode("setup")
else:
print("❌ Invalid mode selection")
@@ -975,11 +973,49 @@ def _probe_serial(device: OpenIrisDevice) -> Dict:
serial, mac = info
return {"serial": serial, "mac": mac}
def _probe_info(device: OpenIrisDevice) -> Dict:
resp = device.send_command("get_who_am_i")
if "error" in resp:
return {"who_am_i": None, "version": None, "error": resp["error"]}
try:
results = resp.get("results", [])
if results:
result_data = json.loads(results[0])
payload = result_data["result"]
if isinstance(payload, str):
payload = json.loads(payload)
return {"who_am_i": payload.get("who_am_i"), "version": payload.get("version")}
except Exception as e:
return {"who_am_i": None, "version": None, "error": str(e)}
return {"who_am_i": None, "version": None}
def _probe_advertised_name(device: OpenIrisDevice) -> Dict:
# Currently the advertised name == mdns hostname
name = device.get_mdns_name()
return {"advertised_name": name}
def _probe_led_pwm(device: OpenIrisDevice) -> Dict:
duty = device.get_led_duty_cycle()
return {"led_external_pwm_duty_cycle": duty}
def _probe_led_current(device: OpenIrisDevice) -> Dict:
# Query device for current in mA via new command
resp = device.send_command("get_led_current")
if "error" in resp:
return {"led_current_ma": None, "error": resp["error"]}
try:
results = resp.get("results", [])
if results:
result_data = json.loads(results[0])
payload = result_data["result"]
if isinstance(payload, str):
payload = json.loads(payload)
return {"led_current_ma": float(payload.get("led_current_ma"))}
except Exception as e:
return {"led_current_ma": None, "error": str(e)}
return {"led_current_ma": None}
def _probe_mode(device: OpenIrisDevice) -> Dict:
mode = device.get_device_mode()
@@ -997,7 +1033,10 @@ def get_settings(device: OpenIrisDevice, args=None):
probes = [
("Identity", _probe_serial),
("AdvertisedName", _probe_advertised_name),
("Info", _probe_info),
("LED", _probe_led_pwm),
("Current", _probe_led_current),
("Mode", _probe_mode),
("WiFi", _probe_wifi_status),
]
@@ -1023,6 +1062,20 @@ def get_settings(device: OpenIrisDevice, args=None):
if not serial and not mac:
print("🔑 Serial/MAC: unavailable")
# Advertised Name
advertised_name_data = summary.get("AdvertisedName", {})
if advertised_name := advertised_name_data.get("advertised_name"):
print(f"📛 Name: {advertised_name}")
# Info
info = summary.get("Info", {})
who = info.get("who_am_i")
ver = info.get("version")
if who:
print(f"🏷️ Device: {who}")
if ver:
print(f"🧭 Version: {ver}")
# LED
led = summary.get("LED", {})
duty = led.get("led_external_pwm_duty_cycle")
@@ -1035,6 +1088,16 @@ def get_settings(device: OpenIrisDevice, args=None):
mode = summary.get("Mode", {}).get("mode")
print(f"🎚️ Mode: {mode if mode else 'unknown'}")
# Current
current_section = summary.get("Current", {})
if (led_current_ma := current_section.get("led_current_ma")) is not None:
print(f"🔌 LED Current: {led_current_ma:.3f} mA")
else:
if (err := current_section.get("error")):
print(f"🔌 LED Current: unavailable ({err})")
else:
print("🔌 LED Current: unavailable")
# WiFi
wifi = summary.get("WiFi", {}).get("wifi_status", {})
if wifi:
@@ -1051,12 +1114,11 @@ def get_settings(device: OpenIrisDevice, args=None):
COMMANDS_MAP = {
"1": wifi_menu,
"2": configure_mdns,
"3": configure_mdns,
"4": start_streaming,
"5": switch_device_mode,
"6": set_led_duty_cycle,
"7": monitor_logs,
"8": get_settings,
"3": start_streaming,
"4": switch_device_mode,
"5": set_led_duty_cycle,
"6": monitor_logs,
"7": get_settings,
}
@@ -1146,15 +1208,14 @@ def main():
while True:
print("\n🔧 Setup Options:")
print(f"{str(1):>2} 📶 WiFi settings")
print(f"{str(2):>2} 🌐 Configure MDNS")
print(f"{str(3):>2} 💻 Configure UVC Name")
print(f"{str(4):>2} 🚀 Start streaming mode")
print(f"{str(5):>2} 🔄 Switch device mode (WiFi/UVC/Auto)")
print(f"{str(6):>2} 💡 Update PWM Duty Cycle")
print(f"{str(7):>2} 📖 Monitor logs")
print(f"{str(8):>2} 🧩 Get settings summary")
print(f"{str(2):>2} 📛 Configure advertised name (mDNS + UVC)")
print(f"{str(3):>2} 🚀 Start streaming mode")
print(f"{str(4):>2} 🔄 Switch device mode (WiFi/UVC/Setup)")
print(f"{str(5):>2} 💡 Update PWM Duty Cycle")
print(f"{str(6):>2} 📖 Monitor logs")
print(f"{str(7):>2} 🧩 Get settings summary")
print("exit 🚪 Exit")
choice = input("\nSelect option (1-8): ").strip()
choice = input("\nSelect option (1-7): ").strip()
if choice == "exit":
break

View File

@@ -1,5 +1,7 @@
import os
import difflib
import argparse
from typing import Dict, Optional, List
HEADER_COLOR = "\033[95m"
OKGREEN = '\033[92m'
@@ -7,38 +9,112 @@ WARNING = '\033[93m'
OKBLUE = '\033[94m'
ENDC = '\033[0m'
sdkconfig_defaults = "sdkconfig.base_defaults"
supported_boards = [
"xiao-esp32s3",
"project_babble",
"facefocusvr_face",
"facefocusvr_eye"
]
parser = argparse.ArgumentParser()
parser.add_argument("-b", "--board", help="Board to switch to", choices=supported_boards)
parser.add_argument("--dry-run", help="Dry run, won't modify files", action="store_true", required=False)
parser.add_argument("--diff", help="Show the difference between base config and selected board", action="store_true", required=False)
parser.add_argument("--ssid", help="Set the SSID for the selected board", required=False, type=str, default="")
parser.add_argument("--password", help="Set the password For the provided network", required=False, type=str, default="")
parser.add_argument("--clear-wifi", help="Should we clear the wifi details", action="store_true", required=False)
args = parser.parse_args()
BOARDS_DIR_NAME = "boards"
SDKCONFIG_DEFAULTS_FILENAME = "sdkconfig.base_defaults"
def get_root_path() -> str:
return os.path.split(os.path.dirname(os.path.realpath(__file__)))[0]
def get_boards_root() -> str:
return os.path.join(get_root_path(), BOARDS_DIR_NAME)
def enumerate_board_configs() -> Dict[str, str]:
"""Walk the boards directory and build a mapping of board names to absolute file paths.
Naming strategy:
- Relative path from boards/ to file with path separators replaced by '_'.
- If the last two path segments are identical (e.g. project_babble/project_babble) collapse to a single segment.
- For facefocusvr eye boards we keep eye_L / eye_R suffix to distinguish configs even though WHO_AM_I is same.
"""
boards_dir = get_boards_root()
mapping: Dict[str, str] = {}
if not os.path.isdir(boards_dir):
return mapping
for root, _dirs, files in os.walk(boards_dir):
for f in files:
if f == SDKCONFIG_DEFAULTS_FILENAME:
continue
rel_path = os.path.relpath(os.path.join(root, f), boards_dir)
parts = rel_path.split(os.sep)
if len(parts) >= 2 and parts[-1] == parts[-2]: # collapse duplicate tail
parts = parts[:-1]
board_key = "_".join(parts)
mapping[board_key] = os.path.join(root, f)
return mapping
BOARD_CONFIGS = enumerate_board_configs()
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser()
p.add_argument("-b", "--board", help="Board name (run with --list to see options). Flexible: accepts path-like or partial if unique.")
p.add_argument("--list", help="List discovered boards and exit", action="store_true")
p.add_argument("--dry-run", help="Dry run, won't modify files", action="store_true")
p.add_argument("--diff", help="Show the difference between base config and selected board", action="store_true")
p.add_argument("--ssid", help="Set the WiFi SSID", type=str, default="")
p.add_argument("--password", help="Set the WiFi password", type=str, default="")
p.add_argument("--clear-wifi", help="Clear WiFi credentials", action="store_true")
return p
def list_boards():
print("Discovered boards:")
width = max((len(k) for k in BOARD_CONFIGS), default=0)
for name, path in sorted(BOARD_CONFIGS.items()):
print(f" {name.ljust(width)} -> {os.path.relpath(path, get_root_path())}")
def _suggest_boards(partial: str) -> List[str]:
if not partial:
return []
partial_low = partial.lower()
contains = [b for b in BOARD_CONFIGS if partial_low in b.lower()]
if contains:
return contains[:10]
# fallback to fuzzy matching using difflib
choices = list(BOARD_CONFIGS.keys())
return difflib.get_close_matches(partial, choices, n=5, cutoff=0.4)
def normalize_board_name(raw: Optional[str]) -> Optional[str]:
if raw is None:
return None
candidate = raw.strip()
if not candidate:
return None
candidate = candidate.replace('\\', '/').rstrip('/')
# strip leading folders like tools/, boards/
parts = [p for p in candidate.split('/') if p not in ('.', '') and p not in ('tools', 'boards')]
if parts:
candidate = parts[-1] if len(parts) == 1 else "_".join(parts)
candidate = candidate.replace('-', '_')
# exact match
if candidate in BOARD_CONFIGS:
return candidate
# try ending match
endings = [b for b in BOARD_CONFIGS if b.endswith(candidate)]
if len(endings) == 1:
return endings[0]
if len(endings) > 1:
print(f"Ambiguous board '{raw}'. Could be: {', '.join(endings)}")
return None
# attempt case-insensitive
lower_map = {b.lower(): b for b in BOARD_CONFIGS}
if candidate.lower() in lower_map:
return lower_map[candidate.lower()]
return None
def get_main_config_path() -> str:
return os.path.join(get_root_path(), "sdkconfig")
def get_board_config_path() -> str:
return os.path.join(get_root_path(), f"sdkconfig.board.{args.board}")
def get_board_config_path(board_key: str) -> str:
return BOARD_CONFIGS[board_key]
def get_base_config_path() -> str:
return os.path.join(get_root_path(), sdkconfig_defaults)
# base defaults moved under boards directory
return os.path.join(get_boards_root(), SDKCONFIG_DEFAULTS_FILENAME)
def parse_config(config_file) -> dict:
@@ -53,15 +129,17 @@ def parse_config(config_file) -> dict:
return config
def handle_wifi_config(_new_config: dict, _main_config: dict) -> dict:
if args.ssid:
_new_config["CONFIG_WIFI_SSID"] = f"\"{args.ssid}\""
_new_config["CONFIG_WIFI_PASSWORD"] = f"\"{args.password}\""
def handle_wifi_config(_new_config: dict, _main_config: dict, _args) -> dict:
if _args.ssid:
_new_config["CONFIG_WIFI_SSID"] = f"\"{_args.ssid}\""
_new_config["CONFIG_WIFI_PASSWORD"] = f"\"{_args.password}\""
else:
_new_config["CONFIG_WIFI_SSID"] = _main_config["CONFIG_WIFI_SSID"]
_new_config["CONFIG_WIFI_PASSWORD"] = _main_config["CONFIG_WIFI_PASSWORD"]
if "CONFIG_WIFI_SSID" in _main_config:
_new_config["CONFIG_WIFI_SSID"] = _main_config["CONFIG_WIFI_SSID"]
if "CONFIG_WIFI_PASSWORD" in _main_config:
_new_config["CONFIG_WIFI_PASSWORD"] = _main_config["CONFIG_WIFI_PASSWORD"]
if args.clear_wifi:
if _args.clear_wifi:
_new_config["CONFIG_WIFI_SSID"] = "\"\""
_new_config["CONFIG_WIFI_PASSWORD"] = "\"\""
return _new_config
@@ -79,51 +157,65 @@ def compute_diff(_parsed_base_config: dict, _parsed_board_config: dict) -> dict:
return _diff
print(f"{OKGREEN}Switching configuration to board:{ENDC} {OKBLUE}{args.board}{ENDC}")
print(f"{OKGREEN}Using defaults from :{ENDC} {get_base_config_path()}", )
print(f"{OKGREEN}Using board config from :{ENDC} {get_board_config_path()}")
def main():
parser = build_arg_parser()
args = parser.parse_args()
main_config = open(get_main_config_path(), "r+")
parsed_main_config = parse_config(main_config)
main_config.close()
if args.list:
list_boards()
return
base_config = open(get_base_config_path(), "r")
board_config = open(get_board_config_path(), "r")
board_input = args.board
if not board_input:
parser.error("--board is required (or use --list)")
normalized = normalize_board_name(board_input)
if not normalized:
print(f"{WARNING}Unknown board '{board_input}'.")
suggestions = _suggest_boards(board_input)
if suggestions:
print("Did you mean: " + ", ".join(suggestions))
print("Use --list to see all boards.")
raise SystemExit(2)
parsed_base_config = parse_config(base_config)
parsed_board_config = parse_config(board_config)
if not os.path.isfile(get_base_config_path()):
raise SystemExit(f"Base defaults file not found: {get_base_config_path()}")
base_config.close()
board_config.close()
print(f"{OKGREEN}Switching configuration to board:{ENDC} {OKBLUE}{normalized}{ENDC}")
print(f"{OKGREEN}Using defaults from :{ENDC} {get_base_config_path()}")
print(f"{OKGREEN}Using board config from :{ENDC} {get_board_config_path(normalized)}")
new_board_config = {**parsed_base_config, **parsed_board_config}
new_board_config = handle_wifi_config(new_board_config, parsed_main_config)
with open(get_main_config_path(), "r+") as main_config:
parsed_main_config = parse_config(main_config)
if args.diff:
print("---"*5, f"{WARNING}DIFF{ENDC}", "---"*5)
diff = compute_diff(parsed_main_config, new_board_config)
if not diff:
print(f"{HEADER_COLOR}[DIFF]{ENDC} Nothing has changed between the base config and {OKBLUE}{args.board}{ENDC} config")
with open(get_base_config_path(), "r") as base_config, open(get_board_config_path(normalized), "r") as board_config:
parsed_base_config = parse_config(base_config)
parsed_board_config = parse_config(board_config)
new_board_config = {**parsed_base_config, **parsed_board_config}
new_board_config = handle_wifi_config(new_board_config, parsed_main_config, args)
if args.diff:
print("---"*5, f"{WARNING}DIFF{ENDC}", "---"*5)
diff = compute_diff(parsed_main_config, new_board_config)
if not diff:
print(f"{HEADER_COLOR}[DIFF]{ENDC} No changes between existing main config and {OKBLUE}{normalized}{ENDC}")
else:
print(f"{HEADER_COLOR}[DIFF]{ENDC} Keys differing (main -> new {OKBLUE}{normalized}{ENDC}):")
for key in sorted(diff):
print(f"{HEADER_COLOR}[DIFF]{ENDC} {key} : {diff[key]}")
print("---"*14)
if not args.dry_run:
print(f"{WARNING}Writing changes to main config file{ENDC}")
with open(get_main_config_path(), "w") as main_config:
for key, value in new_board_config.items():
if value:
main_config.write(f"{key}={value}\n")
else:
main_config.write(f"{key}\n")
else:
print(f"{HEADER_COLOR}[DIFF]{ENDC} The following keys have changed between the base config and {OKBLUE}{args.board}{ENDC} config:")
for key in diff:
print(f"{HEADER_COLOR}[DIFF]{ENDC} {key} : {diff[key]}")
print("---"*14)
print(f"{WARNING}[DRY-RUN]{ENDC} Skipping writing to files")
print(f"{OKGREEN}Done. ESP-IDF is setup to build for:{ENDC} {OKBLUE}{normalized}{ENDC}")
if not args.dry_run:
# the main idea is to always replace the main config with the base config
# and then add the board config on top of that, overriding where necessary.
# This way we can have known working defaults safe from accidental modifications by espidf
# with know working per-board config
# and a still modifiable sdkconfig for espidf
print(f"{WARNING}Writing changes to main config file{ENDC}")
with open(get_main_config_path(), "w") as main_config:
for key, value in new_board_config.items():
if value:
main_config.write(f"{key}={value}\n")
else:
main_config.write(f"{key}\n")
else:
print(f"{WARNING}[DRY-RUN]{ENDC} Skipping writing to files")
print(f"{OKGREEN}Done. ESP-IDF is setup to build for:{ENDC} {OKBLUE}{args.board}{ENDC}")
if __name__ == "__main__": # pragma: no cover
main()