#include #include #include #include #include #include #include #include #include #include #include #include // ========== Debug Configuration ========== #define SERIAL_DEBUG true #define LOG(message) do { \ if (SERIAL_DEBUG) { \ Serial.flush(); \ Serial.print("["); \ Serial.print(millis()); \ Serial.print("] "); \ Serial.println(message); \ } \ } while (0) // ========== Server Configuration ========== const char* VOICERSS_API_KEY = "90927de8275148d79080facd20fb486c"; const char* VOICERSS_URL = "http://api.voicerss.org/?key=%s&hl=%s&v=%s&c=WAV&f=22khz_16bit_mono&src=%s"; // ========== Configurable Parameters ========== char mqtt_server[40] = "192.168.1.5"; char ip_server_laravel[40] = "192.168.110.10"; const char* IP_SERVER_LARAVEL = "192.168.110.10"; const int SERVER_PORT = 8000; const char* API_ENDPOINT = "/api/bell-events"; // ========== MQTT Configuration ========== const char* MQTT_SERVER = "192.168.1.5"; const int MQTT_PORT = 1883; const char* MQTT_CLIENT_ID = "esp32_bel_sekolah"; const char* MQTT_USER = ""; const char* MQTT_PASSWORD = ""; // MQTT Topics const char* TOPIC_COMMAND_STATUS = "bel/sekolah/command/status"; const char* TOPIC_COMMAND_RING = "bel/sekolah/command/ring"; const char* TOPIC_COMMAND_SYNC = "bel/sekolah/command/sync"; const char* TOPIC_RESPONSE_STATUS = "bel/sekolah/response/status"; const char* TOPIC_RESPONSE_ACK = "bel/sekolah/response/ack"; const char* TOPIC_EVENT_SCHEDULE = "bel/sekolah/events/schedule"; const char* TOPIC_EVENT_MANUAL = "bel/sekolah/events/manual"; const char* TOPIC_CONTROL_RELAY = "control/relay"; const char* TOPIC_TTS_PLAY = "tts/play"; const char* TOPIC_ANNOUNCEMENT_STATUS = "announcement/status"; // ========== Hardware Configuration ========== #define RELAY_COUNT 48 #define I2C_SDA_PIN 8 #define I2C_SCL_PIN 9 #define DFPLAYER_RX_PIN 17 #define DFPLAYER_TX_PIN 18 // Konfigurasi I2S #define I2S_BCK_PIN 12 #define I2S_WS_PIN 13 #define I2S_DOUT_PIN 14 #define SAMPLE_RATE 22050 // ========== Hardware Components ========== WiFiClient espClient; PubSubClient mqttClient(espClient); DFRobotDFPlayerMini dfPlayer; RTC_DS3231 rtc; PCF8575 relayController; PCF8575 relayController2; PCF8575 relayController3; void setupRTC(uint8_t i2cAddress); void setupRelayController(uint8_t i2cAddress); // ========== System State ========== struct ActiveSchedule { int index = -1; unsigned long startTime = 0; }; struct SystemState { bool wifiConnected = false; bool rtcConnected = false; bool dfPlayerConnected = false; bool mqttConnected = false; bool relayController1Connected = false; bool relayController2Connected = false; bool relayController3Connected = false; bool isPlaying = false; unsigned long lastCommunication = 0; unsigned long lastSync = 0; unsigned long lastNtpSync = 0; int scheduleCount = 0; bool isSchedulePlaying = false; unsigned long scheduleCooldownStart = 0; const unsigned long SCHEDULE_COOLDOWN = 60000; int currentPlayingSchedule = -1; uint16_t relayStates1 = 0xFFFF; uint16_t relayStates2 = 0xFFFF; uint16_t relayStates3 = 0xFFFF; ActiveSchedule activeSchedules[3]; unsigned long lastI2CCheck = 0; bool i2cStable = true; int i2cErrorCount = 0; unsigned long lastI2CRecovery = 0; }; // ========== Global Variables ========== bool isTTSPending = false; String lastTTSPayload; unsigned long streamStartTime = 0; const unsigned long STREAM_TIMEOUT = 30000; Preferences preferences; // ========== Schedule Storage ========== struct Schedule { String day; // "Senin", "Selasa", etc. String time; // "HH:MM" String fileNumber; // "0001" int volume = 15; // 0-30 int repeat = 1; // 1-5 bool isActive = true; }; SystemState state; Schedule schedules[50]; // Max 50 schedules int scheduleCount = 0; // Konfigurasi I2S i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX), .sample_rate = 22050, .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT, // Stereo format, walau mono .communication_format = I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 16, // 8 buffer .dma_buf_len = 512, // 1024 samples per buffer .use_apll = false, // Nonaktifkan APLL, gunakan PLL_D2_CLK .tx_desc_auto_clear = true, // Auto clear TX descriptor .fixed_mclk = 0 // Biarkan kosong untuk clock default }; i2s_pin_config_t pin_config = { .bck_io_num = I2S_BCK_PIN, .ws_io_num = I2S_WS_PIN, .data_out_num = I2S_DOUT_PIN, .data_in_num = I2S_PIN_NO_CHANGE }; // ========== NTP Client ========== WiFiUDP ntpUDP; // Ganti di deklarasi NTPClient NTPClient timeClient(ntpUDP, "id.pool.ntp.org", 7 * 3600, 60000); // UTC+7 dengan server Indonesia // ========== Setup Functions ========== void setup() { Serial.begin(115200); Serial.println("\n\n==== BOOT ===="); LOG("Serial initialized"); LOG("Starting School Bell System - ESP32-S3 N16R8"); LOG("Firmware Version: 1.0.0"); LOG("CPU Frequency: " + String(getCpuFrequencyMhz()) + "MHz"); // Optimasi khusus ESP32-S3 setupESP32S3(); // Hardcode I2C address untuk PCF8575 Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN); Wire.setClock(100000); Wire.setTimeOut(250); setupRelayController(0x20, 1); setupRelayController(0x21, 2); setupRelayController(0x22, 3); // Tetap coba setup RTC dengan scan setupRTC(0x68); // Coba address default DS3231 (0x68) scanI2CDevices(); setupI2S(); setupDFPlayer(); setupWiFi(); syncRTCWithNTP(); setupMQTT(); loadSchedulesFromPreferences(); LOG("System initialization complete"); } void setupESP32S3() { // Konfigurasi khusus untuk ESP32-S3 WiFi.setTxPower(WIFI_POWER_19_5dBm); // Maksimal power untuk jangkauan lebih baik WiFi.setSleep(false); // Nonaktifkan sleep mode untuk koneksi lebih stabil // Atur clock CPU ke 240MHz untuk performa maksimal setCpuFrequencyMhz(240); LOG("CPU Frequency: " + String(getCpuFrequencyMhz()) + "MHz"); // Enable brownout detector esp_sleep_enable_timer_wakeup(1); } void setupWiFi() { WiFi.mode(WIFI_STA); WiFi.setTxPower(WIFI_POWER_19_5dBm); WiFi.setAutoReconnect(true); WiFi.persistent(true); // Nonaktifkan power save mode esp_wifi_set_ps(WIFI_PS_NONE); WiFi.setSleep(false); // Baca konfigurasi yang tersimpan preferences.begin("wifi_config", true); String savedSSID = preferences.getString("ssid", ""); String savedPass = preferences.getString("pass", ""); String savedMqtt = preferences.getString("mqtt_server", ""); String savedLaravel = preferences.getString("ip_server_laravel", ""); preferences.end(); if (savedMqtt.length() > 0) strcpy(mqtt_server, savedMqtt.c_str()); if (savedLaravel.length() > 0) strcpy(ip_server_laravel, savedLaravel.c_str()); // Jika sudah ada kredensial WiFi, coba konek langsung if (savedSSID.length() > 0) { WiFi.begin(savedSSID.c_str(), savedPass.c_str()); unsigned long startAttemptTime = millis(); while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 10000) { delay(100); } if (WiFi.status() == WL_CONNECTED) { state.wifiConnected = true; LOG("WiFi connected using saved credentials"); LOG("IP Address: " + WiFi.localIP().toString()); return; } } // Jika tidak berhasil, buka portal config startConfigPortal(); } void startConfigPortal() { WiFiManager wifiManager; // Buat parameter custom WiFiManagerParameter custom_mqtt_server("mqtt", "MQTT Server", mqtt_server, 40); WiFiManagerParameter custom_laravel_server("laravel", "Laravel Server", ip_server_laravel, 40); wifiManager.addParameter(&custom_mqtt_server); wifiManager.addParameter(&custom_laravel_server); wifiManager.setConfigPortalTimeout(60); wifiManager.setConnectTimeout(30); if (!wifiManager.autoConnect("ESP32-S3-Bell-AP")) { LOG("Failed to connect and hit timeout"); delay(3000); ESP.restart(); } // Simpan parameter yang diinput strcpy(mqtt_server, custom_mqtt_server.getValue()); strcpy(ip_server_laravel, custom_laravel_server.getValue()); // Simpan ke preferences preferences.begin("wifi_config", false); preferences.putString("ssid", WiFi.SSID()); preferences.putString("pass", WiFi.psk()); preferences.putString("mqtt_server", mqtt_server); preferences.putString("ip_server_laravel", ip_server_laravel); preferences.end(); state.wifiConnected = true; LOG("WiFi connected. IP: " + WiFi.localIP().toString()); LOG("MQTT Server: " + String(mqtt_server)); LOG("Laravel Server: " + String(ip_server_laravel)); } void resetConfiguration() { preferences.begin("wifi_config", false); preferences.clear(); preferences.end(); LOG("Configuration reset. Restarting..."); delay(1000); ESP.restart(); } // Modified setupRTC to accept address void setupRTC(uint8_t i2cAddress) { // Try DS3231 first rtc = RTC_DS3231(); if (rtc.begin()) { state.rtcConnected = true; LOG("DS3231 RTC initialized at 0x" + String(i2cAddress, HEX)); syncRTCWithNTP(); return; } // If DS3231 fails, try other RTC types LOG("RTC not detected at 0x" + String(i2cAddress, HEX)); } void scanI2CDevices() { byte error, address; int nDevices = 0; LOG("Scanning I2C bus..."); for(address = 1; address < 127; address++) { Wire.beginTransmission(address); error = Wire.endTransmission(); if (error == 0) { LOG("I2C device found at 0x" + String(address, HEX)); nDevices++; } else if (error == 4) { LOG("Unknown error at 0x" + String(address, HEX)); } } if (nDevices == 0) { LOG("No I2C devices found"); } else { LOG("Found " + String(nDevices) + " I2C devices"); } } void setupDFPlayer() { LOG("Initializing DFPlayer..."); Serial2.begin(9600, SERIAL_8N1, DFPLAYER_RX_PIN, DFPLAYER_TX_PIN); LOG("Checking DFPlayer connection..."); int retry = 0; while (!dfPlayer.begin(Serial2) && retry < 5) { LOG("DFPlayer not responding, retrying... (" + String(retry+1) + "/5)"); retry++; } if (retry >= 5) { LOG("DFPlayer initialization FAILED!"); LOG("Possible causes:"); LOG("1. Wrong RX/TX wiring (harus cross: TX->RX, RX->TX)"); LOG("2. Power insufficient (butuhkan 3.3V-5V stabil)"); LOG("3. SD card tidak terdeteksi (format FAT32)"); state.dfPlayerConnected = false; return; } state.dfPlayerConnected = true; dfPlayer.enableDAC(); // Aktifkan DAC output dfPlayer.volume(25); dfPlayer.outputDevice(DFPLAYER_DEVICE_SD); LOG("DFPlayer initialized successfully"); LOG("Current volume: " + String(dfPlayer.readVolume())); } // setupRelayController to accept address void setupRelayController(uint8_t i2cAddress, int controllerNumber) { int retryCount = 0; const int maxRetries = 3; while (retryCount < maxRetries) { if (controllerNumber == 1) { relayController = PCF8575(i2cAddress); if (relayController.begin()) { state.relayController1Connected = true; relayController.write16(0xFFFF); LOG("Relay controller 1 initialized at 0x" + String(i2cAddress, HEX)); return; } } else if (controllerNumber == 2) { relayController2 = PCF8575(i2cAddress); if (relayController2.begin()) { state.relayController2Connected = true; relayController2.write16(0xFFFF); LOG("Relay controller 2 initialized at 0x" + String(i2cAddress, HEX)); return; } } else if (controllerNumber == 3) { relayController3 = PCF8575(i2cAddress); if (relayController3.begin()) { state.relayController3Connected = true; relayController3.write16(0xFFFF); LOG("Relay controller 3 initialized at 0x" + String(i2cAddress, HEX)); return; } } LOG("PCF8575 " + String(controllerNumber) + " tidak terdeteksi di 0x" + String(i2cAddress, HEX) + ", retrying (" + String(retryCount+1) + "/" + String(maxRetries) + ")"); retryCount++; delay(100); } // Jika sampai sini berarti gagal if (controllerNumber == 1) { state.relayController1Connected = false; } else if (controllerNumber == 2) { state.relayController2Connected = false; } else if (controllerNumber == 3) { state.relayController3Connected = false; } LOG("Gagal menginisialisasi PCF8575 " + String(controllerNumber) + " setelah " + String(maxRetries) + " percobaan"); } void setupI2S() { esp_err_t err; // 1. Uninstall driver hanya jika diperlukan (dengan penanganan error) err = i2s_driver_uninstall(I2S_NUM_0); if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { LOG("Gagal uninstall driver I2S. Error: " + String(err)); // Lanjutkan saja karena mungkin belum terinstall } // 2. Set konfigurasi buffer baru i2s_config.dma_buf_count = 16; i2s_config.dma_buf_len = 512; // 3. Install driver err = i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL); if (err != ESP_OK) { LOG("Gagal install driver I2S. Error: " + String(err)); if (err == ESP_ERR_INVALID_ARG) { LOG("Invalid configuration"); } else if (err == ESP_ERR_NO_MEM) { LOG("Out of memory"); } return; } // 4. Set pin configuration err = i2s_set_pin(I2S_NUM_0, &pin_config); if (err != ESP_OK) { LOG("Gagal set pin I2S. Error: " + String(err)); i2s_driver_uninstall(I2S_NUM_0); return; } // 5. Set clock rate err = i2s_set_clk(I2S_NUM_0, SAMPLE_RATE, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_MONO); if (err != ESP_OK) { LOG("Gagal set clock I2S. Error: " + String(err)); i2s_driver_uninstall(I2S_NUM_0); return; } // 6. Clear buffer i2s_zero_dma_buffer(I2S_NUM_0); // 7. Log informasi LOG("I2S driver berhasil diinisialisasi"); LOG("Konfigurasi I2S Terbaru:"); LOG("- Sample Rate: " + String(SAMPLE_RATE) + " Hz"); LOG("- Bit Depth: 16-bit"); LOG("- Channel: Mono"); LOG("- DMA Buffers: " + String(i2s_config.dma_buf_count) + " buffers"); LOG("- Samples per Buffer: " + String(i2s_config.dma_buf_len)); LOG("- Total Buffer Size: " + String(i2s_config.dma_buf_count * i2s_config.dma_buf_len * 2) + " bytes"); } void setupMQTT() { mqttClient.setServer(mqtt_server, MQTT_PORT); // Gunakan variabel yang bisa dikonfig mqttClient.setCallback([](char* topic, uint8_t* payload, unsigned int length) { mqttCallback(topic, payload, length); }); mqttClient.setKeepAlive(60); mqttClient.setSocketTimeout(30); mqttClient.setBufferSize(4096); } // ========== Main Loop ========== void loop() { maintainMQTTConnection(); mqttClient.loop(); static unsigned long lastSecondTick = 0; if (millis() - lastSecondTick >= 1000) { lastSecondTick = millis(); checkSchedules(); } // Periksa koneksi I2C setiap 5 detik if (millis() - state.lastI2CCheck > 30000) { state.lastI2CCheck = millis(); bool controller1OK = checkI2CConnection(0x20); bool controller2OK = checkI2CConnection(0x21); bool controller3OK = checkI2CConnection(0x22); if (!controller1OK || !controller2OK) { state.i2cErrorCount++; state.i2cStable = false; // Jika error mencapai 3 kali, coba reset I2C if (state.i2cErrorCount >= 3) { recoverI2CBus(); state.i2cErrorCount = 0; } } else { state.i2cStable = true; } logI2CStatus(); } for (int i = 0; i < 3; i++) { if (state.activeSchedules[i].index != -1 && millis() - state.activeSchedules[i].startTime >= 60000) { LOG("Resetting active schedule index: " + String(i)); state.activeSchedules[i].index = -1; } } if (millis() - state.lastNtpSync > 24 * 60 * 60 * 1000) { LOG("Syncing RTC with NTP"); syncRTCWithNTP(); } state.lastCommunication = millis() / 1000; } // ========== Time Functions ========== void syncRTCWithNTP() { if (WiFi.status() != WL_CONNECTED) { LOG("WiFi not connected for NTP sync"); return; } // Perbaiki dengan menambahkan pengecekan waktu yang valid timeClient.forceUpdate(); unsigned long epochTime = timeClient.getEpochTime(); // Validasi waktu (harus antara tahun 2020-2030) if (epochTime > 1577836800 && epochTime < 1893456000) { // 2020-2030 rtc.adjust(DateTime(epochTime)); state.lastNtpSync = millis(); LOG("RTC synced with NTP: " + getCurrentDateTime()); } else { LOG("Invalid NTP time received: " + String(epochTime)); LOG("Skipping RTC sync to prevent year 2036 problem"); } } String getCurrentDateTime() { DateTime now = rtc.now(); return String(now.year()) + "-" + String(now.month()) + "-" + String(now.day()) + " " + formatTime(now.hour(), now.minute(), now.second()); } String formatTime(int h, int m, int s) { char buf[9]; // Format HH:MM:SS sprintf(buf, "%02d:%02d:%02d", h, m, s); return String(buf); } String getDayName(int dayIndex) { const char* days[] = {"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"}; return String(days[dayIndex]); } // ========== MQTT Functions ========== void maintainMQTTConnection() { if (!mqttClient.connected()) { reconnectMQTT(); } } void reconnectMQTT() { static unsigned long lastAttempt = 0; const unsigned long retryInterval = 5000; // 5 seconds if (millis() - lastAttempt < retryInterval) { return; } lastAttempt = millis(); LOG("Attempting MQTT connection..."); if (mqttClient.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASSWORD)) { state.mqttConnected = true; subscribeToMQTTTopics(); sendSystemStatus(); LOG("MQTT connected and subscribed"); } else { state.mqttConnected = false; LOG("MQTT connection failed, rc=" + String(mqttClient.state())); } } void subscribeToMQTTTopics() { mqttClient.subscribe(TOPIC_COMMAND_STATUS, 1); // QoS 1 mqttClient.subscribe(TOPIC_COMMAND_RING, 1); // QoS 1 mqttClient.subscribe("bel/sekolah/command/sync", 1); // QoS=1 mqttClient.subscribe(TOPIC_RESPONSE_ACK, 1); // QoS 1 - Critical for sync confirmation mqttClient.subscribe(TOPIC_RESPONSE_STATUS, 1); // QoS 1 mqttClient.subscribe(TOPIC_CONTROL_RELAY, 1); mqttClient.subscribe(TOPIC_TTS_PLAY, 1); LOG("Subscribed to all MQTT topics"); } void mqttCallback(char* topic, uint8_t* payload, unsigned int length) { String message; for (int i = 0; i < length; i++) { message += (char)payload[i]; } LOG("MQTT [" + String(topic) + "]: " + message); if (strcmp(topic, TOPIC_COMMAND_STATUS) == 0) { handleStatusRequest(); } else if (strcmp(topic, TOPIC_COMMAND_RING) == 0) { handleRingCommand(message); } else if (strcmp(topic, TOPIC_COMMAND_SYNC) == 0) { handleScheduleSync(message); } else if (strcmp(topic, TOPIC_CONTROL_RELAY) == 0) { handleRelayControl(message); } else if (strcmp(topic, TOPIC_TTS_PLAY) == 0) { handleTTSPlay(message); } } // ========== Command Handlers ========== void handleStatusRequest() { sendSystemStatus(); } void handleRelayControl(String payload) { DynamicJsonDocument doc(256); DeserializationError error = deserializeJson(doc, payload); if (error) { LOG("Error parsing relay control: " + String(error.c_str())); return; } String action = doc["action"] | ""; String mode = doc["mode"] | "manual"; JsonArray ruangans = doc["ruang"].as(); if (action == "activate") { for (int room : ruangans) { if (room >= 0 && room < RELAY_COUNT) { setRelayState(room, true); } } } else if (action == "deactivate") { for (int room : ruangans) { if (room >= 0 && room < RELAY_COUNT) { setRelayState(room, false); } } } if (mode != "tts") { sendRelayStatusUpdate(); } } void handleTTSPlay(String payload) { lastTTSPayload = payload; DynamicJsonDocument doc(512); DeserializationError error = deserializeJson(doc, payload); if (error) { LOG("Error parsing TTS play: " + String(error.c_str())); return; } String text = doc["teks"] | ""; if (text.isEmpty()) { LOG("Empty TTS text"); return; } String lang = doc["hl"] | "id-id"; String voice = doc["v"] | "intan"; JsonArray ruangans = doc["ruang"].as(); // Nyalakan relay terlebih dahulu for (int room : ruangans) { if (room >= 0 && room < RELAY_COUNT) { setRelayState(room, true); } } sendRelayStatusUpdate(); // Mulai streaming TTS playTTS(text, lang, voice); // Tunggu sampai pemutaran selesai unsigned long startTime = millis(); while (state.isPlaying && millis() - startTime < STREAM_TIMEOUT) { delay(10); // Jangan boros CPU } // Setelah selesai, matikan relay for (int room : ruangans) { if (room >= 0 && room < RELAY_COUNT) { setRelayState(room, false); } } sendRelayStatusUpdate(); lastTTSPayload = ""; // Kosongkan payload } void handleRingCommand(String payload) { static String lastPayload; static unsigned long lastTime = 0; // Cek duplikat dalam 5 detik terakhir if (payload == lastPayload && millis() - lastTime < 5000) { LOG("Ignoring duplicate ring command"); return; } lastPayload = payload; lastTime = millis(); DynamicJsonDocument doc(256); DeserializationError error = deserializeJson(doc, payload); if (error) { LOG("Ring command JSON error: " + String(error.c_str())); sendAckResponse("error", "Invalid ring command format"); return; } if (!doc.containsKey("file_number")) { sendAckResponse("error", "Missing file_number"); return; } playBellSound( doc["file_number"].as(), doc["volume"] | 15, doc["repeat"] | 1, "manual" ); } // ========== Relay Control Functions ========== void setRelayState(uint8_t relayNum, bool active) { if (relayNum >= RELAY_COUNT) return; // Jika I2C tidak stabil, coba recovery dulu if (!state.i2cStable && millis() - state.lastI2CRecovery > 5000) { recoverI2CBus(); } // Coba operasi hingga 3 kali for (int attempt = 0; attempt < 3; attempt++) { bool success = false; if (relayNum < 16) { if (!state.relayController1Connected) break; if (active) { state.relayStates1 &= ~(1 << relayNum); } else { state.relayStates1 |= (1 << relayNum); } Wire.beginTransmission(0x20); Wire.write(lowByte(state.relayStates1)); Wire.write(highByte(state.relayStates1)); byte error = Wire.endTransmission(); success = (error == 0); } else if (relayNum < 32) { if (!state.relayController2Connected) break; uint8_t pin = relayNum - 16; if (active) { state.relayStates2 &= ~(1 << pin); } else { state.relayStates2 |= (1 << pin); } Wire.beginTransmission(0x21); Wire.write(lowByte(state.relayStates2)); Wire.write(highByte(state.relayStates2)); byte error = Wire.endTransmission(); success = (error == 0); } else { if (!state.relayController3Connected) break; uint8_t pin = relayNum - 32; if (active) { state.relayStates3 &= ~(1 << pin); } else { state.relayStates3 |= (1 << pin); } Wire.beginTransmission(0x22); Wire.write(lowByte(state.relayStates3)); Wire.write(highByte(state.relayStates3)); byte error = Wire.endTransmission(); success = (error == 0); } if (success) { LOG("Relay " + String(relayNum) + " set to " + (active ? "ON" : "OFF")); return; } LOG("Attempt " + String(attempt+1) + " failed for relay " + String(relayNum)); delay(10); } LOG("Failed to set relay state after 3 attempts"); state.i2cStable = false; } void setAllRelays(bool active) { if (active) { state.relayStates1 = 0x0000; // Semua relay controller 1 ON state.relayStates2 = 0x0000; // Semua relay controller 2 ON state.relayStates3 = 0x0000; // Semua relay controller 3 ON } else { state.relayStates1 = 0xFFFF; // Semua relay controller 1 OFF state.relayStates2 = 0xFFFF; // Semua relay controller 2 OFF state.relayStates3 = 0xFFFF; // Semua relay controller 3 OFF } // Kirim ke semua controller yang terhubung if (state.relayController1Connected) { relayController.write16(state.relayStates1); delay(10); // Jeda singkat antara operasi I2C } if (state.relayController2Connected) { relayController2.write16(state.relayStates2); delay(10); } if (state.relayController3Connected) { relayController3.write16(state.relayStates3); delay(10); } } void sendRelayStatusUpdate() { DynamicJsonDocument doc(256); doc["status"] = "relay_update"; JsonArray activeRelays = doc.createNestedArray("active_relays"); for (int i = 0; i < RELAY_COUNT; i++) { bool isActive = (i < 16) ? !(state.relayStates1 & (1 << i)) : !(state.relayStates2 & (1 << (i - 16))); if (isActive) activeRelays.add(i); } String payload; serializeJson(doc, payload); mqttClient.publish(TOPIC_ANNOUNCEMENT_STATUS, payload.c_str()); } void handleScheduleSync(String payload) { LOG("Received sync payload: " + payload); DynamicJsonDocument doc(2048); DeserializationError error = deserializeJson(doc, payload); if (error) { LOG("JSON error: " + String(error.c_str())); sendAckResponse("error", "Invalid schedule format"); return; } if (!doc.containsKey("schedules")) { LOG("Missing schedules array"); sendAckResponse("error", "No schedules provided"); return; } JsonArray schedulesArray = doc["schedules"]; LOG("Found " + String(schedulesArray.size()) + " schedules"); // Simpan ke Preferences saveSchedulesToPreferences(schedulesArray); // Update schedule di memory updateSchedules(schedulesArray); } void saveSchedulesToPreferences(JsonArray schedulesArray) { // Buka namespace preferences preferences.begin("bell_schedules", false); // Hapus data lama preferences.clear(); // Simpan jumlah schedule preferences.putUInt("count", schedulesArray.size()); // Simpan setiap schedule for (int i = 0; i < schedulesArray.size(); i++) { JsonObject s = schedulesArray[i]; String prefix = "s" + String(i) + "_"; preferences.putString((prefix + "day").c_str(), s["hari"].as()); preferences.putString((prefix + "time").c_str(), s["waktu"].as().substring(0, 5)); preferences.putString((prefix + "file").c_str(), s["file_number"].as()); preferences.putInt((prefix + "vol").c_str(), s["volume"] | 15); preferences.putInt((prefix + "rep").c_str(), s["repeat"] | 1); preferences.putBool((prefix + "active").c_str(), s["is_active"] | true); } preferences.end(); LOG("Schedules saved to flash"); } void loadSchedulesFromPreferences() { preferences.begin("bell_schedules", true); scheduleCount = preferences.getUInt("count", 0); LOG("Loading " + String(scheduleCount) + " schedules from flash"); for (int i = 0; i < scheduleCount; i++) { String prefix = "s" + String(i) + "_"; schedules[i].day = preferences.getString((prefix + "day").c_str(), ""); schedules[i].time = preferences.getString((prefix + "time").c_str(), ""); schedules[i].fileNumber = preferences.getString((prefix + "file").c_str(), ""); schedules[i].volume = preferences.getInt((prefix + "vol").c_str(), 15); schedules[i].repeat = preferences.getInt((prefix + "rep").c_str(), 1); schedules[i].isActive = preferences.getBool((prefix + "active").c_str(), true); LOG("Loaded schedule: " + schedules[i].day + " " + schedules[i].time + " File:" + schedules[i].fileNumber); } preferences.end(); state.scheduleCount = scheduleCount; } void updateSchedules(JsonArray schedulesArray) { scheduleCount = 0; for (int i = 0; i < schedulesArray.size() && scheduleCount < 50; i++) { JsonObject s = schedulesArray[i]; if (!s.containsKey("hari") || !s.containsKey("waktu") || !s.containsKey("file_number")) { LOG("Skipping invalid schedule - missing required fields"); continue; } schedules[scheduleCount] = { s["hari"].as(), // day s["waktu"].as().substring(0, 5), // time s["file_number"].as(),// fileNumber s["volume"] | 15, // volume s["repeat"] | 1, // repeat s["is_active"] | true // isActive }; LOG("Added schedule: " + schedules[scheduleCount].day + " " + schedules[scheduleCount].time + " File:" + schedules[scheduleCount].fileNumber); scheduleCount++; } state.scheduleCount = scheduleCount; state.lastSync = millis() / 1000; LOG("Total schedules: " + String(scheduleCount)); } // ========== Fungsi Audio ========== void playTTS(String text, String language, String voice) { LOG("playTTS() started"); // [1] Persiapan Awal esp_wifi_set_ps(WIFI_PS_NONE); WiFi.setSleep(false); LOG("WiFi power settings adjusted"); // [2] Setup I2S i2s_zero_dma_buffer(I2S_NUM_0); i2s_stop(I2S_NUM_0); LOG("I2S driver reset"); i2s_set_clk(I2S_NUM_0, SAMPLE_RATE, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_MONO); i2s_start(I2S_NUM_0); LOG("I2S clock started"); // [3] Download Audio String url = "http://api.voicerss.org/?key=" + String(VOICERSS_API_KEY) + "&hl=" + language + "&v=" + voice + "&c=WAV&f=22khz_16bit_mono&src=" + urlEncode(text); LOG("Fetching TTS URL: " + url); HTTPClient http; http.setReuse(true); http.setTimeout(60000); if (!http.begin(url)) { LOG("HTTP Begin failed"); return; } LOG("HTTP client started"); int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { LOG("HTTP Error: " + String(httpCode)); http.end(); return; } LOG("HTTP Response OK"); // [4] Baca Data Sekaligus int contentLength = http.getSize(); LOG("Content length: " + String(contentLength)); if (contentLength <= 44) { LOG("Invalid content length"); http.end(); return; } uint8_t* audioBuffer = (uint8_t*)ps_malloc(contentLength); if (!audioBuffer) { LOG("Memory allocation failed"); http.end(); return; } LOG("Memory allocated: " + String(contentLength)); int bytesRead = http.getStreamPtr()->readBytes(audioBuffer, contentLength); http.end(); if (bytesRead != contentLength) { LOG("Download incomplete (" + String(bytesRead) + "/" + String(contentLength) + ")"); free(audioBuffer); return; } LOG("Audio data downloaded"); // [5] Proses Audio Data uint8_t* audioData = audioBuffer + 44; // Skip header WAV size_t audioSize = contentLength - 44; // [6] Pre-fill DMA Buffer (SOLUSI UTAMA) size_t prefillSize = 4096; // Isi sebagian buffer DMA terlebih dahulu if (audioSize > prefillSize) { size_t bytesWritten; i2s_write(I2S_NUM_0, audioData, prefillSize, &bytesWritten, portMAX_DELAY); audioData += prefillSize; audioSize -= prefillSize; } // [7] Mainkan Sisa Audio state.isPlaying = true; isTTSPending = true; size_t totalWritten = 0; while (audioSize > 0 && state.isPlaying) { size_t bytesWritten; esp_err_t err = i2s_write(I2S_NUM_0, audioData, audioSize, &bytesWritten, portMAX_DELAY); if (err != ESP_OK) { LOG("I2S Write Error: " + String(err)); break; } audioData += bytesWritten; audioSize -= bytesWritten; totalWritten += bytesWritten; // Beri jeda kecil untuk memastikan DAC tidak overflow if (bytesWritten > 2048) { delay(1); } } // [9] Cleanup free(audioBuffer); state.isPlaying = false; // Audio sudah dikirim LOG("Audio stream selesai"); } // ========== Fungsi Pendukung ========== String urlEncode(String str) { String encodedString = ""; char c; char code0; char code1; for (unsigned int i = 0; i < str.length(); i++) { c = str.charAt(i); if (c == ' ') { encodedString += '+'; } else if (isalnum(c)) { encodedString += c; } else { code1 = (c & 0xf) + '0'; if ((c & 0xf) > 9) { code1 = (c & 0xf) - 10 + 'A'; } c = (c >> 4) & 0xf; code0 = c + '0'; if (c > 9) { code0 = c - 10 + 'A'; } encodedString += '%'; encodedString += code0; encodedString += code1; } } return encodedString; } bool checkI2CConnection(uint8_t address) { Wire.beginTransmission(address); byte error = Wire.endTransmission(); if (error == 0) { return true; } else { LOG("I2C error at 0x" + String(address, HEX) + ": " + getI2CErrorString(error)); return false; } } String getI2CErrorString(byte error) { switch(error) { case 0: return "Success"; case 1: return "Data too long"; case 2: return "NACK on address"; case 3: return "NACK on data"; case 4: return "Other error"; default: return "Unknown error"; } } void recoverI2CBus() { LOG("Attempting I2C bus recovery..."); // 1. Stop I2C Wire.end(); delay(100); // 2. Kembalikan pin ke default pinMode(I2C_SDA_PIN, INPUT_PULLUP); pinMode(I2C_SCL_PIN, INPUT_PULLUP); delay(100); // 3. Coba clock out any stuck bits for (int i = 0; i < 10; i++) { pinMode(I2C_SCL_PIN, OUTPUT); digitalWrite(I2C_SCL_PIN, LOW); delayMicroseconds(10); digitalWrite(I2C_SCL_PIN, HIGH); pinMode(I2C_SCL_PIN, INPUT_PULLUP); delayMicroseconds(10); } // 4. Re-init I2C Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN); Wire.setClock(100000); Wire.setTimeOut(250); LOG("I2C bus recovery completed"); state.lastI2CRecovery = millis(); // Set ulang status relay controller if (state.relayController1Connected) { relayController.write16(state.relayStates1); } if (state.relayController2Connected) { relayController2.write16(state.relayStates2); } if (state.relayController3Connected) { relayController3.write16(state.relayStates3); } } void logI2CStatus() { DynamicJsonDocument doc(512); // Perbesar ukuran buffer doc["i2c_stable"] = state.i2cStable; doc["i2c_error_count"] = state.i2cErrorCount; doc["controller1_connected"] = state.relayController1Connected; doc["controller2_connected"] = state.relayController2Connected; doc["controller3_connected"] = state.relayController3Connected; doc["last_recovery"] = state.lastI2CRecovery; String payload; serializeJson(doc, payload); LOG("I2C Status: " + payload); } // ========== Bell Functions ========== void playBellSound(String fileNumber, int volume, int repeat, const char* triggerType) { if (!state.dfPlayerConnected) { LOG("DFPlayer not connected - cannot play sound"); sendAckResponse("error", "DFPlayer not connected"); return; } // Set volume ke maksimal (30) int finalVolume = 25; // Ubah ini dari variabel parameter ke nilai tetap 30 LOG("Playing bell: " + fileNumber + " Vol:" + String(finalVolume) + " Repeat:" + String(repeat)); dfPlayer.volume(finalVolume); // Gunakan finalVolume yang sudah di-set ke 30 int repeatCount = constrain(repeat, 1, 5); // Nyalakan semua relay setAllRelays(true); sendRelayStatusUpdate(); for (int i = 0; i < repeatCount; i++) { dfPlayer.play(fileNumber.toInt()); LOG("Putar ke-" + String(i+1) + " dimulai"); // Tunggu selama 30 detik untuk setiap pemutaran unsigned long startTime = millis(); while (millis() - startTime < 30000) { delay(10); } LOG("Putar ke-" + String(i+1) + " selesai (setelah 30 detik)"); } // Matikan semua relay setAllRelays(false); sendRelayStatusUpdate(); logBellEvent(fileNumber, finalVolume, repeatCount, triggerType); sendAckResponse("success", "Bel berbunyi"); } void logBellEvent(String fileNumber, int volume, int repeat, const char* triggerType) { DateTime now = rtc.now(); DynamicJsonDocument doc(256); doc["hari"] = getDayName(now.dayOfTheWeek()); doc["waktu"] = formatTime(now.hour(), now.minute(), now.second()); doc["file_number"] = fileNumber; doc["trigger_type"] = triggerType; doc["volume"] = volume; doc["repeat"] = repeat; String payload; serializeJson(doc, payload); // Tentukan topik berdasarkan trigger type const char* topic = (strcmp(triggerType, "schedule") == 0) ? TOPIC_EVENT_SCHEDULE : TOPIC_EVENT_MANUAL; // Kirim MQTT bool mqttSuccess = mqttClient.publish(topic, payload.c_str()); // Kirim HTTP sendBellEventViaHTTP(payload, triggerType); LOG(mqttSuccess ? "MQTT publish success" : "MQTT publish failed"); } // Fungsi HTTP async sederhana void sendBellEventViaHTTP(String jsonPayload, const char* triggerType) { static WiFiClient client; static HTTPClient http; if (http.connected()) { http.end(); } // Gunakan endpoint berbeda untuk manual dan schedule String endpoint = (strcmp(triggerType, "schedule") == 0) ? "/api/bell-events/schedule" : "/api/bell-events/manual"; String url = "http://" + String(ip_server_laravel) + ":" + String(SERVER_PORT) + endpoint; http.begin(client, url); http.addHeader("Content-Type", "application/json"); LOG("Sending HTTP to: " + url); int httpCode = http.POST(jsonPayload); if (httpCode > 0) { LOG("HTTP response: " + String(httpCode)); } else { LOG("HTTP failed: " + String(httpCode)); } } // ========== Response Functions ========== void sendSystemStatus() { DynamicJsonDocument doc(256); doc["wifi"] = state.wifiConnected; doc["rtc"] = state.rtcConnected; doc["dfplayer"] = state.dfPlayerConnected; doc["mqtt"] = state.mqttConnected; if (state.rtcConnected) { DateTime now = rtc.now(); doc["rtc_time"] = now.unixtime(); doc["rtc_formatted"] = getCurrentDateTime(); } doc["last_communication"] = state.lastCommunication; doc["last_sync"] = state.lastSync; doc["schedule_count"] = state.scheduleCount; doc["ip_address"] = WiFi.localIP().toString(); // Tambahkan status I2C doc["i2c_stable"] = state.i2cStable; doc["i2c_error_count"] = state.i2cErrorCount; doc["last_i2c_check"] = state.lastI2CCheck; doc["last_i2c_recovery"] = state.lastI2CRecovery; String payload; serializeJson(doc, payload); if (!mqttClient.publish(TOPIC_RESPONSE_STATUS, payload.c_str())) { LOG("Failed to publish status"); } } void sendAckResponse(const char* status, const char* message) { DynamicJsonDocument doc(128); doc["status"] = status; doc["message"] = message; doc["timestamp"] = state.lastCommunication; String payload; serializeJson(doc, payload); if (!mqttClient.publish(TOPIC_RESPONSE_ACK, payload.c_str())) { LOG("Failed to publish ack"); } } // ========== Schedule Checking ========== // Di checkSchedules(): void checkSchedules() { static unsigned long lastCheck = 0; if (millis() - lastCheck < 1000) return; lastCheck = millis(); if (!state.rtcConnected || scheduleCount == 0) return; DateTime now = rtc.now(); int currentHour = now.hour(); int currentMinute = now.minute(); String currentDay = getDayName(now.dayOfTheWeek()); for (int i = 0; i < scheduleCount; i++) { if (!schedules[i].isActive) continue; // Cek jika schedule sudah aktif bool alreadyActive = false; for (int j = 0; j < 3; j++) { if (state.activeSchedules[j].index == i) { alreadyActive = true; break; } } if (alreadyActive) continue; // Parse waktu schedule int schedHour = schedules[i].time.substring(0,2).toInt(); int schedMinute = schedules[i].time.substring(3,5).toInt(); if (schedules[i].day == currentDay && currentHour == schedHour && currentMinute == schedMinute) { // Cari slot kosong for (int j = 0; j < 3; j++) { if (state.activeSchedules[j].index == -1) { state.activeSchedules[j].index = i; state.activeSchedules[j].startTime = millis(); playBellSound( schedules[i].fileNumber, schedules[i].volume, schedules[i].repeat, "schedule" ); break; } } } } }