Ivo Sirucek
Published © GPL3+

Smart train for wooden train

Train for wooden track - runs, reads RFID from the tracks, makes sounds, can be controlled with a rotary controller and RFID tags on the tra

AdvancedShowcase (no instructions)Over 1 day42
Smart train for wooden train

Story

Read more

Custom parts and enclosures

train02_02_lNLIpkmiUI.3mf

dial_box02_3KZG5IaRvZ.3mf

Files for 3D print

Schematics

schematic_train2_0_2026-10-05_Y2ORCBKrFz.pdf

gerber_train2_0_pcb_train2_0_2026-10-05_C5VYD7SauJ.zip

Code

WoodenTrain002.ino

C#
// =====================================================
// 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);
  }
}

WoodenTrainOvladac002.ino

C#
// =====================================================
// 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.

Credits

Ivo Sirucek
3 projects • 0 followers
I’ve enjoyed tinkering since I was young. This led to studies in microelectronics. Today, I can develop complex programs for the ESP32.

Comments