티스토리 뷰

728x90

뮤토스코프 인터랙티브 설치 — 프로젝트 정리


1. 주제

사람의 이 바람개비(이 안에 자석들어있음)를 돌리면, 자석 센서가 회전을 감지하고

그 주기에 따라 영상 재생 속도와 모터 속도가 실시간으로 반응하는 인터랙티브 설치 작품.

숨 → 바람개비(자석 회전) → MLX90393 감지 → 속도 계산 → 모터 PWM(뮤토스코프 회전) + TFT 스크린 영상 프레임 동기화

 


2. 방법론

전체 흐름

[숨 → 바람개비 회전]
    → 자석 센서: 회전 1회 감지
    → period(ms) 계산 (회전 주기)
    → 영상 속도: frameDelay = period / 21
    → 모터 속도: PWM = map(period, 500, 10000, 250, 190)
    → TFT: 21프레임 루프 재생

영상 재생 기준

  • 원래 속도: 1초에 2장 (500ms/frame)
  • period = 10500ms일 때 frameDelay = 500ms → 2fps
  • 빠른 숨 → period 짧음 → 빠른 영상 + 높은 PWM
  • 느린 숨 → period 김 → 느린 영상 + 낮은 PWM

정지 감지

  • 자석이 2초 이상 센서에 고정 → 정지 판정
  • 정지 후 자석이 실제로 벗어날 때까지 재감지 차단 (waitingForRelease)
  • 정지 시 lastTrigger = 0 리셋 → 다음 회전 감지 가능

3. 하드웨어

메인 보드

  • XIAO ESP32C6 (싱글코어, 160MHz)

센서: MLX90393 자석 센서

  • 프로토콜: I2C
  • SDA: GPIO16, SCL: GPIO17
  • 설정: GAIN_1X, RES_19(XY), RES_16(Z)
  • 감지값 범위: magnitude 110~210 (자석 없을 때), 임계값 threshold = 160.0
  • 폴링: 16ms (약 60Hz)

디스플레이: ST7789 TFT (2.0", 240×320)

  • 프로토콜: 하드웨어 SPI (SPIClass(SPI))
  • CS: GPIO21, DC: GPIO22, RST: GPIO23, MOSI: GPIO18, SCK: GPIO19
  • SPI 속도: 40MHz
  • 설정: rotation(1), invertDisplay(true)
  • 이미지 위치: setAddrWindow(60, 20, 200, 200) → 320×240 화면 중앙
  • 파일읽기: 27ms, 화면출력: 24ms → 총 51ms

모터: N20 소형 DC 모터

  • 핀: GPIO0 (D0)
  • 트랜지스터: 2N2222A (TO-18 금속캔)
  • PWM 범위: 190~250 (period 기반 자동 조정)
  • 기동 최소 PWM: 200 이상

4. 회로도

[모터 회로]
ESP32C6 D0 ──[1kΩ]── 2N2222A 베이스
                      2N2222A 이미터 → GND
                      2N2222A 컬렉터 → 모터(-)
3.3V ──────────────── 모터(+)
모터 양단에 다이오드 (스트라이프→모터+)

[2N2222A TO-18 핀아웃]
탭 기준:
  탭 옆 = 이미터 → GND
  가운데 = 베이스 → 1kΩ → D0
  반대 = 컬렉터 → 모터(-)

[자석 센서 회로]
MLX90393 SDA → GPIO16
MLX90393 SCL → GPIO17
MLX90393 VCC → 3.3V
MLX90393 GND → GND

[TFT 회로]
TFT CS   → GPIO21
TFT DC   → GPIO22
TFT RST  → GPIO23
TFT MOSI → GPIO18
TFT SCK  → GPIO19
TFT VCC  → 3.3V
TFT GND  → GND
TFT BLK  → 3.3V (백라이트 ON)

5. 초기화 순서 (중요)

1. Wire.begin(16, 17)      // I2C 먼저
2. sensor.begin_I2C()
3. pinMode(MOTOR_PIN) + analogWrite  // 모터
4. spi->begin()            // SPI
5. LittleFS.begin()
6. tft.init()              // TFT 마지막

순서 틀리면 "센서 못찾음" 발생.


6. 영상 파일

  • 경로: LittleFS /img_00.raw ~ /img_20.raw
  • 포맷: RGB565 raw, 200×200px = 80,000 bytes
  • 총 21프레임
  • 바이트스왑 필수: frameBuf[i] = (val >> 8) | (val << 8)

최종 코드 

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <LittleFS.h>
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_MLX90393.h>

#define TFT_CS   21
#define TFT_DC   22
#define TFT_RST  23
#define TFT_MOSI 18
#define TFT_SCK  19
#define MOTOR_PIN 0

#define IMG_WIDTH  200
#define IMG_HEIGHT 200
#define TOTAL_IMGS 21

SPIClass* spi = new SPIClass(SPI);
Adafruit_ST7789 tft = Adafruit_ST7789(spi, TFT_CS, TFT_DC, TFT_RST);
Adafruit_MLX90393 sensor = Adafruit_MLX90393();

uint16_t frameBuf[200 * 200];

float threshold = 360.0;
bool magnetPresent = false;
bool isStopped = false;
unsigned long lastTrigger = 0;
unsigned long frameDelay = 500;
int currentFrame = 0;
unsigned long lastFrame = 0;

void setup() {
  Serial.begin(115200);
  delay(3000);
  Serial.println("시작");

  Wire.begin(16, 17);
  if (!sensor.begin_I2C()) {
    Serial.println("센서 못찾음");
    while(1);
  }
  Serial.println("센서 OK");
  sensor.setGain(MLX90393_GAIN_1X);
  sensor.setResolution(MLX90393_X, MLX90393_RES_19);
  sensor.setResolution(MLX90393_Y, MLX90393_RES_19);
  sensor.setResolution(MLX90393_Z, MLX90393_RES_16);

  ledcAttach(MOTOR_PIN, 5000, 8);
  ledcWrite(MOTOR_PIN, 200);

  spi->begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);
  LittleFS.begin(true);
  tft.init(240, 320, SPI_MODE0);
  tft.setSPISpeed(40000000);
  tft.setRotation(1);
  tft.invertDisplay(true);
  tft.fillScreen(ST77XX_BLACK);
  Serial.println("TFT OK");
}

void loop() {
  float x, y, z;
  if (sensor.readData(&x, &y, &z)) {
    float magnitude = sqrt(x*x + y*y + z*z);

    Serial.print("M:"); Serial.print(magnitude);
    Serial.print(" stopped:"); Serial.print(isStopped);
    Serial.print(" magnet:"); Serial.println(magnetPresent);

    // [1] 상승 엣지
    if (magnitude > threshold && !magnetPresent) {
      magnetPresent = true;
      unsigned long now = millis();
      Serial.println("---1---");

      if (isStopped) {
        isStopped = false;
        ledcWrite(MOTOR_PIN, 200);
        frameDelay = 500;
        Serial.println(">>> Restarting 200");
      } else if (lastTrigger > 0) {
        unsigned long period = now - lastTrigger;
        if (period > 500 && period < 10000) {
          frameDelay = period / 21;
          int pwm = map(period, 500, 10000, 250, 190);
          pwm = constrain(pwm, 190, 250);
          ledcWrite(MOTOR_PIN, pwm);
          Serial.print(">>> period: "); Serial.print(period);
          Serial.print(" PWM: "); Serial.println(pwm);
        } else {
          ledcWrite(MOTOR_PIN, 200);
          Serial.println(">>> period 범위 밖 200");
        }
      }
      lastTrigger = now;
    }

    // 자석 벗어남
    if (magnitude < threshold * 0.9) {
      magnetPresent = false;
    }
  }

  // [2] 마지막 회전 후 2초 = 정지
  if (!isStopped && lastTrigger > 0 && millis() - lastTrigger > 2000) {
    isStopped = true;
    lastTrigger = 0;
    magnetPresent = false;  // 여기
    ledcWrite(MOTOR_PIN, 0);
    Serial.println("---0--- PWM:0");
  }

  unsigned long now = millis();
  if (now - lastFrame > frameDelay) {
    char path[20];
    sprintf(path, "/img_%02d.raw", currentFrame);
    File f = LittleFS.open(path, "r");
    if (f) {
      f.read((uint8_t*)frameBuf, IMG_WIDTH * IMG_HEIGHT * 2);
      f.close();
      for (int i = 0; i < IMG_WIDTH * IMG_HEIGHT; i++) {
        uint16_t val = frameBuf[i];
        frameBuf[i] = (val >> 8) | (val << 8);
      }
      tft.startWrite();
      tft.setAddrWindow(60, 20, IMG_WIDTH, IMG_HEIGHT);
      tft.writePixels(frameBuf, IMG_WIDTH * IMG_HEIGHT);
      tft.endWrite();
    }
    currentFrame = (currentFrame + 1) % TOTAL_IMGS;
    lastFrame = now;
  }

  delay(16);
}

7. 시리얼 출력 형식

---1---   → 자석 감지 (한 바퀴)
---0---   → 정지 (2초 이상 고정)
period: 2000 frameDelay: 95 PWM: 235

8. 주요 해결 이슈 이력

문제 원인 해결

센서 못찾음 초기화 순서 잘못됨 I2C를 SPI보다 먼저
TFT 위에서 아래 렌더링 drawRGBBitmap 줄마다 호출 frameBuf + writePixels 한번에
TFT 화면출력 2169ms 소프트웨어 SPI SPIClass(SPI) 하드웨어 SPI
정지 후 재감지 안됨 lastTrigger 리셋 안됨 + waitingForRelease 누락 정지 시 lastTrigger=0, waitingForRelease 플래그
모터 안돌음 PWM 너무 낮음 최소 200 이상

9. 정지 후 모터 재시작 문제 — 시행착오 전체 기록

목표

숨을 멈추면 모터 정지 → 다시 숨을 불면 모터 재시작

시행착오 목록

시도 1: 2초 고정 감지 자석이 센서 위에 2초 이상 머물면 정지 판정. 문제: 숨을 천천히 불면 정상 회전인데도 정지 판정남. magnetSinceTime 방식 자체가 틀림.

시도 2: analogWrite(MOTOR_PIN, 0) 모터 끄기. 문제: ESP32C6에서 analogWrite(pin, 0)은 LEDC PWM 채널 자체를 해제(detach)해버림. 이후 analogWrite(pin, 200) 호출해도 채널이 없어서 무반응.

시도 3: digitalWrite(MOTOR_PIN, LOW) PWM 채널 유지하면서 끄기 시도. 문제: LEDC PWM이 켜진 상태에서 digitalWrite는 무시됨. PWM이 핀을 장악하고 있어서 효과 없음.

시도 4: analogWrite(MOTOR_PIN, 10) 완전히 끄지 않고 최소값 유지. 문제: PWM 10은 모터가 돌기엔 너무 낮고 꺼지지도 않아서 모터가 버둥대며 전류를 계속 끌어당김 → 삐 소리 + ESP32C6 전력 불안정 → 렉.

시도 5: pinMode 재초기화 + delay 킥스타트

 
 
cpp
pinMode(MOTOR_PIN, OUTPUT);
analogWrite(MOTOR_PIN, 255);
delay(50);
analogWrite(MOTOR_PIN, 200);

문제: 채널이 이미 해제된 상태라 효과 없음.

시도 6: 정지 감지 방식 변경 "자석 2초 고정" → "마지막 회전 후 2초 동안 새 회전 없음"으로 변경. 개선됨. 하지만 재시작 여전히 안됨.

시도 7: ledcAttach + ledcWrite (최종 해결) analogWrite 완전히 제거하고 LEDC API 직접 사용. ledcWrite(pin, 0)은 채널 유지하면서 0 출력 → 채널 해제 안됨.

 
 
cpp
ledcAttach(MOTOR_PIN, 5000, 8);  // setup에서 한번만
ledcWrite(MOTOR_PIN, 200);       // 켜기
ledcWrite(MOTOR_PIN, 0);         // 끄기 (채널 유지)

문제: 재시작 여전히 안됨.

시도 8: magnetPresent 리셋 누락 발견 (최종 해결) ---0--- 후 magnetPresent = true 상태로 남아있어서 상승 엣지 조건 !magnetPresent가 영원히 안 걸림. 정지 시 magnetPresent = false 추가로 완전 해결.

 
 
cpp
if (!isStopped && lastTrigger > 0 && millis() - lastTrigger > 2000) {
  isStopped = true;
  lastTrigger = 0;
  magnetPresent = false;  // 핵심
  ledcWrite(MOTOR_PIN, 0);
  Serial.println("---0---");
}

결론

두 가지가 같이 해결되어야 했음:

  1. analogWrite → ledcWrite 교체 (PWM 채널 유지)
  2. 정지 시 magnetPresent = false 리셋 (상승 엣지 재감지 가능하게)

시행착오들.. 

디스플레이: ST7789 TFT (2.0", 240×320)

  • GND → GND
  • VCC → 3.3V
  • SCL → D8
  • SDA → D10
  • RES → D5
  • DC → D4
  • CS → D3
  • BLK → 3.3V

MOSI 핀 번호를 17이라고 계속 우겼는데 18이 맞았어

바꾼 tft 되는거 빨강.

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <SPI.h>

#define TFT_CS   21
#define TFT_DC   22
#define TFT_RST  23
#define TFT_MOSI 18   // D10 = GPIO18 (맞는 번호)
#define TFT_SCK  19   // D8 = GPIO19

Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCK, TFT_RST);

void setup() {
  tft.init(240, 280);
  tft.invertDisplay(true);
  tft.fillScreen(ST77XX_RED);
}

void loop() {}

 

 

영상출력

참고 : https://ing-min.tistory.com/323

 

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <LittleFS.h>
#include <SPI.h>

#define TFT_CS   21
#define TFT_DC   22
#define TFT_RST  23
#define TFT_MOSI 18
#define TFT_SCK  19

#define IMG_WIDTH  200
#define IMG_HEIGHT 200
#define TOTAL_IMGS 21

Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCK, TFT_RST);
uint16_t lineBuf[200];

void drawRaw(int index) {
  char path[20];
  sprintf(path, "/img_%02d.raw", index);
  File f = LittleFS.open(path, "r");
  if (!f) return;
  for (int y = 0; y < IMG_HEIGHT; y++) {
    int bytesRead = 0;
    uint8_t* buf = (uint8_t*)lineBuf;
    while (bytesRead < IMG_WIDTH * 2) {
      int r = f.read(buf + bytesRead, IMG_WIDTH * 2 - bytesRead);
      if (r <= 0) break;
      bytesRead += r;
    }
    for (int x = 0; x < IMG_WIDTH; x++) {
      uint16_t val = lineBuf[x];
      lineBuf[x] = (val >> 8) | (val << 8);
    }
    tft.drawRGBBitmap(20, 40 + y, lineBuf, IMG_WIDTH, 1);
  }
  f.close();
}

void setup() {
  Serial.begin(115200);
  LittleFS.begin(true);
  tft.init(240, 280);
  tft.setRotation(0);
  tft.invertDisplay(true);
  tft.fillScreen(ST77XX_BLACK);
}

void loop() {
  for (int i = 0; i < TOTAL_IMGS; i++) {
    drawRaw(i);
    delay(80);
  }
}

 

 

영상이 위에서 아래로 선을 그리며 렌더링 되는문제

해결

ms (밀리초) 

  • 파일읽기: 27ms → LittleFS에서 80KB 읽는 시간
  • 화면출력: 24ms → SPI로 TFT에 전송하는 시간

--디버깅 코드

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <LittleFS.h>
#include <SPI.h>

#define TFT_CS   21
#define TFT_DC   22
#define TFT_RST  23
#define TFT_MOSI 18
#define TFT_SCK  19

#define IMG_WIDTH  200
#define IMG_HEIGHT 200
#define TOTAL_IMGS 21

SPIClass* spi = new SPIClass(SPI);
Adafruit_ST7789 tft = Adafruit_ST7789(spi, TFT_CS, TFT_DC, TFT_RST);
uint16_t frameBuf[200 * 200];

int currentFrame = 0;
unsigned long lastFrame = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);

  spi->begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);
  LittleFS.begin(true);
  tft.init(240, 320, SPI_MODE0);
  tft.setSPISpeed(40000000);
  tft.setRotation(0);
  tft.invertDisplay(true);
  tft.fillScreen(ST77XX_BLACK);
  Serial.println("TFT OK");
}

void loop() {
  unsigned long now = millis();
  if (now - lastFrame > 50) {
    char path[20];
    sprintf(path, "/img_%02d.raw", currentFrame);
    File f = LittleFS.open(path, "r");
    if (f) {
      f.read((uint8_t*)frameBuf, IMG_WIDTH * IMG_HEIGHT * 2);
      f.close();
      for (int i = 0; i < IMG_WIDTH * IMG_HEIGHT; i++) {
        uint16_t val = frameBuf[i];
        frameBuf[i] = (val >> 8) | (val << 8);
      }
      tft.startWrite();
      tft.setAddrWindow(20, 60, IMG_WIDTH, IMG_HEIGHT);
      tft.writePixels(frameBuf, IMG_WIDTH * IMG_HEIGHT);
      tft.endWrite();
    }
    currentFrame = (currentFrame + 1) % TOTAL_IMGS;
    lastFrame = now;
  }
}

되는 TFT 센서 코드

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <LittleFS.h>
#include <SPI.h>

#define TFT_CS   21
#define TFT_DC   22
#define TFT_RST  23
#define TFT_MOSI 18
#define TFT_SCK  19

#define IMG_WIDTH  200
#define IMG_HEIGHT 200
#define TOTAL_IMGS 21

SPIClass* spi = new SPIClass(SPI);
Adafruit_ST7789 tft = Adafruit_ST7789(spi, TFT_CS, TFT_DC, TFT_RST);
uint16_t frameBuf[200 * 200];

int currentFrame = 0;
unsigned long lastFrame = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);

  spi->begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);
  LittleFS.begin(true);
  tft.init(240, 320, SPI_MODE0);
  tft.setSPISpeed(40000000);
  tft.setRotation(1);
  tft.invertDisplay(true);
  tft.fillScreen(ST77XX_BLACK);
  Serial.println("TFT OK");
}

void loop() {
  unsigned long now = millis();
  if (now - lastFrame > 50) {
    char path[20];
    sprintf(path, "/img_%02d.raw", currentFrame);
    File f = LittleFS.open(path, "r");
    if (f) {
      f.read((uint8_t*)frameBuf, IMG_WIDTH * IMG_HEIGHT * 2);
      f.close();
      for (int i = 0; i < IMG_WIDTH * IMG_HEIGHT; i++) {
        uint16_t val = frameBuf[i];
        frameBuf[i] = (val >> 8) | (val << 8);
      }
      tft.startWrite();
      tft.setAddrWindow(60, 20, IMG_WIDTH, IMG_HEIGHT);
      tft.writePixels(frameBuf, IMG_WIDTH * IMG_HEIGHT);
      tft.endWrite();
    }
    currentFrame = (currentFrame + 1) % TOTAL_IMGS;
    lastFrame = now;
  }
}

자석감지 센서

센서: MLX90393 자석 센서

  • 프로토콜: I2C
  • SDA: GPIO16, SCL: GPIO17
  • 설정: GAIN_1X, RES_19(XY), RES_16(Z)
  • 감지값 범위: magnitude 110~210 (자석 없을 때), 임계값 threshold = 160.0
  • 폴링: 16ms (약 60Hz)

MLX90393    XIAO
3V3  →  3.3V
GND  →  GND
SDA  →  D6 (GPIO16)
SCL  →  D7 (GPIO17)

 

두개 통합코드 (자석감지 , tft)

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <LittleFS.h>
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_MLX90393.h>

#define TFT_CS   21
#define TFT_DC   22
#define TFT_RST  23
#define TFT_MOSI 18
#define TFT_SCK  19

#define IMG_WIDTH  200
#define IMG_HEIGHT 200
#define TOTAL_IMGS 21

Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCK, TFT_RST);
Adafruit_MLX90393 sensor = Adafruit_MLX90393();

uint16_t lineBuf[200];
float threshold = 200.0;
bool magnetPresent = false;
unsigned long lastTrigger = 0;
float currentSpeed = 0;
int currentFrame = 0;
unsigned long lastFrame = 0;

void drawRaw(int index) {
  char path[20];
  sprintf(path, "/img_%02d.raw", index);
  File f = LittleFS.open(path, "r");
  if (!f) return;
  for (int y = 0; y < IMG_HEIGHT; y++) {
    int bytesRead = 0;
    uint8_t* buf = (uint8_t*)lineBuf;
    while (bytesRead < IMG_WIDTH * 2) {
      int r = f.read(buf + bytesRead, IMG_WIDTH * 2 - bytesRead);
      if (r <= 0) break;
      bytesRead += r;
    }
    for (int x = 0; x < IMG_WIDTH; x++) {
      uint16_t val = lineBuf[x];
      lineBuf[x] = (val >> 8) | (val << 8);
    }
    tft.drawRGBBitmap(20, 40 + y, lineBuf, IMG_WIDTH, 1);
  }
  f.close();
}

void setup() {
  Serial.begin(115200);
  Wire.begin(16, 17);  // SDA=D6, SCL=D7

  LittleFS.begin(true);

  tft.init(240, 280);
  tft.setRotation(0);
  tft.invertDisplay(true);
  tft.fillScreen(ST77XX_BLACK);

  if (!sensor.begin_I2C()) {
    Serial.println("MLX90393 못찾음");
  } else {
    Serial.println("MLX90393 OK");
    sensor.setGain(MLX90393_GAIN_1X);
    sensor.setResolution(MLX90393_X, MLX90393_RES_19);
    sensor.setResolution(MLX90393_Y, MLX90393_RES_19);
    sensor.setResolution(MLX90393_Z, MLX90393_RES_16);
  }
}

void loop() {
  float x, y, z;
  if (sensor.readData(&x, &y, &z)) {
    float magnitude = sqrt(x*x + y*y + z*z);

    if (magnitude > threshold && !magnetPresent) {
      magnetPresent = true;
      unsigned long now = millis();
      if (lastTrigger > 0) {
        float period = (now - lastTrigger) / 1000.0;
        currentSpeed = 1.0 / period;
        Serial.print("속도: ");
        Serial.println(currentSpeed);
      }
      lastTrigger = millis();
    }
    if (magnitude < threshold * 0.7) magnetPresent = false;
  }

  // 속도에 따라 재생 속도 조절
  float frameDelay = 80;
  if (currentSpeed > 0) {
    frameDelay = max(20.0, 150.0 / (currentSpeed * TOTAL_IMGS));
  }

  unsigned long now = millis();
  if (now - lastFrame > frameDelay) {
    drawRaw(currentFrame);
    currentFrame = (currentFrame + 1) % TOTAL_IMGS;
    lastFrame = now;
  }
}

 

 

 

 

자석 110- 310 값 범위.

 

 

 

728x90
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/03   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함