init
This commit is contained in:
commit
1d7af0615e
15 files changed
+942
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
Vendored
+11
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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` их выполняет. Текущее состояние хранится в общей переменной, защищённой мьютексом.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user