From 1d7af0615efa38f66d9882049269a93a4e7349dc Mon Sep 17 00:00:00 2001 From: Vadim Date: Tue, 27 Oct 2026 17:47:54 +0300 Subject: [PATCH] init --- .gitignore | 5 + .vscode/extensions.json | 11 +++ README.md | 128 ++++++++++++++++++++++++ include/README | 37 +++++++ include/app.h | 35 +++++++ include/config.h | 48 +++++++++ lib/README | 46 +++++++++ platformio.ini | 43 ++++++++ src/control_task.cpp | 97 ++++++++++++++++++ src/display_task.cpp | 211 ++++++++++++++++++++++++++++++++++++++++ src/encoder_task.cpp | 56 +++++++++++ src/main.cpp | 43 ++++++++ src/web_page.h | 81 +++++++++++++++ src/web_task.cpp | 90 +++++++++++++++++ test/README | 11 +++ 15 files changed, 942 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 README.md create mode 100644 include/README create mode 100644 include/app.h create mode 100644 include/config.h create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/control_task.cpp create mode 100644 src/display_task.cpp create mode 100644 src/encoder_task.cpp create mode 100644 src/main.cpp create mode 100644 src/web_page.h create mode 100644 src/web_task.cpp create mode 100644 test/README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a91ca8f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack", + "pioarduino.pioarduino-ide" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..69aa250 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Регулятор громкости на ESP32 + +Регулятор крутит потенциометр громкости сервоприводом. Управлять им можно двумя способами: поворотом энкодера и через веб-интерфейс по Wi‑Fi. Кнопка энкодера включает и выключает питание через реле. Громкость и состояние реле видны на TFT-дисплее. + +Прошивка написана на Arduino + FreeRTOS (PlatformIO, плата `esp32dev`). + +## Подключение + +### Энкодер (например, KY-040) + +| Вывод энкодера | GPIO ESP32 | Примечание | +|---|---|---| +| CLK (A) | **32** | внутренняя подтяжка к 3.3 В | +| DT (B) | **33** | внутренняя подтяжка к 3.3 В | +| SW (кнопка) | **25** | активный уровень LOW, подтяжка к 3.3 В | +| + | 3.3V | | +| GND | GND | | + +### Сервопривод + +| Вывод серво | Подключение | Примечание | +|---|---|---| +| Сигнал (оранжевый/жёлтый) | **GPIO 13** | ШИМ 50 Гц, 500–2400 мкс | +| + (красный) | 5 В от отдельного источника | не от 3.3V ESP32 | +| GND (коричневый) | GND | общий с ESP32 | + +### Реле + +| Вывод модуля реле | Подключение | Примечание | +|---|---|---| +| IN | **GPIO 26** | уровень включения задаётся `RELAY_ACTIVE_LEVEL` | +| VCC | 5V / 3.3V (по модулю) | | +| GND | GND | | + +### TFT-дисплей ILI9341 (240×320, SPI) + +| Вывод дисплея | GPIO ESP32 | +|---|---| +| MOSI (SDI) | **23** | +| MISO (SDO) | **19** | +| SCK | **18** | +| CS | **5** | +| DC (RS) | **21** | +| RESET | **15** | +| LED (подсветка) | **22** | +| T_CS (тачскрин) | **4** | +| VCC | 3.3V | +| GND | GND | + +Сенсор XPT2046 подключается к той же шине SPI: T_DIN к MOSI, T_DO к MISO, T_CLK к SCK, T_CS к GPIO 4. Вывод T_IRQ не нужен. + +### Управление с сенсорного экрана + +- Кнопки **−** и **+** меняют громкость на 1 %. Если держать кнопку нажатой, громкость меняется непрерывно. +- Тап по **полосе громкости** сразу ставит нужное значение, по полосе можно вести пальцем. +- Плашка **POWER** включает и выключает реле. + +**Калибровка.** При первом включении на экране появятся стрелки в углах, нажимайте их по очереди. Калибровка сохраняется в NVS. Чтобы откалибровать заново, держите палец на экране при включении питания. + +### Сводная таблица GPIO + +| GPIO | Назначение | +|---|---| +| 4 | TFT T_CS | +| 5 | TFT CS | +| 13 | Серво, сигнал | +| 15 | TFT RESET | +| 18 | TFT SCK | +| 19 | TFT MISO | +| 21 | TFT DC | +| 22 | TFT подсветка | +| 23 | TFT MOSI | +| 25 | Энкодер, кнопка SW | +| 26 | Реле IN | +| 32 | Энкодер CLK | +| 33 | Энкодер DT | + +Пины энкодера, серво и реле меняются в [include/config.h](include/config.h). Пины дисплея меняются в `build_flags` в [platformio.ini](platformio.ini). + +## Настройка + +Все параметры находятся в [include/config.h](include/config.h): + +| Параметр | Что задаёт | +|---|---| +| `WIFI_SSID`, `WIFI_PASSWORD` | домашняя сеть Wi‑Fi | +| `AP_SSID`, `AP_PASSWORD` | собственная точка доступа. ESP32 включает её, если не смог подключиться к сети за 15 с | +| `MDNS_HOSTNAME` | имя в сети, по умолчанию `http://volume.local` | +| `ENCODER_REVERSE` | `1`, если громкость меняется в обратную сторону | +| `VOLUME_STEP` | шаг громкости за один щелчок энкодера, % | +| `SERVO_MIN_US`, `SERVO_MAX_US` | длительность импульса для 0 % и 100 %. Подберите под ход потенциометра | +| `SERVO_SPEED_US_PER_STEP` | скорость плавного хода серво | +| `RELAY_ACTIVE_LEVEL` | `HIGH` или `LOW`. Многие модули реле включаются уровнем `LOW` | +| `TOUCH_VOLUME_STEP` | шаг громкости для кнопок −/+ на экране, % | +| `TOUCH_REPEAT_DELAY_MS`, `TOUCH_REPEAT_MS` | задержка перед автоповтором при удержании −/+ и его период | + +## Сборка и прошивка + +```sh +pio run -t upload +pio device monitor +``` + +После запуска прошивка пишет в монитор порта IP-адрес. Тот же адрес виден в правом верхнем углу дисплея. + +## Веб-интерфейс и API + +Откройте `http://volume.local` или IP-адрес устройства. На странице есть ползунок громкости, кнопки ±5 и кнопка реле. + +| Запрос | Действие | +|---|---| +| `GET /api/state` | вернуть текущее состояние: `{"volume":30,"relay":false}` | +| `POST /api/volume?value=N` | установить громкость 0–100 | +| `POST /api/volume?delta=N` | изменить громкость на N | +| `POST /api/relay?state=toggle\|1\|0` | переключить, включить или выключить реле | + +Громкость и состояние реле сохраняются в памяти ESP32 (NVS) и восстанавливаются после перезагрузки. + +## Устройство прошивки + +| Задача FreeRTOS | Файл | Назначение | +|---|---|---| +| `control` | [src/control_task.cpp](src/control_task.cpp) | управляет серво, реле и сохранением в NVS. Только эта задача работает с этим оборудованием | +| `encoder` | [src/encoder_task.cpp](src/encoder_task.cpp) | опрашивает энкодер и кнопку раз в 1 мс | +| `web` | [src/web_task.cpp](src/web_task.cpp) | подключение к Wi‑Fi, HTTP-сервер, mDNS | +| `display` | [src/display_task.cpp](src/display_task.cpp) | выводит состояние на TFT, обрабатывает касания | + +Энкодер, сенсорный экран и веб-интерфейс отправляют команды в общую очередь, а задача `control` их выполняет. Текущее состояние хранится в общей переменной, защищённой мьютексом. diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/include/app.h b/include/app.h new file mode 100644 index 0000000..f35c68c --- /dev/null +++ b/include/app.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +enum class CmdType : uint8_t { + VolumeDelta, // value: signed change in % + VolumeSet, // value: absolute 0..100 + RelayToggle, + RelaySet, // value: 0 / 1 +}; + +struct Command { + CmdType type; + int16_t value; +}; + +struct AppState { + uint8_t volume; // 0..100 + bool relay; +}; + +// Created in main.cpp +extern QueueHandle_t cmdQueue; + +bool sendCommand(CmdType type, int16_t value = 0); +AppState getState(); +void setState(const AppState &s); + +void controlTask(void *arg); +void encoderTask(void *arg); +void webTask(void *arg); +void displayTask(void *arg); diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..732e66e --- /dev/null +++ b/include/config.h @@ -0,0 +1,48 @@ +#pragma once + +// ---------- Wi-Fi ---------- +#define WIFI_SSID "your_ssid" +#define WIFI_PASSWORD "your_password" +#define WIFI_CONNECT_TIMEOUT_MS 15000 + +// If the station connection fails, the ESP32 starts its own access point +#define AP_SSID "VolumeControl" +#define AP_PASSWORD "12345678" // min 8 chars + +#define MDNS_HOSTNAME "volume" // http://volume.local + +// ---------- Pins (chosen to not clash with the TFT pins in platformio.ini) ---------- +#define PIN_ENC_CLK 32 +#define PIN_ENC_DT 33 +#define PIN_ENC_SW 25 +#define PIN_SERVO 13 +#define PIN_RELAY 26 + +// ---------- Encoder ---------- +#define ENCODER_REVERSE 0 // set to 1 if rotation direction is inverted +#define ENCODER_STEPS_PER_DETENT 4 +#define VOLUME_STEP 2 // % per encoder detent +#define BUTTON_DEBOUNCE_MS 30 + +// ---------- Servo ---------- +// Pulse widths for volume 0% and 100%. Tune to the real travel of the pot. +#define SERVO_MIN_US 500 +#define SERVO_MAX_US 2400 +#define SERVO_SPEED_US_PER_STEP 20 // smoothing: max pulse change per update +#define SERVO_UPDATE_MS 10 +#define SERVO_DETACH_IDLE_MS 1000 // release servo when idle to avoid hum/jitter + +// ---------- Relay ---------- +#define RELAY_ACTIVE_LEVEL HIGH // many relay modules are active LOW + +// ---------- Display ---------- +#define DISPLAY_UPDATE_MS 20 // also the touch polling period + +// ---------- Touch ---------- +#define TOUCH_VOLUME_STEP 1 // % per tap on -/+ +#define TOUCH_REPEAT_DELAY_MS 400 // hold -/+ this long to start auto-repeat +#define TOUCH_REPEAT_MS 80 +#define TOUCH_RECAL_PRESSURE 400 // touch at power-up stronger than this -> recalibrate + +// ---------- Persistence ---------- +#define SAVE_DELAY_MS 2000 // write to NVS after values stop changing diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..d86b5ab --- /dev/null +++ b/platformio.ini @@ -0,0 +1,43 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +lib_deps = + bodmer/TFT_eSPI @ ^2.5.43 + madhephaestus/ESP32Servo @ ^3.0.5 + +build_flags = + -D USER_SETUP_LOADED=1 + -D ILI9341_DRIVER=1 + -D TFT_WIDTH=240 + -D TFT_HEIGHT=320 + -D TFT_MISO=19 + -D TFT_MOSI=23 + -D TFT_SCLK=18 + -D TFT_CS=5 + -D TFT_DC=21 + -D TFT_RST=15 + -D TFT_BL=22 + -D TFT_BACKLIGHT_ON=HIGH + -D TOUCH_CS=4 + -D USE_HSPI_PORT=1 + -D SPI_FREQUENCY=40000000 + -D SPI_READ_FREQUENCY=16000000 + -D SPI_TOUCH_FREQUENCY=2500000 + -D LOAD_GLCD=1 + -D LOAD_FONT2=1 + -D LOAD_FONT4=1 + -D LOAD_FONT7=1 + -D LOAD_GFXFF=1 + -D SMOOTH_FONT=1 \ No newline at end of file diff --git a/src/control_task.cpp b/src/control_task.cpp new file mode 100644 index 0000000..7ae3d70 --- /dev/null +++ b/src/control_task.cpp @@ -0,0 +1,97 @@ +#include +#include +#include "app.h" +#include "config.h" + +static Servo servo; +static Preferences prefs; + +static int volumeToUs(uint8_t volume) { + return map(volume, 0, 100, SERVO_MIN_US, SERVO_MAX_US); +} + +static void applyRelay(bool on) { + digitalWrite(PIN_RELAY, on ? RELAY_ACTIVE_LEVEL : !RELAY_ACTIVE_LEVEL); +} + +// Owns the servo, the relay and NVS. Other tasks only send commands. +void controlTask(void *arg) { + pinMode(PIN_RELAY, OUTPUT); + + prefs.begin("volume", false); + AppState st; + st.volume = prefs.getUChar("vol", 30); + st.relay = prefs.getBool("relay", false); + setState(st); + applyRelay(st.relay); + + servo.setPeriodHertz(50); + int currentUs = volumeToUs(st.volume); + servo.attach(PIN_SERVO, SERVO_MIN_US, SERVO_MAX_US); + servo.writeMicroseconds(currentUs); + TickType_t lastMove = xTaskGetTickCount(); + + bool dirty = false; + TickType_t lastChange = 0; + + Serial.printf("[control] restored volume=%u relay=%d\n", st.volume, st.relay); + + for (;;) { + int targetUs = volumeToUs(st.volume); + bool moving = currentUs != targetUs; + TickType_t wait = (moving || dirty || servo.attached()) + ? pdMS_TO_TICKS(SERVO_UPDATE_MS) + : portMAX_DELAY; + + Command cmd; + if (xQueueReceive(cmdQueue, &cmd, wait) == pdTRUE) { + AppState prev = st; + switch (cmd.type) { + case CmdType::VolumeDelta: + st.volume = constrain((int)st.volume + cmd.value, 0, 100); + break; + case CmdType::VolumeSet: + st.volume = constrain((int)cmd.value, 0, 100); + break; + case CmdType::RelayToggle: + st.relay = !st.relay; + break; + case CmdType::RelaySet: + st.relay = cmd.value != 0; + break; + } + if (st.relay != prev.relay) { + applyRelay(st.relay); + Serial.printf("[control] relay %s\n", st.relay ? "ON" : "OFF"); + } + if (st.volume != prev.volume || st.relay != prev.relay) { + setState(st); + dirty = true; + lastChange = xTaskGetTickCount(); + } + targetUs = volumeToUs(st.volume); + } + + TickType_t now = xTaskGetTickCount(); + + // Smooth servo movement toward the target + if (currentUs != targetUs) { + int diff = targetUs - currentUs; + int step = constrain(diff, -SERVO_SPEED_US_PER_STEP, SERVO_SPEED_US_PER_STEP); + currentUs += step; + if (!servo.attached()) servo.attach(PIN_SERVO, SERVO_MIN_US, SERVO_MAX_US); + servo.writeMicroseconds(currentUs); + lastMove = now; + } else if (servo.attached() && now - lastMove >= pdMS_TO_TICKS(SERVO_DETACH_IDLE_MS)) { + servo.detach(); + } + + // Delayed save to spare flash wear while the encoder is being turned + if (dirty && now - lastChange >= pdMS_TO_TICKS(SAVE_DELAY_MS)) { + prefs.putUChar("vol", st.volume); + prefs.putBool("relay", st.relay); + dirty = false; + Serial.printf("[control] saved volume=%u relay=%d\n", st.volume, st.relay); + } + } +} diff --git a/src/display_task.cpp b/src/display_task.cpp new file mode 100644 index 0000000..8e94256 --- /dev/null +++ b/src/display_task.cpp @@ -0,0 +1,211 @@ +#include +#include +#include +#include "app.h" +#include "config.h" + +static TFT_eSPI tft; + +struct Rect { + int x, y, w, h; + bool contains(int px, int py) const { return px >= x && px < x + w && py >= y && py < y + h; } +}; + +// Landscape 320x240 layout +static const int kW = 320; +static const Rect kBar = {20, 150, 280, 24}; +static const Rect kBarTouch = {0, 136, 320, 50}; // larger than the bar for easier dragging +static const Rect kRelay = {60, 190, 200, 40}; +static const Rect kMinus = {10, 55, 60, 70}; +static const Rect kPlus = {250, 55, 60, 70}; + +static const uint16_t kBg = TFT_BLACK; +static const uint16_t kAccent = 0x3D3F; // light blue +static const uint16_t kBarBg = 0x2945; // dark grey +static const uint16_t kBtn = 0x2945; +static const uint16_t kBtnPressed = 0x3D3F; +static const uint16_t kOn = TFT_GREEN; +static const uint16_t kOff = 0x4208; // grey + +enum class TouchTarget { None, Minus, Plus, Bar, Relay }; + +static void drawButton(const Rect &r, const char *label, bool pressed) { + uint16_t bg = pressed ? kBtnPressed : kBtn; + tft.fillRoundRect(r.x, r.y, r.w, r.h, 10, bg); + tft.setTextColor(TFT_WHITE, bg); + tft.setTextDatum(MC_DATUM); + tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2, 4); +} + +static void drawStatic() { + tft.fillScreen(kBg); + tft.setTextDatum(TL_DATUM); + tft.setTextColor(TFT_WHITE, kBg); + tft.drawString("VOLUME", 10, 8, 4); + tft.drawFastHLine(0, 38, kW, kBarBg); + tft.drawRect(kBar.x - 2, kBar.y - 2, kBar.w + 4, kBar.h + 4, kBarBg); + drawButton(kMinus, "-", false); + drawButton(kPlus, "+", false); +} + +static void drawVolume(uint8_t volume) { + tft.setTextColor(kAccent, kBg); + tft.setTextDatum(TR_DATUM); + tft.setTextPadding(tft.textWidth("100", 7)); + tft.drawNumber(volume, 200, 70, 7); + tft.setTextPadding(0); + tft.setTextDatum(TL_DATUM); + tft.drawString("%", 208, 94, 4); + + int fill = kBar.w * volume / 100; + tft.fillRect(kBar.x, kBar.y, fill, kBar.h, kAccent); + tft.fillRect(kBar.x + fill, kBar.y, kBar.w - fill, kBar.h, kBarBg); +} + +static void drawRelay(bool on) { + uint16_t bg = on ? kOn : kOff; + tft.fillRoundRect(kRelay.x, kRelay.y, kRelay.w, kRelay.h, 10, bg); + tft.setTextColor(on ? TFT_BLACK : TFT_WHITE, bg); + tft.setTextDatum(MC_DATUM); + tft.drawString(on ? "POWER: ON" : "POWER: OFF", kRelay.x + kRelay.w / 2, kRelay.y + kRelay.h / 2 + 1, 4); +} + +static String networkText() { + if (WiFi.getMode() & WIFI_MODE_AP) return "AP " + WiFi.softAPIP().toString(); + if (WiFi.status() == WL_CONNECTED) return WiFi.localIP().toString(); + return "WiFi..."; +} + +static void drawNetwork(const String &text) { + tft.setTextColor(TFT_LIGHTGREY, kBg); + tft.setTextDatum(TR_DATUM); + tft.setTextPadding(150); + tft.drawString(text, kW - 8, 14, 2); + tft.setTextPadding(0); +} + +// Loads touch calibration from NVS. Runs calibration if there is none +// or if the screen is being touched at power-up. +static void setupTouch() { + Preferences prefs; + prefs.begin("touch", false); + uint16_t cal[5]; + bool haveCal = prefs.getBytes("cal", cal, sizeof(cal)) == sizeof(cal); + + if (!haveCal || tft.getTouchRawZ() > TOUCH_RECAL_PRESSURE) { + tft.fillScreen(kBg); + tft.setTextColor(TFT_WHITE, kBg); + tft.setTextDatum(MC_DATUM); + tft.drawString("Touch the corners", kW / 2, 100, 4); + tft.drawString("as indicated", kW / 2, 130, 4); + tft.calibrateTouch(cal, TFT_MAGENTA, kBg, 15); + prefs.putBytes("cal", cal, sizeof(cal)); + Serial.println("[display] touch calibrated"); + } + prefs.end(); + tft.setTouch(cal); +} + +static TouchTarget hitTest(int x, int y) { + if (kMinus.contains(x, y)) return TouchTarget::Minus; + if (kPlus.contains(x, y)) return TouchTarget::Plus; + if (kBarTouch.contains(x, y)) return TouchTarget::Bar; + if (kRelay.contains(x, y)) return TouchTarget::Relay; + return TouchTarget::None; +} + +static int barToVolume(int x) { + return constrain((x - kBar.x) * 100 / kBar.w, 0, 100); +} + +// The only task touching the TFT and its touch controller (shared SPI bus): +// handles touch input, polls the shared state and redraws what changed +void displayTask(void *arg) { + tft.init(); + tft.setRotation(1); + setupTouch(); + drawStatic(); + + int lastVolume = -1; + int lastRelay = -1; + String lastNet; + TickType_t lastNetCheck = 0; + + TouchTarget active = TouchTarget::None; + TickType_t pressStart = 0, lastRepeat = 0; + int releaseCount = 0; + int lastBarVolume = -1; + + for (;;) { + TickType_t now = xTaskGetTickCount(); + + // ---- Touch input ---- + uint16_t tx, ty; + if (tft.getTouch(&tx, &ty)) { + releaseCount = 0; + if (active == TouchTarget::None) { + // New press: the target is fixed until release + active = hitTest(tx, ty); + pressStart = lastRepeat = now; + lastBarVolume = -1; + switch (active) { + case TouchTarget::Minus: + drawButton(kMinus, "-", true); + sendCommand(CmdType::VolumeDelta, -TOUCH_VOLUME_STEP); + break; + case TouchTarget::Plus: + drawButton(kPlus, "+", true); + sendCommand(CmdType::VolumeDelta, TOUCH_VOLUME_STEP); + break; + case TouchTarget::Relay: + sendCommand(CmdType::RelayToggle); + break; + default: + break; + } + } + + if (active == TouchTarget::Bar) { + int v = barToVolume(tx); + if (v != lastBarVolume) { + sendCommand(CmdType::VolumeSet, v); + lastBarVolume = v; + } + } else if ((active == TouchTarget::Minus || active == TouchTarget::Plus) && + now - pressStart >= pdMS_TO_TICKS(TOUCH_REPEAT_DELAY_MS) && + now - lastRepeat >= pdMS_TO_TICKS(TOUCH_REPEAT_MS)) { + // Auto-repeat while holding -/+ + lastRepeat = now; + int step = active == TouchTarget::Plus ? TOUCH_VOLUME_STEP : -TOUCH_VOLUME_STEP; + sendCommand(CmdType::VolumeDelta, step); + } + } else if (active != TouchTarget::None && ++releaseCount >= 3) { + // Several polls without touch: treat as release (filters short dropouts) + if (active == TouchTarget::Minus) drawButton(kMinus, "-", false); + if (active == TouchTarget::Plus) drawButton(kPlus, "+", false); + active = TouchTarget::None; + } + + // ---- State rendering ---- + AppState s = getState(); + if (s.volume != lastVolume) { + drawVolume(s.volume); + lastVolume = s.volume; + } + if ((int)s.relay != lastRelay) { + drawRelay(s.relay); + lastRelay = s.relay; + } + + if (now - lastNetCheck >= pdMS_TO_TICKS(1000)) { + lastNetCheck = now; + String net = networkText(); + if (net != lastNet) { + drawNetwork(net); + lastNet = net; + } + } + + vTaskDelay(pdMS_TO_TICKS(DISPLAY_UPDATE_MS)); + } +} diff --git a/src/encoder_task.cpp b/src/encoder_task.cpp new file mode 100644 index 0000000..024a15c --- /dev/null +++ b/src/encoder_task.cpp @@ -0,0 +1,56 @@ +#include "app.h" +#include "config.h" + +// Quadrature transition table: index = (prev << 2) | current +static const int8_t kTransitions[16] = { + 0, -1, 1, 0, + 1, 0, 0, -1, + -1, 0, 0, 1, + 0, 1, -1, 0, +}; + +static uint8_t readAB() { + return (digitalRead(PIN_ENC_CLK) << 1) | digitalRead(PIN_ENC_DT); +} + +// Polls the encoder every 1 ms: tolerant to contact bounce, no ISR needed +void encoderTask(void *arg) { + pinMode(PIN_ENC_CLK, INPUT_PULLUP); + pinMode(PIN_ENC_DT, INPUT_PULLUP); + pinMode(PIN_ENC_SW, INPUT_PULLUP); + + uint8_t prevAB = readAB(); + int8_t acc = 0; + + bool btnStable = digitalRead(PIN_ENC_SW); + bool btnLast = btnStable; + TickType_t btnChanged = xTaskGetTickCount(); + + TickType_t lastWake = xTaskGetTickCount(); + for (;;) { + uint8_t ab = readAB(); + if (ab != prevAB) { + acc += kTransitions[(prevAB << 2) | ab]; + prevAB = ab; + if (acc >= ENCODER_STEPS_PER_DETENT || acc <= -ENCODER_STEPS_PER_DETENT) { + int dir = acc > 0 ? 1 : -1; + if (ENCODER_REVERSE) dir = -dir; + acc = 0; + sendCommand(CmdType::VolumeDelta, dir * VOLUME_STEP); + } + } + + // Button: debounce, act on press (active LOW) + bool btn = digitalRead(PIN_ENC_SW); + TickType_t now = xTaskGetTickCount(); + if (btn != btnLast) { + btnLast = btn; + btnChanged = now; + } else if (btn != btnStable && now - btnChanged >= pdMS_TO_TICKS(BUTTON_DEBOUNCE_MS)) { + btnStable = btn; + if (btnStable == LOW) sendCommand(CmdType::RelayToggle); + } + + vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(1)); + } +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..2c6cf6a --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,43 @@ +#include +#include "app.h" + +QueueHandle_t cmdQueue; + +static SemaphoreHandle_t stateMutex; +static AppState state = {0, false}; + +bool sendCommand(CmdType type, int16_t value) { + Command c{type, value}; + return xQueueSend(cmdQueue, &c, pdMS_TO_TICKS(50)) == pdTRUE; +} + +AppState getState() { + xSemaphoreTake(stateMutex, portMAX_DELAY); + AppState s = state; + xSemaphoreGive(stateMutex); + return s; +} + +void setState(const AppState &s) { + xSemaphoreTake(stateMutex, portMAX_DELAY); + state = s; + xSemaphoreGive(stateMutex); +} + +void setup() { + Serial.begin(115200); + + cmdQueue = xQueueCreate(32, sizeof(Command)); + stateMutex = xSemaphoreCreateMutex(); + + // Control first, so the restored state is ready before others read it + xTaskCreatePinnedToCore(controlTask, "control", 4096, nullptr, 3, nullptr, 1); + xTaskCreatePinnedToCore(encoderTask, "encoder", 3072, nullptr, 4, nullptr, 1); + xTaskCreatePinnedToCore(webTask, "web", 8192, nullptr, 1, nullptr, 0); + xTaskCreatePinnedToCore(displayTask, "display", 4096, nullptr, 1, nullptr, 1); +} + +void loop() { + // All work is done in FreeRTOS tasks + vTaskDelete(nullptr); +} diff --git a/src/web_page.h b/src/web_page.h new file mode 100644 index 0000000..3d94096 --- /dev/null +++ b/src/web_page.h @@ -0,0 +1,81 @@ +#pragma once +#include + +static const char INDEX_HTML[] PROGMEM = R"HTML( + + + + +Громкость + + + +
+

Регулятор громкости

+
--%
+ +
+ + +
+ +
подключение…
+
+ + + +)HTML"; diff --git a/src/web_task.cpp b/src/web_task.cpp new file mode 100644 index 0000000..fb0a47a --- /dev/null +++ b/src/web_task.cpp @@ -0,0 +1,90 @@ +#include +#include +#include +#include "app.h" +#include "config.h" +#include "web_page.h" + +static WebServer server(80); + +static void sendState() { + AppState s = getState(); + char json[48]; + snprintf(json, sizeof(json), "{\"volume\":%u,\"relay\":%s}", s.volume, s.relay ? "true" : "false"); + server.send(200, "application/json", json); +} + +// Command is processed asynchronously by controlTask, so answer with the expected state +static void sendStateAfter(CmdType type, int16_t value) { + if (!sendCommand(type, value)) { + server.send(503, "text/plain", "busy"); + return; + } + vTaskDelay(pdMS_TO_TICKS(20)); + sendState(); +} + +static void handleVolume() { + if (server.hasArg("value")) { + sendStateAfter(CmdType::VolumeSet, server.arg("value").toInt()); + } else if (server.hasArg("delta")) { + sendStateAfter(CmdType::VolumeDelta, server.arg("delta").toInt()); + } else { + server.send(400, "text/plain", "need value or delta"); + } +} + +static void handleRelay() { + String st = server.arg("state"); + if (st == "toggle" || st.isEmpty()) { + sendStateAfter(CmdType::RelayToggle, 0); + } else { + sendStateAfter(CmdType::RelaySet, (st == "1" || st == "on") ? 1 : 0); + } +} + +static void startWifi() { + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + Serial.printf("[web] connecting to %s", WIFI_SSID); + + TickType_t start = xTaskGetTickCount(); + while (WiFi.status() != WL_CONNECTED && + xTaskGetTickCount() - start < pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS)) { + vTaskDelay(pdMS_TO_TICKS(500)); + Serial.print('.'); + } + Serial.println(); + + if (WiFi.status() == WL_CONNECTED) { + Serial.printf("[web] connected, IP: %s\n", WiFi.localIP().toString().c_str()); + } else { + WiFi.disconnect(true); + WiFi.mode(WIFI_AP); + WiFi.softAP(AP_SSID, AP_PASSWORD); + Serial.printf("[web] STA failed, AP \"%s\" started, IP: %s\n", AP_SSID, + WiFi.softAPIP().toString().c_str()); + } + + if (MDNS.begin(MDNS_HOSTNAME)) { + MDNS.addService("http", "tcp", 80); + Serial.printf("[web] http://%s.local\n", MDNS_HOSTNAME); + } +} + +void webTask(void *arg) { + startWifi(); + + server.on("/", HTTP_GET, [] { server.send_P(200, "text/html", INDEX_HTML); }); + server.on("/api/state", HTTP_GET, sendState); + server.on("/api/volume", HTTP_POST, handleVolume); + server.on("/api/relay", HTTP_POST, handleRelay); + server.onNotFound([] { server.send(404, "text/plain", "not found"); }); + server.begin(); + + for (;;) { + server.handleClient(); + vTaskDelay(pdMS_TO_TICKS(2)); + } +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html