Upload files to "/"

This commit is contained in:
2026-03-27 21:45:58 +00:00
commit 2681e792c4

451
AquaLED_Controller.ino Normal file
View File

@@ -0,0 +1,451 @@
/*
* ============================================================
* AquaLED Controller - ESP32-WROOM-32 (30pin)
* GPIO13 -> PWM LED (수초 어항용)
* ESP32 Arduino Core 3.x 전용
* 보드: ESP32 Dev Module
* ============================================================
*/
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
#define LED_PIN 13
#define PWM_RESOLUTION 8
const char* AP_SSID = "AquaLED_AP";
const char* AP_PASS = "aqua1234";
const char* NTP_SERVER = "pool.ntp.org";
const long GMT_OFFSET = 9L * 3600L;
const int DST_OFFSET = 0;
Preferences prefs;
WebServer server(80);
struct Config {
int duty;
int freq;
int onH, onM;
int offH, offM;
bool scheduleOn;
bool manualState;
String staSSID;
String staPASS;
};
Config cfg;
bool ntpSynced = false;
bool staConnected = false;
bool needReconnect = false;
void loadConfig() {
prefs.begin("aled", true);
cfg.duty = prefs.getInt ("duty", 200);
cfg.freq = prefs.getInt ("freq", 1000);
cfg.onH = prefs.getInt ("onH", 8);
cfg.onM = prefs.getInt ("onM", 0);
cfg.offH = prefs.getInt ("offH", 22);
cfg.offM = prefs.getInt ("offM", 0);
cfg.scheduleOn = prefs.getBool ("sched", true);
cfg.manualState = prefs.getBool ("mstate", false);
cfg.staSSID = prefs.getString("ssid", "");
cfg.staPASS = prefs.getString("pass", "");
prefs.end();
}
void saveConfig() {
prefs.begin("aled", false);
prefs.putInt ("duty", cfg.duty);
prefs.putInt ("freq", cfg.freq);
prefs.putInt ("onH", cfg.onH);
prefs.putInt ("onM", cfg.onM);
prefs.putInt ("offH", cfg.offH);
prefs.putInt ("offM", cfg.offM);
prefs.putBool ("sched", cfg.scheduleOn);
prefs.putBool ("mstate", cfg.manualState);
prefs.putString("ssid", cfg.staSSID);
prefs.putString("pass", cfg.staPASS);
prefs.end();
}
// Core 3.x: 핀 번호로 PWM 제어
void applyPWM(int duty) {
ledcWrite(LED_PIN, constrain(duty, 0, 255));
}
void reconfigPWM(int freq, int duty) {
ledcDetach(LED_PIN);
ledcAttach(LED_PIN, freq, PWM_RESOLUTION);
ledcWrite(LED_PIN, constrain(duty, 0, 255));
}
void startAP() {
WiFi.softAP(AP_SSID, AP_PASS);
Serial.printf("[AP] SSID: %s IP: %s\n", AP_SSID, WiFi.softAPIP().toString().c_str());
}
bool connectSTA(const String& ssid, const String& pass, int timeoutMs = 10000) {
if (ssid.length() == 0) return false;
Serial.printf("[STA] 연결 시도: %s\n", ssid.c_str());
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long t = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t < (unsigned long)timeoutMs) {
delay(300);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[STA] 연결됨 IP: %s\n", WiFi.localIP().toString().c_str());
configTime(GMT_OFFSET, DST_OFFSET, NTP_SERVER);
return true;
}
Serial.println("[STA] 연결 실패");
return false;
}
void tryNTP() {
if (ntpSynced) return;
struct tm t;
if (getLocalTime(&t, 2000)) {
ntpSynced = true;
Serial.printf("[NTP] 동기화: %04d-%02d-%02d %02d:%02d:%02d\n",
t.tm_year+1900, t.tm_mon+1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec);
}
}
void checkSchedule() {
if (!cfg.scheduleOn || !ntpSynced) return;
struct tm t;
if (!getLocalTime(&t, 500)) return;
int now = t.tm_hour * 60 + t.tm_min;
int onT = cfg.onH * 60 + cfg.onM;
int offT = cfg.offH * 60 + cfg.offM;
bool shouldOn;
if (onT < offT) {
shouldOn = (now >= onT && now < offT);
} else {
shouldOn = (now >= onT || now < offT);
}
static bool prevState = false;
if (shouldOn != prevState) {
prevState = shouldOn;
applyPWM(shouldOn ? cfg.duty : 0);
Serial.printf("[SCHED] LED %s\n", shouldOn ? "ON" : "OFF");
}
}
String getTimeStr() {
if (!ntpSynced) return "NTP 미동기화";
struct tm t;
if (!getLocalTime(&t, 500)) return "시간 오류";
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &t);
return String(buf);
}
String buildPage() {
char onBuf[6], offBuf[6];
snprintf(onBuf, 6, "%02d:%02d", cfg.onH, cfg.onM);
snprintf(offBuf, 6, "%02d:%02d", cfg.offH, cfg.offM);
int pct = cfg.duty * 100 / 255;
String staIP = staConnected ? WiFi.localIP().toString() : "-";
String timeStr = getTimeStr();
String ntpBadge = ntpSynced
? "<span class='badge ok'>동기화됨</span>"
: "<span class='badge err'>미동기화</span>";
String staBadge = staConnected
? "<span class='badge ok'>연결됨</span>"
: "<span class='badge err'>미연결</span>";
String schedSel = cfg.scheduleOn ? "checked" : "";
String manualSel = !cfg.scheduleOn ? "checked" : "";
String manStyle = cfg.scheduleOn ? "none" : "block";
String schedStyle = cfg.scheduleOn ? "block" : "none";
String ledState = "";
if (!cfg.scheduleOn) {
ledState = cfg.manualState
? "<span class='badge ok'>ON</span>"
: "<span class='badge err'>OFF</span>";
}
String html = "<!DOCTYPE html><html lang='ko'><head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>AquaLED Controller</title>"
"<style>"
"@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Noto+Sans+KR:wght@300;400;500&display=swap');"
":root{"
"--bg:#050f1a;--card:#0b1e30;--border:#0e3050;"
"--acc:#00e5ff;--acc2:#0088aa;--text:#cde8ff;"
"--muted:#4a7a9b;--on:#00ff88;--off:#ff4455;"
"}"
"*{box-sizing:border-box;margin:0;padding:0}"
"body{background:var(--bg);color:var(--text);font-family:'Noto Sans KR',sans-serif;min-height:100vh;"
"background-image:radial-gradient(ellipse at 20% 10%,#001e3440 0%,transparent 60%),"
"radial-gradient(ellipse at 80% 90%,#003a5530 0%,transparent 60%)}"
"header{background:linear-gradient(135deg,#061525,#0b2540);"
"border-bottom:1px solid var(--border);padding:18px 20px;text-align:center;"
"position:relative;overflow:hidden}"
"header::before{content:'';position:absolute;inset:0;"
"background:repeating-linear-gradient(90deg,transparent,transparent 40px,#00aaff08 40px,#00aaff08 41px);"
"pointer-events:none}"
"header h1{font-family:'Orbitron',monospace;font-size:1.35em;letter-spacing:.1em;"
"color:var(--acc);text-shadow:0 0 20px #00e5ff66}"
"header h1 span{font-size:.6em;color:var(--muted);display:block;margin-top:2px;"
"font-family:'Noto Sans KR',sans-serif;font-weight:300;letter-spacing:.05em}"
".meta{display:flex;gap:16px;justify-content:center;flex-wrap:wrap;"
"font-size:.78em;color:var(--muted);margin-top:10px}"
".meta strong{color:var(--text)}"
".badge{display:inline-block;padding:1px 8px;border-radius:20px;font-size:.75em;font-weight:500}"
".badge.ok{background:#00ff8818;color:var(--on);border:1px solid #00ff8844}"
".badge.err{background:#ff445518;color:var(--off);border:1px solid #ff445544}"
".wrap{max-width:580px;margin:0 auto;padding:16px 14px}"
".card{background:var(--card);border:1px solid var(--border);border-radius:14px;"
"padding:20px;margin-bottom:14px;position:relative;overflow:hidden}"
".card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;"
"background:linear-gradient(90deg,transparent,var(--acc),transparent)}"
".card h2{font-family:'Orbitron',monospace;font-size:.85em;letter-spacing:.12em;"
"color:var(--acc);margin-bottom:16px;display:flex;align-items:center;gap:8px}"
"label{display:block;font-size:.8em;color:var(--muted);margin:12px 0 4px}"
"input[type=range]{width:100%;accent-color:var(--acc);cursor:pointer;height:4px}"
"input[type=number],input[type=text],input[type=password],input[type=time]{"
"width:100%;padding:9px 12px;background:#061525;border:1px solid var(--border);"
"border-radius:8px;color:var(--text);font-size:.9em;outline:none;transition:border-color .2s}"
"input:focus{border-color:var(--acc)}"
".vrow{display:flex;justify-content:space-between;align-items:center;margin-top:4px;font-size:.82em}"
".vrow .val{font-family:'Orbitron',monospace;color:var(--acc)}"
".grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}"
".seg{display:flex;border-radius:8px;overflow:hidden;border:1px solid var(--border);margin-top:6px}"
".seg input{display:none}"
".seg label{flex:1;text-align:center;padding:8px;cursor:pointer;background:#061525;"
"color:var(--muted);font-size:.85em;margin:0;transition:.2s}"
".seg input:checked+label{background:var(--acc2);color:#fff}"
".btn{display:block;width:100%;padding:11px;border:none;border-radius:8px;font-size:.92em;"
"cursor:pointer;margin-top:14px;font-family:'Noto Sans KR',sans-serif;font-weight:500;"
"transition:opacity .2s,transform .1s}"
".btn:active{transform:scale(.98);opacity:.85}"
".btn-primary{background:linear-gradient(135deg,var(--acc2),#00ccdd);color:#fff}"
".btn-on{background:linear-gradient(135deg,#007744,#00bb66);color:#fff}"
".btn-off{background:linear-gradient(135deg,#880022,#cc3344);color:#fff}"
".manual-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}"
".info-row{display:flex;justify-content:space-between;align-items:center;"
"padding:6px 0;border-bottom:1px solid var(--border);font-size:.84em}"
".info-row:last-child{border-bottom:none}"
".info-row .lbl{color:var(--muted)}"
".info-row .val{font-family:'Orbitron',monospace;font-size:.82em;color:var(--acc)}"
".fish{position:absolute;right:16px;top:14px;font-size:1.6em;opacity:.15;"
"animation:swim 6s ease-in-out infinite}"
"@keyframes swim{0%,100%{transform:translateY(0) rotate(-3deg)}50%{transform:translateY(4px) rotate(3deg)}}"
"</style></head><body>"
"<header>"
"<h1>&#x1F420; AQUALED <span>어항 LED 컨트롤러</span></h1>"
"<div class='meta'>"
"<span>&#x1F550; <strong>" + timeStr + "</strong> " + ntpBadge + "</span>"
"<span>&#x1F4F6; STA <strong>" + staIP + "</strong> " + staBadge + "</span>"
"</div></header>"
"<div class='wrap'>"
// PWM 카드
"<div class='card'><div class='fish'>&#x1F4A1;</div>"
"<h2>&#x26A1; PWM 제어</h2>"
"<form action='/set_pwm' method='GET'>"
"<label>밝기 (Duty Cycle)</label>"
"<input type='range' name='duty' min='0' max='255' value='" + String(cfg.duty) + "'"
" oninput=\"document.getElementById('dv').textContent=this.value\">"
"<div class='vrow'>"
"<span style='color:var(--muted);font-size:.8em'>0 &larr; 어둡게 / 밝게 &rarr; 255</span>"
"<span class='val'><span id='dv'>" + String(cfg.duty) + "</span>"
"<small style='font-size:.7em;color:var(--muted)'> (" + String(pct) + "%)</small></span>"
"</div>"
"<label>PWM 주파수 (Hz) &nbsp;<small style='color:var(--muted)'>- 낮을수록 플리커 줄어듦</small></label>"
"<input type='number' name='freq' value='" + String(cfg.freq) + "' min='1' max='40000'>"
"<button type='submit' class='btn btn-primary'>&#x2713; PWM 적용</button>"
"</form></div>"
// 스케줄 카드
"<div class='card'><div class='fish'>&#x23F0;</div>"
"<h2>&#x1F4C5; 동작 설정</h2>"
"<form action='/set_schedule' method='GET'>"
"<label>동작 모드</label>"
"<div class='seg'>"
"<input type='radio' name='mode' id='ms' value='sched' " + schedSel + " onchange='toggleManual(false)'>"
"<label for='ms'>&#x23F1; 스케줄</label>"
"<input type='radio' name='mode' id='mm' value='manual' " + manualSel + " onchange='toggleManual(true)'>"
"<label for='mm'>&#x1F590; 수동</label>"
"</div>"
"<div id='schedBlock' style='display:" + schedStyle + "'>"
"<div class='grid2'>"
"<div><label>&#x1F305; 켜지는 시각</label>"
"<input type='time' name='on_time' value='" + String(onBuf) + "'></div>"
"<div><label>&#x1F319; 꺼지는 시각</label>"
"<input type='time' name='off_time' value='" + String(offBuf) + "'></div>"
"</div>"
"<p style='font-size:.75em;color:var(--muted);margin-top:8px'>"
"&#x203B; 꺼지는 시각이 켜지는 시각보다 이를 경우 자정을 넘기는 스케줄로 처리됩니다</p>"
"</div>"
"<div id='manualBlock' style='display:" + manStyle + "'>"
"<p style='font-size:.82em;color:var(--muted);margin-top:10px'>현재 상태: " + ledState + "</p>"
"<div class='manual-row'>"
"<button type='button' class='btn btn-on' onclick=\"location.href='/manual?s=1'\">&#x1F4A1; 켜기</button>"
"<button type='button' class='btn btn-off' onclick=\"location.href='/manual?s=0'\">&#x1F319; 끄기</button>"
"</div></div>"
"<button type='submit' class='btn btn-primary' style='margin-top:16px'>&#x2713; 스케줄 저장</button>"
"</form></div>"
// WiFi 카드
"<div class='card'><div class='fish'>&#x1F4E1;</div>"
"<h2>&#x1F4F6; 외부 WiFi 연결 (STA)</h2>"
"<form action='/set_wifi' method='GET'>"
"<label>SSID (네트워크 이름)</label>"
"<input type='text' name='ssid' value='" + cfg.staSSID + "' placeholder='공유기 SSID'>"
"<label>비밀번호</label>"
"<input type='password' name='pass' placeholder='비밀번호 (변경 시에만 입력)' autocomplete='off'>"
"<p style='font-size:.75em;color:var(--muted);margin-top:6px'>"
"&#x203B; 저장 후 STA 재연결합니다. AP(192.168.4.1)는 계속 유지됩니다</p>"
"<button type='submit' class='btn btn-primary'>&#x2713; 저장 후 연결</button>"
"</form></div>"
// 시스템 정보 카드
"<div class='card'>"
"<h2>&#x2139;&#xFE0F; 시스템 정보</h2>"
"<div class='info-row'><span class='lbl'>AP SSID</span><span class='val'>" + String(AP_SSID) + "</span></div>"
"<div class='info-row'><span class='lbl'>AP PW</span><span class='val'>" + String(AP_PASS) + "</span></div>"
"<div class='info-row'><span class='lbl'>AP IP</span><span class='val'>192.168.4.1</span></div>"
"<div class='info-row'><span class='lbl'>STA IP</span><span class='val'>" + staIP + "</span></div>"
"<div class='info-row'><span class='lbl'>GPIO</span><span class='val'>13</span></div>"
"<div class='info-row'><span class='lbl'>Duty / Freq</span>"
"<span class='val'>" + String(cfg.duty) + " / " + String(cfg.freq) + " Hz</span></div>"
"</div>"
"</div>" // /wrap
"<script>"
"function toggleManual(m){"
"document.getElementById('schedBlock').style.display=m?'none':'block';"
"document.getElementById('manualBlock').style.display=m?'block':'none';"
"}"
"setTimeout(function(){location.reload();},30000);"
"</script>"
"</body></html>";
return html;
}
void setupRoutes() {
server.on("/", HTTP_GET, []() {
server.send(200, "text/html; charset=utf-8", buildPage());
});
server.on("/set_pwm", HTTP_GET, []() {
if (server.hasArg("duty"))
cfg.duty = constrain(server.arg("duty").toInt(), 0, 255);
if (server.hasArg("freq"))
cfg.freq = constrain(server.arg("freq").toInt(), 1, 40000);
reconfigPWM(cfg.freq, 0);
if (!cfg.scheduleOn && cfg.manualState) applyPWM(cfg.duty);
saveConfig();
server.sendHeader("Location", "/");
server.send(302, "text/plain", "");
});
server.on("/set_schedule", HTTP_GET, []() {
if (server.hasArg("mode"))
cfg.scheduleOn = (server.arg("mode") == "sched");
if (server.hasArg("on_time")) {
String t = server.arg("on_time");
if (t.length() >= 5) { cfg.onH = t.substring(0,2).toInt(); cfg.onM = t.substring(3,5).toInt(); }
}
if (server.hasArg("off_time")) {
String t = server.arg("off_time");
if (t.length() >= 5) { cfg.offH = t.substring(0,2).toInt(); cfg.offM = t.substring(3,5).toInt(); }
}
if (!cfg.scheduleOn) applyPWM(cfg.manualState ? cfg.duty : 0);
saveConfig();
server.sendHeader("Location", "/");
server.send(302, "text/plain", "");
});
server.on("/manual", HTTP_GET, []() {
if (server.hasArg("s")) {
cfg.manualState = (server.arg("s") == "1");
applyPWM(cfg.manualState ? cfg.duty : 0);
saveConfig();
}
server.sendHeader("Location", "/");
server.send(302, "text/plain", "");
});
server.on("/set_wifi", HTTP_GET, []() {
if (server.hasArg("ssid")) cfg.staSSID = server.arg("ssid");
if (server.hasArg("pass") && server.arg("pass").length() > 0)
cfg.staPASS = server.arg("pass");
saveConfig();
needReconnect = true;
server.sendHeader("Location", "/");
server.send(302, "text/plain", "");
});
server.onNotFound([]() {
server.send(404, "text/plain", "Not Found");
});
}
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("\n====== AquaLED Controller ======");
loadConfig();
// Core 3.x PWM 초기화 (ledcSetup / ledcAttachPin 없음)
ledcAttach(LED_PIN, cfg.freq, PWM_RESOLUTION);
applyPWM(0);
Serial.printf("[PWM] GPIO%d %dHz 8bit\n", LED_PIN, cfg.freq);
WiFi.mode(WIFI_AP_STA);
startAP();
staConnected = connectSTA(cfg.staSSID, cfg.staPASS);
if (staConnected) tryNTP();
setupRoutes();
server.begin();
Serial.println("[WEB] 서버 시작 -> http://192.168.4.1");
}
unsigned long lastScheduleCheck = 0;
unsigned long lastNtpRetry = 0;
void loop() {
server.handleClient();
if (needReconnect) {
needReconnect = false;
WiFi.disconnect();
ntpSynced = false;
staConnected = false;
delay(200);
staConnected = connectSTA(cfg.staSSID, cfg.staPASS, 10000);
if (staConnected) tryNTP();
}
unsigned long now = millis();
if (now - lastScheduleCheck >= 30000UL) {
lastScheduleCheck = now;
staConnected = (WiFi.status() == WL_CONNECTED);
checkSchedule();
}
if (!ntpSynced && staConnected && (now - lastNtpRetry >= 60000UL)) {
lastNtpRetry = now;
tryNTP();
}
delay(5);
}