#include <Arduino.h>
#include <ezButton.h>
#include <U8g2lib.h>
// 핀 설정
#define ENCODER_PIN_A 2 // DT
#define ENCODER_PIN_B 3 // CLK
#define ENCODER_BUTTON_PIN 1 // Button
#define BUZZER_PIN 0 // D1에 부저 연결
// 2옥타브 도~솔~도(3옥타브) 주파수 정의
#define NOTE_C2 65
#define NOTE_G2 98
#define NOTE_C3 131
// 메뉴 이동 효과음 (2옥타브 도)
void playMoveSound()
{
tone(BUZZER_PIN, NOTE_C2, 30);
delay(30);
noTone(BUZZER_PIN);
}
// 선택 효과음 (띠리릭: 도-솔-도)
void playSelectSound()
{
tone(BUZZER_PIN, NOTE_C2, 60);
delay(60);
tone(BUZZER_PIN, NOTE_G2, 60);
delay(60);
tone(BUZZER_PIN, NOTE_C3, 120);
delay(120);
noTone(BUZZER_PIN);
}
// 라이브러리 객체
volatile long encoderPosition = 0;
volatile int lastEncoded = 0;
void handleEncoder(); // 함수 선언 (ISR에서 사용되므로)
ezButton button(ENCODER_BUTTON_PIN);
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/U8X8_PIN_NONE);
// 메뉴 상태
uint8_t currentIndex = 0;
uint8_t selectedIndex = 255; // 255는 선택되지 않음을 의미 (uint8_t의 최대값)
// 엔코더 단계 저장용 (encoderPosition/4)
long prevStep = 0;
void drawMenu(uint8_t sel)
{
u8g2.clearBuffer();
uint8_t w = u8g2.getDisplayWidth(); // 128
uint8_t h = u8g2.getDisplayHeight(); // 64
uint8_t cellW = w / 3;
uint8_t cellH = h / 3;
// 메뉴 항목 그릴 때는 한글 폰트 사용
u8g2.setFont(u8g2_font_unifont_t_korean2);
for (uint8_t i = 0; i < 9; i++)
{
uint8_t x = (i % 3) * cellW;
uint8_t y = (i / 3) * cellH;
// 폰트 렌더링 모드 설정 (중요: 투명 배경, 기본값)
u8g2.setFontMode(0);
u8g2.setDrawColor(1); // 기본 그리기 색상 (흰색)
if (i == sel)
{
// 선택된 항목: 반전 배경
u8g2.drawBox(x, y, cellW, cellH); // 배경 채우기
u8g2.setDrawColor(0); // 글자 색상 (검정색)
char buf[2] = {(char)('1' + i), 0};
uint8_t tw = u8g2.getStrWidth(buf);
// u8g2.getAscent() 사용 권장 (폰트 높이 기준)
uint8_t th = u8g2.getAscent() - u8g2.getDescent();
u8g2.drawStr(x + (cellW - tw) / 2, y + (cellH + th) / 2, buf);
u8g2.setDrawColor(1); // 다음 루프를 위해 그리기 색상 복원
}
else
{
// 선택되지 않은 항목: 테두리만
u8g2.drawFrame(x, y, cellW, cellH);
// 글자 색상 (흰색) - 위에서 setDrawColor(1) 했으므로 필요 없음
char buf[2] = {(char)('1' + i), 0};
uint8_t tw = u8g2.getStrWidth(buf);
uint8_t th = u8g2.getAscent() - u8g2.getDescent();
u8g2.drawStr(x + (cellW - tw) / 2, y + (cellH + th) / 2, buf);
}
}
u8g2.sendBuffer();
}
// 선택 확정 메시지 출력
void drawSelection(uint8_t idx)
{
playSelectSound(); // 선택 효과음
u8g2.clearBuffer();
// *** 수정된 부분: 깨짐 현상이 보고된 폰트 대신 다른 기본 폰트 사용 ***
// u8g2.setFont(u8g2_font_5x7_tr); // 기존 폰트 (숫자 9에서 문제 발생 가능성)
u8g2.setFont(u8g2_font_ncenB08_tr); // 다른 안정적인 기본 ASCII 폰트 사용 (추천)
// 또는 u8g2_font_6x10_tf 등 다른 폰트도 가능
// 폰트 렌더링 모드 설정 (투명 배경)
u8g2.setFontMode(0);
u8g2.setDrawColor(1); // 글자 색상 (흰색)
char buf[16];
// idx는 0부터 시작하므로 사용자에게 보여줄 때는 +1
snprintf(buf, sizeof(buf), "Selected: %d", idx + 1);
// 디버깅: 시리얼 출력은 유지
Serial.print("drawSelection: idx=");
Serial.print(idx); // 실제 인덱스(0~8)
Serial.print(", string=");
Serial.println(buf); // 화면에 표시될 문자열
// 문자열 폭과 높이 계산
uint8_t tw = u8g2.getStrWidth(buf);
uint8_t th = u8g2.getAscent() - u8g2.getDescent(); // 폰트의 실제 높이
// 화면 중앙에 출력
u8g2.drawStr((u8g2.getDisplayWidth() - tw) / 2, (u8g2.getDisplayHeight() + th) / 2, buf);
u8g2.sendBuffer();
delay(1000); // 1초 동안 선택 메시지 표시
}
void setup()
{
// 시리얼
Serial.begin(9600);
Serial.println("Setup Start");
// I2C 속도 400kHz로 올리기 (FPS 향상)
u8g2.begin();
#if defined(TWBR)
Wire.setClock(400000);
#endif
// 엔코더 핀
pinMode(ENCODER_PIN_A, INPUT_PULLUP);
pinMode(ENCODER_PIN_B, INPUT_PULLUP);
// 버튼 핀 (ezButton 라이브러리가 내부적으로 처리하므로 pinMode 불필요)
// pinMode(ENCODER_BUTTON_PIN, INPUT_PULLUP); // ezButton 사용 시 불필요
// 버튼 설정
button.setDebounceTime(50); // 디바운스 시간 설정
// 초기 엔코더 값 읽기 (인터럽트 설정 전)
int MSB = digitalRead(ENCODER_PIN_A);
int LSB = digitalRead(ENCODER_PIN_B);
lastEncoded = (MSB << 1) | LSB;
// 인터럽트 설정
attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_A), handleEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_B), handleEncoder, CHANGE);
// OLED 초기화
if (!u8g2.begin())
{
Serial.println("U8g2 initialization failed!");
while (1)
; // 실패 시 무한 루프
}
Serial.println("U8g2 Initialized");
// 초기 화면 그리기
drawMenu(currentIndex);
// prevStep 초기화 (초기 encoderPosition 값 기준)
prevStep = encoderPosition / 4;
Serial.println("Setup Complete");
}
void loop()
{
// 버튼 상태 갱신 (매 루프마다 호출 필수)
button.loop();
// 버튼이 눌렸을 때
if (button.isPressed())
{
selectedIndex = currentIndex; // 현재 인덱스를 선택된 인덱스로 저장
Serial.print("Button Pressed. Selected Index: ");
Serial.println(selectedIndex); // 디버깅: 선택된 인덱스(0~8) 출력
// 선택된 번호(1~9)를 화면에 표시
drawSelection(selectedIndex);
// 선택 후 엔코더 값과 prevStep 동기화 (선택 화면 후 메뉴 복귀 시 현재 위치 유지)
// 중요: delay(1000) 동안 엔코더가 돌아갈 수 있으므로, drawSelection 후에 동기화
noInterrupts(); // 인터럽트 잠시 비활성화 (encoderPosition 안전하게 읽기)
prevStep = encoderPosition / 4;
interrupts(); // 인터럽트 다시 활성화
// selectedIndex를 바로 초기화하지 않고, released 상태에서 메뉴를 다시 그림
}
// 버튼이 떼어졌고, 이전에 선택이 완료되었을 때 (drawSelection이 호출된 후)
else if (button.isReleased() && selectedIndex != 255)
{
Serial.println("Button Released after selection. Returning to menu.");
selectedIndex = 255; // 선택 상태 초기화 (다시 메뉴 조작 가능하게)
// 현재 인덱스 기준으로 메뉴 다시 그리기
drawMenu(currentIndex);
}
// 엔코더 값 변화 감지 (선택되지 않은 상태일 때만 메뉴 이동)
if (selectedIndex == 255) // 선택 메시지가 표시 중이지 않을 때만 엔코더 처리
{
// 인터럽트 비활성화하고 volatile 변수 읽기 (더 안전)
long currentEncoderPos;
noInterrupts();
currentEncoderPos = encoderPosition;
interrupts();
long step = currentEncoderPos / 4; // 4틱 당 1단계
if (step != prevStep)
{
prevStep = step;
// 인덱스 계산 (0~8 범위, 음수 처리 포함)
currentIndex = ((step % 9) + 9) % 9;
Serial.print("Encoder Changed. Current Index: ");
Serial.println(currentIndex); // 디버깅: 변경된 인덱스 출력
playMoveSound(); // 메뉴 이동시 효과음
// 메뉴 다시 그리기
drawMenu(currentIndex);
}
}
}
// 엔코더 인터럽트 핸들러
void handleEncoder()
{
int MSB = digitalRead(ENCODER_PIN_A); // digitalRead는 ISR에서 안전함
int LSB = digitalRead(ENCODER_PIN_B);
int encoded = (MSB << 1) | LSB; // 현재 상태 (2비트)
int sum = (lastEncoded << 2) | encoded; // 이전 상태와 현재 상태 결합 (4비트)
// 표준 쿼드러처 엔코더 상태 전이 테이블 기반
// 시계 방향 (CW)
if (sum == 0b1101 || sum == 0b0100 || sum == 0b0010 || sum == 0b1011)
{
encoderPosition++;
}
// 반시계 방향 (CCW)
if (sum == 0b1110 || sum == 0b0111 || sum == 0b0001 || sum == 0b1000)
{
encoderPosition--;
}
lastEncoded = encoded; // 다음 비교를 위해 현재 상태 저장
}