This commit is contained in:
vadim committed 2026-10-27 17:47:54 +03:00
commit 1d7af0615e
15 files changed
+942

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
+11
View File
@@ -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"
]
}
+128
View File
@@ -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` их выполняет. Текущее состояние хранится в общей переменной, защищённой мьютексом.
+37
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
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);
+48
View File
@@ -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
+46
View File
@@ -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 <Foo.h>
#include <Bar.h>
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
+43
View File
@@ -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
+97
View File
@@ -0,0 +1,97 @@
#include <ESP32Servo.h>
#include <Preferences.h>
#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);
}
}
}
+211
View File
@@ -0,0 +1,211 @@
#include <TFT_eSPI.h>
#include <WiFi.h>
#include <Preferences.h>
#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));
}
}
+56
View File
@@ -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));
}
}
+43
View File
@@ -0,0 +1,43 @@
#include <Arduino.h>
#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);
}
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <Arduino.h>
static const char INDEX_HTML[] PROGMEM = R"HTML(<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Громкость</title>
<style>
:root { --bg:#111; --card:#1d1d1d; --fg:#eee; --accent:#3fa7ff; --on:#2ecc71; --off:#555; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,sans-serif; background:var(--bg); color:var(--fg);
display:flex; justify-content:center; padding:24px 16px; }
.card { background:var(--card); border-radius:16px; padding:24px; width:100%; max-width:380px; }
h1 { font-size:18px; margin:0 0 20px; font-weight:600; }
.vol { font-size:64px; font-weight:700; text-align:center; font-variant-numeric:tabular-nums; }
.vol span { font-size:24px; opacity:.6; }
input[type=range] { width:100%; margin:16px 0; accent-color:var(--accent); height:32px; }
.row { display:flex; gap:12px; }
button { flex:1; font-size:22px; padding:14px; border:0; border-radius:12px;
background:#2a2a2a; color:var(--fg); cursor:pointer; }
button:active { transform:scale(.97); }
#relay { margin-top:20px; width:100%; font-size:18px; background:var(--off); }
#relay.on { background:var(--on); color:#000; }
.status { margin-top:16px; font-size:12px; opacity:.5; text-align:center; }
</style>
</head>
<body>
<div class="card">
<h1>Регулятор громкости</h1>
<div class="vol"><b id="val">--</b><span>%</span></div>
<input id="slider" type="range" min="0" max="100" step="1">
<div class="row">
<button onclick="delta(-5)">−</button>
<button onclick="delta(5)">+</button>
</div>
<button id="relay" onclick="relay()">Реле</button>
<div class="status" id="status">подключение…</div>
</div>
<script>
const $ = id => document.getElementById(id);
let dragging = false, sendTimer = null;
function render(s) {
$('val').textContent = s.volume;
if (!dragging) $('slider').value = s.volume;
$('relay').classList.toggle('on', s.relay);
$('relay').textContent = s.relay ? 'Реле: ВКЛ' : 'Реле: ВЫКЛ';
$('status').textContent = 'онлайн';
}
async function api(path, method = 'GET') {
try {
const r = await fetch(path, { method });
if (r.ok) render(await r.json());
} catch (e) { $('status').textContent = 'нет связи'; }
}
const delta = d => api('/api/volume?delta=' + d, 'POST');
const relay = () => api('/api/relay?state=toggle', 'POST');
const slider = $('slider');
slider.addEventListener('pointerdown', () => dragging = true);
slider.addEventListener('pointerup', () => dragging = false);
slider.addEventListener('input', () => {
$('val').textContent = slider.value;
clearTimeout(sendTimer);
sendTimer = setTimeout(() => api('/api/volume?value=' + slider.value, 'POST'), 80);
});
slider.addEventListener('change', () => {
dragging = false;
api('/api/volume?value=' + slider.value, 'POST');
});
api('/api/state');
setInterval(() => { if (!dragging) api('/api/state'); }, 700);
</script>
</body>
</html>
)HTML";
+90
View File
@@ -0,0 +1,90 @@
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#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));
}
}
+11
View File
@@ -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