아두이노 나노로 만드는 방수 샤워 라디오: RDA5807, 노키아 LCD, PAM8403
부품 및 소모품
| 부품 |
수량 |
| 아두이노 나노 R3 |
1 |
| RDA5807 FM 라디오 모듈 |
1 |
| 노키아 5110 LCD (3V-5V 버전) |
1 |
| Tiny RTC 모듈 (DS1307 + 24C32 EEPROM) |
1 |
| PAM8403 2×3W 클래스 D 앰프 |
1 |
| LM2596 DC-DC 강압 컨버터 모듈 |
1 |
| LIR2032 3.6V 충전식 배터리 |
1 |
| 100옴 저항 |
1 |
| 1k옴 저항 |
1 |
| 10k옴 저항 |
6 |
| 100k옴 저항 |
1 |
| IRLZ44 파워 MOSFET |
1 |
| PEIYING PY1010C 60W 4옴 스피커 |
1 |
| 12V 3A 파워 서플라이 |
1 |
| 2200µF 25V 커패시터 |
2 |
| 100µF 커패시터 |
1 |
| 3W LED 전구 (G4 10W 할로겐 대체용) |
1 |
프로젝트 소개
몇 년 전 중국산 샤워 부스를 구입했는데, TR028 제어 시스템에 라디오가 내장되어 있었습니다. 어느 날 시스템이 완전히 고장 났고, 아무도 수리할 수 없어 저렴한 샤워 라디오를 하나 더 샀지만 1년 만에 또 고장 났습니다.
가족들은 샤워 부스에 라디오가 있는 걸 좋아했기에, 이 라디오들이 어떻게 만들어졌는지 연구하기 시작했습니다. 고장 난 TR028 시스템 안에서 TEA5767 모듈을 발견했는데, 검색해 보니 저렴한 FM 라디오 모듈이었습니다. 더 찾아보니 RDA5807이라는 더 나은 모듈이 있었는데, TEA5767과 비슷하지만 RDS, 볼륨 제어, 베이스 부스트 기능이 추가되어 있었습니다. 그래서 새 프로젝트에 RDA5807을 사용하기로 결정했습니다.
내가 원했던 기능
- 터치스크린으로 방수 구조 구현 (고장 난 TR028의 터치 패널 재사용)
- FM 라디오
- 즐겨찾기 방송국 프리셋 여러 개
- 볼륨 제어
- 자동 방송국 검색
- 검색한 방송국 저장 기능
- 현재 시간 표시 시계
- 전원 온/오프
- 조명 제어
- 실내 온도, RDS 등 부가 정보 표시
알리익스프레스에서 RDA5807, Tiny RTC, PAM8403, 노키아 5110 LCD, LM2596 모듈 등을 10유로 미만에 구입해 실험을 시작했습니다.
최종 구현된 기능
- 2줄 RDS 표시 FM 라디오
- 즐겨찾기 방송국 6개 프리셋
- 자동/수동 튜닝
- 검색한 방송국을 6개 프리셋 중 하나에 저장
- 볼륨 및 베이스 부스트 제어
- 샤워 부스 조명 제어
- 시계 및 캘린더
- RSSI (신호 강도 표시)
- 스테레오 모드 표시
- 전원 온/오프 (대기 모드)
작동 원리
- 전원 켜기: 전원 연결 후 라디오가 바로 켜지지 않습니다(전원 불안정 시 오작동 방지). 처음 켤 때는 전원 연결 후 몇 초 뒤 전원 키를 눌러야 합니다. 마지막으로 청취한 방송국이 볼륨 3으로 재생되며, 기본 모드는 볼륨 제어입니다. 전원 끄기는 전원 키 한 번으로 가능하며, LCD, 백라이트, 앰프, LED/할로겐 램프가 모두 꺼집니다.
- 방송국 검색: 'Mod' 버튼으로 자동/수동 튜닝 모드 전환. '<' 또는 '>' 버튼으로 주파수 감소/증가하며 검색. 'Mem' 버튼을 누르면 4초 내에 6개 프리셋 중 하나를 선택해 저장 가능.
- 날짜 확인: 'I'(Info) 키를 누르면 현재 날짜가 4초간 표시됩니다(이 부분은 delay() 사용으로 최적화 가능).
- 시계 조정: 'D' 키를 2초 이상 길게 누르면(정각 신호 소리 들릴 때 또는 정확한 시계의 마지막 초를 볼 때) 놓는 순간 hh:00:00로 설정됩니다. 시계가 15~1분 늦었으면 시가 1 증가하고 분/초가 00으로, 1~15분 빨랐으면 분/초만 00으로 설정됩니다.
개선하고 싶은 점
- RTC 모듈의 발진기 교체로 정확도 향상 (시계 조정 기능으로 보완 중)
- 5110 LCD를 더 크고 밝은 1.8" 또는 2.0" 컬러 LCD로 교체 (가독성 문제)
- PAM8403 앰프를 2×15W 출력의 PAM8610이나 TDA7297로 교체
결론
한 달간 문제없이 작동 중이며(시계 정확도 제외), 매우 만족스럽습니다. 프로그래머가 아니라 코드 최적화 여지가 많습니다. C/C++ 독학 1년 차로, 아두이노 플랫폼 첫 실용 프로젝트이자 허브 첫 공유작입니다. 미흡한 점 양해 바랍니다.
업데이트 1: 하드웨어 - 12V 라인에 2A 퓨즈 추가(안전). 소프트웨어 - 586번째 줄에 menu = 1; 추가로 전원 키 누르 후 볼륨 모드로 복구.
업데이트 2: 불행히도 LCD가 수명을 다해가고 있습니다. 5110 LCD는 작고 읽기 힘들어 재설치하지 않고 1.8" TFT LCD로 교체 실험 중입니다. 장점: 더 크고 밝고 해상도 좋음. 단점: 리소스 더 많이 소모. 조언 환영합니다.
코드
프로젝트 스케치 (아두이노)
/////////////////////////////////////////////////////////////////
// Arduino based shower FM Radio Project
// Arduino NANO, RDA5807M, RTC, EEPROM, LCD5110, Thermistor
/////////////////////////////////////////////////////////////////
#include <LCD5110_Graph.h>
#include <AT24CX.h>
#include <Wire.h>
#include <RDSParser.h>
#include <radio.h>
#include <RTClib.h>
#include <RDA5807M.h>
#include <Keypad.h>
#define MAXmenu 4
#define ledPin 13
#define blPin 7
char keys[7][2] = {
{'L', 'P'}, // LED, POWER
{'I', 'D'}, // INFO, DISPLAY
{'1', '2'}, // 프리셋 1-2
{'3', '4'}, // 프리셋 3-4
{'5', '6'}, // 프리셋 5-6
{'M', 'm'}, // MODE, MEM
{'<', '>'} // down, up
};
byte rowPins[7] = {11, 12, 10, 17, 9, 16, 8};
byte colPins[2] = {15, 14};
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, 7, 2);
boolean bass = 0, dspl = 0, memdisplay = 0, mempress = 0, adj = 0;
boolean ledPin_state, power_state;
int menu, volume, volumeOld = 5;
int frequency, frequencyOld;
int txtl = 0, temparray = 0;
int samples[5];
unsigned int status[6];
unsigned long timeprevious = 0, timeprev = 0;
AT24CX mem;
RTC_DS1307 rtc;
LCD5110 lcd(6, 5, 4, 2, 3);
RDA5807M radio;
RDSParser rds;
extern unsigned char SmallFont[];
extern uint8_t signal5[];
extern uint8_t signal4[];
extern uint8_t signal3[];
extern uint8_t signal2[];
extern uint8_t signal1[];
void setup() {
analogReference(EXTERNAL);
Serial.begin(9600);
Wire.begin();
radio.init();
radio.debugEnable();
lcd.InitLCD();
lcd.clrScr();
lcd.setFont(SmallFont);
lcd.enableSleep();
power_state = 0;
frequency = mem.readInt(201);
volume = 2;
menu = 1;
if (volume < 0) volume = 0;
if (volume > 15) volume = 15;
if (frequency < 0) frequency = 0;
if (frequency > 210) frequency = 210;
WriteReg(0x02, 0xC00d);
radio.attachReceiveRDS(RDS_process);
rds.attachServicenNameCallback(DisplayServiceName);
rds.attachTextCallback(DisplayText);
}
void loop() {
if (frequency != frequencyOld) {
frequencyOld = frequency;
mem.writeInt(201, frequency);
canal(frequency);
}
if (volume != volumeOld) {
volumeOld = volume;
WriteReg(5, 0x84D0 | volume);
}
char key = keypad.getKey();
radio.checkRDS();
// 온도 측정 (0.6초마다 5회 평균)
float average;
unsigned long timenow = millis();
if ((unsigned long)(timenow - timeprevious) > 600) {
timeprevious = timenow;
samples[temparray] = analogRead(A7);
temparray++;
}
if (temparray == 5) {
average = 0;
for (int i = 0; i < 5; i++) average += samples[i];
printTemp(average);
temparray = 0;
}
// MEM 표시 4초 타임아웃
unsigned long dabar = millis();
if (mempress == 1) { timeprev = dabar; memdisplay = 1; mempress = 0; }
if (memdisplay == 1) {
if ((unsigned long)(dabar - timeprev) < 4000) memdisplay = 1;
else memdisplay = 0;
}
// 시리얼을 통한 시간 조정 기능
DateTime now = rtc.now();
if (Serial.available() > 0) {
char t = Serial.read();
switch (t) {
case 'h': {
unsigned int hours = Serial.parseInt();
rtc.adjust(DateTime(now.year(), now.month(), now.day(), hours, now.minute(), now.second()));
Serial.println(F("Hours")); Serial.println(hours); break;
}
case 'm': {
unsigned int mins = Serial.parseInt();
rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour(), mins, now.second()));
Serial.println(F("Minutes")); Serial.println(mins); break;
}
case 's': {
unsigned int sec = Serial.parseInt();
rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour(), now.minute(), sec));
Serial.println(F("Seconds")); Serial.println(sec); break;
}
}
}
printSignalStrength();
printLines();
printTime();
printFreq();
printStereo();
printMode();
printMenu();
printDate();
lcd.update();
}
// 신호 강도 표시 (0-63을 5단계로)
void printSignalStrength() {
unsigned int sig;
Readstatus();
sig = status[1] / 1000;
if (sig <= 12) lcd.drawBitmap(1, 1, signal1, 17, 6);
else if (sig <= 24) lcd.drawBitmap(1, 1, signal2, 17, 6);
else if (sig <= 36) lcd.drawBitmap(1, 1, signal3, 17, 6);
else if (sig <= 48) lcd.drawBitmap(1, 1, signal4, 17, 6);
else lcd.drawBitmap(1, 1, signal5, 17, 6);
}
void printLines() {
lcd.drawLine(0, 9, 84, 9);
lcd.drawLine(0, 39, 84, 39);
}
void printTemp(float average) {
average /= 5;
average = 1023 / average - 1;
average = 51700 / average;
float steinhart = average / 50000;
steinhart = log(steinhart);
steinhart /= 3950;
steinhart += 1.0 / (25 + 273.15);
steinhart = 1.0 / steinhart;
steinhart -= 273.15;
int tmp = round(steinhart);
lcd.printNumI(tmp, 60, 1, 2);
lcd.print(F("~C"), 72, 1);
}
void DisplayServiceName(char *name) { lcd.print(name, 18, 22); }
void DisplayText(char *text) {
lcd.print(text, txtl, 30);
txtl = txtl - 66;
if (txtl == -396) txtl = 0;
}
void printTime() {
DateTime now = rtc.now();
lcd.printNumI(now.hour(), 24, 1, 2, '0');
lcd.print(":", 36, 1);
lcd.printNumI(now.minute(), 42, 1, 2, '0');
}
void printDate() {
if (dspl == 1) {
ClearRDS();
DateTime now = rtc.now();
lcd.printNumI(now.year(), 12, 22, 4);
lcd.print(".", 36, 22);
lcd.printNumI(now.month(), 42, 22, 2, '0');
lcd.print(".", 54, 22);
lcd.printNumI(now.day(), 60, 22, 2, '0');
int dw = now.dayOfTheWeek();
const char* days[] = {"Sekmadienis","Pirmadienis","Antradienis","Treciadienis","Ketvirtadienis","Penktadienis","Sestadienis"};
lcd.print(F(days[dw]), CENTER, 30);
lcd.update();
delay(4000);
ClearRDS();
dspl = 0;
}
}
void printMode() { lcd.print(F("MODE "), 0, 41); }
void printMenu() {
if (menu == 1) {
lcd.print(F("VOLUME "), 30, 41);
lcd.printNumI(volume + 1, 72, 41, 2, '0');
}
if (menu == 2) lcd.print(F("AUTO-TUNE"), 30, 41);
if (menu == 3) lcd.print(F("MAN.-TUNE"), 30, 41);
if (menu == 4) {
lcd.print(F(" BASS "), 30, 41);
lcd.print(bass ? F(" ON") : F("OFF"), 66, 41);
}
}
void printFreq() {
int fr = 870 + frequency;
lcd.printNumI(fr / 10, 30, 12, 3);
lcd.print(F("."), 48, 12);
lcd.printNumI(fr % 10, 54, 12, 1);
lcd.print(F("MHz"), 66, 12);
}
void printStereo() {
if (memdisplay) lcd.print(F("MEM>"), 0, 12);
else if ((status[0] & 0x0400) == 0) lcd.print(F("( )"), 0, 12);
else lcd.print(F("(ST)"), 0, 12);
}
void search(byte direc) {
byte i;
if (!direc) WriteReg(0x02, 0xC30d);
else WriteReg(0x02, 0xC10d);
for (i = 0; i < 10; i++) {
delay(200);
Readstatus();
if (status[0] & 0x4000) { frequency = status[0] & 0x03ff; break; }
}
}
void canal(int canal) {
byte numberH = canal >> 2;
byte numberL = ((canal & 3) << 6) | 0x10;
Wire.beginTransmission(0x11);
Wire.write(0x03);
Wire.write(numberH);
Wire.write(numberL);
Wire.endTransmission();
}
int Readstatus() {
Wire.requestFrom(0x10, 12);
for (int i = 0; i < 6; i++) status[i] = 256 * Wire.read() + Wire.read();
Wire.endTransmission();
}
void WriteReg(byte reg, unsigned int valor) {
Wire.beginTransmission(0x11);
Wire.write(reg);
Wire.write(valor >> 8);
Wire.write(valor & 0xFF);
Wire.endTransmission();
}
void RDS_process(uint16_t b1, uint16_t b2, uint16_t b3, uint16_t b4) {
rds.processData(b1, b2, b3, b4);
}
void ClearRDS() {
lcd.print(" ", 0, 22);
lcd.print(" ", 0, 30);
}
void keypadEvent(KeypadEvent key, KeyState kpadState) {
switch (kpadState) {
case HOLD:
if (key == 'D' && power_state == 1) adj = 1;
break;
case RELEASED:
if (key == 'D' && adj == 1) {
DateTime now = rtc.now();
if (now.minute() >= 45) rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour() + 1, 0, 0));
else if (now.minute() >= 1 && now.minute() <= 15) rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour(), 0, 0));
adj = 0;
}
break;
case PRESSED:
if (key == 'M' && power_state) { memdisplay = 0; if (++menu > MAXmenu) menu = 1; }
if (key == '>' && power_state) {
memdisplay = 0;
switch (menu) {
case 1: if (volume < 0) { if (bass) WriteReg(0x02, 0xD00D); else WriteReg(0x02, 0xC00D); volume = 0; } else volume++; if (volume > 15) volume = 15; break;
case 2: search(0); ClearRDS(); break;
case 3: if (++frequency > 210) frequency = 210; ClearRDS(); break;
case 4: if (!bass) { bass = 1; WriteReg(0x02, 0xD00D); } break;
}
}
if (key == '<' && power_state) {
memdisplay = 0;
switch (menu) {
case 1: if (--volume < 0) WriteReg(0x02, 0x800D); break;
case 2: search(1); ClearRDS(); break;
case 3: if (--frequency < 0) frequency = 0; ClearRDS(); break;
case 4: if (bass) { bass = 0; WriteReg(0x02, 0xC00D); } break;
}
}
if (key == 'L' && power_state) { digitalWrite(ledPin, !digitalRead(ledPin)); ledPin_state = digitalRead(ledPin); }
if (key == 'P') {
digitalWrite(blPin, !digitalRead(blPin));
power_state = digitalRead(blPin);
if (!power_state) { lcd.enableSleep(); digitalWrite(ledPin, LOW); }
else lcd.disableSleep();
volume = 2; menu = 1;
}
// 프리셋 1-6 처리
for (int i = 1; i <= 6; i++) {
if (key == char('0' + i) && power_state) {
int addr = 100 + i * 10;
if (!memdisplay) { frequency = mem.readInt(addr); ClearRDS(); }
else { mem.writeInt(addr, frequency); memdisplay = 0; }
}
}
if (key == 'm' && power_state) mempress = 1; else mempress = 0;
if (key == 'I' && power_state) dspl = 1;
break;
}
}
신호 강도 심볼 (C/C++)
#if defined(__AVR__)
#include <avr/pgmspace.h>
#define imagedatatype const uint8_t
#elif defined(__PIC32MX__) || defined(__arm__)
#define PROGMEM
#define imagedatatype const unsigned char
#endif
imagedatatype signal5[] PROGMEM = {0xC1,0xC2,0xC4,0xFF,0xC4,0xC2,0xC1,0xC0,0xE0,0xC0,0xF0,0xC0,0xF8,0xC0,0xFC,0xC0,0xFE};
imagedatatype signal4[] PROGMEM = {0xC1,0xC2,0xC4,0xFF,0xC4,0xC2,0xC1,0xC0,0xE0,0xC0,0xF0,0xC0,0xF8,0xC0,0xFC,0xC0,0xC0};
imagedatatype signal3[] PROGMEM = {0xC1,0xC2,0xC4,0xFF,0xC4,0xC2,0xC1,0xC0,0xE0,0xC0,0xF0,0xC0,0xF8,0xC0,0xC0,0xC0,0xC0};
imagedatatype signal2[] PROGMEM = {0xC1,0xC2,0xC4,0xFF,0xC4,0xC2,0xC1,0xC0,0xE0,0xC0,0xF0,0xC0,0xC0,0xC0,0xC0,0xC0,0xC0};
imagedatatype signal1[] PROGMEM = {0xC1,0xC2,0xC4,0xFF,0xC4,0xC2,0xC1,0xC0,0xE0,0xC0,0xC0,0xC0,0xC0,0xC0,0xC0,0xC0,0xC0};