-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Implement ESP32-S3 hardware UI and SVGs #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,11 @@ | |
| #include "esp_log.h" | ||
| #include "freertos/FreeRTOS.h" | ||
| #include "freertos/task.h" | ||
| #include "driver/gpio.h" | ||
| #include "board_config.h" | ||
|
|
||
| #include <algorithm> | ||
| #include <cmath> | ||
|
|
||
| namespace orbit::embedded { | ||
| namespace { | ||
|
|
@@ -22,6 +27,21 @@ bool UiTft::start(const Config& config, UiTickCallback callback, void* userData) | |
| callback_ = callback; | ||
| userData_ = userData; | ||
|
|
||
| // Configuração dos GPIOs para o Encoder e Botão Bypass | ||
| gpio_config_t io_conf = {}; | ||
| io_conf.intr_type = GPIO_INTR_DISABLE; | ||
| io_conf.mode = GPIO_MODE_INPUT; | ||
| io_conf.pin_bit_mask = (1ULL << board::encoder::kA) | | ||
| (1ULL << board::encoder::kB) | | ||
| (1ULL << board::encoder::kSwitch) | | ||
| (1ULL << board::controls::kBypassButton); | ||
| io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; | ||
| io_conf.pull_up_en = GPIO_PULLUP_ENABLE; | ||
| gpio_config(&io_conf); | ||
|
|
||
| // Incializa o estado do encoder | ||
| lastEncoderA_ = gpio_get_level(static_cast<gpio_num_t>(board::encoder::kA)); | ||
|
|
||
| if (!stoppedSignal_) { | ||
| stoppedSignal_ = xSemaphoreCreateBinary(); | ||
| if (!stoppedSignal_) { | ||
|
|
@@ -72,14 +92,139 @@ void UiTft::taskEntry(void* ctx) { | |
| static_cast<UiTft*>(ctx)->taskLoop(); | ||
| } | ||
|
|
||
| void UiTft::processInputs() { | ||
| uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS; | ||
|
|
||
| // Encoder - Leitura baseada em polling de quadratura | ||
| int a = gpio_get_level(static_cast<gpio_num_t>(board::encoder::kA)); | ||
| int b = gpio_get_level(static_cast<gpio_num_t>(board::encoder::kB)); | ||
|
|
||
| if (a != lastEncoderA_) { | ||
| if (a == 0) { // Na borda de descida de A | ||
| if (b == 1) { | ||
| encoderPos_++; // CW | ||
| } else { | ||
| encoderPos_--; // CCW | ||
| } | ||
| } | ||
| lastEncoderA_ = a; | ||
| } | ||
|
|
||
| // Encoder Switch (Debounced) | ||
| bool switchState = gpio_get_level(static_cast<gpio_num_t>(board::encoder::kSwitch)); | ||
| if (switchState != lastSwitchState_) { | ||
| if (now - switchDebounceTime_ > 50) { // 50ms debounce | ||
| if (switchState == 0) { // Pressionado (pull-up) | ||
| switchPressed_ = true; | ||
| } | ||
| lastSwitchState_ = switchState; | ||
| switchDebounceTime_ = now; | ||
| } | ||
| } | ||
|
|
||
| // Bypass Button (Debounced) | ||
| bool bypassState = gpio_get_level(static_cast<gpio_num_t>(board::controls::kBypassButton)); | ||
| if (bypassState != lastBypassState_) { | ||
| if (now - bypassDebounceTime_ > 50) { // 50ms debounce | ||
| if (bypassState == 0) { // Pressionado (pull-up) | ||
| bypassPressed_ = true; | ||
| } | ||
| lastBypassState_ = bypassState; | ||
| bypassDebounceTime_ = now; | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+95
to
+136
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: The official ESP-IDF example for using the PCNT (Pulse Counter) peripheral with a rotary encoder quadrature signals on ESP32-S3 is located at examples/peripherals/pcnt/rotary_encoder. This example decodes quadrature signals from a common EC11 rotary encoder using PCNT units and channels. Key steps from the example code (rotary_encoder_example_main.c): 1. Create PCNT unit with high/low limits (e.g., 100 / -100): pcnt_unit_config_t unit_config = { .high_limit = EXAMPLE_PCNT_HIGH_LIMIT, .low_limit = EXAMPLE_PCNT_LOW_LIMIT, }; ESP_ERROR_CHECK(pcnt_new_unit(&unit_config, &pcnt_unit)); 2. Set glitch filter (e.g., 1000 ns): pcnt_glitch_filter_config_t filter_config = { .max_glitch_ns = 1000, }; ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(pcnt_unit, &filter_config)); 3. Create two channels for quadrature A/B signals (e.g., GPIO 0 and 2): pcnt_chan_config_t chan_a_config = { .edge_gpio_num = EXAMPLE_EC11_GPIO_A, // GPIO A .level_gpio_num = EXAMPLE_EC11_GPIO_B, // GPIO B as control }; ESP_ERROR_CHECK(pcnt_new_channel(pcnt_unit, &chan_a_config, &pcnt_chan_a)); pcnt_chan_config_t chan_b_config = { .edge_gpio_num = EXAMPLE_EC11_GPIO_B, .level_gpio_num = EXAMPLE_EC11_GPIO_A, }; ESP_ERROR_CHECK(pcnt_new_channel(pcnt_unit, &chan_b_config, &pcnt_chan_b)); 4. Set edge and level actions for 4x quadrature decoding (increase/decrease/hold based on edges and levels): ESP_ERROR_CHECK(pcnt_channel_set_edge_action(pcnt_chan_a, PCNT_CHANNEL_EDGE_ACTION_HOLD, PCNT_CHANNEL_EDGE_ACTION_INCREASE)); ESP_ERROR_CHECK(pcnt_channel_set_edge_action(pcnt_chan_b, PCNT_CHANNEL_EDGE_ACTION_HOLD, PCNT_CHANNEL_EDGE_ACTION_DECREASE)); ESP_ERROR_CHECK(pcnt_channel_set_level_action(pcnt_chan_a, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_INCREASE)); ESP_ERROR_CHECK(pcnt_channel_set_level_action(pcnt_chan_b, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_DECREASE)); 5. Add watch points (e.g., -100, -50, 0, 50, 100) and register event callbacks. 6. Enable, clear, and start the unit: ESP_ERROR_CHECK(pcnt_unit_enable(pcnt_unit)); ESP_ERROR_CHECK(pcnt_unit_clear_count(pcnt_unit)); ESP_ERROR_CHECK(pcnt_unit_start(pcnt_unit)); 7. Read count periodically with pcnt_unit_get_count. This supports ESP32-S3 as confirmed in ESP-IDF docs (v5.0+), where PCNT is used as quadrature decoder. Clone ESP-IDF repo, build/flash the example, adjust GPIOs for your wiring (pull-ups recommended). Full code: https://github.com/espressif/esp-idf/blob/master/examples/peripherals/pcnt/rotary_encoder/main/rotary_encoder_example_main.c To build: idf.py set-target esp32s3 menuconfig flash monitor Citations:
🏁 Script executed: # Find the UiTft class and understand the refresh period
fd -e hpp -e cpp -e h | xargs rg -l "UiTft" | head -5Repository: ovelhaaa/orbit-echo Length of output: 185 🏁 Script executed: # Search for refreshPeriodMs definition
rg "refreshPeriodMs\s*=" --type cpp --type hpp -A 2Repository: ovelhaaa/orbit-echo Length of output: 90 🏁 Script executed: # Find taskLoop or processInputs calls
rg "processInputs|taskLoop" --type cpp --type hpp -B 2 -A 2Repository: ovelhaaa/orbit-echo Length of output: 90 🏁 Script executed: # Check the full processInputs context and task definition
rg "void.*processInputs|class UiTft" --type cpp --type hpp -A 1Repository: ovelhaaa/orbit-echo Length of output: 90 🏁 Script executed: # Search for refreshPeriodMs in the found files
rg "refreshPeriodMs" targets/embedded_esp32s3/main/ -A 2 -B 2Repository: ovelhaaa/orbit-echo Length of output: 1135 🏁 Script executed: # Search for processInputs calls and context
rg "processInputs|taskLoop" targets/embedded_esp32s3/main/ -A 3 -B 1Repository: ovelhaaa/orbit-echo Length of output: 1691 🏁 Script executed: # Check UiTft header for task-related members
cat targets/embedded_esp32s3/main/ui_tft.h | head -100Repository: ovelhaaa/orbit-echo Length of output: 1609 🏁 Script executed: # Check app_main to see how the UI task is scheduled
cat targets/embedded_esp32s3/main/app_main.cpp | head -150Repository: ovelhaaa/orbit-echo Length of output: 5071 Polling the encoder at the UI refresh rate (33 ms) will miss pulses.
Recommended options:
Leaving the current loop in place will make the on-device UX noticeably worse than it appears in unit tests. 🤖 Prompt for AI Agents |
||
|
|
||
| void UiTft::updateState() { | ||
| bool paramsUpdated = false; | ||
|
|
||
| // Trata o botão Bypass | ||
| if (bypassPressed_) { | ||
| isBypassed_ = !isBypassed_; | ||
| bypassPressed_ = false; | ||
|
|
||
| // Em caso de bypass, podemos mudar o readMode ou zerar o mix. | ||
| // Aqui assumimos zerar o mix como forma de bypass. | ||
| if (isBypassed_) { | ||
| preBypassMix_ = currentParams_.mix; | ||
| currentParams_.mix = 0.0f; | ||
| } else { | ||
| currentParams_.mix = preBypassMix_; | ||
| } | ||
| paramsUpdated = true; | ||
| } | ||
|
|
||
| // Trata o botão do Encoder (Muda a página) | ||
| if (switchPressed_) { | ||
| int nextPage = static_cast<int>(currentPage_) + 1; | ||
| if (nextPage >= static_cast<int>(Page::Count)) { | ||
| nextPage = 0; | ||
| } | ||
| currentPage_ = static_cast<Page>(nextPage); | ||
| switchPressed_ = false; | ||
| } | ||
|
|
||
| // Trata a rotação do Encoder (Muda os valores da página atual) | ||
| if (encoderPos_ != 0) { | ||
| if (!isBypassed_) { // Ignora rotações no bypass, ou desabilita bypass? | ||
| switch (currentPage_) { | ||
| case Page::Mix: | ||
| currentParams_.mix = std::clamp(currentParams_.mix + (encoderPos_ * 0.05f), 0.0f, 1.0f); | ||
| break; | ||
| case Page::Feedback: | ||
| currentParams_.feedback = std::clamp(currentParams_.feedback + (encoderPos_ * 0.05f), 0.0f, 1.0f); | ||
| break; | ||
| case Page::Time: | ||
| // 24000.0f is kMaxDelaySamples. It would be better to share it, but for now we clamp against it. | ||
| currentParams_.offsetSamples = std::clamp(currentParams_.offsetSamples + (encoderPos_ * 1000.0f), 0.0f, 24000.0f); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. I've left a comment clarifying that 24000.0f relates to the max delay. In a future iteration, we should lift |
||
| break; | ||
| case Page::Tone: | ||
| currentParams_.toneHz = std::clamp(currentParams_.toneHz + (encoderPos_ * 500.0f), 200.0f, 20000.0f); | ||
| break; | ||
| default: | ||
| break; | ||
| } | ||
| paramsUpdated = true; | ||
| } | ||
| encoderPos_ = 0; | ||
| } | ||
|
|
||
| // Publica se houve mudança real e o ParameterBridge estiver configurado | ||
| if (paramsUpdated && config_.paramBridge) { | ||
| config_.paramBridge->publish(currentParams_); | ||
| } | ||
| } | ||
|
|
||
| void UiTft::drawDisplay() { | ||
| // Stub: Simular o desenho do display | ||
| // Em uma implementação real usaria SPI para enviar os dados da tela | ||
| } | ||
|
|
||
|
|
||
| void UiTft::taskLoop() { | ||
| const TickType_t periodTicks = pdMS_TO_TICKS(config_.refreshPeriodMs); | ||
| TickType_t lastWake = xTaskGetTickCount(); | ||
|
|
||
| // Sincroniza parâmetros iniciais | ||
| if (config_.paramBridge) { | ||
| // Se o paramBridge tivesse método getter, a gente puxava. | ||
| // Vamos inicializar com alguns valores padrão conhecidos, | ||
| // alinhados com o estado default da struct AudioParams | ||
| currentParams_.mix = 0.35f; | ||
| currentParams_.feedback = 0.35f; | ||
| currentParams_.offsetSamples = 1200.0f; | ||
| currentParams_.toneHz = 8000.0f; | ||
| } | ||
|
Comment on lines
+208
to
+217
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Initial
This is the root cause; the header comment on Two reasonable fixes:
Until this is addressed, any UI interaction effectively resets audio-engine state that was intentionally configured at boot. 🛠️ Sketch of option 1- // Sincroniza parâmetros iniciais
- if (config_.paramBridge) {
- // Se o paramBridge tivesse método getter, a gente puxava.
- // Vamos inicializar com alguns valores padrão conhecidos,
- // alinhados com o estado default da struct AudioParams
- currentParams_.mix = 0.35f;
- currentParams_.feedback = 0.35f;
- currentParams_.offsetSamples = 1200.0f;
- currentParams_.toneHz = 8000.0f;
- }
+ // Seed local mirror from the bridge so the first publish doesn't stomp
+ // fields the UI never touches (e.g., readMode, dcBlockEnabled).
+ if (config_.paramBridge) {
+ (void)config_.paramBridge->snapshot(currentParams_); // new helper on ParameterBridge
+ }Add a 🤖 Prompt for AI Agents |
||
|
|
||
| while (running_) { | ||
| processInputs(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Polling the quadrature encoder at the UI refresh rate (approx. 30Hz) is likely too slow to reliably capture transitions, especially during fast rotations. This can lead to missed steps or incorrect direction detection. It is recommended to use GPIO interrupts or a dedicated high-frequency timer (e.g., 1ms) to process encoder inputs independently of the display refresh rate.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jules address
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged. While polling in the UI loop works for this foundational stub, a robust production implementation should indeed use PCNT or an ISR for the rotary encoder to avoid missing rapid steps. |
||
| updateState(); | ||
| drawDisplay(); | ||
|
|
||
| if (callback_) { | ||
| callback_(userData_); | ||
| } | ||
|
|
||
| vTaskDelayUntil(&lastWake, periodTicks); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| #include "freertos/FreeRTOS.h" | ||
| #include "freertos/semphr.h" | ||
| #include "freertos/task.h" | ||
| #include "parameter_bridge.h" | ||
|
|
||
| namespace orbit::embedded { | ||
|
|
||
|
|
@@ -15,6 +16,7 @@ class UiTft { | |
| uint32_t stackWords = 4096; | ||
| uint32_t priority = 3; | ||
| int core = 1; | ||
| ParameterBridge* paramBridge = nullptr; | ||
| }; | ||
|
|
||
| using UiTickCallback = void (*)(void* userData); | ||
|
|
@@ -29,12 +31,42 @@ class UiTft { | |
| static void taskEntry(void* ctx); | ||
| void taskLoop(); | ||
|
|
||
| void processInputs(); | ||
| void updateState(); | ||
| void drawDisplay(); | ||
|
|
||
| Config config_{}; | ||
| UiTickCallback callback_ = nullptr; | ||
| void* userData_ = nullptr; | ||
| TaskHandle_t taskHandle_ = nullptr; | ||
| SemaphoreHandle_t stoppedSignal_ = nullptr; | ||
| bool running_ = false; | ||
|
|
||
| // Encoder State | ||
| int lastEncoderA_ = 1; | ||
| int encoderPos_ = 0; | ||
| bool switchPressed_ = false; | ||
| bool lastSwitchState_ = true; | ||
| uint32_t switchDebounceTime_ = 0; | ||
|
|
||
| // Bypass State | ||
| bool bypassPressed_ = false; | ||
| bool isBypassed_ = false; | ||
| bool lastBypassState_ = true; | ||
| uint32_t bypassDebounceTime_ = 0; | ||
| float preBypassMix_ = 0.35f; | ||
|
|
||
| // UI Parameters | ||
| enum class Page { | ||
| Mix = 0, | ||
| Feedback, | ||
| Time, | ||
| Tone, | ||
| Count | ||
| }; | ||
| Page currentPage_ = Page::Mix; | ||
|
|
||
| AudioParams currentParams_; | ||
|
Comment on lines
+57
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
See the corresponding comment on Same concern for 🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| } // namespace orbit::embedded | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ovelhaaa/orbit-echo
Length of output: 3760
Minor: unchecked
gpio_configreturn and comment typo.gpio_config(&io_conf)returnsesp_err_t; check the result and log failures.🩹 Proposed change
📝 Committable suggestion
🤖 Prompt for AI Agents