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

+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));
}
}