Transforming a classic wooden toy train into an interactive, high-tech railway using ESP32 microcontrollers, low-latency ESP-NOW wireless protocol, custom power management, and localized station announcements.
nspiration & Project Overview
Wooden train sets are a timeless staple of childhood, but their original mechanical engines are often binary: turned either completely ON or OFF via a single mechanical switch. There is no remote control, no variable speed, and no realistic sound interaction.
The goal of this project was to modernise standard wooden railway engines (compatible with BRIO and similar tracks) without sacrificing their physical charm. By embedding custom electronics inside the locomotive and building an intuitive, tactile remote controller inspired by automotive rotary dials (such as BMW iDrive), the train becomes an interactive experience complete with responsive throttle, horn triggers, audio announcements, and safety failsafes.
Key FeaturesZero-Latency Wireless Control: Uses ESP-NOW peer-to-peer protocol (2.4 GHz) instead of traditional Wi-Fi or Bluetooth, eliminating router dependencies and pairing lag.
- Zero-Latency Wireless Control: Uses ESP-NOW peer-to-peer protocol (2.4 GHz) instead of traditional Wi-Fi or Bluetooth, eliminating router dependencies and pairing lag.
Tactile Dial Interface: Smooth rotary encoder control for velocity and direction adjustment, coupled with an interactive display and custom button inputs.
- Tactile Dial Interface: Smooth rotary encoder control for velocity and direction adjustment, coupled with an interactive display and custom button inputs.
Smart Audio Integration: Real-time horn activation and custom station announcements formatted specifically for low-latency playback.
- Smart Audio Integration: Real-time horn activation and custom station announcements formatted specifically for low-latency playback.
Robust Safety Failsafe: Software watchdog on the receiver that automatically halts motor output if communication packets are missed or disconnected.
- Robust Safety Failsafe: Software watchdog on the receiver that automatically halts motor output if communication packets are missed or disconnected.
Custom Integrated Power & Drive: Integrated LiPo battery management with step-up boosting and dual H-bridge motor driving.
- Custom Integrated Power & Drive: Integrated LiPo battery management with step-up boosting and dual H-bridge motor driving.
The system is split into two main physical hardware nodes:
1. Transmitter (Remote Controller)Core Microcontroller: M5Stack M5Dial (ESP32-S3) / ESP32-C6 microcontroller with an integrated rotary encoder and display.
- Core Microcontroller: M5Stack M5Dial (ESP32-S3) / ESP32-C6 microcontroller with an integrated rotary encoder and display.
Controls:
Rotary Knob: Controls forward/reverse speed gradients.
- Rotary Knob: Controls forward/reverse speed gradients.
Push Button / Touch UI: Triggers the horn and cycles through station announcement sound clips.
- Push Button / Touch UI: Triggers the horn and cycles through station announcement sound clips.
- Controls:Rotary Knob: Controls forward/reverse speed gradients.Push Button / Touch UI: Triggers the horn and cycles through station announcement sound clips.
Power: Rechargeable LiPo battery pack.
- Power: Rechargeable LiPo battery pack.
Core Microcontroller: ESP32 Dev / XIAO ESP32 board.
- Core Microcontroller: ESP32 Dev / XIAO ESP32 board.
Motor Driver: DRV8833 dual H-bridge IC driving a micro DC gearbox motor.
- Motor Driver: DRV8833 dual H-bridge IC driving a micro DC gearbox motor.
Power Management: TP5400 IC providing single-cell LiPo battery charging and steady 5V boost step-up regulation.
- Power Management: TP5400 IC providing single-cell LiPo battery charging and steady 5V boost step-up regulation.
Audio System: Compact speaker driven by an audio module / I2S amplifier for localized horn sounds and station arrivals.
- Audio System: Compact speaker driven by an audio module / I2S amplifier for localized horn sounds and station arrivals.
Chassis: Custom 3D-printed body elements modified to fit standard wooden track dimensions and switch points.
- Chassis: Custom 3D-printed body elements modified to fit standard wooden track dimensions and switch points.
// =====================================================
// WOODENTRAIN - MASINKA (prijimac)
// - po zapnuti se sama plynule rozjede dopredu (PWM 140)
// - NFC tagy v kolejich: zastavka1..3 / horn / stop
// - po zapnuti M5Dial posloucha ovladac (ESP-NOW)
// - NFC tagy funguji i pri rizeni z ovladace
// Piny odpovidaji sketchi train03.
// =====================================================
#include <Arduino.h>
#include <WiFi.h>
#include <esp_now.h>
#include <SPI.h>
#include <MFRC522.h>
#include "FS.h"
#include "LittleFS.h"
// AUDIO KNIHOVNY (Knihovna ESP8266Audio)
#include "AudioFileSourceLittleFS.h"
#include "AudioGeneratorWAV.h"
#include "AudioOutputI2S.h"
// ===== KONFIGURACE PIN =====
static const int PIN_IN1 = 6;
static const int PIN_IN2 = 5;
// RFID RC522 SPI (SCK=4, MISO=3, MOSI=7, SDA=10)
#define RST_PIN 8
#define SS_PIN 10
// MAX98357 I2S
#define I2S_BCLK 1
#define I2S_LRC 2
#define I2S_DOUT 0
// ===== PARAMETRY JZDY =====
static const int AUTO_PWM = 180; // rychlost po zapnuti (bez ovladace)
static const int MIN_MOVING_PWM = 150; // pod touto hodnotou se motor netoci
static const int MAX_PWM = 255;
static const uint32_t RAMP_INTERVAL_MS = 20;
static const int RAMP_UP_STEP = 2; // plynuly rozjezd (~0,8 s z 60 na 140)
static const int RAMP_DOWN_STEP = 3; // plynule zpomaleni
static const int RAMP_STOP_STEP = 10; // rychle zastaveni na tagu
static const uint32_t STATION_MIN_WAIT_MS = 2000; // minimalni stani v zastavce
static const uint32_t TAG_COOLDOWN_MS = 3000; // stejny tag se znovu nezpracuje
static const uint32_t RFID_POLL_MS = 50;
static const uint32_t DIAL_TIMEOUT_MS = 1000; // ztrata signalu ovladace -> zastavit
// ===== ESP-NOW PROTOKOL (stejny jako v ovladaci) =====
typedef struct __attribute__((packed)) {
int8_t speed; // -100 .. +100
bool run;
uint8_t cnt;
uint8_t hornSeq; // zmena hodnoty = zahoukej (dotyk displeje M5Dial)
} control_packet_t;
static const int CONTROL_PACKET_MIN_LEN = 3; // starsi ovladac bez houkani
// ===== OBJEKTY =====
MFRC522 mfrc522(SS_PIN, RST_PIN);
AudioGeneratorWAV *wav = nullptr;
AudioFileSourceLittleFS *audioFile = nullptr;
AudioOutputI2S *out = nullptr;
// ===== STAV =====
enum Mode : uint8_t { MODE_AUTO = 0, MODE_REMOTE = 1 };
Mode mode = MODE_AUTO;
int currentPwm = 0; // se znamenkem: + dopredu, - dozadu
bool stationWaiting = false; // stoji v zastavce, ceka na dohrani zvuku
uint32_t stationStartMs = 0;
bool stopHold = false; // tag "stop" - stoji, dokud ovladac nezmeni povel
// Prijem z ovladace (callback bezi v jine uloze)
portMUX_TYPE rxMux = portMUX_INITIALIZER_UNLOCKED;
control_packet_t rxPacket;
volatile bool rxNew = false;
control_packet_t dial = {0, false, 0};
uint32_t lastDialRxMs = 0;
// Povel ovladace v okamziku zastaveni na tagu
int8_t holdDialSpeed = 0;
bool holdDialRun = false;
// Ochrana proti opakovanemu cteni stejneho tagu
uint8_t lastUid[10];
uint8_t lastUidLen = 0;
uint32_t lastTagMs = 0;
// ===== MOTOR (DRV8833) =====
static inline void setCoast() { ledcWrite(PIN_IN1, 0); ledcWrite(PIN_IN2, 0); }
static void applyMotor(int pwm) {
if (pwm > 0) { ledcWrite(PIN_IN1, pwm); ledcWrite(PIN_IN2, 0); }
else if (pwm < 0) { ledcWrite(PIN_IN1, 0); ledcWrite(PIN_IN2, -pwm); }
else { setCoast(); }
}
// Posune currentPwm o jeden krok smerem k cili. Pri zmene smeru nejdriv zastavi.
static void rampTowards(int target, int upStep, int downStep) {
int cur = currentPwm;
if (cur == target) return;
bool reversing = (cur > 0 && target < 0) || (cur < 0 && target > 0);
if (cur != 0 && (target == 0 || reversing)) {
int mag = abs(cur) - downStep;
cur = (mag < MIN_MOVING_PWM) ? 0 : (cur > 0 ? mag : -mag);
} else if (cur == 0) {
cur = (target > 0) ? MIN_MOVING_PWM : -MIN_MOVING_PWM; // rozjezd od prahove hodnoty
} else {
int mag = abs(cur);
int tmag = abs(target);
mag = (mag < tmag) ? min(mag + upStep, tmag) : max(mag - downStep, tmag);
cur = (cur > 0) ? mag : -mag;
}
currentPwm = cur;
applyMotor(currentPwm);
}
// ===== AUDIO =====
void stopPlayback() {
if (wav) { wav->stop(); delete wav; wav = nullptr; }
if (audioFile) { delete audioFile; audioFile = nullptr; }
}
void playWav(const char* filename) {
stopPlayback();
if (!LittleFS.exists(filename)) {
Serial.printf("Soubor %s neexistuje!\n", filename);
return;
}
audioFile = new AudioFileSourceLittleFS(filename);
wav = new AudioGeneratorWAV();
if (!wav->begin(audioFile, out)) {
stopPlayback();
Serial.println("Chyba inicializace WAV");
}
}
// ===== OVLADA =====
static void snapshotDial() {
holdDialSpeed = dial.speed;
holdDialRun = dial.run;
}
static bool dialChangedSinceHold() {
return dial.speed != holdDialSpeed || dial.run != holdDialRun;
}
void onDataRecv(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
if (len < CONTROL_PACKET_MIN_LEN || len > (int)sizeof(control_packet_t)) return;
portENTER_CRITICAL(&rxMux);
memset(&rxPacket, 0, sizeof(rxPacket));
memcpy(&rxPacket, data, len);
rxNew = true;
portEXIT_CRITICAL(&rxMux);
}
static void processDial(uint32_t now) {
if (!rxNew) return;
control_packet_t p;
portENTER_CRITICAL(&rxMux);
p = rxPacket;
rxNew = false;
portEXIT_CRITICAL(&rxMux);
// Houkani: prvni paket jen nastavi vychozi hodnotu, dalsi zmena = zahoukat.
// Hlaseni v zastavce se houkanim neprerusuje.
static bool hornSeqKnown = false;
if (!hornSeqKnown) {
hornSeqKnown = true;
} else if (p.hornSeq != dial.hornSeq) {
bool announcing = stationWaiting && wav && wav->isRunning();
if (!announcing) playWav("/horn.wav");
}
dial = p;
lastDialRxMs = now;
if (mode == MODE_AUTO) {
mode = MODE_REMOTE;
stopHold = false;
if (stationWaiting) snapshotDial(); // zastavka se nejdriv dokonci
Serial.println("Ovladac pripojen - rizeni z M5Dial");
return;
}
// Otoceni koleckem / klik uvolni zastaveni z tagu
if ((stationWaiting || stopHold) && dialChangedSinceHold()) {
stationWaiting = false;
stopHold = false;
lastTagMs = now;
Serial.println("Ovladac zrusil zastaveni");
}
}
// ===== NFC =====
void processNfcCommand(const String& command, uint32_t now) {
Serial.print("NFC Prikaz: "); Serial.println(command);
if (command == "zastavka1" || command == "zastavka2" || command == "zastavka3") {
stationWaiting = true;
stationStartMs = now;
stopHold = false;
snapshotDial();
playWav(("/" + command + ".wav").c_str());
}
else if (command == "horn") {
playWav("/horn.wav");
}
else if (command == "stop") {
stopHold = true;
stationWaiting = false;
snapshotDial();
playWav("/stop.wav");
}
}
void readNtagData(uint32_t now) {
byte buffer[18];
String fullContent = "";
for (byte page = 4; page < 12; page += 4) {
byte size = sizeof(buffer);
if (mfrc522.MIFARE_Read(page, buffer, &size) == MFRC522::STATUS_OK) {
for (byte i = 0; i < 16; i++) {
char c = (char)buffer[i];
if (isprint(c)) fullContent += c;
}
}
}
if (fullContent.indexOf("zastavka1") >= 0) processNfcCommand("zastavka1", now);
else if (fullContent.indexOf("zastavka2") >= 0) processNfcCommand("zastavka2", now);
else if (fullContent.indexOf("zastavka3") >= 0) processNfcCommand("zastavka3", now);
else if (fullContent.indexOf("horn") >= 0) processNfcCommand("horn", now);
else if (fullContent.indexOf("stop") >= 0) processNfcCommand("stop", now);
}
static void pollRfid(uint32_t now) {
static uint32_t lastPollMs = 0;
if (now - lastPollMs < RFID_POLL_MS) return;
lastPollMs = now;
if (!mfrc522.PICC_IsNewCardPresent() || !mfrc522.PICC_ReadCardSerial()) return;
const MFRC522::Uid &uid = mfrc522.uid;
bool sameUid = (uid.size == lastUidLen) && memcmp(uid.uidByte, lastUid, uid.size) == 0;
if (!(sameUid && now - lastTagMs < TAG_COOLDOWN_MS)) {
memcpy(lastUid, uid.uidByte, uid.size);
lastUidLen = uid.size;
lastTagMs = now;
readNtagData(now);
}
mfrc522.PICC_HaltA();
}
// ===== CLOV RYCHLOST =====
static int computeTargetPwm(uint32_t now) {
if (stationWaiting || stopHold) return 0;
if (mode == MODE_AUTO) return AUTO_PWM;
if (now - lastDialRxMs > DIAL_TIMEOUT_MS) return 0; // ztraceny signal
if (!dial.run || dial.speed == 0) return 0;
int mag = map(constrain(abs(dial.speed), 1, 100), 1, 100, MIN_MOVING_PWM, MAX_PWM);
return (dial.speed > 0) ? mag : -mag;
}
// =====================================================
void setup() {
// 1) PRVN VC: vstupy driveru na 0, aby mainka nepopojela
pinMode(PIN_IN1, OUTPUT);
pinMode(PIN_IN2, OUTPUT);
digitalWrite(PIN_IN1, LOW);
digitalWrite(PIN_IN2, LOW);
ledcAttach(PIN_IN1, 20000, 8);
ledcAttach(PIN_IN2, 20000, 8);
setCoast();
currentPwm = 0;
Serial.begin(115200);
if (!LittleFS.begin(true)) Serial.println("LittleFS Error");
// Audio I2S
out = new AudioOutputI2S();
out->SetPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
out->SetGain(0.8);
// RFID SPI
SPI.begin(4, 3, 7, 10);
mfrc522.PCD_Init();
// ESP-NOW
WiFi.mode(WIFI_STA);
WiFi.disconnect();
if (esp_now_init() == ESP_OK) {
esp_now_register_recv_cb(onDataRecv);
} else {
Serial.println("Chyba inicializace ESP-NOW");
}
Serial.print("MAC masinky (zadej do ovladace): ");
Serial.println(WiFi.macAddress());
Serial.println("Masinka pripravena - automaticky rozjezd");
}
void loop() {
const uint32_t now = millis();
// 1. AUDIO (volat co nejcasteji)
bool audioIsPlaying = false;
if (wav && wav->isRunning()) {
audioIsPlaying = true;
if (!wav->loop()) {
stopPlayback();
audioIsPlaying = false;
}
}
// 2. OVLADA
processDial(now);
// 3. KONEC ZASTVKY: stoji, zvuk dohral, minimalni doba ubehla
if (stationWaiting && currentPwm == 0 && !audioIsPlaying &&
now - stationStartMs >= STATION_MIN_WAIT_MS) {
stationWaiting = false;
lastTagMs = now; // at se pri odjezdu tag nenacte znovu
Serial.println("Zastavka dokoncena, rozjizdim se...");
}
// 4. RFID
pollRfid(now);
// 5. RAMPA
static uint32_t lastRampMs = 0;
if (now - lastRampMs >= RAMP_INTERVAL_MS) {
lastRampMs = now;
int target = computeTargetPwm(now);
bool tagStop = stationWaiting || stopHold;
rampTowards(target, RAMP_UP_STEP, tagStop ? RAMP_STOP_STEP : RAMP_DOWN_STEP);
}
}
// =====================================================
// WOODENTRAIN - OVLADAC (M5Dial v1.1)
// Posila rychlost masince WoodenTrain002 pres ESP-NOW.
//
// Ovladani:
// kolecko = rychlost (+ vpred / - vzad)
// klik tlacitka = STOP
// dvojklik = vypnuti ovladace
// dotyk displeje = masinka zahouka
// =====================================================
#include <M5Dial.h>
#include <WiFi.h>
#include <esp_now.h>
#include <esp_idf_version.h>
#include "soc/soc.h"
#include "soc/gpio_reg.h"
// =====================================================
// ESP-NOW PROTOKOL
// Musi zustat stejny jako na prijimaci.
// =====================================================
typedef struct __attribute__((packed)) {
int8_t speed;
bool run;
uint8_t cnt;
uint8_t hornSeq; // zmena hodnoty = zahoukej
} control_packet_t;
control_packet_t packet = {0, false, 0, 0};
// MAC adresa MASINKY (WoodenTrain002).
// Masinka ji po zapnuti vypise na Serial:
// "MAC masinky (zadej do ovladace): XX:XX:XX:XX:XX:XX"
uint8_t peerMac[] = {
0x08, 0x92, 0x72, 0x84, 0x3C, 0xA4
};
// =====================================================
// PARAMETRY OVLADANI
// =====================================================
constexpr int SPEED_STEP = 5;
constexpr int SPEED_MAX = 100;
constexpr uint32_t AUTO_SLEEP_MS = 300000UL; // 5 minut
constexpr uint32_t DIM_AFTER_MS = 20000UL; // ztlumeni displeje
constexpr uint32_t DOUBLE_CLICK_MS = 400UL;
constexpr uint32_t SEND_INTERVAL_MS = 100UL;
constexpr uint32_t FRAME_MS = 33UL; // ~30 snimku/s
constexpr uint32_t LINK_TIMEOUT_MS = 600UL;
constexpr uint8_t BRIGHT_ACTIVE = 170;
constexpr uint8_t BRIGHT_DIM = 45;
// Enkoder se cte vlastnim prerusenim (knihovna M5Dial nema preruseni
// pro GPIO 40/41 a pri kresleni animace ztraci impulzy).
constexpr int ENC_PIN_A = 41;
constexpr int ENC_PIN_B = 40;
// Krok se vyhodnoti, az kolecko zustane v klidu (zakmity se tak ignoruji).
constexpr uint32_t ENCODER_SETTLE_MS = 12UL;
// Pocet impulzu na cvaknuti se zjisti sam (2 nebo 4); vychozi odhad:
constexpr int ENCODER_DEFAULT_COUNTS_PER_DETENT = 4;
constexpr bool ENCODER_DEBUG = false;
// =====================================================
// GEOMETRIE SCENY
// =====================================================
constexpr int CX = 120;
constexpr int CY = 120;
constexpr int TRACK_R = 110; // stred koleje
constexpr int GAUGE_R0 = 83;
constexpr int GAUGE_R1 = 94;
constexpr float GAUGE_SPAN = 135.0f; // +-135 stupnu od vrcholu
constexpr float LOCO_DEG = 15.0f;
constexpr float WAGON_DEG = 11.0f;
constexpr float GAP_DEG = 2.5f;
constexpr int WAGON_COUNT = 3;
// =====================================================
// BARVY
// =====================================================
struct RGB { uint8_t r, g, b; };
constexpr RGB COL_BG = { 6, 10, 24 };
constexpr RGB COL_BG2 = { 10, 16, 36 };
constexpr RGB COL_BG3 = { 13, 21, 46 };
constexpr RGB COL_STAR = {200, 215, 255 };
constexpr RGB COL_RAIL = {150, 150, 160 };
constexpr RGB COL_SLEEPER = { 92, 60, 34 };
constexpr RGB COL_WOOD_LIGHT = {214, 164, 98 };
constexpr RGB COL_WOOD_DARK = {150, 100, 52 };
constexpr RGB COL_LOCO_RED = {205, 45, 40 };
constexpr RGB COL_LOCO_GREEN = { 40, 125, 80 };
constexpr RGB COL_GOLD = {240, 195, 70 };
constexpr RGB COL_WINDOW = {255, 225, 130 };
constexpr RGB COL_SMOKE = {235, 235, 242 };
constexpr RGB COL_GAUGE_BG = { 26, 32, 58 };
constexpr RGB COL_WHITE = {255, 255, 255 };
constexpr RGB COL_CREAM = {255, 232, 185 };
constexpr RGB COL_CYAN = { 0, 200, 255 };
constexpr RGB COL_ORANGE = {255, 150, 30 };
constexpr RGB COL_RED = {255, 50, 45 };
constexpr RGB COL_GREEN = { 60, 230, 110 };
constexpr RGB COL_DIM_TEXT = {120, 130, 160 };
constexpr RGB COL_HORN_YELLOW = {255, 220, 60 };
const RGB CARGO[WAGON_COUNT] = {
{ 60, 130, 230 }, // modra kostka
{250, 200, 50 }, // zluta kostka
{ 90, 190, 90 }, // zelena kostka
};
static inline uint16_t c565(uint8_t r, uint8_t g, uint8_t b)
{
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}
static inline uint16_t c565(RGB c) { return c565(c.r, c.g, c.b); }
static inline uint16_t mix(RGB a, RGB b, float t)
{
t = constrain(t, 0.0f, 1.0f);
return c565(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t
);
}
static RGB lerpRGB(RGB a, RGB b, float t)
{
t = constrain(t, 0.0f, 1.0f);
return {
(uint8_t)(a.r + (b.r - a.r) * t),
(uint8_t)(a.g + (b.g - a.g) * t),
(uint8_t)(a.b + (b.b - a.b) * t)
};
}
// 0 = pomalu (modra) ... 1 = naplno (cervena)
static RGB heatRGB(float t)
{
const RGB c0 = { 0, 190, 255 };
const RGB c1 = {110, 255, 90 };
const RGB c2 = {255, 205, 0 };
const RGB c3 = {255, 50, 40 };
if (t < 0.45f) return lerpRGB(c0, c1, t / 0.45f);
if (t < 0.75f) return lerpRGB(c1, c2, (t - 0.45f) / 0.30f);
return lerpRGB(c2, c3, (t - 0.75f) / 0.25f);
}
// =====================================================
// STAV PROGRAMU
// =====================================================
M5Canvas canvas(&M5Dial.Display);
int currentSpeed = 0;
bool isRunning = false;
uint8_t hornSeq = 0;
// Enkoder (plni preruseni)
volatile int32_t encCount = 0;
volatile uint32_t encLastEdgeMs = 0;
volatile uint8_t encState = 0;
int32_t encRestPosition = 0; // poloha v poslednim klidu (na cvaknuti)
int encCountsPerDetent = ENCODER_DEFAULT_COUNTS_PER_DETENT;
uint32_t lastSendTime = 0;
uint32_t lastActivityTime = 0;
uint32_t lastClickTime = 0;
uint32_t lastFrameMs = 0;
bool espNowReady = false;
bool dimmed = false;
volatile uint32_t lastAckOkMs = 0;
// Animace
float displaySpeed = 0.0f; // plynule dobihajici rychlost pro animaci
float trainAngle = 250.0f; // poloha predku lokomotivy na koleji
float starRotation = 0.0f;
float puffTimer = 0.0f;
uint32_t hornFxStart = 0;
uint32_t stopFxStart = 0;
struct Star { float r, a, phase, twinkle; bool big; };
constexpr int STAR_COUNT = 48;
Star stars[STAR_COUNT];
struct Puff { float x, y, vx, vy, r, grow, life, maxLife; };
constexpr int PUFF_COUNT = 32;
Puff puffs[PUFF_COUNT];
// =====================================================
// POMOCNE FUNKCE KRESLENI
// =====================================================
static inline float polarX(float angleDeg, float r) { return CX + cosf(angleDeg * DEG_TO_RAD) * r; }
static inline float polarY(float angleDeg, float r) { return CY + sinf(angleDeg * DEG_TO_RAD) * r; }
static inline float frand(float lo, float hi)
{
return lo + (hi - lo) * (random(0, 10001) / 10000.0f);
}
// Vyplneny oblouk mezikruzi; uhly ve stupnich (0 = vpravo, po smeru hodin).
void ringArc(int rInner, int rOuter, float a0, float a1, uint16_t color)
{
if (a1 <= a0) return;
while (a0 < 0) { a0 += 360.0f; a1 += 360.0f; }
while (a0 >= 360) { a0 -= 360.0f; a1 -= 360.0f; }
if (a1 <= 360.0f) {
canvas.fillArc(CX, CY, rOuter, rInner, a0, a1, color);
} else {
canvas.fillArc(CX, CY, rOuter, rInner, a0, 360.0f, color);
canvas.fillArc(CX, CY, rOuter, rInner, 0.0f, a1 - 360.0f, color);
}
}
// Mekka "zarici" tecka
void glowDot(float x, float y, RGB color, float intensity)
{
canvas.fillSmoothCircle(x, y, 9, mix(COL_BG, color, 0.18f * intensity));
canvas.fillSmoothCircle(x, y, 6, mix(COL_BG, color, 0.45f * intensity));
canvas.fillSmoothCircle(x, y, 4, mix(COL_BG, color, 1.0f * intensity));
canvas.fillSmoothCircle(x, y, 2, mix(color, COL_WHITE, 0.7f * intensity));
}
// =====================================================
// POZADI - HVEZDNA OBLOHA
// =====================================================
void initStars()
{
for (int i = 0; i < STAR_COUNT; i++) {
stars[i].r = frand(18, 100);
stars[i].a = frand(0, 360);
stars[i].phase = frand(0, 6.28f);
stars[i].twinkle = frand(1.2f, 4.0f);
stars[i].big = (random(0, 6) == 0);
}
}
void drawBackground(float starFade, uint32_t now)
{
canvas.fillScreen(c565(COL_BG));
canvas.fillCircle(CX, CY, 100, c565(COL_BG2));
canvas.fillCircle(CX, CY, 66, c565(COL_BG3));
const float t = now / 1000.0f;
for (int i = 0; i < STAR_COUNT; i++) {
const Star &s = stars[i];
float b = 0.3f + 0.7f * (0.5f + 0.5f * sinf(t * s.twinkle + s.phase));
b *= starFade;
float a = s.a + starRotation;
int x = (int)polarX(a, s.r);
int y = (int)polarY(a, s.r);
uint16_t col = mix(COL_BG, COL_STAR, b);
canvas.drawPixel(x, y, col);
if (s.big) {
uint16_t halo = mix(COL_BG, COL_STAR, b * 0.45f);
canvas.drawPixel(x + 1, y, halo);
canvas.drawPixel(x - 1, y, halo);
canvas.drawPixel(x, y + 1, halo);
canvas.drawPixel(x, y - 1, halo);
}
}
}
// =====================================================
// KOLEJ
// grow = 0..1 (pro uvodni animaci pokladani koleje)
// =====================================================
void drawTrack(float grow)
{
const float half = 180.0f * grow;
const float from = 270.0f - half;
const float to = 270.0f + half;
const uint16_t sleeperColor = c565(COL_SLEEPER);
for (int i = 0; i < 72; i++) {
float a = i * 5.0f;
float d = fabsf(a - 270.0f);
if (d > 180.0f) d = 360.0f - d;
if (d > half) continue;
ringArc(101, 119, a - 1.1f, a + 1.1f, sleeperColor);
}
const uint16_t railColor = c565(COL_RAIL);
ringArc(104, 105, from, to, railColor);
ringArc(115, 116, from, to, railColor);
}
// =====================================================
// RYCHLOMER
// =====================================================
void drawGauge(uint32_t now)
{
ringArc(GAUGE_R0, GAUGE_R1, 270.0f - GAUGE_SPAN, 270.0f + GAUGE_SPAN, c565(COL_GAUGE_BG));
// Znacky po 10
for (int v = -100; v <= 100; v += 10) {
float a = 270.0f + v / 100.0f * GAUGE_SPAN;
bool major = (v % 50 == 0);
RGB col = (v == 0) ? COL_WHITE : (major ? COL_DIM_TEXT : COL_GAUGE_BG);
ringArc(major ? 74 : 77, 80, a - 0.8f, a + 0.8f, c565(col));
}
const float v = displaySpeed;
if (fabsf(v) < 0.5f) return;
const float end = 270.0f + v / 100.0f * GAUGE_SPAN;
const float from = min(270.0f, end);
const float to = max(270.0f, end);
const float step = 3.0f;
for (float s = from; s < to; s += step) {
float e = min(s + step + 0.4f, to);
float t = fabsf((s + e) * 0.5f - 270.0f) / GAUGE_SPAN;
ringArc(GAUGE_R0, GAUGE_R1, s, e, c565(heatRGB(t)));
}
// Zarici hrot
RGB tip = heatRGB(fabsf(v) / 100.0f);
float pulse = 0.85f + 0.15f * sinf(now * 0.012f);
float rMid = (GAUGE_R0 + GAUGE_R1) * 0.5f;
glowDot(polarX(end, rMid), polarY(end, rMid), tip, pulse);
}
// =====================================================
// VLACEK
// Predek lokomotivy je na uhlu 'front', vagony jsou za ni.
// =====================================================
void drawWagon(float a0, float a1, RGB cargo)
{
ringArc(103, 117, a0, a1, c565(COL_WOOD_LIGHT));
ringArc(103, 105, a0, a1, c565(COL_WOOD_DARK));
ringArc(115, 117, a0, a1, c565(COL_WOOD_DARK));
float mid = (a0 + a1) * 0.5f;
float x = polarX(mid, TRACK_R);
float y = polarY(mid, TRACK_R);
canvas.fillSmoothCircle(x, y, 4, c565(cargo));
}
void drawLoco(float a0, float a1, float speed)
{
// Kabina
ringArc(102, 118, a0, a0 + 5.0f, c565(COL_LOCO_RED));
ringArc(107, 113, a0 + 1.3f, a0 + 3.7f, c565(COL_WINDOW));
// Kotel
ringArc(105, 115, a0 + 5.0f, a1 - 1.6f, c565(COL_LOCO_GREEN));
ringArc(105, 115, a0 + 8.0f, a0 + 8.9f, c565(COL_GOLD));
ringArc(105, 115, a0 + 11.0f, a0 + 11.9f, c565(COL_GOLD));
// Predek
ringArc(104, 116, a1 - 1.6f, a1, c565(COL_LOCO_RED));
// Komin
float chimney = a1 - 4.0f;
canvas.fillSmoothCircle(polarX(chimney, TRACK_R), polarY(chimney, TRACK_R), 4, c565(20, 20, 24));
canvas.fillSmoothCircle(polarX(chimney, TRACK_R), polarY(chimney, TRACK_R), 2, c565(70, 70, 80));
// Svetlo vpredu (pri jizde vpred sviti vic)
float lx = polarX(a1 + 0.9f, TRACK_R);
float ly = polarY(a1 + 0.9f, TRACK_R);
if (speed > 0.5f) {
glowDot(lx, ly, COL_WINDOW, 1.0f);
} else {
canvas.fillSmoothCircle(lx, ly, 2, c565(COL_WINDOW));
}
}
void drawTrain(float front, float speed)
{
const float totalDeg = LOCO_DEG + WAGON_COUNT * (WAGON_DEG + GAP_DEG);
const float tail = front - totalDeg;
// Spojka pod celym vlakem
ringArc(109, 111, tail, front, c565(40, 30, 20));
float end = front - LOCO_DEG - GAP_DEG;
for (int i = 0; i < WAGON_COUNT; i++) {
drawWagon(end - WAGON_DEG, end, CARGO[i]);
end -= WAGON_DEG + GAP_DEG;
}
// Zadni cervene svetlo pri couvani
if (speed < -0.5f) {
glowDot(polarX(tail + 1.0f, TRACK_R), polarY(tail + 1.0f, TRACK_R), COL_RED, 0.9f);
}
drawLoco(front - LOCO_DEG, front, speed);
}
// =====================================================
// KOUR Z KOMINA
// =====================================================
void spawnPuff(float chimneyAngle, float mag, float dir)
{
for (int i = 0; i < PUFF_COUNT; i++) {
Puff &p = puffs[i];
if (p.life > 0) continue;
float rad = chimneyAngle * DEG_TO_RAD;
float inX = -cosf(rad), inY = -sinf(rad); // smerem do stredu
float tX = -sinf(rad), tY = cosf(rad); // tecna ve smeru jizdy vpred
float vIn = frand(12, 22) + mag * 0.08f;
float vT = -dir * mag * 0.35f; // kour zustava za lokomotivou
p.x = polarX(chimneyAngle, TRACK_R);
p.y = polarY(chimneyAngle, TRACK_R);
p.vx = inX * vIn + tX * vT + frand(-3, 3);
p.vy = inY * vIn + tY * vT + frand(-3, 3);
p.r = 2.5f;
p.grow = 5.5f + mag * 0.05f;
p.maxLife = p.life = frand(1.0f, 1.5f);
return;
}
}
void updatePuffs(float dt)
{
for (int i = 0; i < PUFF_COUNT; i++) {
Puff &p = puffs[i];
if (p.life <= 0) continue;
p.life -= dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= 0.985f;
p.vy *= 0.985f;
p.r += p.grow * dt;
}
}
void drawPuffs()
{
for (int i = 0; i < PUFF_COUNT; i++) {
const Puff &p = puffs[i];
if (p.life <= 0) continue;
float k = p.life / p.maxLife;
canvas.fillSmoothCircle(p.x, p.y, (int)p.r, mix(COL_BG2, COL_SMOKE, k * 0.8f));
}
}
// =====================================================
// STRED DISPLEJE
// =====================================================
void drawChevrons(int dir, RGB color, uint32_t now)
{
const float speedFactor = 0.8f + fabsf(displaySpeed) / 30.0f;
const float phase = fmodf(now / 1000.0f * speedFactor, 1.0f);
const int y = CY - 40;
for (int k = 0; k < 3; k++) {
// vlna jasu bezi ve smeru jizdy
float wave = 0.5f + 0.5f * cosf(6.2832f * (phase - k / 3.0f));
uint16_t col = mix(COL_BG3, color, 0.25f + 0.75f * wave);
int x = CX + dir * (k - 1) * 12;
canvas.fillTriangle(x - dir * 4, y - 6, x - dir * 4, y + 6, x + dir * 5, y, col);
}
}
void drawCenter(uint32_t now)
{
canvas.setTextDatum(middle_center);
canvas.setTextSize(1);
const bool hornActive = hornFxStart != 0 && now - hornFxStart < 700;
const bool stopped = (!isRunning || currentSpeed == 0);
if (hornActive) {
canvas.setFont(&fonts::Orbitron_Light_32);
canvas.setTextColor(c565(COL_HORN_YELLOW));
canvas.drawString("TUUU!", CX, CY - 2);
} else if (stopped) {
float pulse = 0.55f + 0.45f * sinf(now * 0.005f);
canvas.setFont(&fonts::Orbitron_Light_32);
canvas.setTextColor(mix(COL_BG3, COL_RED, pulse));
canvas.drawString("STOP", CX, CY - 4);
} else {
RGB dirColor = currentSpeed > 0 ? COL_CYAN : COL_ORANGE;
drawChevrons(currentSpeed > 0 ? 1 : -1, dirColor, now);
char text[6];
snprintf(text, sizeof(text), "%d", abs(currentSpeed));
canvas.setFont(&fonts::Font7);
canvas.setTextColor(c565(COL_WHITE));
canvas.drawString(text, CX, CY - 2);
}
// Popisek
canvas.setFont(&fonts::Font2);
if (stopped) {
bool hint = (now / 2500) % 2;
canvas.setTextColor(c565(COL_DIM_TEXT));
canvas.drawString(hint ? "TUKNI = HOUKANI" : "OTOC KOLECKEM", CX, CY + 32);
} else if (currentSpeed > 0) {
canvas.setTextColor(c565(COL_CYAN));
canvas.drawString("VPRED", CX, CY + 32);
} else {
canvas.setTextColor(c565(COL_ORANGE));
canvas.drawString("VZAD", CX, CY + 32);
}
// Stav spojeni
const int sy = CY + 54;
if (!espNowReady) {
canvas.setTextColor(c565(COL_RED));
canvas.drawString("ESP-NOW CHYBA", CX, sy);
return;
}
const bool linkOk = lastAckOkMs != 0 && now - lastAckOkMs < LINK_TIMEOUT_MS;
if (linkOk) {
float pulse = 0.6f + 0.4f * sinf(now * 0.004f);
canvas.fillSmoothCircle(CX - 34, sy, 3, mix(COL_BG3, COL_GREEN, pulse));
canvas.setTextColor(mix(COL_BG3, COL_GREEN, 0.75f));
canvas.drawString("SPOJENO", CX + 6, sy);
} else {
bool blink = (now / 400) % 2;
canvas.fillSmoothCircle(CX - 46, sy, 3, blink ? c565(COL_RED) : c565(COL_BG3));
canvas.setTextColor(mix(COL_BG3, COL_RED, 0.8f));
canvas.drawString("HLEDAM VLAK", CX + 6, sy);
}
}
// =====================================================
// EFEKTY
// =====================================================
void drawEffects(uint32_t now)
{
// Houkani - zvukove vlny od lokomotivy
if (hornFxStart != 0 && now - hornFxStart < 900) {
float t = (now - hornFxStart) / 900.0f;
float hx = polarX(trainAngle, TRACK_R);
float hy = polarY(trainAngle, TRACK_R);
for (int k = 0; k < 3; k++) {
float tt = t * 1.4f - k * 0.2f;
if (tt <= 0 || tt >= 1) continue;
int r = 6 + (int)(tt * 40);
uint16_t col = mix(COL_BG, COL_HORN_YELLOW, 1.0f - tt);
canvas.drawCircle(hx, hy, r, col);
canvas.drawCircle(hx, hy, r + 1, col);
}
}
// STOP - cerveny zablesk po obvodu
if (stopFxStart != 0 && now - stopFxStart < 500) {
float t = (now - stopFxStart) / 500.0f;
uint16_t col = mix(COL_BG, COL_RED, 1.0f - t);
for (int r = 116; r <= 119; r++) canvas.drawCircle(CX, CY, r, col);
canvas.drawCircle(CX, CY, 99, col);
canvas.drawCircle(CX, CY, 100, col);
}
}
// =====================================================
// HLAVNI SNIMEK
// =====================================================
void renderFrame(uint32_t now)
{
float dt = (now - lastFrameMs) / 1000.0f;
lastFrameMs = now;
dt = constrain(dt, 0.0f, 0.1f);
// Animace dobiha nastavenou rychlost podobne jako rampa masinky
float target = isRunning ? (float)currentSpeed : 0.0f;
float maxStep = 70.0f * dt;
displaySpeed += constrain(target - displaySpeed, -maxStep, maxStep);
trainAngle += displaySpeed * 1.3f * dt; // 100 = ~130 stupnu/s
if (trainAngle >= 360.0f) trainAngle -= 360.0f;
if (trainAngle < 0.0f) trainAngle += 360.0f;
starRotation -= displaySpeed * 0.08f * dt;
// Kour: cim rychleji, tim casteji. Ve stoji obcas maly oblacek.
float mag = fabsf(displaySpeed);
float interval = (mag > 1.0f) ? (0.42f - mag * 0.0033f) : 1.2f;
puffTimer += dt;
if (puffTimer >= interval) {
puffTimer = 0;
spawnPuff(trainAngle - 4.0f, mag, displaySpeed >= 0 ? 1.0f : -1.0f);
}
updatePuffs(dt);
drawBackground(1.0f, now);
drawGauge(now);
drawTrack(1.0f);
drawTrain(trainAngle, displaySpeed);
drawPuffs();
drawCenter(now);
drawEffects(now);
canvas.pushSprite(0, 0);
}
// =====================================================
// UVODNI ANIMACE
// kolej se polozi, vlacek objede kolo, objevi se nazev
// =====================================================
void playIntro()
{
const uint32_t DURATION = 2800;
const uint32_t start = millis();
uint32_t last = start;
bool whistled = false;
while (true) {
const uint32_t now = millis();
const float t = (now - start) / (float)DURATION;
if (t > 1.0f) break;
float dt = (now - last) / 1000.0f;
last = now;
drawBackground(constrain(t * 3.0f, 0.0f, 1.0f), now);
drawTrack(constrain(t / 0.3f, 0.0f, 1.0f));
if (t > 0.22f) {
float tt = constrain((t - 0.22f) / 0.65f, 0.0f, 1.0f);
float eased = tt * tt * (3.0f - 2.0f * tt);
float prev = trainAngle;
trainAngle = 90.0f + eased * 540.0f;
float degPerSec = dt > 0 ? (trainAngle - prev) / dt : 0;
puffTimer += dt;
if (puffTimer > 0.07f) {
puffTimer = 0;
spawnPuff(trainAngle - 4.0f, constrain(degPerSec / 1.3f, 0.0f, 100.0f), 1.0f);
}
updatePuffs(dt);
drawTrain(trainAngle, 50.0f);
drawPuffs();
}
float ft = constrain((t - 0.45f) / 0.3f, 0.0f, 1.0f);
canvas.setTextDatum(middle_center);
canvas.setTextSize(1);
canvas.setFont(&fonts::Orbitron_Light_32);
canvas.setTextColor(mix(COL_BG3, COL_CREAM, ft));
canvas.drawString("WOODEN", CX, CY - 20);
canvas.drawString("TRAIN", CX, CY + 14);
canvas.setFont(&fonts::Font2);
canvas.setTextColor(mix(COL_BG3, COL_CYAN, ft));
canvas.drawString("ESP-NOW DIAL", CX, CY + 46);
canvas.pushSprite(0, 0);
// Parni pistala - akord ze dvou tonu
if (!whistled && t > 0.5f) {
whistled = true;
M5Dial.Speaker.tone(587, 380, 0);
M5Dial.Speaker.tone(740, 380, 1);
}
delay(5);
}
trainAngle = fmodf(trainAngle, 360.0f);
delay(350);
}
// =====================================================
// ROZLOUCENI - vlacek odjede, displej pohasne
// =====================================================
void playOutro()
{
const uint32_t DURATION = 1600;
const uint32_t start = millis();
uint32_t last = start;
float angularSpeed = 60.0f;
bool secondTone = false;
M5Dial.Speaker.tone(740, 220, 0);
M5Dial.Speaker.tone(587, 220, 1);
while (true) {
const uint32_t now = millis();
const float t = (now - start) / (float)DURATION;
if (t > 1.0f) break;
float dt = (now - last) / 1000.0f;
last = now;
angularSpeed += 700.0f * dt;
trainAngle += angularSpeed * dt;
puffTimer += dt;
if (puffTimer > 0.05f) {
puffTimer = 0;
spawnPuff(trainAngle - 4.0f, 100.0f, 1.0f);
}
updatePuffs(dt);
drawBackground(1.0f - t, now);
drawTrack(1.0f);
drawTrain(trainAngle, 100.0f);
drawPuffs();
canvas.setTextDatum(middle_center);
canvas.setTextSize(1);
canvas.setFont(&fonts::Font4);
canvas.setTextColor(mix(COL_BG3, COL_CREAM, constrain(t * 3.0f, 0.0f, 1.0f)));
canvas.drawString("NA SHLEDANOU", CX, CY);
canvas.pushSprite(0, 0);
M5Dial.Display.setBrightness((uint8_t)(BRIGHT_ACTIVE * (1.0f - t * t)));
if (!secondTone && t > 0.35f) {
secondTone = true;
M5Dial.Speaker.tone(440, 450, 0);
M5Dial.Speaker.tone(554, 450, 1);
}
delay(5);
}
canvas.fillScreen(TFT_BLACK);
canvas.pushSprite(0, 0);
}
// =====================================================
// ODESLANI ESP-NOW PAKETU
// =====================================================
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
void onDataSent(const wifi_tx_info_t *info, esp_now_send_status_t status)
#else
void onDataSent(const uint8_t *mac, esp_now_send_status_t status)
#endif
{
// Potvrzeni prijmu od masinky = spojeni funguje
if (status == ESP_NOW_SEND_SUCCESS) lastAckOkMs = millis();
}
bool sendControlPacket()
{
lastSendTime = millis();
if (!espNowReady) {
return false;
}
packet.speed = (int8_t)currentSpeed;
packet.run = isRunning;
packet.hornSeq = hornSeq;
packet.cnt++;
esp_err_t result = esp_now_send(
peerMac,
reinterpret_cast<const uint8_t *>(&packet),
sizeof(packet)
);
return result == ESP_OK;
}
// =====================================================
// ENKODER - vlastni cteni prerusenim
// =====================================================
// Tabulka platnych prechodu kvadraturniho signalu.
// Index = stary stav | (novy stav << 2), stav = A | (B << 1).
// Neplatne skoky (preskoceny stav = zakmit) se pocitaji jako 0.
static const int8_t QUAD_TABLE[16] = {
0, 1, -1, 0,
-1, 0, 0, 1,
1, 0, 0, -1,
0, -1, 1, 0
};
static inline uint8_t readEncoderPins()
{
// GPIO 40 a 41 jsou v registru GPIO_IN1 (bity 8 a 9)
uint32_t in = REG_READ(GPIO_IN1_REG);
uint8_t a = (in >> (ENC_PIN_A - 32)) & 1;
uint8_t b = (in >> (ENC_PIN_B - 32)) & 1;
return a | (b << 1);
}
void IRAM_ATTR encoderISR()
{
uint8_t s = readEncoderPins();
int8_t delta = QUAD_TABLE[encState | (s << 2)];
encState = s;
if (delta != 0) {
encCount += delta;
encLastEdgeMs = millis();
}
}
void initEncoder()
{
pinMode(ENC_PIN_A, INPUT_PULLUP);
pinMode(ENC_PIN_B, INPUT_PULLUP);
delay(2);
encState = readEncoderPins();
encCount = 0;
encRestPosition = 0;
attachInterrupt(digitalPinToInterrupt(ENC_PIN_A), encoderISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC_PIN_B), encoderISR, CHANGE);
}
// Vrati pocet cvaknuti od posledniho klidu (+ doprava, - doleva).
// Vyhodnocuje se az v klidu, takze zakmity na hrane cvaknuti se neprojevi.
int readEncoderSteps(uint32_t now)
{
// 32bitove cteni je atomicke; noInterrupts() na M5Dial rusi USB (issue #9)
uint32_t lastEdge = encLastEdgeMs;
int32_t pos = encCount;
if (pos == encRestPosition) return 0;
if (now - lastEdge < ENCODER_SETTLE_MS) return 0; // jeste se toci / kmita
int32_t diff = pos - encRestPosition;
// Samokalibrace: kolecko v klidu stoji vzdy na cvaknuti, takze rozdil je
// nasobek impulzu na cvaknuti. Kdyz se objevi rozdil 2, ma kolecko 2 na cvaknuti.
if (encCountsPerDetent == 4 && (abs(diff) % 4) == 2) {
encCountsPerDetent = 2;
}
int steps = diff / encCountsPerDetent;
encRestPosition += steps * encCountsPerDetent;
if (ENCODER_DEBUG) {
Serial.printf("ENC pos %ld, rozdil %+ld, kroku %+d (impulzu/cvaknuti %d)\n",
(long)pos, (long)diff, steps, encCountsPerDetent);
}
return steps;
}
// =====================================================
// AKCE
// =====================================================
void markActivity(uint32_t now)
{
lastActivityTime = now;
if (dimmed) {
dimmed = false;
M5Dial.Display.setBrightness(BRIGHT_ACTIVE);
}
}
void stopTrain()
{
currentSpeed = 0;
isRunning = false;
encRestPosition = encCount;
M5Dial.Speaker.tone(900, 60);
stopFxStart = millis();
sendControlPacket();
}
void soundHorn()
{
hornSeq++;
hornFxStart = millis();
if (hornFxStart == 0) hornFxStart = 1;
M5Dial.Speaker.tone(587, 350, 0);
M5Dial.Speaker.tone(740, 350, 1);
sendControlPacket();
}
// =====================================================
// VYPNUTI
// Probuzeni: tlacitkem pod koleckem (overeno na M5Dial V1.1).
// =====================================================
void goToSleep()
{
// Pred vypnutim vzdy nastavime a odesleme STOP.
currentSpeed = 0;
isRunning = false;
// STOP vysleme trikrat pro vyssi pravdepodobnost prijeti.
for (int i = 0; i < 3; i++) {
sendControlPacket();
delay(60);
}
playOutro();
delay(300);
M5Dial.Power.powerOff();
}
// =====================================================
// INICIALIZACE ESP-NOW
// =====================================================
bool initializeEspNow()
{
WiFi.mode(WIFI_STA);
WiFi.disconnect();
delay(100);
if (esp_now_init() != ESP_OK) {
Serial.println("Chyba inicializace ESP-NOW.");
return false;
}
esp_now_register_send_cb(onDataSent);
esp_now_peer_info_t peerInfo = {};
memcpy(peerInfo.peer_addr, peerMac, sizeof(peerMac));
// 0 = pouzit aktualni kanal Wi-Fi rozhrani.
peerInfo.channel = 0;
peerInfo.encrypt = false;
peerInfo.ifidx = WIFI_IF_STA;
if (esp_now_is_peer_exist(peerMac)) {
esp_now_del_peer(peerMac);
}
esp_err_t addResult = esp_now_add_peer(&peerInfo);
if (addResult != ESP_OK) {
Serial.printf("Chyba pridani ESP-NOW peeru: %d\n", (int)addResult);
esp_now_deinit();
return false;
}
Serial.print("MAC M5Dial: ");
Serial.println(WiFi.macAddress());
Serial.println("ESP-NOW inicializovano, peer channel = 0.");
return true;
}
// =====================================================
// SETUP
// =====================================================
void setup()
{
Serial.begin(115200);
delay(200);
auto config = M5.config();
M5Dial.begin(config, false, false); // enkoder knihovny ne (vlastni cteni), RFID ne
M5Dial.Display.setRotation(0);
M5Dial.Display.setBrightness(BRIGHT_ACTIVE);
M5Dial.Display.fillScreen(TFT_BLACK);
M5Dial.Speaker.setVolume(50);
// Snimkovy buffer (alokovat pred Wi-Fi, dokud je dost souvisle pameti)
canvas.setColorDepth(16);
if (!canvas.createSprite(240, 240)) {
canvas.setColorDepth(8);
canvas.createSprite(240, 240);
}
randomSeed(esp_random());
initStars();
espNowReady = initializeEspNow();
// Po kazdem zapnuti se odesle bezpecny STOP.
currentSpeed = 0;
isRunning = false;
sendControlPacket();
playIntro();
initEncoder();
lastActivityTime = millis();
lastFrameMs = millis();
lastClickTime = 0;
}
// =====================================================
// HLAVNI SMYCKA
// =====================================================
void loop()
{
M5Dial.update();
const uint32_t now = millis();
bool stateChanged = false;
// ---------------------------------------------------
// ENKODER
// ---------------------------------------------------
int encoderSteps = readEncoderSteps(now);
if (encoderSteps != 0) {
int previousSpeed = currentSpeed;
currentSpeed += encoderSteps * SPEED_STEP;
currentSpeed = constrain(currentSpeed, -SPEED_MAX, SPEED_MAX);
if (currentSpeed != previousSpeed) {
isRunning = (currentSpeed != 0);
stateChanged = true;
markActivity(now);
...
This file has been truncated, please download it to see its full contents.




Comments