update 25 juni 2k25

This commit is contained in:
Didin-29 2025-07-01 11:27:50 +07:00
commit fe6c81940e
104 changed files with 20028 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

59
.env.example Normal file
View File

@ -0,0 +1,59 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode

66
README.md Normal file
View File

@ -0,0 +1,66 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[WebReinvent](https://webreinvent.com/)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Jump24](https://jump24.co.uk)**
- **[Redberry](https://redberry.international/laravel/)**
- **[Active Logic](https://activelogic.com)**
- **[byte5](https://byte5.de)**
- **[OP.GG](https://op.gg)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

777
anggrek.cpp Normal file
View File

@ -0,0 +1,777 @@
#include <ESP8266WiFi.h>
#include <FirebaseESP8266.h>
#include <DHT.h>
#include <Wire.h>
#include <BH1750.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
// Konfigurasi WiFi
#define WIFI_SSID "didinganteng"
#define WIFI_PASSWORD "didin123"
// Konfigurasi Firebase
#define FIREBASE_HOST "https://sensoranggrek-3d9ac-default-rtdb.firebaseio.com/"
#define FIREBASE_AUTH "suOXlb5gENTmy6PbRNWsbpWiFOeUVDQoWq61GhRu"
#define DHTPIN D4 // Pin data DHT11 terhubung ke D4
#define DHTTYPE DHT11 // Jenis sensor DHT
// Definisi pin relay
#define RELAY_FAN D5 // Kipas pada relay channel 1
#define RELAY_LIGHT D6 // Lampu pada relay channel 2
#define RELAY_PUMP D7 // Water pump pada relay channel 3
#define RELAY_CAMERA_TRIGGER D3 // Relay untuk mengaktifkan kamera pada jadwal tertentu
// PERBAIKAN 1: Ubah ke pin analog jika sensor mendukung output analog
#define SOIL_MOISTURE_PIN A0 // Ganti ke A0 untuk sensor analog
// Nilai threshold kelembapan tanah untuk media tanam anggrek
const int SOIL_DRY_THRESHOLD = 48; // Di bawah nilai ini dianggap kering (perlu penyiraman)
const int SOIL_OPTIMAL_MIN = 50; // Batas bawah optimal
const int SOIL_OPTIMAL_THRESHOLD = 60; // Di atas nilai ini dianggap optimal
const int SOIL_WET_THRESHOLD = 70; // Di atas nilai ini dianggap terlalu basah
// Nilai threshold intensitas cahaya
const int LIGHT_LOW_THRESHOLD = 8000; // Di bawah nilai ini dianggap rendah (perlu lampu)
const int LIGHT_OPTIMAL_MAX = 11000; // Di atas nilai ini dianggap tinggi
// Konfigurasi Watchdog dan Power Management
#define WATCHDOG_TIMEOUT 60 // 60 detik timeout
#define POWER_STABILIZATION_DELAY 500 // Delay stabilisasi daya
#define MAX_SENSOR_INIT_ATTEMPTS 3
#define MAX_WIFI_CONNECT_ATTEMPTS 30
DHT dht(DHTPIN, DHTTYPE);
BH1750 lightMeter; // Inisialisasi objek BH1750
FirebaseData firebaseData;
FirebaseConfig config;
FirebaseAuth auth;
// Untuk NTP (Network Time Protocol)
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "pool.ntp.org", 7 * 3600, 60000);
// Variabel untuk sensor dan perangkat
unsigned long pumpStartTime = 0;
unsigned long pumpCycleTime = 0;
bool pumpCycleOn = false;
const unsigned long PUMP_ON_DURATION = 10000; // 10 detik ON
const unsigned long PUMP_OFF_DURATION = 10000; // 10 detik OFF
// Variabel untuk pengaktifan kamera terjadwal
unsigned long cameraTriggerStartTime = 0;
bool cameraTriggerOn = false;
const unsigned long CAMERA_TRIGGER_DURATION = 300000; // 5 menit (5 * 60 * 1000 ms)
// Variabel untuk melacak status perangkat terakhir
bool lastKipasStatus = false;
bool lastLampuStatus = false;
bool lastPumpStatus = false;
bool lastCamStatus = false;
bool lastCameraTriggerStatus = false;
String lastLampReason = "";
// Variabel untuk melacak status sensor
bool dht11Status = false;
bool bh1750Status = false;
bool soilMoistureStatus = false;
// Variabel untuk throttling pengiriman data
unsigned long lastSensorUpdateTime = 0;
unsigned long lastDeviceControlTime = 0;
unsigned long lastFirebaseUpdateTime = 0;
const unsigned long sensorUpdateInterval = 5000; // Update setiap 5 detik
const unsigned long deviceControlInterval = 2000; // Kontrol perangkat setiap 2 detik
// Variabel untuk heartbeat status koneksi
unsigned long lastHeartbeatTime = 0;
const unsigned long heartbeatInterval = 30000; // Update status koneksi setiap 30 detik
// Deklarasi fungsi
void checkRestartStatus();
void updateSensorStatus();
void updateSensorData(float temp, float humidity, float lux, int soilMoisture);
void updateHeartbeat();
void checkWiFiConnection();
void checkRestartCommand();
void controlDevices();
void controlPump();
void controlCameraTrigger(); // Fungsi untuk mengontrol pengaktifan kamera terjadwal
void updateDeviceStatus();
String getCurrentTime();
/// Fungsi pembacaan sensor yang ditingkatkan dengan penanganan error
int readSoilMoisture() {
int rawValue;
int readAttempts = 0;
const int MAX_READ_ATTEMPTS = 3;
// Coba baca sensor beberapa kali jika nilainya tidak valid
do {
rawValue = analogRead(SOIL_MOISTURE_PIN);
readAttempts++;
if (rawValue < 0 || rawValue > 1023) {
Serial.println("Error: Pembacaan sensor kelembapan tidak valid!");
delay(100); // Tunggu sebentar sebelum coba lagi
}
} while ((rawValue < 0 || rawValue > 1023) && readAttempts < MAX_READ_ATTEMPTS);
// Jika setelah beberapa percobaan masih error, catat error dan kembalikan nilai default
if (rawValue < 0 || rawValue > 1023) {
Serial.println("Error: Gagal membaca sensor kelembapan tanah setelah beberapa percobaan!");
Firebase.setString(firebaseData, "/logs/soil_moisture/error", "Pembacaan tidak valid setelah " + String(MAX_READ_ATTEMPTS) + " percobaan");
return -1; // Nilai error
}
// Debugging: Tampilkan nilai raw di Serial Monitor
Serial.print("Nilai Raw Soil Moisture: ");
Serial.println(rawValue);
// Kalibrasi nilai (sesuaikan dengan sensor Anda)
// Contoh kalibrasi:
// Nilai 1023 = sangat kering (0% kelembapan)
// Nilai 300 = sangat basah (100% kelembapan)
int moisturePercentage = map(rawValue, 1023, 300, 0, 100);
// Pastikan nilai dalam rentang 0-100%
moisturePercentage = constrain(moisturePercentage, 0, 100);
// Debugging: Tampilkan nilai hasil konversi
Serial.print("Kelembapan Tanah: ");
Serial.print(moisturePercentage);
Serial.println("%");
return moisturePercentage;
}
// Fungsi Tambahan untuk Penanganan Error
void handleError(const String& errorMessage) {
Serial.println("Error: " + errorMessage);
// Log error ke Firebase
Firebase.setString(firebaseData, "/logs/system/error", errorMessage);
delay(1000);
}
void setupPowerManagement() {
system_update_cpu_freq(80);
WiFi.setSleepMode(WIFI_NONE_SLEEP);
WiFi.setPhyMode(WIFI_PHY_MODE_11N);
}
// PERBAIKAN: Implementasi fungsi checkRestartStatus yang lebih baik
void checkRestartStatus() {
if (Firebase.ready() && Firebase.getBool(firebaseData, "/system/restart")) {
if (firebaseData.dataType() == "boolean" && firebaseData.boolData() == true) {
Serial.println("Restart command detected on startup");
Firebase.setString(firebaseData, "/logs/system", "System baru saja di-restart");
Firebase.setBool(firebaseData, "/system/restart", false);
}
}
Firebase.setString(firebaseData, "/logs/system", "System starting up");
}
void setup() {
setupPowerManagement();
ESP.wdtDisable();
delay(POWER_STABILIZATION_DELAY);
Serial.begin(115200);
// Koneksi WiFi
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Menghubungkan ke WiFi");
int wifiAttempts = 0;
while (WiFi.status() != WL_CONNECTED && wifiAttempts < MAX_WIFI_CONNECT_ATTEMPTS) {
Serial.print(".");
delay(500);
wifiAttempts++;
ESP.wdtFeed();
}
if (WiFi.status() != WL_CONNECTED) {
handleError("Gagal terhubung ke WiFi");
delay(5000);
ESP.restart();
}
Serial.println("\nTerhubung ke WiFi");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
// Konfigurasi Firebase
config.host = FIREBASE_HOST;
config.signer.tokens.legacy_token = FIREBASE_AUTH;
Firebase.begin(&config, &auth);
if (!Firebase.ready()) {
handleError("Gagal inisialisasi Firebase");
delay(5000);
ESP.restart();
}
Firebase.reconnectWiFi(true);
Firebase.setMaxRetry(firebaseData, 3);
Firebase.setMaxErrorQueue(firebaseData, 10);
Firebase.setReadTimeout(firebaseData, 1000);
Firebase.setwriteSizeLimit(firebaseData, "tiny");
// Inisialisasi I2C
Wire.begin(4, 5);
Wire.setClock(100000);
// Inisialisasi Sensor BH1750
int bh1750Attempts = 0;
while (!lightMeter.begin() && bh1750Attempts < MAX_SENSOR_INIT_ATTEMPTS) {
Serial.println("Inisialisasi BH1750 gagal, mencoba ulang...");
delay(500);
bh1750Attempts++;
}
bh1750Status = lightMeter.begin();
Firebase.setString(firebaseData, "/logs/bh1750/status",
bh1750Status ? "Terhubung" : "Gagal terhubung");
// Setup relay pins
pinMode(RELAY_FAN, OUTPUT);
pinMode(RELAY_LIGHT, OUTPUT);
pinMode(RELAY_PUMP, OUTPUT);
pinMode(RELAY_CAMERA_TRIGGER, OUTPUT); // Inisialisasi pin trigger kamera
// PERBAIKAN 3: Tidak perlu set pinMode untuk A0 karena sudah analog input secara default
// Kondisi default relay (HIGH = OFF)
digitalWrite(RELAY_FAN, HIGH);
digitalWrite(RELAY_LIGHT, HIGH);
digitalWrite(RELAY_PUMP, HIGH);
digitalWrite(RELAY_CAMERA_TRIGGER, HIGH); // Matikan relay kamera saat inisialisasi
// Inisialisasi NTP Client
timeClient.begin();
timeClient.setTimeOffset(25200); // GMT+7 (WIB)
while(!timeClient.update()) {
timeClient.forceUpdate();
delay(500);
}
// PERBAIKAN: Panggil checkRestartStatus tanpa mengatur restart ke false lagi
checkRestartStatus();
// Baris berikut dihapus karena sudah diatur dalam checkRestartStatus
// Firebase.setBool(firebaseData, "/system/restart", false);
// Inisialisasi DHT11
dht.begin();
// Periksa DHT11
float testHumidity = dht.readHumidity();
float testTemperature = dht.readTemperature();
if (!isnan(testHumidity) && !isnan(testTemperature)) {
dht11Status = true;
Firebase.setString(firebaseData, "/logs/dht11/message", "Perangkat terhubung");
} else {
dht11Status = false;
Firebase.setString(firebaseData, "/logs/dht11/message", "Sensor tidak terhubung");
}
// PERBAIKAN 4: Pengecekan status soil moisture sensor yang lebih baik
int testSoilMoisture = readSoilMoisture();
if (testSoilMoisture >= 0 && testSoilMoisture <= 100) {
soilMoistureStatus = true;
Firebase.setString(firebaseData, "/logs/soil_moisture/message", "Perangkat terhubung");
Firebase.setInt(firebaseData, "/sensor/kelembapan_tanah", testSoilMoisture);
} else {
soilMoistureStatus = false;
Firebase.setString(firebaseData, "/logs/soil_moisture/message", "Sensor tidak terhubung");
}
// Kirim status awal
Firebase.setBool(firebaseData, "/status/connected", true);
Firebase.setString(firebaseData, "/status/last_seen", getCurrentTime());
// Inisialisasi waktu
lastDeviceControlTime = millis();
lastFirebaseUpdateTime = millis();
lastHeartbeatTime = millis();
ESP.wdtEnable(WATCHDOG_TIMEOUT * 1000);
}
void loop() {
ESP.wdtFeed();
unsigned long currentMillis = millis();
if (currentMillis - lastFirebaseUpdateTime >= sensorUpdateInterval) {
lastFirebaseUpdateTime = currentMillis;
// Baca sensor
float humidity = dht.readHumidity();
float temperature = dht.readTemperature();
float lux = lightMeter.readLightLevel();
int soilMoisture = readSoilMoisture();
if (!isnan(temperature) && !isnan(humidity) && lux >= 0 && soilMoisture != -1) {
updateSensorData(temperature, humidity, lux, soilMoisture);
}
if (currentMillis - lastDeviceControlTime >= deviceControlInterval) {
lastDeviceControlTime = currentMillis;
controlDevices();
controlPump();
controlCameraTrigger();
ESP.wdtFeed();
}
updateDeviceStatus();
updateSensorStatus(); // PERBAIKAN: Menambahkan panggilan ke fungsi updateSensorStatus()
updateHeartbeat();
ESP.wdtFeed();
}
timeClient.update();
// PERBAIKAN: Panggil checkRestartCommand setiap iterasi loop
checkRestartCommand();
delay(50);
}
void controlDevices() {
float temperature = dht.readTemperature();
float humidity = dht.readHumidity();
float lightIntensity = lightMeter.readLightLevel();
if (isnan(temperature) || isnan(humidity)) {
Serial.println("Gagal membaca sensor DHT11");
return;
}
timeClient.update();
int currentHour = timeClient.getHours();
bool shouldFanBeOn = false;
String fanReason = "";
if (temperature > 27) {
shouldFanBeOn = true;
fanReason = "Suhu tinggi";
} else if (humidity < 60) {
shouldFanBeOn = true;
fanReason = "Kelembaban rendah";
}
digitalWrite(RELAY_FAN, shouldFanBeOn ? LOW : HIGH);
if (shouldFanBeOn != lastKipasStatus) {
lastKipasStatus = shouldFanBeOn;
Serial.println("Kipas " + String(shouldFanBeOn ? "ON: " + fanReason : "OFF: Kondisi normal"));
}
// Logika baru untuk lampu berdasarkan sensor cahaya BH1750
bool shouldLightBeOn = false;
String lightReason = "";
// Prioritas 1: Mode malam (jam 17:00 - 06:00)
if (currentHour >= 17 || currentHour < 6) {
shouldLightBeOn = true;
lightReason = "Mode malam";
}
// Prioritas 2: Berdasarkan intensitas cahaya
else if (lightIntensity < LIGHT_LOW_THRESHOLD) {
shouldLightBeOn = true;
lightReason = "Intensitas cahaya rendah";
}
// Update status intensitas cahaya ke Firebase
String lightStatus = "";
if (lightIntensity < LIGHT_LOW_THRESHOLD) {
lightStatus = "Rendah";
} else if (lightIntensity <= LIGHT_OPTIMAL_MAX) {
lightStatus = "Optimal";
} else {
lightStatus = "Tinggi";
}
Firebase.setString(firebaseData, "sensor/status_cahaya", lightStatus);
digitalWrite(RELAY_LIGHT, shouldLightBeOn ? LOW : HIGH);
if (shouldLightBeOn != lastLampuStatus || (shouldLightBeOn && lightReason != lastLampReason)) {
lastLampuStatus = shouldLightBeOn;
lastLampReason = lightReason;
Serial.println("Lampu " + String(shouldLightBeOn ? "ON: " + lightReason : "OFF: Kondisi normal"));
if (shouldLightBeOn) {
Firebase.setString(firebaseData, "devices/lampu_reason", lightReason);
}
}
}
void controlPump() {
int soilMoistureValue = readSoilMoisture();
unsigned long currentMillis = millis();
// Jika pembacaan sensor error (-1), jangan lakukan kontrol pompa
if (soilMoistureValue == -1) {
Serial.println("Tidak dapat mengontrol pompa: error pembacaan sensor");
return;
}
// Tentukan status kelembapan tanah
String soilStatus = "";
if (soilMoistureValue < SOIL_DRY_THRESHOLD) {
soilStatus = "Kering";
} else if (soilMoistureValue < SOIL_OPTIMAL_MIN) {
soilStatus = "Cukup";
} else if (soilMoistureValue <= SOIL_OPTIMAL_THRESHOLD) {
soilStatus = "Optimal";
} else if (soilMoistureValue < SOIL_WET_THRESHOLD) {
soilStatus = "Lembab";
} else {
soilStatus = "Terlalu Basah";
}
// Update status ke Firebase
Firebase.setString(firebaseData, "sensor/status_kelembapan_tanah", soilStatus);
// Fungsi helper untuk menghitung elapsed time dengan penanganan overflow
auto calculateElapsedTime = [](unsigned long startTime, unsigned long currentTime) -> unsigned long {
if (currentTime >= startTime) {
return currentTime - startTime;
} else {
// Overflow terjadi
return (ULONG_MAX - startTime) + currentTime + 1;
}
};
// Logika kontrol pompa yang diperbaiki
if (soilMoistureValue < SOIL_DRY_THRESHOLD) {
// Tanah kering - perlu penyiraman dengan siklus
handleDrySoil(currentMillis, soilStatus, calculateElapsedTime);
}
else if (soilMoistureValue < SOIL_OPTIMAL_MIN) {
// Tanah cukup - penyiraman ringan atau hentikan siklus
handleModeratelySoil(currentMillis, soilStatus, calculateElapsedTime);
}
else {
// Tanah sudah optimal/lembab/terlalu basah - matikan pompa
handleWetSoil(soilStatus);
}
// Update status pompa ke Firebase
updatePumpStatus();
}
// Fungsi helper untuk menangani tanah kering
void handleDrySoil(unsigned long currentMillis, String soilStatus,
std::function<unsigned long(unsigned long, unsigned long)> calculateElapsedTime) {
// Jika pompa belum dalam mode siklus, mulai siklus penyiraman
if (pumpCycleTime == 0) {
pumpCycleTime = currentMillis;
pumpCycleOn = true;
digitalWrite(RELAY_PUMP, LOW); // Nyalakan pompa
Firebase.setString(firebaseData, "devices/pump_reason", "Penyiraman otomatis - " + soilStatus);
Serial.println("Pompa ON - Penyiraman dimulai untuk status: " + soilStatus);
}
// Jika dalam siklus, atur ON/OFF sesuai timing
else {
unsigned long elapsedTime = calculateElapsedTime(pumpCycleTime, currentMillis);
// Jika dalam fase ON dan waktunya sudah lewat, ganti ke OFF
if (pumpCycleOn && (elapsedTime >= PUMP_ON_DURATION)) {
pumpCycleTime = currentMillis;
pumpCycleOn = false;
digitalWrite(RELAY_PUMP, HIGH); // Matikan pompa untuk sementara
Serial.println("Pompa OFF - Jeda sementara dalam siklus penyiraman");
}
// Jika dalam fase OFF dan waktunya sudah lewat, ganti ke ON
else if (!pumpCycleOn && (elapsedTime >= PUMP_OFF_DURATION)) {
pumpCycleTime = currentMillis;
pumpCycleOn = true;
digitalWrite(RELAY_PUMP, LOW); // Nyalakan pompa lagi
Serial.println("Pompa ON - Melanjutkan siklus penyiraman");
}
}
}
// Fungsi helper untuk menangani tanah cukup
void handleModeratelySoil(unsigned long currentMillis, String soilStatus,
std::function<unsigned long(unsigned long, unsigned long)> calculateElapsedTime) {
// Jika pompa sedang dalam siklus penyiraman
if (pumpCycleTime != 0) {
unsigned long elapsedTime = calculateElapsedTime(pumpCycleTime, currentMillis);
// Jika sedang dalam fase ON, biarkan selesai dulu satu siklus ON
if (pumpCycleOn && (elapsedTime >= PUMP_ON_DURATION)) {
// Selesaikan fase ON dan langsung hentikan siklus
digitalWrite(RELAY_PUMP, HIGH); // Matikan pompa
pumpCycleTime = 0; // Reset siklus
pumpCycleOn = false;
Firebase.setString(firebaseData, "devices/pump_reason", "Kelembapan tanah sudah " + soilStatus);
Serial.println("Pompa OFF - Siklus dihentikan, kelembapan tanah sudah " + soilStatus);
}
// Jika dalam fase OFF, langsung hentikan siklus
else if (!pumpCycleOn) {
pumpCycleTime = 0; // Reset siklus
// Pompa sudah OFF, cukup reset status
Firebase.setString(firebaseData, "devices/pump_reason", "Kelembapan tanah sudah " + soilStatus);
Serial.println("Siklus pompa dihentikan - Kelembapan tanah sudah " + soilStatus);
}
// Jika sedang ON tapi belum waktunya OFF, biarkan lanjut
}
// Jika pompa tidak dalam siklus dan sedang OFF, biarkan tetap OFF
else if (digitalRead(RELAY_PUMP) == HIGH) {
// Tidak perlu action, pompa sudah OFF
}
// Jika pompa tidak dalam siklus tapi sedang ON (kondisi aneh), matikan
else {
digitalWrite(RELAY_PUMP, HIGH);
Firebase.setString(firebaseData, "devices/pump_reason", "Kelembapan tanah sudah " + soilStatus);
Serial.println("Pompa OFF - Kelembapan tanah sudah " + soilStatus);
}
}
// Fungsi helper untuk menangani tanah basah
void handleWetSoil(String soilStatus) {
// Tanah sudah optimal/lembab/terlalu basah - matikan pompa
if (digitalRead(RELAY_PUMP) != HIGH) {
digitalWrite(RELAY_PUMP, HIGH); // Matikan pompa
pumpCycleTime = 0; // Reset siklus penyiraman
pumpCycleOn = false;
Firebase.setString(firebaseData, "devices/pump_reason", "Kelembapan tanah sudah " + soilStatus);
Serial.println("Pompa OFF - Kelembapan tanah sudah " + soilStatus);
}
}
// Fungsi helper untuk update status pompa
void updatePumpStatus() {
bool currentPumpStatus = (digitalRead(RELAY_PUMP) == LOW);
if (currentPumpStatus != lastPumpStatus) {
lastPumpStatus = currentPumpStatus;
// Retry mechanism untuk Firebase
if (!Firebase.setBool(firebaseData, "devices/pump", currentPumpStatus)) {
Serial.println("Gagal update status pump ke Firebase, mencoba lagi...");
delay(1000);
Firebase.setBool(firebaseData, "devices/pump", currentPumpStatus);
}
if (!Firebase.setString(firebaseData, "devices/pump_timestamp", getCurrentTime())) {
Serial.println("Gagal update pump timestamp ke Firebase");
}
}
}
void controlCameraTrigger() {
// Update waktu NTP dengan penanganan error
if (!timeClient.update()) {
Serial.println("Gagal update NTP, menggunakan waktu terakhir");
// Tetap lanjutkan dengan waktu terakhir yang tersedia
}
// Dapatkan jam saat ini (format 24 jam)
int currentHour = timeClient.getHours();
int currentMinute = timeClient.getMinutes();
int currentSecond = timeClient.getSeconds();
// Konversi ke total detik dalam satu hari untuk perbandingan yang lebih mudah
int currentTimeInSeconds = currentHour * 3600 + currentMinute * 60 + currentSecond;
// Waktu aktivasi kamera pagi: 06:00:00 (6 jam * 3600 detik)
int morningTriggerTime = 6 * 3600;
// Waktu aktivasi kamera sore: 16:00:00 (16 jam * 3600 detik)
int afternoonTriggerTime = 16 * 3600;
// Cek apakah kamera sedang aktif
if (cameraTriggerOn) {
// Penanganan overflow millis() - gunakan unsigned long dan hitung selisih dengan benar
unsigned long currentTime = millis();
unsigned long elapsedTime;
// Hitung elapsed time dengan mempertimbangkan kemungkinan overflow
if (currentTime >= cameraTriggerStartTime) {
elapsedTime = currentTime - cameraTriggerStartTime;
} else {
// Overflow terjadi
elapsedTime = (ULONG_MAX - cameraTriggerStartTime) + currentTime + 1;
}
// Jika kamera sedang aktif, cek apakah sudah waktunya mematikan
if (elapsedTime >= CAMERA_TRIGGER_DURATION) {
// Matikan relay kamera setelah durasi yang ditentukan
digitalWrite(RELAY_CAMERA_TRIGGER, HIGH); // HIGH = OFF
cameraTriggerOn = false;
// Log ke Serial dan Firebase
Serial.println("Kamera dimatikan setelah 5 menit");
Firebase.setString(firebaseData, "devices/camera_trigger_reason", "Pengaktifan kamera selesai");
}
} else {
// Jika kamera tidak aktif, cek apakah sudah waktunya menyalakan (pagi atau sore)
// Perluas toleransi waktu menjadi 60 detik untuk memastikan tidak terlewat
bool shouldTriggerMorning = (currentTimeInSeconds >= morningTriggerTime &&
currentTimeInSeconds < morningTriggerTime + 60);
bool shouldTriggerAfternoon = (currentTimeInSeconds >= afternoonTriggerTime &&
currentTimeInSeconds < afternoonTriggerTime + 60);
// Tambahan: Cek juga apakah sudah lewat 24 jam sejak aktivasi terakhir
// untuk mencegah kamera tidak diaktifkan karena masalah timing
static unsigned long lastTriggerDay = 0;
unsigned long currentDay = currentTimeInSeconds / 86400; // Hari ke-n sejak epoch
if (shouldTriggerMorning || shouldTriggerAfternoon ||
(currentDay > lastTriggerDay && (currentHour == 6 || currentHour == 16))) {
// Aktifkan relay kamera
digitalWrite(RELAY_CAMERA_TRIGGER, LOW); // LOW = ON
cameraTriggerStartTime = millis();
cameraTriggerOn = true;
lastTriggerDay = currentDay;
// Log informasi ke Serial dan Firebase
String triggerReason;
if (shouldTriggerMorning || (currentHour == 6 && currentDay > lastTriggerDay)) {
Serial.println("Aktivasi kamera pagi (06:00) dimulai");
triggerReason = "Aktivasi kamera pagi";
} else {
Serial.println("Aktivasi kamera sore (16:00) dimulai");
triggerReason = "Aktivasi kamera sore";
}
// Tambahkan timestamp untuk debugging
char timeStr[64];
sprintf(timeStr, "%s - %02d:%02d:%02d", triggerReason.c_str(), currentHour, currentMinute, currentSecond);
Firebase.setString(firebaseData, "devices/camera_trigger_reason", timeStr);
}
}
// Update status aktivasi kamera jika ada perubahan
bool currentCameraTriggerStatus = (digitalRead(RELAY_CAMERA_TRIGGER) == LOW);
if (currentCameraTriggerStatus != lastCameraTriggerStatus) {
lastCameraTriggerStatus = currentCameraTriggerStatus;
// Tambahkan retry untuk Firebase jika gagal
if (!Firebase.setBool(firebaseData, "devices/camera_trigger", currentCameraTriggerStatus)) {
Serial.println("Gagal update status camera_trigger ke Firebase");
// Coba lagi setelah delay singkat
delay(1000);
Firebase.setBool(firebaseData, "devices/camera_trigger", currentCameraTriggerStatus);
}
if (!Firebase.setString(firebaseData, "devices/camera_trigger_timestamp", getCurrentTime())) {
Serial.println("Gagal update timestamp ke Firebase");
}
}
// Tambahkan debugging info setiap 1 jam
static unsigned long lastDebugTime = 0;
if (millis() - lastDebugTime > 3600000) { // 1 jam
lastDebugTime = millis();
Serial.printf("Debug: Waktu sekarang %02d:%02d:%02d, Camera aktif: %s\n",
currentHour, currentMinute, currentSecond,
cameraTriggerOn ? "Ya" : "Tidak");
}
}void updateHeartbeat() {
unsigned long currentMillis = millis();
if (currentMillis - lastHeartbeatTime >= heartbeatInterval) {
lastHeartbeatTime = currentMillis;
Firebase.setBool(firebaseData, "/status/connected", true);
Firebase.setString(firebaseData, "/status/last_seen", getCurrentTime());
Serial.println("Heartbeat: Status koneksi diperbarui");
}
}
void updateSensorStatus() {
float testHumidity = dht.readHumidity();
float testTemperature = dht.readTemperature();
bool currentDht11Status = !isnan(testHumidity) && !isnan(testTemperature);
if (currentDht11Status != dht11Status) {
dht11Status = currentDht11Status;
Firebase.setString(firebaseData, "/logs/dht11/message",
dht11Status ? "Perangkat terhubung" : "Sensor tidak terhubung");
}
float testLux = lightMeter.readLightLevel();
bool currentBh1750Status = testLux >= 0;
if (currentBh1750Status != bh1750Status) {
bh1750Status = currentBh1750Status;
Firebase.setString(firebaseData, "/logs/bh1750/message",
bh1750Status ? "Perangkat terhubung" : "Sensor tidak terhubung");
}
int testSoilMoisture = readSoilMoisture();
bool currentSoilMoistureStatus = testSoilMoisture >= 0 && testSoilMoisture <= 100;
if (currentSoilMoistureStatus != soilMoistureStatus) {
soilMoistureStatus = currentSoilMoistureStatus;
Firebase.setString(firebaseData, "/logs/soil_moisture/message",
soilMoistureStatus ? "Perangkat terhubung" : "Sensor tidak terhubung");
}
}
// Perbarui updateSensorData untuk tidak mengirim data kelembapan tanah dua kali
void updateSensorData(float temp, float humidity, float lux, int soilMoisture) {
Firebase.setFloat(firebaseData, "sensor/suhu", temp);
Firebase.setFloat(firebaseData, "sensor/kelembaban", humidity);
Firebase.setFloat(firebaseData, "sensor/cahaya", lux);
Firebase.setInt(firebaseData, "sensor/kelembapan_tanah", soilMoisture);
// Status kelembapan tanah sekarang ditangani di controlPump()
}
void updateDeviceStatus() {
bool fanStatus = (digitalRead(RELAY_FAN) == LOW);
bool lightStatus = (digitalRead(RELAY_LIGHT) == LOW);
bool pumpStatus = (digitalRead(RELAY_PUMP) == LOW);
bool cameraTriggerStatus = (digitalRead(RELAY_CAMERA_TRIGGER) == LOW);
Firebase.setBool(firebaseData, "devices/kipas", fanStatus);
Firebase.setBool(firebaseData, "devices/lampu", lightStatus);
Firebase.setBool(firebaseData, "devices/pump", pumpStatus);
Firebase.setBool(firebaseData, "devices/camera_trigger", cameraTriggerStatus);
if (fanStatus != lastKipasStatus) {
lastKipasStatus = fanStatus;
Firebase.setString(firebaseData, "devices/kipas_timestamp", getCurrentTime());
}
if (lightStatus != lastLampuStatus) {
lastLampuStatus = lightStatus;
Firebase.setString(firebaseData, "devices/lampu_timestamp", getCurrentTime());
}
if (pumpStatus != lastPumpStatus) {
lastPumpStatus = pumpStatus;
Firebase.setString(firebaseData, "devices/pump_timestamp", getCurrentTime());
}
if (cameraTriggerStatus != lastCameraTriggerStatus) {
lastCameraTriggerStatus = cameraTriggerStatus;
Firebase.setString(firebaseData, "devices/camera_trigger_timestamp", getCurrentTime());
}
}
String getCurrentTime() {
timeClient.update();
return timeClient.getFormattedTime();
}
// PERBAIKAN: Implementasi fungsi checkRestartCommand yang lebih robust
void checkRestartCommand() {
if (Firebase.ready() && Firebase.getBool(firebaseData, "/system/restart")) {
// Check if we got a valid response and the value is true
if (firebaseData.dataType() == "boolean" && firebaseData.boolData() == true) {
Serial.println("Restart command detected, restarting system...");
// Log the restart event
Firebase.setString(firebaseData, "/logs/system", "System sedang di restart");
// Set the restart flag back to false
Firebase.setBool(firebaseData, "/system/restart", false);
delay(1000); // Give Firebase some time to update
ESP.restart();
}
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class CleanupESP32CamImages extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'esp32cam:cleanup {--days=7 : Number of days to keep images} {--keep=10 : Minimum number of images to keep}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Cleanup old ESP32-CAM images';
/**
* Execute the console command.
*/
public function handle()
{
try {
$uploadDir = 'esp32cam/';
$files = Storage::disk('public')->files($uploadDir);
$deletedCount = 0;
$daysToKeep = (int) $this->option('days');
$minFilesToKeep = (int) $this->option('keep');
$this->info("Membersihkan foto lama dari ESP32-CAM...");
$this->info("Menyimpan foto dari {$daysToKeep} hari terakhir, minimal {$minFilesToKeep} foto");
// Hapus file yang lebih lama dari N hari, tapi simpan minimal X file terakhir
if (count($files) > $minFilesToKeep) {
// Urutkan file berdasarkan waktu modifikasi (terlama lebih dulu)
usort($files, function($a, $b) {
return Storage::disk('public')->lastModified($a) - Storage::disk('public')->lastModified($b);
});
// Ambil file lama untuk dihapus (tapi sisakan X file terakhir)
$filesToDelete = array_slice($files, 0, count($files) - $minFilesToKeep);
foreach ($filesToDelete as $file) {
$fileTime = Storage::disk('public')->lastModified($file);
if (time() - $fileTime > $daysToKeep * 24 * 60 * 60) { // N hari dalam detik
Storage::disk('public')->delete($file);
$this->line("Menghapus: " . basename($file));
Log::info("Deleted old ESP32-CAM image: " . $file);
$deletedCount++;
}
}
}
$this->info("Selesai. {$deletedCount} foto lama telah dihapus.");
$this->info("Sisa foto: " . (count($files) - $deletedCount));
return 0;
} catch (\Exception $e) {
$this->error("ESP32-CAM cleanup error: " . $e->getMessage());
Log::error("ESP32-CAM cleanup error: " . $e->getMessage());
return 1;
}
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Carbon;
class FetchESP32CamImage extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'esp32cam:fetch {ip? : IP address of ESP32-CAM web server}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fetch image from ESP32-CAM web server and save it to storage';
/**
* Execute the console command.
*/
public function handle()
{
try {
// Ambil IP dari argumen command atau gunakan default
$cameraIp = $this->argument('ip');
if (empty($cameraIp)) {
$this->error('IP address ESP32-CAM diperlukan');
return 1;
}
$this->info("Menghubungi ESP32-CAM di {$cameraIp}...");
// URL untuk mengambil gambar dari ESP32-CAM
$captureUrl = "http://{$cameraIp}/capture";
// Ambil gambar dari ESP32-CAM dengan timeout yang lebih lama
$response = Http::timeout(30)->get($captureUrl);
if ($response->successful()) {
// Direktori untuk menyimpan gambar
$uploadDir = 'esp32cam/';
// Pastikan direktori ada di storage/app/public
if (!Storage::disk('public')->exists($uploadDir)) {
Storage::disk('public')->makeDirectory($uploadDir);
}
// Buat nama file unik dengan timestamp
$filename = 'esp32cam_' . Carbon::now()->format('Ymd_His') . '.jpg';
$fullPath = $uploadDir . $filename;
// Simpan gambar menggunakan Laravel Storage
Storage::disk('public')->put($fullPath, $response->body());
// Log informasi
$fileSize = strlen($response->body());
$this->info("Gambar berhasil diambil dari ESP32-CAM ({$fileSize} bytes)");
$this->info("Tersimpan sebagai: {$fullPath}");
Log::info("ESP32-CAM image fetched successfully via command", [
'camera_ip' => $cameraIp,
'filename' => $filename,
'path' => $fullPath,
'size' => $fileSize . ' bytes',
'time' => Carbon::now()->format('Y-m-d H:i:s')
]);
return 0;
}
$this->error("Gagal mengambil gambar dari ESP32-CAM. Status: " . $response->status());
Log::error("ESP32-CAM fetch error: Failed with status " . $response->status());
return 1;
} catch (\Exception $e) {
$this->error("ESP32-CAM fetch error: " . $e->getMessage());
Log::error("ESP32-CAM fetch error: " . $e->getMessage());
return 1;
}
}
}

44
app/Console/Kernel.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
// Ambil gambar dari ESP32-CAM setiap 15 menit
// Catatan: Ganti IP address dengan IP ESP32-CAM Anda
$schedule->command('esp32cam:fetch 192.168.240.201')
->everyFifteenMinutes()
->runInBackground()
->appendOutputTo(storage_path('logs/esp32cam.log'));
// Membersihkan foto lama setiap hari pada jam 1 pagi
$schedule->command('esp32cam:cleanup')
->dailyAt('01:00')
->appendOutputTo(storage_path('logs/esp32cam-cleanup.log'));
// Sinkronisasi data Firebase ke history setiap menit
$schedule->call(function () {
app(\App\Http\Controllers\HistoryController::class)->syncFromFirebase();
})->everyMinute();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
class AuthController extends Controller
{
/**
* Menampilkan halaman login
*/
public function showLoginForm()
{
return view('auth.login');
}
/**
* Memproses login Firebase dari frontend via AJAX
*/
public function processLogin(Request $request)
{
// Validasi request
$request->validate([
'firebase_uid' => 'required|string',
'email' => 'required|email'
]);
// Simpan data user di session
session([
'firebase_auth_checked' => true,
'firebase_uid' => $request->firebase_uid,
'user_email' => $request->email
]);
return response()->json([
'success' => true,
'redirect' => route('dashboard')
]);
}
/**
* Proses logout
*/
public function logout()
{
// Hapus data autentikasi dari session
Session::forget(['firebase_auth_checked', 'firebase_uid', 'user_email']);
return redirect()->route('login');
}
/**
* Memeriksa status autentikasi user untuk mencegah redirect loop
*/
public function checkSession()
{
return response()->json([
'authenticated' => session()->has('firebase_auth_checked') && session()->get('firebase_auth_checked')
]);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
/**
* Menampilkan halaman dashboard monitoring
*
* @return \Illuminate\View\View
*/
public function index()
{
// Di sini bisa dimasukkan logika untuk mendapatkan data dashboard
// seperti jumlah tanaman, data suhu, kelembaban, dll
return view('dashboard');
}
}

View File

@ -0,0 +1,696 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
class ESP32CamController extends Controller
{
/**
* Upload foto dari ESP32-CAM
*/
public function upload(Request $request)
{
try {
// Direktori untuk menyimpan gambar
$uploadDir = 'esp32cam/';
// Pastikan direktori ada di storage/app/public
if (!Storage::disk('public')->exists($uploadDir)) {
Storage::disk('public')->makeDirectory($uploadDir);
}
// Buat nama file unik dengan timestamp
$filename = 'esp32cam_' . Carbon::now()->format('Ymd_His') . '.jpg';
$fullPath = $uploadDir . $filename;
// Ambil konten gambar dari request
$imageContent = $request->getContent();
if (!empty($imageContent)) {
// Simpan gambar menggunakan Laravel Storage
if (Storage::disk('public')->put($fullPath, $imageContent)) {
// Mengatur waktu file agar sesuai dengan waktu pengambilan saat ini
$now = time();
$filePath = storage_path('app/public/' . $fullPath);
@touch($filePath, $now);
// Log informasi upload menggunakan Laravel Logger
Log::info("ESP32-CAM image uploaded successfully", [
'filename' => $filename,
'path' => $fullPath,
'size' => strlen($imageContent) . ' bytes',
'time' => Carbon::now()->format('Y-m-d H:i:s')
]);
return response()->json([
'status' => 'success',
'message' => 'Gambar tersimpan sebagai ' . $filename,
'path' => $fullPath,
'time' => Carbon::now()->format('Y-m-d H:i:s')
]);
}
throw new \Exception('Gagal menyimpan gambar');
}
throw new \Exception('Tidak ada data yang diterima');
} catch (\Exception $e) {
Log::error("ESP32-CAM upload error: " . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Menampilkan semua foto
*/
public function all(Request $request)
{
$uploadDir = 'esp32cam/';
$files = Storage::disk('public')->files($uploadDir);
// Urutkan file berdasarkan waktu modifikasi (terbaru lebih dulu)
usort($files, function($a, $b) {
return Storage::disk('public')->lastModified($b) - Storage::disk('public')->lastModified($a);
});
$photos = [];
foreach ($files as $file) {
$filePath = storage_path('app/public/' . $file);
$fileModTime = Storage::disk('public')->lastModified($file);
$fileName = basename($file);
$fileSize = round(Storage::disk('public')->size($file) / 1024, 2) . ' KB';
// Mendapatkan timestamp dari nama file (format nama file: esp32cam_YmdHis)
$timestampFromFilename = null;
if (preg_match('/esp32cam_(\d{8})_(\d{6})/', $fileName, $matches)) {
$dateStr = $matches[1];
$timeStr = $matches[2];
$timestampFromFilename = strtotime(
substr($dateStr, 0, 4) . '-' .
substr($dateStr, 4, 2) . '-' .
substr($dateStr, 6, 2) . ' ' .
substr($timeStr, 0, 2) . ':' .
substr($timeStr, 2, 2) . ':' .
substr($timeStr, 4, 2)
);
}
// Menggunakan timestamp dari nama file jika tersedia, jika tidak gunakan waktu modifikasi
$captureTimestamp = $timestampFromFilename ?: $fileModTime;
$captureTime = date('Y-m-d H:i:s', $captureTimestamp);
// Mendapatkan timestamp dari EXIF data jika tersedia
if (function_exists('exif_read_data')) {
try {
$exifData = @exif_read_data($filePath);
if ($exifData && isset($exifData['DateTimeOriginal'])) {
// Format EXIF datetime: YYYY:MM:DD HH:MM:SS
$exifTime = strtotime($exifData['DateTimeOriginal']);
if ($exifTime) {
$captureTimestamp = $exifTime;
$captureTime = date('Y-m-d H:i:s', $exifTime);
}
}
} catch (\Exception $e) {
Log::warning("Error reading EXIF data: " . $e->getMessage());
}
}
// Format untuk tampilan
$displayTime = date('d-m-Y H:i:s', $captureTimestamp);
$photoItem = [
'name' => $fileName,
'path' => 'storage/' . $file,
'time' => $displayTime,
'size' => $fileSize
];
$photos[] = $photoItem;
}
return view('photos', ['photos' => $photos]);
}
/**
* Mengambil foto terbaru
*/
public function latest()
{
$uploadDir = 'esp32cam/';
$files = Storage::disk('public')->files($uploadDir);
if (count($files) > 0) {
// Urutkan file berdasarkan waktu modifikasi (terbaru lebih dulu)
usort($files, function($a, $b) {
return Storage::disk('public')->lastModified($b) - Storage::disk('public')->lastModified($a);
});
$latestPhoto = $files[0];
// Get file path for EXIF reading
$filePath = storage_path('app/public/' . $latestPhoto);
$fileModTime = Storage::disk('public')->lastModified($latestPhoto);
$fileSize = round(Storage::disk('public')->size($latestPhoto) / 1024, 2) . ' KB';
$fileName = basename($latestPhoto);
// Mendapatkan timestamp dari nama file (format nama file: esp32cam_YmdHis)
$timestampFromFilename = null;
if (preg_match('/esp32cam_(\d{8})_(\d{6})/', $fileName, $matches)) {
$dateStr = $matches[1];
$timeStr = $matches[2];
$timestampFromFilename = strtotime(
substr($dateStr, 0, 4) . '-' .
substr($dateStr, 4, 2) . '-' .
substr($dateStr, 6, 2) . ' ' .
substr($timeStr, 0, 2) . ':' .
substr($timeStr, 2, 2) . ':' .
substr($timeStr, 4, 2)
);
}
// Menggunakan timestamp dari nama file jika tersedia, jika tidak gunakan waktu modifikasi
$captureTimestamp = $timestampFromFilename ?: $fileModTime;
$captureTime = date('Y-m-d H:i:s', $captureTimestamp);
// Get timestamp from EXIF data if available
if (function_exists('exif_read_data')) {
try {
$exifData = @exif_read_data($filePath);
if ($exifData && isset($exifData['DateTimeOriginal'])) {
// Format EXIF datetime: YYYY:MM:DD HH:MM:SS
$exifTime = strtotime($exifData['DateTimeOriginal']);
if ($exifTime) {
$captureTimestamp = $exifTime;
$captureTime = date('Y-m-d H:i:s', $exifTime);
}
}
} catch (\Exception $e) {
Log::warning("Error reading EXIF data: " . $e->getMessage());
}
}
// Format untuk tampilan
$displayTime = date('d-m-Y H:i:s', $captureTimestamp);
return response()->json([
'status' => 'success',
'photo' => [
'name' => $fileName,
'path' => 'storage/' . $latestPhoto,
'time' => $displayTime,
'timestamp' => $captureTimestamp,
'size' => $fileSize
]
]);
}
return response()->json([
'status' => 'error',
'message' => 'Tidak ada foto yang tersedia'
], 404);
}
/**
* Hapus foto tertentu
*/
public function delete(Request $request)
{
$filename = $request->input('filename');
$uploadDir = 'esp32cam/';
if (Storage::disk('public')->exists($uploadDir . $filename)) {
Storage::disk('public')->delete($uploadDir . $filename);
return response()->json([
'status' => 'success',
'message' => 'Foto berhasil dihapus'
]);
}
return response()->json([
'status' => 'error',
'message' => 'Foto tidak ditemukan'
], 404);
}
/**
* Membersihkan foto lama
*/
public function cleanup()
{
try {
$uploadDir = 'esp32cam/';
$files = Storage::disk('public')->files($uploadDir);
$deletedCount = 0;
// Hapus file yang lebih lama dari 7 hari, tapi simpan minimal 10 file terakhir
if (count($files) > 10) {
// Urutkan file berdasarkan waktu modifikasi (terlama lebih dulu)
usort($files, function($a, $b) {
return Storage::disk('public')->lastModified($a) - Storage::disk('public')->lastModified($b);
});
// Ambil file lama untuk dihapus (tapi sisakan 10 file terakhir)
$filesToDelete = array_slice($files, 0, count($files) - 10);
foreach ($filesToDelete as $file) {
$fileTime = Storage::disk('public')->lastModified($file);
if (time() - $fileTime > 7 * 24 * 60 * 60) { // 7 hari dalam detik
Storage::disk('public')->delete($file);
Log::info("Deleted old ESP32-CAM image: " . $file);
$deletedCount++;
}
}
}
return response()->json([
'status' => 'success',
'message' => 'Cleanup completed. Deleted ' . $deletedCount . ' old files.',
'remaining_files' => count($files) - $deletedCount
]);
} catch (\Exception $e) {
Log::error("ESP32-CAM cleanup error: " . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => $e->getMessage()
], 500);
}
}
/**
* Fetch image from ESP32-CAM web server and save it
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function fetchFromCamera(Request $request)
{
try {
// Debug untuk melihat isi request
Log::info('ESP32CAM Debug - Request Content:', [
'all' => $request->all(),
'content' => $request->getContent(),
'content_decoded' => json_decode($request->getContent(), true),
'headers' => $request->header()
]);
// Gunakan IP kamera dari request atau default
$cameraIp = '192.168.1.19'; // Default IP
// Coba ambil dari request JSON
$requestData = json_decode($request->getContent(), true);
if (isset($requestData['camera_ip']) && !empty($requestData['camera_ip'])) {
$cameraIp = $requestData['camera_ip'];
}
Log::info('ESP32CAM Debug - Camera IP:', [
'camera_ip' => $cameraIp,
'is_empty' => empty($cameraIp)
]);
if (empty($cameraIp)) {
return response()->json([
'status' => 'error',
'message' => 'IP address ESP32-CAM diperlukan'
], 400);
}
// URL untuk mengambil gambar dari ESP32-CAM
$captureUrl = "http://{$cameraIp}/capture";
Log::info('ESP32CAM Capture URL: ' . $captureUrl);
// Ambil gambar dari ESP32-CAM dengan timeout yang lebih lama
$response = Http::timeout(30)->get($captureUrl);
if ($response->successful()) {
// Direktori untuk menyimpan gambar
$uploadDir = 'esp32cam/';
// Pastikan direktori ada di storage/app/public
if (!Storage::disk('public')->exists($uploadDir)) {
Storage::disk('public')->makeDirectory($uploadDir);
}
// Buat nama file unik dengan timestamp
$filename = 'esp32cam_' . Carbon::now()->format('Ymd_His') . '.jpg';
$fullPath = $uploadDir . $filename;
// Simpan gambar menggunakan Laravel Storage
Storage::disk('public')->put($fullPath, $response->body());
// Mengatur waktu file agar sesuai dengan waktu pengambilan saat ini
$now = time();
$filePath = storage_path('app/public/' . $fullPath);
@touch($filePath, $now);
// Log informasi
Log::info("ESP32-CAM image fetched successfully", [
'camera_ip' => $cameraIp,
'filename' => $filename,
'path' => $fullPath,
'size' => strlen($response->body()) . ' bytes',
'time' => Carbon::now()->format('Y-m-d H:i:s')
]);
return response()->json([
'status' => 'success',
'message' => 'Gambar berhasil diambil dari ESP32-CAM',
'filename' => $filename,
'path' => $fullPath,
'time' => Carbon::now()->format('Y-m-d H:i:s')
]);
}
Log::error('ESP32-CAM fetch error: Failed with status ' . $response->status());
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil gambar dari ESP32-CAM. Status: ' . $response->status(),
'camera_ip' => $cameraIp
], 500);
} catch (\Exception $e) {
Log::error("ESP32-CAM fetch error: " . $e->getMessage(), [
'camera_ip' => $cameraIp ?? '192.168.1.19',
'exception' => $e
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil gambar: ' . $e->getMessage(),
'camera_ip' => $cameraIp ?? '192.168.1.19'
], 500);
}
}
/**
* Proxy untuk endpoint status dari ESP32-CAM
*
* @param Request $request
* @return \Illuminate\Http\Response
*/
public function proxyStatus(Request $request)
{
try {
$cameraIp = $request->query('ip');
if (empty($cameraIp)) {
return response()->json([
'status' => 'error',
'message' => 'IP address ESP32-CAM diperlukan'
], 400);
}
// Periksa apakah URL sudah mengandung http:// atau https://
if (!preg_match('/^https?:\/\//', $cameraIp)) {
$statusUrl = "http://{$cameraIp}/status";
} else {
// Gunakan URL langsung jika sudah termasuk protokol
$statusUrl = "{$cameraIp}/status";
}
Log::info('ESP32CAM Proxy Status URL: ' . $statusUrl);
$response = Http::timeout(10)->get($statusUrl);
if ($response->successful()) {
return response($response->body(), 200)
->header('Content-Type', 'application/json');
}
return response()->json([
'status' => 'error',
'message' => 'Gagal mengakses status ESP32-CAM. Status: ' . $response->status(),
'camera_ip' => $cameraIp
], 500);
} catch (\Exception $e) {
Log::error("ESP32-CAM proxy status error: " . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => 'Gagal mengakses status: ' . $e->getMessage()
], 500);
}
}
/**
* Proxy untuk stream video dari ESP32-CAM
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\StreamedResponse
*/
public function proxyStream(Request $request)
{
$cameraIp = $request->query('ip');
if (empty($cameraIp)) {
return response()->json([
'status' => 'error',
'message' => 'IP address ESP32-CAM diperlukan'
], 400);
}
// Periksa apakah URL sudah mengandung http:// atau https://
if (!preg_match('/^https?:\/\//', $cameraIp)) {
$streamUrl = "http://{$cameraIp}/stream";
} else {
// Gunakan URL langsung jika sudah termasuk protokol
$streamUrl = "{$cameraIp}/stream";
}
Log::info('ESP32CAM Proxy Stream URL: ' . $streamUrl);
// Menggunakan streaming response untuk meneruskan MJPEG stream
return response()->stream(function() use ($streamUrl) {
$ch = curl_init($streamUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); // Langsung kirim output ke browser
curl_exec($ch);
curl_close($ch);
}, 200, [
'Content-Type' => 'multipart/x-mixed-replace; boundary=frame',
'Cache-Control' => 'no-cache, private',
'Connection' => 'close',
'Pragma' => 'no-cache'
]);
}
/**
* Proxy untuk pengaturan kamera ESP32-CAM
*
* @param Request $request
* @return \Illuminate\Http\Response
*/
public function proxySettings(Request $request)
{
try {
$cameraIp = $request->query('ip');
$resolution = $request->query('resolution');
$quality = $request->query('quality');
if (empty($cameraIp)) {
return response()->json([
'status' => 'error',
'message' => 'IP address ESP32-CAM diperlukan'
], 400);
}
// Periksa apakah URL sudah mengandung http:// atau https://
if (!preg_match('/^https?:\/\//', $cameraIp)) {
$baseUrl = "http://{$cameraIp}";
} else {
// Gunakan URL langsung jika sudah termasuk protokol
$baseUrl = $cameraIp;
}
// Bangun URL dengan parameter yang diperlukan
$settingsUrl = "{$baseUrl}/camera-settings";
$params = [];
if (!empty($resolution)) {
$params['resolution'] = $resolution;
}
if (!empty($quality)) {
$params['quality'] = $quality;
}
if (!empty($params)) {
$settingsUrl .= '?' . http_build_query($params);
}
Log::info('ESP32CAM Proxy Settings URL: ' . $settingsUrl);
$response = Http::timeout(10)->get($settingsUrl);
if ($response->successful()) {
return response($response->body(), 200)
->header('Content-Type', 'application/json');
}
return response()->json([
'status' => 'error',
'message' => 'Gagal mengubah pengaturan ESP32-CAM. Status: ' . $response->status(),
'camera_ip' => $cameraIp
], 500);
} catch (\Exception $e) {
Log::error("ESP32-CAM proxy settings error: " . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => 'Gagal mengubah pengaturan: ' . $e->getMessage()
], 500);
}
}
/**
* Proxy untuk endpoint capture dari ESP32-CAM
*
* @param Request $request
* @return \Illuminate\Http\Response
*/
public function proxyCapture(Request $request)
{
try {
$cameraIp = $request->query('ip');
if (empty($cameraIp)) {
return response()->json([
'status' => 'error',
'message' => 'IP address ESP32-CAM diperlukan'
], 400);
}
// Periksa apakah URL sudah mengandung http:// atau https://
if (!preg_match('/^https?:\/\//', $cameraIp)) {
$captureUrl = "http://{$cameraIp}/capture";
} else {
// Gunakan URL langsung jika sudah termasuk protokol
$captureUrl = "{$cameraIp}/capture";
}
Log::info('ESP32CAM Proxy Capture URL: ' . $captureUrl);
$response = Http::timeout(15)->get($captureUrl);
if ($response->successful()) {
// Jika hanya ingin menampilkan gambar tanpa menyimpan
if ($request->query('display_only') === 'true') {
return response($response->body(), 200)
->header('Content-Type', 'image/jpeg');
}
// Jika ingin menyimpan gambar
$uploadDir = 'esp32cam/';
// Pastikan direktori ada di storage/app/public
if (!Storage::disk('public')->exists($uploadDir)) {
Storage::disk('public')->makeDirectory($uploadDir);
}
// Buat nama file unik dengan timestamp
$filename = 'esp32cam_' . Carbon::now()->format('Ymd_His') . '.jpg';
$fullPath = $uploadDir . $filename;
// Simpan gambar menggunakan Laravel Storage
Storage::disk('public')->put($fullPath, $response->body());
// Mengatur waktu file agar sesuai dengan waktu pengambilan saat ini
$now = time();
$filePath = storage_path('app/public/' . $fullPath);
@touch($filePath, $now);
return response()->json([
'status' => 'success',
'message' => 'Gambar berhasil diambil dan disimpan',
'filename' => $filename,
'path' => $fullPath,
'time' => Carbon::now()->format('Y-m-d H:i:s')
]);
}
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil gambar dari ESP32-CAM. Status: ' . $response->status(),
'camera_ip' => $cameraIp
], 500);
} catch (\Exception $e) {
Log::error("ESP32-CAM proxy capture error: " . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil gambar: ' . $e->getMessage()
], 500);
}
}
/**
* Proxy untuk menghentikan streaming dari ESP32-CAM
*
* @param Request $request
* @return \Illuminate\Http\Response
*/
public function proxyStopStream(Request $request)
{
try {
$cameraIp = $request->query('ip');
if (empty($cameraIp)) {
return response()->json([
'status' => 'error',
'message' => 'IP address ESP32-CAM diperlukan'
], 400);
}
// Periksa apakah URL sudah mengandung http:// atau https://
if (!preg_match('/^https?:\/\//', $cameraIp)) {
$stopStreamUrl = "http://{$cameraIp}/stopstream";
} else {
// Gunakan URL langsung jika sudah termasuk protokol
$stopStreamUrl = "{$cameraIp}/stopstream";
}
Log::info('ESP32CAM Proxy Stop Stream URL: ' . $stopStreamUrl);
$response = Http::timeout(5)->get($stopStreamUrl);
if ($response->successful()) {
return response()->json([
'status' => 'success',
'message' => 'Streaming dihentikan'
]);
}
return response()->json([
'status' => 'error',
'message' => 'Gagal menghentikan streaming. Status: ' . $response->status(),
'camera_ip' => $cameraIp
], 500);
} catch (\Exception $e) {
Log::error("ESP32-CAM proxy stop stream error: " . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => 'Gagal menghentikan streaming: ' . $e->getMessage()
], 500);
}
}
}

View File

@ -0,0 +1,511 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class HistoryController extends Controller
{
// Path relatif ke file Firebase JSON export
protected $firebaseJsonPath = 'public/reports/firebase_export.json';
// Path relatif untuk file history sensor lokal
protected $reportFilePath = 'public/reports/report.json';
/**
* Mendapatkan semua data history sensor
*/
public function getAll()
{
// Debug log
Log::info('HistoryController@getAll dipanggil');
// Inisialisasi array untuk menyimpan history
$history = [];
try {
// Periksa apakah file Firebase export ada
if (Storage::exists($this->firebaseJsonPath)) {
Log::info('File Firebase export ditemukan');
// Baca file JSON Firebase
$jsonData = Storage::get($this->firebaseJsonPath);
$firebaseData = json_decode($jsonData, true);
// Jika struktur Firebase terdeteksi, ambil data sensor dari situ
if ($firebaseData && isset($firebaseData['sensor'])) {
$timestamp = now()->toIso8601String();
// Jika ada timestamp di file Firebase, gunakan itu
if (isset($firebaseData['status']['last_seen'])) {
$today = Carbon::today()->format('Y-m-d');
$timestamp = Carbon::parse($today . ' ' . $firebaseData['status']['last_seen'])->toIso8601String();
}
// Tambahkan data sensor suhu
if (isset($firebaseData['sensor']['suhu'])) {
$history[] = [
'timestamp' => $timestamp,
'sensor' => 'suhu',
'newValue' => $firebaseData['sensor']['suhu']
];
}
// Tambahkan data sensor kelembaban
if (isset($firebaseData['sensor']['kelembaban'])) {
$history[] = [
'timestamp' => $timestamp,
'sensor' => 'kelembaban',
'newValue' => $firebaseData['sensor']['kelembaban']
];
}
// Tambahkan data sensor cahaya
if (isset($firebaseData['sensor']['cahaya'])) {
$history[] = [
'timestamp' => $timestamp,
'sensor' => 'cahaya',
'newValue' => $firebaseData['sensor']['cahaya']
];
}
// Tambahkan data sensor kelembapan tanah
if (isset($firebaseData['sensor']['kelembapan_tanah'])) {
$history[] = [
'timestamp' => $timestamp,
'sensor' => 'kelembapan_tanah',
'newValue' => $firebaseData['sensor']['kelembapan_tanah']
];
}
Log::info('Data sensor dari Firebase berhasil diambil');
}
}
// Periksa juga file history lokal jika ada
if (Storage::exists($this->reportFilePath)) {
Log::info('File history lokal ditemukan');
$localJsonData = Storage::get($this->reportFilePath);
$localHistory = json_decode($localJsonData, true) ?: [];
// Gabungkan data lokal dengan data dari Firebase
$history = array_merge($history, $localHistory);
// Urutkan berdasarkan timestamp terbaru
usort($history, function($a, $b) {
return strtotime($b['timestamp']) - strtotime($a['timestamp']);
});
Log::info('Data history lokal berhasil diambil dan digabungkan');
}
// Jika tidak ada data sama sekali, kembalikan array kosong
if (empty($history)) {
Log::info('Tidak ada data history yang ditemukan');
return response()->json([]);
}
Log::info('Total data history: ' . count($history));
return response()->json($history);
} catch (\Exception $e) {
Log::error('Error saat mengambil data history: ' . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => 'Terjadi kesalahan saat mengambil data history: ' . $e->getMessage()
], 500);
}
}
/**
* Mendapatkan data langsung dari Firebase (untuk testing)
*/
public function getFirebaseData()
{
try {
// Cek apakah file Firebase export ada
if (!Storage::exists($this->firebaseJsonPath)) {
return response()->json([
'status' => 'error',
'message' => 'File Firebase export tidak ditemukan'
], 404);
}
// Baca file JSON Firebase
$jsonData = Storage::get($this->firebaseJsonPath);
$firebaseData = json_decode($jsonData, true);
return response()->json([
'status' => 'success',
'data' => $firebaseData
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
/**
* Mendapatkan data sensor terkini dengan status dan aksi
*/
public function getCurrentSensorData()
{
try {
// Cek apakah file Firebase export ada
if (!Storage::exists($this->firebaseJsonPath)) {
return response()->json([
'status' => 'error',
'message' => 'File Firebase export tidak ditemukan'
], 404);
}
// Baca file JSON Firebase
$jsonData = Storage::get($this->firebaseJsonPath);
$firebaseData = json_decode($jsonData, true);
// Periksa apakah data sensor ada
if (!isset($firebaseData['sensor'])) {
return response()->json([
'status' => 'error',
'message' => 'Data sensor tidak ditemukan'
], 404);
}
// Siapkan data yang akan dikembalikan
$sensorData = [
'suhu' => [
'nilai' => $firebaseData['sensor']['suhu'] ?? null,
'status' => $this->getSuhuStatus($firebaseData['sensor']['suhu'] ?? null),
'aksi' => $this->getSuhuAksi($firebaseData['sensor']['suhu'] ?? null)
],
'kelembaban' => [
'nilai' => $firebaseData['sensor']['kelembapan_tanah'] ?? null,
'status' => $this->getKelembabanStatus($firebaseData['sensor']['kelembapan_tanah'] ?? null),
'aksi' => $this->getKelembabanAksi($firebaseData['sensor']['kelembapan_tanah'] ?? null)
],
'cahaya' => [
'nilai' => $firebaseData['sensor']['cahaya'] ?? null,
'status' => $this->getCahayaStatus($firebaseData['sensor']['cahaya'] ?? null),
'aksi' => $this->getCahayaAksi($firebaseData['sensor']['cahaya'] ?? null)
],
'connected' => $firebaseData['status']['connected'] ?? false,
'last_seen' => $firebaseData['status']['last_seen'] ?? null
];
return response()->json([
'status' => 'success',
'data' => $sensorData
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
/**
* Menentukan status suhu berdasarkan nilai
*/
private function getSuhuStatus($value)
{
if ($value === null) return '--';
if ($value < 24) {
return 'Rendah';
} else if ($value >= 25 && $value <= 27) {
return 'Normal';
} else if ($value >= 28) {
return 'Tinggi';
}
return '--';
}
/**
* Menentukan aksi suhu berdasarkan nilai
*/
private function getSuhuAksi($value)
{
if ($value === null) return '--';
if ($value < 24) {
return 'Kipas Off';
} else if ($value >= 25 && $value <= 27) {
return 'Kipas Off';
} else if ($value >= 28) {
return 'Kipas On';
}
return '--';
}
/**
* Menentukan status kelembaban tanah berdasarkan nilai
*/
private function getKelembabanStatus($value)
{
if ($value === null) return '--';
if ($value < 40) {
return 'Kering';
} else if ($value >= 40 && $value < 50) {
return 'Cukup';
} else if ($value >= 50 && $value < 60) {
return 'Optimal';
} else if ($value >= 60 && $value <= 70) {
return 'Lembap';
} else if ($value > 70) {
return 'Sangat Lembap';
}
return '--';
}
/**
* Menentukan aksi kelembaban tanah berdasarkan nilai
*/
private function getKelembabanAksi($value)
{
if ($value === null) return '--';
if ($value < 40) {
return 'Pompa Air On';
} else {
return 'Pompa Air Off';
}
}
/**
* Menentukan status cahaya berdasarkan nilai
*/
private function getCahayaStatus($value)
{
if ($value === null) return '--';
if ($value < 8000) {
return 'Rendah';
} else if ($value >= 8001 && $value <= 11000) {
return 'Normal';
} else if ($value > 11001) {
return 'Tinggi';
}
return '--';
}
/**
* Menentukan aksi cahaya berdasarkan nilai
*/
private function getCahayaAksi($value)
{
if ($value === null) return '--';
if ($value < 8000) {
return 'Lampu On';
} else {
return 'Lampu Off';
}
}
/**
* Menyimpan data baru ke history
*/
public function store(Request $request)
{
// Validasi request
$request->validate([
'sensor' => 'required|string',
'newValue' => 'required|numeric',
'timestamp' => 'nullable|string'
]);
// Baca data yang ada
$history = [];
if (Storage::exists($this->reportFilePath)) {
$jsonData = Storage::get($this->reportFilePath);
$history = json_decode($jsonData, true) ?: [];
}
// Buat data baru
$newData = [
'timestamp' => $request->input('timestamp', now()->toIso8601String()),
'sensor' => $request->input('sensor'),
'newValue' => $request->input('newValue')
];
// Tambahkan data baru ke awal array (terbaru di atas)
array_unshift($history, $newData);
// Simpan kembali ke file
Storage::put($this->reportFilePath, json_encode($history, JSON_PRETTY_PRINT));
return response()->json([
'status' => 'success',
'message' => 'Data berhasil disimpan',
'data' => $newData
]);
}
/**
* Menyinkronkan data dari Firebase Export ke file history lokal
*/
public function syncFromFirebase()
{
try {
// Periksa apakah file Firebase export ada
if (!Storage::exists($this->firebaseJsonPath)) {
return response()->json([
'status' => 'error',
'message' => 'File Firebase export tidak ditemukan'
], 404);
}
// Baca file JSON Firebase
$jsonData = Storage::get($this->firebaseJsonPath);
$firebaseData = json_decode($jsonData, true);
// Periksa apakah data sensor ada
if (!isset($firebaseData['sensor'])) {
return response()->json([
'status' => 'error',
'message' => 'Data sensor tidak ditemukan di file Firebase'
], 400);
}
// Baca data history yang ada
$history = [];
if (Storage::exists($this->reportFilePath)) {
$localJsonData = Storage::get($this->reportFilePath);
$history = json_decode($localJsonData, true) ?: [];
}
// Ambil timestamp
$timestamp = now()->toIso8601String();
if (isset($firebaseData['status']['last_seen'])) {
$today = Carbon::today()->format('Y-m-d');
$timestamp = Carbon::parse($today . ' ' . $firebaseData['status']['last_seen'])->toIso8601String();
}
// Tambahkan data sensor suhu
if (isset($firebaseData['sensor']['suhu'])) {
$history[] = [
'timestamp' => $timestamp,
'sensor' => 'suhu',
'newValue' => $firebaseData['sensor']['suhu']
];
}
// // Tambahkan data sensor kelembaban
// if (isset($firebaseData['sensor']['kelembaban'])) {
// $history[] = [
// 'timestamp' => $timestamp,
// 'sensor' => 'kelembaban',
// 'newValue' => $firebaseData['sensor']['kelembaban']
// ];
// }
// Tambahkan data sensor cahaya
if (isset($firebaseData['sensor']['cahaya'])) {
$history[] = [
'timestamp' => $timestamp,
'sensor' => 'cahaya',
'newValue' => $firebaseData['sensor']['cahaya']
];
}
// Tambahkan data sensor kelembapan tanah
if (isset($firebaseData['sensor']['kelembapan_tanah'])) {
$history[] = [
'timestamp' => $timestamp,
'sensor' => 'kelembapan_tanah',
'newValue' => $firebaseData['sensor']['kelembapan_tanah']
];
}
// Urutkan berdasarkan timestamp terbaru
usort($history, function($a, $b) {
return strtotime($b['timestamp']) - strtotime($a['timestamp']);
});
// Simpan kembali ke file
Storage::put($this->reportFilePath, json_encode($history, JSON_PRETTY_PRINT));
return response()->json([
'status' => 'success',
'message' => 'Data berhasil disinkronkan dari Firebase',
'data_count' => count($history)
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => 'Terjadi kesalahan saat sinkronisasi: ' . $e->getMessage()
], 500);
}
}
/**
* Menghapus satu data dari history
*/
public function delete(Request $request)
{
$request->validate([
'index' => 'required|integer|min:0'
]);
$index = $request->input('index');
// Baca data yang ada
if (!Storage::exists($this->reportFilePath)) {
return response()->json([
'status' => 'error',
'message' => 'File history tidak ditemukan'
], 404);
}
$jsonData = Storage::get($this->reportFilePath);
$history = json_decode($jsonData, true) ?: [];
// Pastikan index valid
if (!isset($history[$index])) {
return response()->json([
'status' => 'error',
'message' => 'Data dengan index tersebut tidak ditemukan'
], 404);
}
// Hapus data
array_splice($history, $index, 1);
// Simpan kembali ke file
Storage::put($this->reportFilePath, json_encode($history, JSON_PRETTY_PRINT));
return response()->json([
'status' => 'success',
'message' => 'Data berhasil dihapus'
]);
}
/**
* Menghapus semua data history
*/
public function clear()
{
// Hapus semua data dengan menyimpan array kosong
Storage::put($this->reportFilePath, json_encode([]));
return response()->json([
'status' => 'success',
'message' => 'Semua data history berhasil dihapus'
]);
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class SensorController extends Controller
{
/**
* Menerima data sensor dari ESP8266/ESP32
*/
public function update(Request $request)
{
$validated = $request->validate([
'suhu' => 'nullable|numeric',
'kelembaban' => 'nullable|numeric',
'cahaya' => 'nullable|numeric',
]);
// Simpan data sensor ke database
DB::table('sensor_logs')->insert([
'suhu' => $validated['suhu'] ?? null,
'kelembaban' => $validated['kelembaban'] ?? null,
'cahaya' => $validated['cahaya'] ?? null,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
// Update status koneksi
$this->updateConnectionStatus(true);
return response()->json([
'success' => true,
'message' => 'Data sensor berhasil disimpan'
]);
}
/**
* Mendapatkan data sensor terbaru
*/
public function latest()
{
// Ambil data sensor terbaru
$latestData = DB::table('sensor_logs')
->latest('created_at')
->first();
// Periksa status koneksi
$connectionStatus = $this->getConnectionStatus();
return response()->json([
'suhu' => $latestData->suhu ?? null,
'kelembaban' => $latestData->kelembaban ?? null,
'cahaya' => $latestData->cahaya ?? null,
'timestamp' => $latestData ? Carbon::parse($latestData->created_at)->format('Y-m-d H:i:s') : null,
'connected' => $connectionStatus
]);
}
/**
* Mendapatkan riwayat data sensor
*/
public function history(Request $request)
{
$limit = $request->input('limit', 24);
$page = $request->input('page', 1);
$offset = ($page - 1) * $limit;
// Ambil data riwayat
$logs = DB::table('sensor_logs')
->orderBy('created_at', 'desc')
->skip($offset)
->take($limit)
->get();
// Hitung total data
$total = DB::table('sensor_logs')->count();
return response()->json([
'data' => $logs,
'pagination' => [
'total' => $total,
'per_page' => $limit,
'current_page' => $page,
'last_page' => ceil($total / $limit)
]
]);
}
/**
* Update status koneksi ESP8266/ESP32
*/
private function updateConnectionStatus($connected)
{
DB::table('device_status')->updateOrInsert(
['device' => 'esp32'],
[
'connected' => $connected,
'last_seen' => Carbon::now(),
'updated_at' => Carbon::now()
]
);
}
/**
* Cek status koneksi ESP8266/ESP32
*/
private function getConnectionStatus()
{
$status = DB::table('device_status')
->where('device', 'esp32')
->first();
if (!$status) {
return false;
}
// Jika terakhir update > 1 menit, anggap terputus
$lastSeen = Carbon::parse($status->last_seen);
$now = Carbon::now();
return $status->connected && $lastSeen->diffInMinutes($now) < 1;
}
}

72
app/Http/Kernel.php Normal file
View File

@ -0,0 +1,72 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'firebase.auth' => [
\App\Http\Middleware\CheckFirebaseAuth::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
*
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckFirebaseAuth
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Periksa apakah ada session auth token yang ada
if (!session()->has('firebase_auth_checked') || !session()->get('firebase_auth_checked')) {
// Jika tidak ada, redirect ke halaman login
return redirect()->route('login');
}
return $next($request);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array<int, string>
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
'api/esp32cam/*',
];
}

45
app/Models/User.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Providers;
// use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* The model to policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
//
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
//
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event to listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
/**
* Register any events for your application.
*/
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*/
public function shouldDiscoverEvents(): bool
{
return false;
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to your application's "home" route.
*
* Typically, users are redirected here after authentication.
*
* @var string
*/
public const HOME = '/home';
/**
* Define your route model bindings, pattern filters, and other route configuration.
*/
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
}

53
artisan Normal file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env php
<?php
define('LARAVEL_START', microtime(true));
/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader
| for our application. We just need to utilize it! We'll require it
| into the script here so that we do not have to worry about the
| loading of any of our classes manually. It's great to relax.
|
*/
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
/*
|--------------------------------------------------------------------------
| Run The Artisan Application
|--------------------------------------------------------------------------
|
| When we run the console application, the current CLI command will be
| executed in this console and the response sent back to a terminal
| or another output device for the developers. Here goes nothing!
|
*/
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
/*
|--------------------------------------------------------------------------
| Shutdown The Application
|--------------------------------------------------------------------------
|
| Once Artisan has finished running, we will fire off the shutdown events
| so that any final work may be done by the application before we shut
| down the process. This is the last thing to happen to the request.
|
*/
$kernel->terminate($input, $status);
exit($status);

55
bootstrap/app.php Normal file
View File

@ -0,0 +1,55 @@
<?php
/*
|--------------------------------------------------------------------------
| Create The Application
|--------------------------------------------------------------------------
|
| The first thing we will do is create a new Laravel application instance
| which serves as the "glue" for all the components of Laravel, and is
| the IoC container for the system binding all of the various parts.
|
*/
$app = new Illuminate\Foundation\Application(
$_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
);
/*
|--------------------------------------------------------------------------
| Bind Important Interfaces
|--------------------------------------------------------------------------
|
| Next, we need to bind some important interfaces into the container so
| we will be able to resolve them when needed. The kernels serve the
| incoming requests to this application from both the web and CLI.
|
*/
$app->singleton(
Illuminate\Contracts\Http\Kernel::class,
App\Http\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
);
/*
|--------------------------------------------------------------------------
| Return The Application
|--------------------------------------------------------------------------
|
| This script returns the application instance. The instance is given to
| the calling script so we can separate the building of the instances
| from the actual running of the application and sending responses.
|
*/
return $app;

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

66
composer.json Normal file
View File

@ -0,0 +1,66 @@
{
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.1",
"guzzlehttp/guzzle": "^7.2",
"laravel/framework": "^10.10",
"laravel/sanctum": "^3.3",
"laravel/tinker": "^2.8"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
"laravel/pint": "^1.0",
"laravel/sail": "^1.18",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^7.0",
"phpunit/phpunit": "^10.1",
"spatie/laravel-ignition": "^2.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8124
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

188
config/app.php Normal file
View File

@ -0,0 +1,188 @@
<?php
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application. This value is used when the
| framework needs to place the application's name in a notification or
| any other location as required by the application or its packages.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| your application so that it is used when running Artisan tasks.
|
*/
'url' => env('APP_URL', 'http://localhost'),
'asset_url' => env('ASSET_URL'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
'locale' => 'en',
/*
|--------------------------------------------------------------------------
| Application Fallback Locale
|--------------------------------------------------------------------------
|
| The fallback locale determines the locale to use when the current one
| is not available. You may change the value to correspond to any of
| the language folders that are provided through your application.
|
*/
'fallback_locale' => 'en',
/*
|--------------------------------------------------------------------------
| Faker Locale
|--------------------------------------------------------------------------
|
| This locale will be used by the Faker PHP library when generating fake
| data for your database seeds. For example, this will be used to get
| localized telephone numbers, street address information and more.
|
*/
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => 'file',
// 'store' => 'redis',
],
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => ServiceProvider::defaultProviders()->merge([
/*
* Package Service Providers...
*/
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
])->toArray(),
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => Facade::defaultAliases()->merge([
// 'Example' => App\Facades\Example::class,
])->toArray(),
];

115
config/auth.php Normal file
View File

@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option controls the default authentication "guard" and password
| reset options for your application. You may change these defaults
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => 'web',
'passwords' => 'users',
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| here which uses session storage and the Eloquent user provider.
|
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data.
|
| If you have multiple user tables or models you may configure multiple
| sources which represent each model / table. These sources may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| You may specify multiple password reset configurations if you have more
| than one user table or model in the application and you want to have
| separate password reset settings based on the specific user types.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the amount of seconds before a password confirmation
| times out and the user is prompted to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => 10800,
];

71
config/broadcasting.php Normal file
View File

@ -0,0 +1,71 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_DRIVER', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over websockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

111
config/cache.php Normal file
View File

@ -0,0 +1,111 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache connection that gets used while
| using this caching library. This connection is used when another is
| not explicitly specified when executing a given caching function.
|
*/
'default' => env('CACHE_DRIVER', 'file'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "apc", "array", "database", "file",
| "memcached", "redis", "dynamodb", "octane", "null"
|
*/
'stores' => [
'apc' => [
'driver' => 'apc',
],
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, or DynamoDB cache
| stores there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
];

34
config/cors.php Normal file
View File

@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];

151
config/database.php Normal file
View File

@ -0,0 +1,151 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for all database work. Of course
| you may use many connections at once using the Database library.
|
*/
'default' => env('DB_CONNECTION', 'mysql'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Here are each of the database connections setup for your application.
| Of course, examples of configuring each database platform that is
| supported by Laravel is shown below to make development simple.
|
|
| All database work in Laravel is done through the PHP PDO facilities
| so make sure you have the driver for your particular database of
| choice installed on your machine before you begin development.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DATABASE_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run in the database.
|
*/
'migrations' => 'migrations',
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as APC or Memcached. Laravel makes it easy to dig right in.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
];

76
config/filesystems.php Normal file
View File

@ -0,0 +1,76 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application. Just store away!
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Here you may configure as many filesystem "disks" as you wish, and you
| may even configure multiple disks of the same driver. Defaults have
| been set up for each driver as an example of the required values.
|
| Supported Drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

54
config/hashing.php Normal file
View File

@ -0,0 +1,54 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Hash Driver
|--------------------------------------------------------------------------
|
| This option controls the default hash driver that will be used to hash
| passwords for your application. By default, the bcrypt algorithm is
| used; however, you remain free to modify this option if you wish.
|
| Supported: "bcrypt", "argon", "argon2id"
|
*/
'driver' => 'bcrypt',
/*
|--------------------------------------------------------------------------
| Bcrypt Options
|--------------------------------------------------------------------------
|
| Here you may specify the configuration options that should be used when
| passwords are hashed using the Bcrypt algorithm. This will allow you
| to control the amount of time it takes to hash the given password.
|
*/
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => true,
],
/*
|--------------------------------------------------------------------------
| Argon Options
|--------------------------------------------------------------------------
|
| Here you may specify the configuration options that should be used when
| passwords are hashed using the Argon algorithm. These will allow you
| to control the amount of time it takes to hash the given password.
|
*/
'argon' => [
'memory' => 65536,
'threads' => 1,
'time' => 4,
'verify' => true,
],
];

131
config/logging.php Normal file
View File

@ -0,0 +1,131 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that gets used when writing
| messages to the logs. The name specified in this option should match
| one of the channels defined in the "channels" configuration array.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => false,
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Out of
| the box, Laravel uses the Monolog PHP logging library. This gives
| you a variety of powerful log handlers / formatters to utilize.
|
| Available Drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog",
| "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => LOG_USER,
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

134
config/mail.php Normal file
View File

@ -0,0 +1,134 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send any email
| messages sent by your application. Alternative mailers may be setup
| and used as needed; however, this mailer will be used by default.
|
*/
'default' => env('MAIL_MAILER', 'smtp'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers to be used while
| sending an e-mail. You will specify which one you are using for your
| mailers below. You are free to add additional mailers as required.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "log", "array", "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => null,
// 'client' => [
// 'timeout' => 5,
// ],
],
'mailgun' => [
'transport' => 'mailgun',
// 'client' => [
// 'timeout' => 5,
// ],
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all e-mails sent by your application to be sent from
| the same address. Here, you may specify a name and address that is
| used globally for all e-mails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
/*
|--------------------------------------------------------------------------
| Markdown Mail Settings
|--------------------------------------------------------------------------
|
| If you are using Markdown based email rendering, you may configure your
| theme and component paths here, allowing you to customize the design
| of the emails. Or, you may simply stick with the Laravel defaults!
|
*/
'markdown' => [
'theme' => 'default',
'paths' => [
resource_path('views/vendor/mail'),
],
],
];

109
config/queue.php Normal file
View File

@ -0,0 +1,109 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue API supports an assortment of back-ends via a single
| API, giving you convenient access to each back-end using the same
| syntax for every one. Here you may define a default connection.
|
*/
'default' => env('QUEUE_CONNECTION', 'sync'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection information for each server that
| is used by your application. A default configuration has been added
| for each back-end shipped with Laravel. You are free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => 'localhost',
'queue' => 'default',
'retry_after' => 90,
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control which database and table are used to store the jobs that
| have failed. You may change them to any database / table you wish.
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];

83
config/sanctum.php Normal file
View File

@ -0,0 +1,83 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
],
];

34
config/services.php Normal file
View File

@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'mailgun' => [
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
'scheme' => 'https',
],
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
];

214
config/session.php Normal file
View File

@ -0,0 +1,214 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option controls the default session "driver" that will be used on
| requests. By default, we will use the lightweight native driver but
| you may specify any of the other wonderful drivers provided here.
|
| Supported: "file", "cookie", "database", "apc",
| "memcached", "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'file'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to immediately expire on the browser closing, set that option.
|
*/
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => false,
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it is stored. All encryption will be run
| automatically by Laravel and you can use the Session like normal.
|
*/
'encrypt' => false,
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When using the native session driver, we need a location where session
| files may be stored. A default has been set for you but a different
| location may be specified. This is only needed for file sessions.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table we
| should use to manage the sessions. Of course, a sensible default is
| provided for you; however, you are free to change this as needed.
|
*/
'table' => 'sessions',
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| While using one of the framework's cache driven session backends you may
| list a cache store that should be used for these sessions. This value
| must match with one of the application's configured cache "stores".
|
| Affects: "apc", "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the cookie used to identify a session
| instance by ID. The name specified here will get used every time a
| new session cookie is created by the framework for every driver.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application but you are free to change this when necessary.
|
*/
'path' => '/',
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| Here you may change the domain of the cookie used to identify a session
| in your application. This will determine which domains the cookie is
| available to in your application. A sensible default has been set.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. You are free to modify this option if needed.
|
*/
'http_only' => true,
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" since this is a secure default value.
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => 'lax',
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => false,
];

36
config/view.php Normal file
View File

@ -0,0 +1,36 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| View Storage Paths
|--------------------------------------------------------------------------
|
| Most templating systems load templates from disk. Here you may specify
| an array of paths that should be checked for your views. Of course
| the usual Laravel view path has already been registered for you.
|
*/
'paths' => [
resource_path('views'),
],
/*
|--------------------------------------------------------------------------
| Compiled View Path
|--------------------------------------------------------------------------
|
| This option determines where all the compiled Blade templates will be
| stored for your application. Typically, this is within the storage
| directory. However, as usual, you are free to change this value.
|
*/
'compiled' => env(
'VIEW_COMPILED_PATH',
realpath(storage_path('framework/views'))
),
];

1
database/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.sqlite*

View File

@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('password_reset_tokens');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->string('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sensor_logs', function (Blueprint $table) {
$table->id();
$table->float('suhu')->nullable();
$table->float('kelembaban')->nullable();
$table->float('cahaya')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sensor_logs');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('device_status', function (Blueprint $table) {
$table->id();
$table->string('device');
$table->boolean('connected')->default(false);
$table->timestamp('last_seen')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('device_status');
}
};

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,22 @@
<?php
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// \App\Models\User::factory(10)->create();
// \App\Models\User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
}
}

720
esp32cam.cpp Normal file
View File

@ -0,0 +1,720 @@
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include "esp_camera.h"
#include "time.h"
#include "SPIFFS.h" // Tambahkan SPIFFS untuk penyimpanan file
// WiFi credentials
const char* ssid = "didinganteng";
const char* password = "didin123";
// NTP Server untuk waktu
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 25200; // GMT+7 (WIB) in seconds (7*3600)
const int daylightOffset_sec = 0;
// Web server on port 80
WebServer server(80);
// Server Laravel (ganti dengan alamat server Laravel Anda)
const char* laravelServerUrl = "https://monitoring-anggrek.oyi.web.id/api/esp32cam/upload"; // Ganti dengan IP komputer Anda
// Status variables
bool cameraInitialized = false;
unsigned long startTime = 0;
// Menghilangkan timeout 5 menit
// const unsigned long TIMEOUT = 300000; // 5 menit (300000 ms) sebelum sleep
// Variabel untuk pengambilan gambar otomatis
const unsigned long AUTO_CAPTURE_DELAY = 5000; // 5 detik setelah boot
bool hasAutoCapture = false; // Flag untuk menandai sudah auto capture atau belum
// Variabel untuk streaming
bool isStreaming = false; // Flag untuk status streaming
const int streamingFrameRate = 10; // Frame rate untuk streaming (fps)
const int streamingDelay = 1000 / streamingFrameRate; // Delay antara frame dalam ms
// Variabel untuk pengaturan kamera
framesize_t streamResolution = FRAMESIZE_SVGA; // Default 800x600
int streamQuality = 10; // Default quality (0-63, lower is better)
// Variabel untuk pengiriman ulang foto
const int MAX_RETRY_PHOTOS = 5; // Maksimal foto yang disimpan sementara
const unsigned long RETRY_INTERVAL = 30000; // Interval pengiriman ulang (30 detik)
unsigned long lastRetryTime = 0; // Waktu terakhir mencoba mengirim ulang
int pendingPhotoCount = 0; // Jumlah foto yang menunggu dikirim
bool isRetryScheduled = false; // Flag untuk menandai jadwal pengiriman ulang
// ESP32 Camera pins
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
// Helper function to add CORS headers to all responses
void setCorsHeaders() {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.sendHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
server.sendHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
// Helper function to initialize SPIFFS
bool initSPIFFS() {
if (!SPIFFS.begin(true)) { // Format SPIFFS if mounting fails
Serial.println("SPIFFS gagal diinisialisasi");
return false;
}
Serial.println("SPIFFS berhasil diinisialisasi");
// Bersihkan file foto lama jika ada
cleanupPendingPhotos();
return true;
}
// Helper function untuk membersihkan file foto yang pending
void cleanupPendingPhotos() {
// Cek jumlah foto pending
int count = 0;
File root = SPIFFS.open("/");
File file = root.openNextFile();
while (file) {
String fileName = file.name();
if (fileName.startsWith("/photo_") && fileName.endsWith(".jpg")) {
count++;
Serial.println("Found pending photo: " + fileName);
}
file = root.openNextFile();
}
pendingPhotoCount = count;
Serial.println("Jumlah foto pending: " + String(pendingPhotoCount));
// Jika ada foto pending, jadwalkan pengiriman ulang
if (pendingPhotoCount > 0) {
isRetryScheduled = true;
Serial.println("Pengiriman ulang foto terjadwal");
}
}
bool initCamera() {
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sscb_sda = SIOD_GPIO_NUM;
config.pin_sscb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
// Initialize with high quality
config.frame_size = FRAMESIZE_SVGA; // 800x600
config.jpeg_quality = 10; // 0-63, lower is better quality
config.fb_count = 2; // Meningkatkan jumlah frame buffer untuk streaming
// Camera init
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Kamera gagal diinisialisasi, error 0x%x", err);
return false;
}
Serial.println("Kamera berhasil diinisialisasi");
return true;
}
void connectToWifi() {
Serial.print("Menghubungkan ke WiFi...");
WiFi.begin(ssid, password);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println();
Serial.println("WiFi terhubung!");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
Serial.print("Subnet Mask: ");
Serial.println(WiFi.subnetMask());
Serial.print("Gateway IP: ");
Serial.println(WiFi.gatewayIP());
} else {
Serial.println();
Serial.println("Gagal terhubung ke WiFi. Restart ESP32...");
delay(1000);
ESP.restart();
}
}
void initTime() {
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
Serial.println("Gagal mendapatkan waktu dari NTP server");
return;
}
Serial.println("NTP waktu diinisialisasi");
Serial.print("Waktu saat ini: ");
Serial.print(&timeinfo, "%A, %d-%m-%Y %H:%M:%S");
Serial.println();
}
// Simpan foto ke SPIFFS untuk dikirim nanti
bool savePhotoForLater(camera_fb_t *fb) {
if (pendingPhotoCount >= MAX_RETRY_PHOTOS) {
Serial.println("Terlalu banyak foto pending, tidak bisa menyimpan lagi");
return false;
}
// Buat nama file dengan timestamp
char fileName[32];
struct tm timeinfo;
if(getLocalTime(&timeinfo)){
sprintf(fileName, "/photo_%04d%02d%02d_%02d%02d%02d.jpg",
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
} else {
sprintf(fileName, "/photo_%d.jpg", millis());
}
// Simpan ke SPIFFS
File file = SPIFFS.open(fileName, FILE_WRITE);
if (!file) {
Serial.println("Gagal membuka file untuk menulis");
return false;
}
if (file.write(fb->buf, fb->len) != fb->len) {
Serial.println("Gagal menulis file");
file.close();
return false;
}
file.close();
Serial.println("Foto berhasil disimpan di SPIFFS: " + String(fileName));
pendingPhotoCount++;
isRetryScheduled = true;
return true;
}
// Fungsi untuk mengirim ulang foto yang tersimpan di SPIFFS
void retryPendingPhotos() {
if (pendingPhotoCount <= 0) {
isRetryScheduled = false;
return;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Tidak ada koneksi WiFi, menunda pengiriman ulang");
return;
}
Serial.println("Mencoba mengirim foto yang tertunda...");
File root = SPIFFS.open("/");
File file = root.openNextFile();
bool anySuccess = false;
while (file && pendingPhotoCount > 0) {
String fileName = file.name();
if (fileName.startsWith("/photo_") && fileName.endsWith(".jpg")) {
Serial.println("Mengirim foto: " + fileName);
// Baca file
if (file.size() > 0) {
uint8_t *buffer = (uint8_t*) malloc(file.size());
if (buffer) {
size_t size = file.read(buffer, file.size());
// Kirim ke server
HTTPClient http;
http.begin(laravelServerUrl);
http.addHeader("Content-Type", "image/jpeg");
int httpResponseCode = http.POST(buffer, size);
free(buffer);
if (httpResponseCode > 0) {
String response = http.getString();
Serial.println("HTTP Response code: " + String(httpResponseCode));
Serial.println("Response: " + response);
// Jika sukses, hapus file
String fullFileName = fileName;
file.close();
if (SPIFFS.remove(fullFileName)) {
Serial.println("File berhasil dihapus: " + fullFileName);
pendingPhotoCount--;
anySuccess = true;
} else {
Serial.println("Gagal menghapus file: " + fullFileName);
}
} else {
Serial.print("Error pada HTTP request: ");
Serial.println(httpResponseCode);
}
http.end();
}
}
if (!anySuccess) {
// Jika tidak ada yang berhasil dikirim, kita hentikan dulu
// untuk menghemat resource dan mencoba lagi nanti
break;
}
}
file = root.openNextFile();
}
// Update status
if (pendingPhotoCount <= 0) {
isRetryScheduled = false;
Serial.println("Semua foto tertunda berhasil dikirim");
} else {
Serial.println("Masih ada " + String(pendingPhotoCount) + " foto tertunda");
}
}
// Rute untuk halaman utama
void handleRoot() {
setCorsHeaders();
String html = "<html><body>";
html += "<h1>ESP32-CAM Monitoring Anggrek</h1>";
html += "<p>Akses <a href='/capture'>capture</a> untuk mengambil foto</p>";
// Hilangkan link sleep
// html += "<p>Akses <a href='/sleep'>sleep</a> untuk masuk mode deep sleep</p>";
html += "<p>Akses <a href='/status'>status</a> untuk melihat status perangkat</p>";
html += "<p>Akses <a href='/stream'>stream</a> untuk melihat live streaming</p>";
struct tm timeinfo;
if (getLocalTime(&timeinfo)) {
char timeString[50];
strftime(timeString, sizeof(timeString), "%A, %d-%m-%Y %H:%M:%S", &timeinfo);
html += "<p>Waktu saat ini: " + String(timeString) + "</p>";
}
html += "<p>Waktu hidup: " + String((millis() - startTime) / 1000) + " detik</p>";
// Tambahkan info foto pending
html += "<p>Foto tertunda: " + String(pendingPhotoCount) + "</p>";
html += "</body></html>";
server.send(200, "text/html", html);
}
// Rute untuk mengambil foto
void handleCapture() {
setCorsHeaders();
if (!cameraInitialized) {
server.send(500, "text/plain", "Kamera tidak diinisialisasi");
return;
}
camera_fb_t *fb = NULL;
// Ambil foto
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Gagal mengambil foto");
server.send(500, "text/plain", "Gagal mengambil foto");
return;
}
Serial.println("Foto berhasil diambil");
Serial.print("Ukuran: ");
Serial.print(fb->len);
Serial.println(" bytes");
// Kirim foto sebagai respons HTTP
server.sendHeader("Content-Type", "image/jpeg");
server.sendHeader("Content-Disposition", "inline; filename=capture.jpg");
server.sendHeader("Content-Length", String(fb->len));
server.send_P(200, "image/jpeg", (const char *)fb->buf, fb->len);
// Bebaskan memori
esp_camera_fb_return(fb);
Serial.println("Foto berhasil dikirim ke client");
}
// Rute untuk status perangkat
void handleStatus() {
setCorsHeaders();
String status = "{";
// Informasi sistem
status += "\"heap_size\":" + String(ESP.getFreeHeap()) + ",";
status += "\"uptime\":" + String(millis() / 1000) + ",";
// Informasi WiFi
status += "\"wifi\":{";
status += "\"connected\":" + String(WiFi.status() == WL_CONNECTED ? "true" : "false") + ",";
status += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
status += "\"rssi\":" + String(WiFi.RSSI());
status += "},";
// Informasi kamera
status += "\"camera\":{";
status += "\"initialized\":" + String(cameraInitialized ? "true" : "false") + ",";
status += "\"streaming\":" + String(isStreaming ? "true" : "false");
status += "},";
// Informasi foto pending
status += "\"pending_photos\":{";
status += "\"count\":" + String(pendingPhotoCount) + ",";
status += "\"retry_scheduled\":" + String(isRetryScheduled ? "true" : "false");
status += "}";
status += "}";
server.sendHeader("Content-Type", "application/json");
server.send(200, "application/json", status);
}
// Handler untuk streaming MJPEG
void handleStream() {
if (!cameraInitialized) {
setCorsHeaders();
server.send(500, "text/plain", "Kamera tidak diinisialisasi");
return;
}
Serial.println("Permintaan streaming diterima");
// Set header HTTP untuk streaming MJPEG
WiFiClient client = server.client();
// Tambahkan CORS headers manuallly (tanpa DefaultHeaders)
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: multipart/x-mixed-replace; boundary=frame");
client.println("Access-Control-Allow-Origin: *");
client.println("Connection: keep-alive");
client.println();
// Set flag streaming
isStreaming = true;
Serial.println("Streaming dimulai");
// Loop untuk mengirim frame selama klien terhubung dan streaming aktif
while (client.connected() && isStreaming) {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Capture gagal selama streaming");
delay(100);
continue;
}
client.println("--frame");
client.println("Content-Type: image/jpeg");
client.println("Content-Length: " + String(fb->len));
client.println();
client.write(fb->buf, fb->len);
client.println();
// Bebaskan memori
esp_camera_fb_return(fb);
// Delay sesuai frame rate
delay(streamingDelay);
}
// Jika keluar dari loop, streaming dihentikan
isStreaming = false;
Serial.println("Streaming dihentikan");
}
// Handler untuk menghentikan streaming
void handleStopStream() {
isStreaming = false;
setCorsHeaders();
Serial.println("Permintaan menghentikan streaming diterima");
server.send(200, "text/plain", "Streaming dihentikan");
}
// Fungsi untuk mengirim gambar ke server Laravel
void sendImageToServer() {
if (!cameraInitialized) {
Serial.println("Kamera tidak diinisialisasi, tidak bisa mengirim gambar");
return;
}
camera_fb_t *fb = NULL;
// Ambil foto
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Gagal mengambil foto untuk dikirim ke server");
return;
}
Serial.println("Foto berhasil diambil untuk dikirim ke server");
Serial.print("Ukuran: ");
Serial.print(fb->len);
Serial.println(" bytes");
// Cek koneksi WiFi
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Tidak ada koneksi WiFi, menyimpan foto untuk dikirim nanti");
bool saved = savePhotoForLater(fb);
if (saved) {
Serial.println("Foto disimpan untuk dikirim nanti");
} else {
Serial.println("Gagal menyimpan foto untuk dikirim nanti");
}
esp_camera_fb_return(fb);
return;
}
// Kirim foto ke server Laravel
HTTPClient http;
http.begin(laravelServerUrl);
http.addHeader("Content-Type", "image/jpeg");
int httpResponseCode = http.POST(fb->buf, fb->len);
if (httpResponseCode > 0) {
String response = http.getString();
Serial.println("HTTP Response code: " + String(httpResponseCode));
Serial.println("Response: " + response);
} else {
Serial.print("Error pada HTTP request: ");
Serial.println(httpResponseCode);
// Jika gagal kirim, simpan untuk dikirim nanti
bool saved = savePhotoForLater(fb);
if (saved) {
Serial.println("Foto disimpan untuk dikirim nanti");
} else {
Serial.println("Gagal menyimpan foto untuk dikirim nanti");
}
}
http.end();
// Bebaskan memori
esp_camera_fb_return(fb);
Serial.println("Proses pengiriman gambar ke server selesai");
}
// Fungsi untuk pengambilan gambar otomatis
void autoCapture() {
Serial.println("Melakukan pengambilan gambar otomatis...");
sendImageToServer();
hasAutoCapture = true;
Serial.println("Pengambilan gambar otomatis selesai");
}
// Handler untuk CORS preflight requests
void handleOptions() {
setCorsHeaders();
server.send(204);
}
// Handler untuk mengubah pengaturan kamera
void handleCameraSettings() {
setCorsHeaders();
// Ambil parameter
String resolution = server.arg("resolution");
String quality = server.arg("quality");
bool changed = false;
// Ubah resolusi jika diperlukan
if (resolution.length() > 0) {
framesize_t newResolution = FRAMESIZE_SVGA; // Default
if (resolution == "UXGA") newResolution = FRAMESIZE_UXGA; // 1600x1200
else if (resolution == "SXGA") newResolution = FRAMESIZE_SXGA; // 1280x1024
else if (resolution == "XGA") newResolution = FRAMESIZE_XGA; // 1024x768
else if (resolution == "SVGA") newResolution = FRAMESIZE_SVGA; // 800x600
else if (resolution == "VGA") newResolution = FRAMESIZE_VGA; // 640x480
else if (resolution == "CIF") newResolution = FRAMESIZE_CIF; // 400x296
else if (resolution == "QVGA") newResolution = FRAMESIZE_QVGA; // 320x240
else if (resolution == "HQVGA") newResolution = FRAMESIZE_HQVGA; // 240x176
else if (resolution == "QQVGA") newResolution = FRAMESIZE_QQVGA; // 160x120
if (newResolution != streamResolution) {
streamResolution = newResolution;
changed = true;
// Terapkan ke sensor
sensor_t *s = esp_camera_sensor_get();
if (s) {
s->set_framesize(s, streamResolution);
Serial.println("Resolusi diubah ke " + resolution);
}
}
}
// Ubah kualitas jika diperlukan
if (quality.length() > 0) {
int newQuality = quality.toInt();
if (newQuality >= 0 && newQuality <= 63 && newQuality != streamQuality) {
streamQuality = newQuality;
changed = true;
// Terapkan ke sensor
sensor_t *s = esp_camera_sensor_get();
if (s) {
s->set_quality(s, streamQuality);
Serial.println("Kualitas gambar diubah ke " + quality);
}
}
}
// Kirim response
String response = "{";
response += "\"success\":" + String(changed ? "true" : "false") + ",";
response += "\"resolution\":\"";
switch (streamResolution) {
case FRAMESIZE_UXGA: response += "UXGA"; break;
case FRAMESIZE_SXGA: response += "SXGA"; break;
case FRAMESIZE_XGA: response += "XGA"; break;
case FRAMESIZE_SVGA: response += "SVGA"; break;
case FRAMESIZE_VGA: response += "VGA"; break;
case FRAMESIZE_CIF: response += "CIF"; break;
case FRAMESIZE_QVGA: response += "QVGA"; break;
case FRAMESIZE_HQVGA: response += "HQVGA"; break;
case FRAMESIZE_QQVGA: response += "QQVGA"; break;
default: response += "Unknown"; break;
}
response += "\",";
response += "\"quality\":" + String(streamQuality);
response += "}";
server.sendHeader("Content-Type", "application/json");
server.send(200, "application/json", response);
}
// Handler untuk mencoba pengiriman ulang foto secara manual
void handleRetryPhotos() {
setCorsHeaders();
retryPendingPhotos();
server.send(200, "text/plain", "Pengiriman ulang foto dimulai");
}
void setup() {
Serial.begin(115200);
delay(1000); // Tunggu serial siap
Serial.println("\n\n=== ESP32-CAM Monitoring Anggrek ===");
Serial.println("Starting as Web Server");
// Catat waktu mulai
startTime = millis();
// Inisialisasi SPIFFS
bool spiffsReady = initSPIFFS();
// Inisialisasi kamera
cameraInitialized = initCamera();
// Hubungkan ke WiFi
connectToWifi();
// Inisialisasi waktu
initTime();
// Konfigurasi rute server
server.on("/", handleRoot);
server.on("/capture", handleCapture);
server.on("/status", handleStatus);
// Tambahkan rute untuk streaming
server.on("/stream", HTTP_GET, handleStream);
server.on("/stopstream", HTTP_GET, handleStopStream);
// Tambahkan rute untuk pengambilan gambar dan pengiriman ke server
server.on("/send-to-server", []() {
setCorsHeaders();
sendImageToServer();
server.send(200, "text/plain", "Gambar berhasil dikirim ke server");
});
// Tambahkan rute untuk mencoba mengirim ulang foto
server.on("/retry-photos", HTTP_GET, handleRetryPhotos);
// Tambahkan rute untuk mengubah pengaturan kamera
server.on("/camera-settings", HTTP_GET, handleCameraSettings);
// Handle CORS preflight requests
server.on("/capture", HTTP_OPTIONS, handleOptions);
server.on("/status", HTTP_OPTIONS, handleOptions);
server.on("/stream", HTTP_OPTIONS, handleOptions);
server.on("/stopstream", HTTP_OPTIONS, handleOptions);
server.on("/send-to-server", HTTP_OPTIONS, handleOptions);
server.on("/retry-photos", HTTP_OPTIONS, handleOptions);
server.on("/camera-settings", HTTP_OPTIONS, handleOptions);
// Start server
server.begin();
Serial.println("HTTP server dimulai");
Serial.println("Buka http://" + WiFi.localIP().toString() + " di browser untuk mengakses ESP32-CAM");
Serial.println("Pengambilan gambar otomatis akan dilakukan dalam 5 detik...");
Serial.println("Streaming tersedia di http://" + WiFi.localIP().toString() + "/stream");
}
void loop() {
// Tangani request yang masuk
server.handleClient();
// Cek jika sudah waktunya untuk pengambilan gambar otomatis
if (!hasAutoCapture && (millis() - startTime > AUTO_CAPTURE_DELAY)) {
autoCapture();
}
// Cek jika perlu mengirim ulang foto yang tersimpan
if (isRetryScheduled && pendingPhotoCount > 0 && WiFi.status() == WL_CONNECTED) {
unsigned long currentTime = millis();
if (currentTime - lastRetryTime > RETRY_INTERVAL) {
lastRetryTime = currentTime;
retryPendingPhotos();
}
}
// Beri waktu untuk WiFi stack
delay(1);
}

2078
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"autoprefixer": "^10.4.21",
"axios": "^1.6.4",
"laravel-vite-plugin": "^1.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"vite": "^5.0.0"
}
}

32
phpunit.xml Normal file
View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

21
public/.htaccess Normal file
View File

@ -0,0 +1,21 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
public/images/bg-login.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

55
public/index.php Normal file
View File

@ -0,0 +1,55 @@
<?php
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
/*
|--------------------------------------------------------------------------
| Check If The Application Is Under Maintenance
|--------------------------------------------------------------------------
|
| If the application is in maintenance / demo mode via the "down" command
| we will load this file so that any pre-rendered content can be shown
| instead of starting the framework, which could cause an exception.
|
*/
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader for
| this application. We just need to utilize it! We'll simply require it
| into the script here so we don't need to manually load our classes.
|
*/
require __DIR__.'/../vendor/autoload.php';
/*
|--------------------------------------------------------------------------
| Run The Application
|--------------------------------------------------------------------------
|
| Once we have the application, we can handle the incoming request using
| the application's HTTP kernel. Then, we will send the response back
| to this client's browser, allowing them to enjoy our application.
|
*/
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Kernel::class);
$response = $kernel->handle(
$request = Request::capture()
)->send();
$kernel->terminate($request, $response);

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

3
resources/css/app.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

1
resources/js/app.js Normal file
View File

@ -0,0 +1 @@
import './bootstrap';

32
resources/js/bootstrap.js vendored Normal file
View File

@ -0,0 +1,32 @@
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
// import Echo from 'laravel-echo';
// import Pusher from 'pusher-js';
// window.Pusher = Pusher;
// window.Echo = new Echo({
// broadcaster: 'pusher',
// key: import.meta.env.VITE_PUSHER_APP_KEY,
// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
// enabledTransports: ['ws', 'wss'],
// });

View File

@ -0,0 +1,293 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Login - florAura</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Firebase SDK -->
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-auth.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-database.js"></script>
<style>
.bg-gradient-custom {
background: linear-gradient(to bottom, #052659, #021024);
}
body {
background-image: url('{{ asset("images/bg-login.jpg") }}');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
}
/* Overlay untuk background agar form lebih mudah dibaca */
.bg-overlay {
background-color: rgba(0, 0, 0, 0.5);
}
</style>
<script>
// Konfigurasi Firebase
const firebaseConfig = {
apiKey: "AIzaSyDf2QTksasAup4pzsNs9_JDpCXmBbUbywY",
authDomain: "sensoranggrek-3d9ac.firebaseapp.com",
databaseURL: "https://sensoranggrek-3d9ac-default-rtdb.firebaseio.com",
projectId: "sensoranggrek-3d9ac",
storageBucket: "sensoranggrek-3d9ac.firebasestorage.app",
messagingSenderId: "16998798790",
appId: "1:16998798790:web:885e1155255b24ab98cec7"
};
// Inisialisasi Firebase
firebase.initializeApp(firebaseConfig);
</script>
</head>
<body class="font-sans antialiased">
<div class="min-h-screen flex items-center justify-center bg-overlay py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div class="bg-gradient-custom p-8 rounded-lg shadow-lg text-white">
<div class="text-center mb-6">
<h2 class="text-3xl font-extrabold">
florAura
</h2>
<p class="mt-2 text-sm text-gray-300">
Silakan login untuk melanjutkan
</p>
</div>
<div id="alert-message" class="hidden mb-4 p-4 rounded-md"></div>
<!-- Tampilan status login saat ini -->
<div id="current-login-status" class="hidden mb-4 p-4 bg-blue-900 bg-opacity-50 text-blue-100 rounded-md">
<p class="mb-2">Anda sudah login sebagai: <span id="current-user-email" class="font-semibold"></span></p>
<div class="flex space-x-2 mt-3">
<button id="continue-session" type="button" class="flex-1 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Lanjutkan
</button>
<button id="clear-session" type="button" class="flex-1 py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-white bg-transparent hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Login dengan akun lain
</button>
</div>
</div>
<form id="login-form" class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-200">Email</label>
<div class="mt-1">
<input id="email" name="email" type="email" required autocomplete="email" class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-gray-100 text-gray-900">
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-200">Password</label>
<div class="mt-1">
<input id="password" name="password" type="password" required autocomplete="current-password" class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-gray-100 text-gray-900">
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input id="remember_me" name="remember_me" type="checkbox" class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded bg-gray-100">
<label for="remember_me" class="ml-2 block text-sm text-gray-200">
Ingat saya
</label>
</div>
</div>
<div>
<button type="submit" id="login-button" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Login
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Referensi elemen UI
const loginForm = document.getElementById('login-form');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const rememberMeCheckbox = document.getElementById('remember_me');
const loginButton = document.getElementById('login-button');
const alertMessage = document.getElementById('alert-message');
const currentLoginStatus = document.getElementById('current-login-status');
const currentUserEmail = document.getElementById('current-user-email');
const continueSessionButton = document.getElementById('continue-session');
const clearSessionButton = document.getElementById('clear-session');
// Tambahkan event listener untuk tombol "Lanjutkan" dan "Login dengan akun lain"
if (continueSessionButton) {
continueSessionButton.addEventListener('click', function() {
// Ambil user saat ini dan kirim ke server untuk membuat session
const user = firebase.auth().currentUser;
if (user) {
fetch("{{ route('login.process') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
firebase_uid: user.uid,
email: user.email
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect;
}
});
}
});
}
if (clearSessionButton) {
clearSessionButton.addEventListener('click', function() {
// Logout dari Firebase untuk menghapus state login
firebase.auth().signOut().then(function() {
// Sembunyikan status login, tampilkan form login
currentLoginStatus.classList.add('hidden');
loginForm.classList.remove('hidden');
// Reset form inputs
if (loginForm.querySelector('input[type="email"]')) {
loginForm.querySelector('input[type="email"]').value = '';
}
if (loginForm.querySelector('input[type="password"]')) {
loginForm.querySelector('input[type="password"]').value = '';
}
});
});
}
// Cek apakah user sudah login
firebase.auth().onAuthStateChanged(function(user) {
if (user) {
// Tampilkan info user saat ini dan sembunyikan form login
if (currentLoginStatus && currentUserEmail) {
currentUserEmail.textContent = user.email;
currentLoginStatus.classList.remove('hidden');
loginForm.classList.add('hidden');
}
// Verifikasi status session hanya jika user memilih untuk melanjutkan
// dengan sesi yang ada (klik tombol "Lanjutkan")
// Kita tidak melakukan redirect otomatis lagi
}
});
// Fungsi untuk menampilkan pesan error
function showAlert(message, type = 'error') {
alertMessage.textContent = message;
alertMessage.classList.remove('hidden', 'bg-red-100', 'text-red-700', 'bg-green-100', 'text-green-700');
if (type === 'error') {
alertMessage.classList.add('bg-red-900', 'bg-opacity-50', 'text-red-100');
} else {
alertMessage.classList.add('bg-green-900', 'bg-opacity-50', 'text-green-100');
}
}
// Event listener untuk form login
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
// Disable button saat proses login
loginButton.disabled = true;
loginButton.textContent = 'Memproses...';
const email = emailInput.value;
const password = passwordInput.value;
const rememberMe = rememberMeCheckbox.checked;
// Set persistence berdasarkan remember me
const persistence = rememberMe ? firebase.auth.Auth.Persistence.LOCAL : firebase.auth.Auth.Persistence.SESSION;
firebase.auth().setPersistence(persistence)
.then(() => {
// Sign in dengan email dan password
return firebase.auth().signInWithEmailAndPassword(email, password);
})
.then((userCredential) => {
// Login berhasil
showAlert('Login berhasil! Mengalihkan...', 'success');
// Kirim data user ke backend Laravel untuk membuat session
const user = userCredential.user;
fetch("{{ route('login.process') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
firebase_uid: user.uid,
email: user.email
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Redirect ke dashboard
window.location.href = data.redirect;
} else {
showAlert('Terjadi kesalahan saat memproses sesi login.');
loginButton.disabled = false;
loginButton.textContent = 'Login';
}
})
.catch(error => {
console.error('Error:', error);
showAlert('Terjadi kesalahan saat memproses sesi login.');
loginButton.disabled = false;
loginButton.textContent = 'Login';
});
})
.catch((error) => {
// Error handling
loginButton.disabled = false;
loginButton.textContent = 'Login';
let errorMessage = 'Terjadi kesalahan saat login. Silakan coba lagi.';
switch (error.code) {
case 'auth/invalid-email':
errorMessage = 'Format email tidak valid.';
break;
case 'auth/user-disabled':
errorMessage = 'Akun pengguna telah dinonaktifkan.';
break;
case 'auth/user-not-found':
case 'auth/wrong-password':
errorMessage = 'Email atau password tidak valid.';
break;
case 'auth/too-many-requests':
errorMessage = 'Terlalu banyak percobaan login. Silakan coba lagi nanti.';
break;
}
showAlert(errorMessage);
});
});
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,281 @@
@extends('layouts.app')
@section('title', 'Pengaturan ESP32-CAM')
@section('content')
<div class="mt-8 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow-lg rounded-lg overflow-hidden">
<div class="p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6">Pengaturan ESP32-CAM</h2>
<!-- Status ESP32-CAM -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 class="text-lg font-semibold text-gray-700 mb-2">Status ESP32-CAM</h3>
<div class="flex flex-col sm:flex-row sm:items-center">
<div class="mb-4 sm:mb-0 sm:mr-6">
<form id="checkStatusForm" class="flex items-center">
<input type="text" id="cameraIp" placeholder="192.168.240.201" value="192.168.240.201"
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
<button type="submit" class="ml-2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition duration-300">
Cek Status
</button>
</form>
</div>
<div id="cameraStatus" class="flex items-center">
<span class="inline-block h-3 w-3 rounded-full mr-2 bg-gray-300"></span>
<span class="text-gray-500">Belum diperiksa</span>
</div>
</div>
</div>
<!-- Pengambilan Gambar Manual -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 class="text-lg font-semibold text-gray-700 mb-2">Pengambilan Gambar Manual</h3>
<form id="captureImageForm" class="flex flex-col sm:flex-row sm:items-center">
<div class="mb-4 sm:mb-0 sm:mr-6">
<input type="text" id="captureIp" placeholder="192.168.240.201" value="192.168.240.201"
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
</div>
<button type="submit" class="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition duration-300 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Ambil Foto Sekarang
</button>
</form>
<div id="captureResult" class="mt-4 hidden"></div>
</div>
<!-- Penjadwalan -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 class="text-lg font-semibold text-gray-700 mb-2">Penjadwalan Pengambilan Gambar</h3>
<p class="text-sm text-gray-600 mb-4">
Sistem mengambil gambar dari ESP32-CAM secara otomatis setiap 15 menit.
Untuk mengubah ini, administrator perlu mengatur cron job di server.
</p>
<div class="flex items-center">
<div class="h-4 w-4 rounded-full bg-green-500 mr-2"></div>
<span class="text-sm text-gray-700">Penjadwalan aktif</span>
</div>
</div>
<!-- Riwayat Gambar Terakhir -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 class="text-lg font-semibold text-gray-700 mb-4">Riwayat Gambar Terakhir</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nama File</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tanggal</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ukuran</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200" id="recentImagesTable">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center" colspan="3">
Memuat data...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Manual Cleanup -->
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<h3 class="text-lg font-semibold text-gray-700 mb-2">Pembersihan Manual</h3>
<p class="text-sm text-gray-600 mb-4">
Sistem secara otomatis membersihkan foto yang berusia lebih dari 7 hari tetapi menyimpan minimal 10 foto terakhir.
</p>
<button id="cleanupButton" class="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition duration-300">
Bersihkan Foto Lama
</button>
<div id="cleanupResult" class="mt-2 hidden"></div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Ambil data foto terbaru
fetch('/api/esp32cam/latest')
.then(response => response.json())
.then(data => {
const tableBody = document.getElementById('recentImagesTable');
if (data.status === 'success') {
tableBody.innerHTML = '';
// Tambahkan foto terbaru
let row = document.createElement('tr');
row.innerHTML = `
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${data.photo.name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${data.photo.time}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${data.photo.size}</td>
`;
tableBody.appendChild(row);
} else {
tableBody.innerHTML = `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center" colspan="3">
Belum ada foto tersedia
</td>
</tr>
`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('recentImagesTable').innerHTML = `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-red-500 text-center" colspan="3">
Gagal memuat data: ${error.message}
</td>
</tr>
`;
});
// Form cek status ESP32-CAM
document.getElementById('checkStatusForm').addEventListener('submit', function(e) {
e.preventDefault();
const cameraIp = document.getElementById('cameraIp').value;
const statusElement = document.getElementById('cameraStatus');
if (!cameraIp) {
alert('Masukkan IP address ESP32-CAM');
return;
}
statusElement.innerHTML = `
<span class="inline-block h-3 w-3 rounded-full mr-2 bg-yellow-500 animate-pulse"></span>
<span class="text-yellow-600">Memeriksa status...</span>
`;
fetch(`http://${cameraIp}/status`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
})
.then(data => {
statusElement.innerHTML = `
<span class="inline-block h-3 w-3 rounded-full mr-2 bg-green-500"></span>
<span class="text-green-600">ESP32-CAM terhubung!</span>
<span class="ml-2 text-sm text-gray-500">
RSSI: ${data.wifi.rssi} dBm,
Uptime: ${data.uptime} detik
</span>
`;
})
.catch(error => {
statusElement.innerHTML = `
<span class="inline-block h-3 w-3 rounded-full mr-2 bg-red-500"></span>
<span class="text-red-600">Tidak dapat terhubung ke ESP32-CAM</span>
<span class="ml-2 text-sm text-gray-500">${error.message}</span>
`;
});
});
// Form pengambilan gambar
document.getElementById('captureImageForm').addEventListener('submit', function(e) {
e.preventDefault();
const cameraIp = document.getElementById('captureIp').value;
const resultElement = document.getElementById('captureResult');
if (!cameraIp) {
alert('Masukkan IP address ESP32-CAM');
return;
}
resultElement.innerHTML = `
<div class="p-3 bg-yellow-50 text-yellow-700 rounded-md">
<span class="font-medium">Mengambil gambar...</span> Tunggu sebentar.
</div>
`;
resultElement.classList.remove('hidden');
// Debug request payload
const payload = {
camera_ip: cameraIp
};
console.log('Sending request with payload:', payload);
fetch('/api/esp32cam/fetch-from-camera', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
resultElement.innerHTML = `
<div class="p-3 bg-green-50 text-green-700 rounded-md">
<span class="font-medium">Berhasil!</span> Gambar telah disimpan dengan nama ${data.filename}.
<a href="/dashboard" class="text-blue-500 hover:underline">Lihat di dashboard</a>
</div>
`;
} else {
resultElement.innerHTML = `
<div class="p-3 bg-red-50 text-red-700 rounded-md">
<span class="font-medium">Gagal:</span> ${data.message}
</div>
`;
}
})
.catch(error => {
resultElement.innerHTML = `
<div class="p-3 bg-red-50 text-red-700 rounded-md">
<span class="font-medium">Error:</span> ${error.message}
</div>
`;
});
});
// Tombol cleanup
document.getElementById('cleanupButton').addEventListener('click', function() {
const resultElement = document.getElementById('cleanupResult');
resultElement.innerHTML = `
<div class="p-3 bg-yellow-50 text-yellow-700 rounded-md">
<span class="font-medium">Membersihkan foto lama...</span> Tunggu sebentar.
</div>
`;
resultElement.classList.remove('hidden');
fetch('/api/esp32cam/cleanup')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
resultElement.innerHTML = `
<div class="p-3 bg-green-50 text-green-700 rounded-md">
<span class="font-medium">Berhasil!</span> ${data.message}
</div>
`;
} else {
resultElement.innerHTML = `
<div class="p-3 bg-red-50 text-red-700 rounded-md">
<span class="font-medium">Gagal:</span> ${data.message}
</div>
`;
}
})
.catch(error => {
resultElement.innerHTML = `
<div class="p-3 bg-red-50 text-red-700 rounded-md">
<span class="font-medium">Error:</span> ${error.message}
</div>
`;
});
});
});
</script>
@endsection

View File

@ -0,0 +1,984 @@
@extends('layouts.app')
@section('title', 'History Sensor')
@section('content')
<style>
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
<div class="mt-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm rounded-lg">
<div class="p-6 bg-white border-b border-gray-200">
<!-- Info Pembaruan Otomatis -->
<div class="mb-4 bg-blue-50 p-3 rounded-md border border-blue-200">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Pembaruan Realtime</h3>
<div class="mt-1 text-sm text-blue-700">
<p>Data sensor akan otomatis terekam saat ada perubahan nilai pada setiap sensor dan ditampilkan secara realtime pada tabel history tanpa perlu refresh halaman.</p>
</div>
</div>
</div>
</div>
<!-- Filter dan Kontrol -->
<div class="flex flex-col md:flex-row justify-between items-center mb-6">
<div class="flex items-center space-x-4 mb-4 md:mb-0">
<select id="sensor-filter" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<option value="all">Semua Sensor</option>
<option value="suhu">Suhu</option>
<option value="kelembapan_tanah">Kelembapan Tanah</option>
<option value="cahaya">Cahaya</option>
</select>
<div class="flex items-center">
<input type="date" id="date-filter" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" title="Filter berdasarkan tanggal">
<button id="reset-date" class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 focus:outline-none transition-colors" title="Reset filter tanggal">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button id="sync-firebase" class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sinkronisasi
</button>
<button id="clear-history" class="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors">
Hapus Riwayat
</button>
</div>
<div class="flex items-center text-sm text-gray-500">
<div>Total data: <span id="total-records">0</span></div>
<div class="ml-4 flex items-center space-x-2">
<span id="realtime-status" class="px-2 py-1 bg-green-100 text-green-800 rounded-full flex items-center">
<span class="h-2 w-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Realtime aktif
</span>
<button id="toggle-realtime" class="text-gray-600 hover:text-gray-800 focus:outline-none" title="Toggle realtime update">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
</div>
<!-- Tabel Riwayat -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Waktu</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sensor</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nilai Baru</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aksi</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Hapus</th>
</tr>
</thead>
<tbody id="history-table-body" class="bg-white divide-y divide-gray-200">
<!-- Data akan diisi menggunakan JavaScript -->
<tr id="no-data-row">
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
Tidak ada data riwayat
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-between items-center mt-4">
<div class="flex items-center">
<label for="rows-per-page" class="text-sm text-gray-600 mr-2">Baris per halaman:</label>
<select id="rows-per-page" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">
Halaman <span id="current-page">1</span> dari <span id="total-pages">1</span>
</span>
<div class="flex space-x-1">
<button id="prev-page" class="px-3 py-1 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50">
&lt;
</button>
<button id="next-page" class="px-3 py-1 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50">
&gt;
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- SweetAlert2 CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<!-- SweetAlert2 JS -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
// Definisikan variabel global di luar DOMContentLoaded
let currentPage = 1;
let rowsPerPage = 10;
let currentFilterType = 'all';
let currentFilterDate = null;
let filteredHistoryGlobal = [];
let csrfToken = "{{ csrf_token() }}"; // Tambahkan CSRF token untuk request API
// Tambahkan referensi elemen global di awal script, sebelum semua fungsi
let historyTableBody;
let totalRecordsElement;
let sensorFilter;
let dateFilter;
let resetDateButton;
let clearHistoryButton;
let noDataRow;
let itemToDeleteIndex = null;
// Tambahkan variabel global baru
let currentPageElement;
let totalPagesElement;
let prevPageButton;
let nextPageButton;
// Fungsi inisialisasi elemen
function initializeElements() {
historyTableBody = document.getElementById('history-table-body');
totalRecordsElement = document.getElementById('total-records');
sensorFilter = document.getElementById('sensor-filter');
dateFilter = document.getElementById('date-filter');
resetDateButton = document.getElementById('reset-date');
clearHistoryButton = document.getElementById('clear-history');
noDataRow = document.getElementById('no-data-row');
// Tambahkan inisialisasi elemen paginasi
currentPageElement = document.getElementById('current-page');
totalPagesElement = document.getElementById('total-pages');
prevPageButton = document.getElementById('prev-page');
nextPageButton = document.getElementById('next-page');
}
// Pindahkan fungsi-fungsi ke scope global
function parseFirebaseTimestamp(timestamp) {
// Jika timestamp kosong atau undefined, gunakan waktu sekarang
if (!timestamp) return new Date();
// Handle Firebase Timestamp object
if (timestamp && typeof timestamp === 'object' && timestamp.seconds) {
return new Date(timestamp.seconds * 1000);
}
// Handle string timestamp dalam format ISO
if (typeof timestamp === 'string') {
try {
return new Date(timestamp);
} catch (e) {
console.error('Error parsing timestamp string:', e);
return new Date();
}
}
// Handle timestamp dalam bentuk number (unix timestamp)
if (typeof timestamp === 'number') {
return new Date(timestamp);
}
console.warn('Timestamp format tidak dikenal:', timestamp);
return new Date(); // Fallback ke waktu sekarang jika format tidak dikenal
}
// Fungsi untuk memuat data dari file JSON melalui API
async function loadHistory() {
console.log('loadHistory() dipanggil');
try {
console.log('Mencoba mengambil data dari /api/history');
const response = await fetch('/api/history');
console.log('Response status:', response.status);
console.log('Response OK:', response.ok);
if (!response.ok) {
throw new Error('Network response was not ok: ' + response.status);
}
const historyData = await response.json();
console.log('Data berhasil diterima:', historyData);
// Validasi data
if (!Array.isArray(historyData)) {
console.error('Data yang diterima bukan array:', historyData);
return [];
}
console.log('Jumlah data:', historyData.length);
// Pastikan data memiliki format yang benar dan validasi timestamp
return historyData.map(item => {
// Validasi item
if (!item || typeof item !== 'object') {
console.warn('Item tidak valid:', item);
return {
timestamp: new Date().toISOString(),
sensor: 'unknown',
newValue: 0
};
}
// Validasi timestamp
let timestamp = item.timestamp;
if (timestamp) {
try {
// Coba parse timestamp untuk memastikan format valid
new Date(timestamp);
} catch (e) {
console.warn('Timestamp tidak valid:', timestamp);
timestamp = new Date().toISOString();
}
} else {
timestamp = new Date().toISOString();
}
return {
timestamp: timestamp,
sensor: item.sensor || 'unknown',
newValue: isNaN(parseFloat(item.newValue)) ? 0 : parseFloat(item.newValue)
};
});
} catch (error) {
console.error('Error loading history:', error);
Swal.fire({
icon: 'error',
title: 'Gagal memuat data',
text: 'Terjadi kesalahan saat memuat data history: ' + error.message
});
return [];
}
}
// Fungsi untuk menampilkan history
async function displayHistory(filterType = 'all', filterDate = null, forceRefresh = false) {
console.log('Display History dipanggil dengan filter:', { filterType, filterDate, rowsPerPage, forceRefresh });
currentFilterType = filterType;
currentFilterDate = filterDate;
try {
// Tampilkan loading indicator jika ini adalah refresh penuh (bukan update realtime)
if (!forceRefresh) {
historyTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
<div class="flex justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Memuat data...</span>
</div>
</td>
</tr>
`;
}
// Ambil data dari API
let history = await loadHistory();
if (!history || history.length === 0) {
historyTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
Tidak ada data history yang tersedia
</td>
</tr>
`;
totalRecordsElement.textContent = "0";
currentPageElement.textContent = "1";
totalPagesElement.textContent = "1";
return;
}
// Filter berdasarkan jenis sensor
if (filterType !== 'all') {
if (filterType === 'kelembapan_tanah') {
// Filter untuk kedua kemungkinan nama sensor (untuk kompatibilitas data lama)
history = history.filter(item => item.sensor === 'kelembapan_tanah' || item.sensor === 'kelembaban');
} else {
history = history.filter(item => item.sensor === filterType);
}
}
// Filter berdasarkan tanggal jika ada
if (filterDate) {
try {
const selectedDate = new Date(filterDate);
selectedDate.setHours(0, 0, 0, 0);
const nextDay = new Date(selectedDate);
nextDay.setDate(selectedDate.getDate() + 1);
history = history.filter(item => {
try {
const itemDate = parseFirebaseTimestamp(item.timestamp);
return itemDate >= selectedDate && itemDate < nextDay;
} catch (error) {
console.error('Error filtering date:', error);
return false;
}
});
} catch (error) {
console.error('Error in date filtering:', error);
}
}
// Urutkan history berdasarkan timestamp terbaru
history.sort((a, b) => {
const dateA = new Date(a.timestamp);
const dateB = new Date(b.timestamp);
return dateB - dateA; // Urutkan dari yang terbaru
});
// Jika ini adalah update realtime, periksa apakah data berubah
if (forceRefresh) {
// Jika tidak ada data baru, keluar dari fungsi
if (historyDataUnchanged(history, filteredHistoryGlobal)) {
console.log('Data tidak berubah, tidak perlu update tampilan');
return;
}
}
// Update data global
filteredHistoryGlobal = history;
totalRecordsElement.textContent = history.length;
// Hitung total halaman berdasarkan rowsPerPage
const totalPages = Math.ceil(history.length / rowsPerPage);
// Pastikan currentPage valid
if (currentPage > totalPages) {
currentPage = totalPages || 1;
}
// Update indikator halaman
currentPageElement.textContent = currentPage;
totalPagesElement.textContent = totalPages;
// Hitung indeks awal dan akhir untuk data yang akan ditampilkan
const startIdx = (currentPage - 1) * rowsPerPage;
const endIdx = Math.min(startIdx + rowsPerPage, history.length);
const pageData = history.slice(startIdx, endIdx);
// Update status tombol navigasi
prevPageButton.disabled = currentPage === 1;
nextPageButton.disabled = currentPage >= totalPages;
// Update styling tombol
if (prevPageButton.disabled) {
prevPageButton.classList.add('opacity-50', 'cursor-not-allowed');
} else {
prevPageButton.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (nextPageButton.disabled) {
nextPageButton.classList.add('opacity-50', 'cursor-not-allowed');
} else {
nextPageButton.classList.remove('opacity-50', 'cursor-not-allowed');
}
// Bersihkan tabel
if (pageData.length === 0) {
historyTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">
Tidak ada data history yang tersedia
</td>
</tr>
`;
return;
} else {
// Render tabel baru dengan data terbaru
historyTableBody.innerHTML = '';
// Render data
pageData.forEach((item, index) => {
try {
const rowId = startIdx + index;
// Format waktu dengan penanganan error
let date;
try {
date = parseFirebaseTimestamp(item.timestamp);
} catch (error) {
console.error('Error parsing date:', error, item);
date = new Date(); // Fallback ke waktu sekarang
}
const formattedDate = date.toLocaleDateString('id-ID', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
const formattedTime = date.toLocaleTimeString('id-ID');
// Tentukan warna berdasarkan jenis sensor
let sensorColor = 'text-gray-800';
if (item.sensor === 'suhu') sensorColor = 'text-red-600';
if (item.sensor === 'kelembapan_tanah' || item.sensor === 'kelembaban') sensorColor = 'text-blue-600';
if (item.sensor === 'cahaya') sensorColor = 'text-yellow-600';
// Unit berdasarkan jenis sensor
let unit = '';
if (item.sensor === 'suhu') unit = '°C';
if (item.sensor === 'kelembapan_tanah' || item.sensor === 'kelembaban') unit = '%';
if (item.sensor === 'cahaya') unit = ' lux';
// Dapatkan status dan aksi berdasarkan nilai baru
const { status, action, statusClass } = getSensorStatusAndAction(item.sensor, item.newValue);
// Buat elemen baris baru
const tr = document.createElement('tr');
tr.dataset.id = rowId;
tr.className = `${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'} ${forceRefresh ? 'animate-fadeIn' : ''}`;
// Set HTML untuk baris
tr.innerHTML = `
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formattedDate} ${formattedTime}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-medium ${sensorColor} capitalize">${item.sensor === 'kelembapan_tanah' || item.sensor === 'kelembaban' ? 'Kelembapan Tanah' : item.sensor}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${item.newValue}${unit}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-medium ${statusClass}">${status}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm ${action.includes('On') ? 'text-green-600' : 'text-gray-600'} font-medium">
${action}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<button class="delete-btn text-red-500 hover:text-red-700 focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
`;
// Tambahkan baris ke tabel
historyTableBody.appendChild(tr);
} catch (error) {
console.error('Error rendering row:', error, item);
}
});
// Tambahkan event listener untuk tombol hapus
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', function() {
const row = this.closest('tr');
itemToDeleteIndex = parseInt(row.dataset.id);
confirmDeleteSingleItem();
});
});
}
console.log('Menampilkan data:', {
total: history.length,
halaman: currentPage,
totalHalaman: totalPages,
barisPerHalaman: rowsPerPage,
dataYangDitampilkan: pageData.length
});
} catch (error) {
console.error('Error displaying history:', error);
// Hanya tampilkan pesan error jika ini adalah refresh penuh (bukan update realtime)
if (!forceRefresh) {
historyTableBody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-4 text-center text-sm text-red-500">
<div class="flex flex-col items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Error: ${error.message || 'Terjadi kesalahan saat menampilkan data.'}</span>
</div>
</td>
</tr>
`;
}
}
}
// Fungsi untuk membandingkan dua array data history
function historyDataUnchanged(newData, oldData) {
if (!oldData || !newData) return false;
// Jika jumlah data berbeda, data telah berubah
if (newData.length !== oldData.length) return false;
// Periksa 3 data teratas untuk efisiensi
// Ini mengasumsikan bahwa data baru muncul di awal array (setelah pengurutan)
const compareLimit = Math.min(3, newData.length);
for (let i = 0; i < compareLimit; i++) {
// Bandingkan timestamp dan nilai untuk menentukan apakah ada perubahan
if (newData[i].timestamp !== oldData[i].timestamp ||
newData[i].sensor !== oldData[i].sensor ||
newData[i].newValue !== oldData[i].newValue) {
return false; // Data telah berubah
}
}
return true; // Data tidak berubah
}
// Event listener untuk document ready
document.addEventListener('DOMContentLoaded', function() {
initializeElements();
// Status realtime update
let isRealtimeEnabled = true;
// Referensi ke Firebase Database
const historyRef = firebase.database().ref('historySensor');
const sensorRef = firebase.database().ref('sensor');
// Fungsi untuk menangani perubahan data sensor
function onSensorChange(snapshot) {
if (!isRealtimeEnabled) return;
console.log('Perubahan pada sensor terdeteksi');
// Refresh data history
displayHistory(currentFilterType, currentFilterDate, true);
}
// Fungsi untuk menangani penambahan data baru ke history
function onHistoryAdded(snapshot) {
if (!isRealtimeEnabled) return;
console.log('Data history baru terdeteksi:', snapshot.key);
// Refresh data history ketika ada data baru ditambahkan
displayHistory(currentFilterType, currentFilterDate, true);
}
// Fungsi untuk toggle realtime update
function toggleRealtimeUpdate() {
isRealtimeEnabled = !isRealtimeEnabled;
const realtimeStatus = document.getElementById('realtime-status');
const toggleRealtime = document.getElementById('toggle-realtime');
if (isRealtimeEnabled) {
// Aktifkan fitur realtime
realtimeStatus.innerHTML = `
<span class="h-2 w-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Realtime aktif
`;
realtimeStatus.className = 'px-2 py-1 bg-green-100 text-green-800 rounded-full flex items-center';
toggleRealtime.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
`;
// Update tampilan tabel
displayHistory(currentFilterType, currentFilterDate);
// Aktifkan listener Firebase - tidak perlu setup ulang karena kita pakai .on di luar
} else {
// Nonaktifkan fitur realtime
realtimeStatus.innerHTML = `
<span class="h-2 w-2 bg-gray-500 rounded-full mr-1"></span>
Realtime nonaktif
`;
realtimeStatus.className = 'px-2 py-1 bg-gray-100 text-gray-800 rounded-full flex items-center';
toggleRealtime.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
`;
}
}
// Toggle button event
document.getElementById('toggle-realtime').addEventListener('click', toggleRealtimeUpdate);
// Ambil preferensi tersimpan dari localStorage
const savedRowsPerPage = localStorage.getItem('preferredRowsPerPage');
if (savedRowsPerPage) {
document.getElementById('rows-per-page').value = savedRowsPerPage;
rowsPerPage = parseInt(savedRowsPerPage);
} else {
rowsPerPage = parseInt(document.getElementById('rows-per-page').value) || 10;
}
// Event listener untuk rows per page
document.getElementById('rows-per-page').addEventListener('change', function() {
rowsPerPage = parseInt(this.value);
currentPage = 1; // Reset ke halaman pertama ketika mengubah jumlah baris
localStorage.setItem('preferredRowsPerPage', this.value); // Simpan preferensi
displayHistory(currentFilterType, currentFilterDate);
});
// Event listeners
sensorFilter.addEventListener('change', function() {
currentPage = 1;
displayHistory(this.value, dateFilter.value);
});
dateFilter.addEventListener('change', function() {
currentPage = 1;
displayHistory(sensorFilter.value, this.value);
});
resetDateButton.addEventListener('click', function() {
dateFilter.value = null;
currentPage = 1;
displayHistory(sensorFilter.value, null);
});
// Tombol Hapus Riwayat
clearHistoryButton.addEventListener('click', function() {
confirmDeleteAllItems();
});
// Tombol Sinkronisasi Firebase
document.getElementById('sync-firebase').addEventListener('click', function() {
syncFromFirebase();
});
// Event listener untuk tombol navigasi
prevPageButton.addEventListener('click', function() {
if (currentPage > 1) {
currentPage--;
displayHistory(currentFilterType, currentFilterDate);
}
});
nextPageButton.addEventListener('click', function() {
const totalPages = Math.ceil(filteredHistoryGlobal.length / rowsPerPage);
if (currentPage < totalPages) {
currentPage++;
displayHistory(currentFilterType, currentFilterDate);
}
});
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft' && !prevPageButton.disabled) {
prevPageButton.click();
} else if (e.key === 'ArrowRight' && !nextPageButton.disabled) {
nextPageButton.click();
}
});
// Tambahkan event listener ke Firebase untuk deteksi perubahan data
// 1. Listener untuk child_added - saat entri baru ditambahkan ke history
historyRef.on('child_added', onHistoryAdded);
// 2. Listener untuk perubahan nilai sensor
sensorRef.on('child_changed', onSensorChange);
// 3. Listener untuk nilai baru dari sensor
sensorRef.on('value', (snapshot) => {
if (isRealtimeEnabled) {
// Tidak perlu memeriksa setiap nilai, hanya perlu memeriksa perubahan
// Ini akan memicu hampir sama dengan onSensorChange, tetapi lebih akurat untuk perubahan nilai total
console.log('Snapshot nilai sensor baru terdeteksi');
displayHistory(currentFilterType, currentFilterDate, true);
}
});
// 4. Tampilkan alert jika koneksi Firebase terputus
firebase.database().ref('.info/connected').on('value', function(snap) {
if (snap.val() === true) {
console.log('Terhubung ke Firebase Realtime Database');
} else {
console.log('Terputus dari Firebase Realtime Database');
// Mungkin tampilkan notifikasi ke pengguna
}
});
// Tampilkan data awal
displayHistory();
// Setup auto refresh setiap 3 detik sebagai fallback jika Firebase listener tidak berfungsi
setInterval(function() {
if (isRealtimeEnabled) {
console.log('Auto refresh data history (fallback polling)');
displayHistory(currentFilterType, currentFilterDate, true);
}
}, 3000);
});
// Fungsi konfirmasi hapus menggunakan SweetAlert2
function confirmDeleteSingleItem() {
Swal.fire({
title: 'Apakah Anda yakin?',
text: "Data yang dihapus tidak dapat dikembalikan!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, hapus!',
cancelButtonText: 'Batal'
}).then((result) => {
if (result.isConfirmed) {
deleteSingleItem();
}
});
}
function confirmDeleteAllItems() {
Swal.fire({
title: 'Hapus semua data?',
text: "Semua data riwayat sensor akan dihapus dan tidak dapat dikembalikan!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Ya, hapus semua!',
cancelButtonText: 'Batal'
}).then((result) => {
if (result.isConfirmed) {
deleteAllItems();
}
});
}
// Fungsi hapus data menggunakan API
async function deleteSingleItem() {
if (itemToDeleteIndex === null) return;
try {
// Tampilkan loading
Swal.fire({
title: 'Menghapus data...',
text: 'Mohon tunggu sebentar',
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading();
}
});
// Kirim request ke API untuk menghapus item
const response = await fetch('/api/history/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ index: itemToDeleteIndex })
});
if (!response.ok) {
throw new Error('Gagal menghapus data');
}
const result = await response.json();
// Tampilkan pesan sukses
Swal.fire({
title: 'Terhapus!',
text: 'Data berhasil dihapus.',
icon: 'success',
timer: 1500,
showConfirmButton: false
});
// Refresh tampilan
displayHistory(currentFilterType, currentFilterDate);
} catch (error) {
console.error('Error deleting item:', error);
Swal.fire({
title: 'Error!',
text: error.message || 'Terjadi kesalahan saat menghapus data',
icon: 'error'
});
}
}
async function deleteAllItems() {
try {
// Tampilkan loading
Swal.fire({
title: 'Menghapus semua data...',
text: 'Mohon tunggu sebentar',
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading();
}
});
// Kirim request ke API untuk menghapus semua item
const response = await fetch('/api/history/clear', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken
}
});
if (!response.ok) {
throw new Error('Gagal menghapus data');
}
const result = await response.json();
// Tampilkan pesan sukses
Swal.fire({
title: 'Terhapus!',
text: 'Semua data riwayat berhasil dihapus.',
icon: 'success',
timer: 1500,
showConfirmButton: false
});
// Refresh tampilan
displayHistory();
} catch (error) {
console.error('Error clearing history:', error);
Swal.fire({
title: 'Error!',
text: error.message || 'Terjadi kesalahan saat menghapus data',
icon: 'error'
});
}
}
// Tambahkan fungsi untuk mendapatkan status dan aksi berdasarkan jenis sensor dan nilai
function getSensorStatusAndAction(sensor, value) {
let status = '--';
let action = '--';
let statusClass = '';
if (sensor === 'suhu') {
if (value < 24) {
status = 'Rendah';
action = 'Kipas Off';
statusClass = 'text-blue-600';
} else if (value >= 25 && value <= 27) {
status = 'Normal';
action = 'Kipas Off';
statusClass = 'text-green-600';
} else if (value >= 28) {
status = 'Tinggi';
action = 'Kipas On';
statusClass = 'text-red-600';
}
} else if (sensor === 'kelembapan_tanah' || sensor === 'kelembaban') {
if (value < 40) {
status = 'Kering';
action = 'Pompa Air On';
statusClass = 'text-yellow-600';
} else if (value >= 40 && value < 50) {
status = 'Cukup';
action = 'Pompa Air Off';
statusClass = 'text-green-600';
} else if (value >= 50 && value < 60) {
status = 'Optimal';
action = 'Pompa Air Off';
statusClass = 'text-green-600';
} else if (value >= 60 && value <= 70) {
status = 'Lembap';
action = 'Pompa Air Off';
statusClass = 'text-blue-600';
} else if (value > 70) {
status = 'Sangat Lembap';
action = 'Pompa Air Off';
statusClass = 'text-blue-400';
}
} else if (sensor === 'cahaya') {
if (value < 8000) {
status = 'Rendah';
action = 'Lampu On';
statusClass = 'text-blue-600';
} else if (value >= 8001 && value <= 11000) {
status = 'Normal';
action = 'Lampu Off';
statusClass = 'text-green-600';
} else if (value > 11001) {
status = 'Tinggi';
action = 'Lampu Off';
statusClass = 'text-yellow-600';
}
}
return { status, action, statusClass };
}
// Fungsi untuk sinkronisasi data dari Firebase
async function syncFromFirebase() {
try {
// Tampilkan loading
Swal.fire({
title: 'Sinkronisasi Data...',
text: 'Sedang mengambil data terbaru dari Firebase',
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading();
}
});
// Kirim request ke API untuk sinkronisasi
const response = await fetch('/api/history/sync-firebase', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken
}
});
if (!response.ok) {
throw new Error('Gagal melakukan sinkronisasi');
}
const result = await response.json();
// Tampilkan pesan sukses
Swal.fire({
title: 'Berhasil!',
text: 'Data berhasil disinkronkan dari Firebase',
icon: 'success',
timer: 1500,
showConfirmButton: false
});
// Refresh tampilan
displayHistory(currentFilterType, currentFilterDate);
} catch (error) {
console.error('Error syncing from Firebase:', error);
Swal.fire({
title: 'Error!',
text: error.message || 'Terjadi kesalahan saat sinkronisasi data',
icon: 'error'
});
}
}
</script>
@endsection

View File

@ -0,0 +1,686 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>florAura</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Flowbite - Untuk komponen UI seperti dropdown -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.0/flowbite.min.css" rel="stylesheet" />
<!-- Firebase SDK -->
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-database.js"></script>
<script>
// Konfigurasi Firebase
const firebaseConfig = {
apiKey: "AIzaSyDf2QTksasAup4pzsNs9_JDpCXmBbUbywY",
authDomain: "sensoranggrek-3d9ac.firebaseapp.com",
databaseURL: "https://sensoranggrek-3d9ac-default-rtdb.firebaseio.com",
projectId: "sensoranggrek-3d9ac",
storageBucket: "sensoranggrek-3d9ac.firebasestorage.app",
messagingSenderId: "16998798790",
appId: "1:16998798790:web:885e1155255b24ab98cec7"
};
// Inisialisasi Firebase
firebase.initializeApp(firebaseConfig);
const database = firebase.database();
</script>
</head>
<body class="font-sans antialiased bg-gray-100">
<div class="min-h-screen flex flex-col">
<!-- Navbar -->
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{{ route('dashboard') }}" class="flex items-center space-x-3 rtl:space-x-reverse">
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">florAura</span>
</a>
<!-- Menu navigasi diletakkan sebelum profile menu (di sebelah kanan) -->
<div class="hidden md:flex items-center space-x-1 md:me-4">
<a href="{{ route('dashboard') }}" class="py-2 px-3 {{ Request::routeIs('dashboard') ? 'text-blue-700 border-b-2 border-blue-700' : 'text-gray-900 hover:text-blue-700' }}">Dashboard</a>
<a href="{{ route('history') }}" class="py-2 px-3 {{ Request::routeIs('history') ? 'text-blue-700 border-b-2 border-blue-700' : 'text-gray-900 hover:text-blue-700' }}">History</a>
</div>
<div class="flex items-center md:order-2">
<button type="button" class="flex text-sm bg-gray-800 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
<span class="sr-only">Open user menu</span>
<span class="inline-flex h-8 w-8 rounded-full bg-gray-500 text-white justify-center items-center">
<span class="text-sm font-medium leading-none">{{ substr(session('user_email') ?? 'User', 0, 1) }}</span>
</span>
</button>
<!-- Dropdown menu -->
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-dropdown">
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">{{ session('user_email') ?? 'User' }}</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white w-full text-left">Logout</button>
</form>
</li>
</ul>
</div>
<button data-collapse-toggle="navbar-user" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600 ml-2" aria-controls="navbar-user" aria-expanded="false" id="navbar-toggle">
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15"/>
</svg>
</button>
</div>
<!-- Navbar untuk tampilan mobile -->
<div class="items-center justify-between hidden w-full md:hidden" id="navbar-user">
<ul class="flex flex-col font-medium p-4 mt-4 border border-gray-100 rounded-lg bg-gray-50 dark:bg-gray-800 dark:border-gray-700">
<li>
<a href="{{ route('dashboard') }}" class="block py-2 px-3 {{ Request::routeIs('dashboard') ? 'text-white bg-blue-700 rounded' : 'text-gray-900 rounded hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700' }}">Dashboard</a>
</li>
<li>
<a href="{{ route('history') }}" class="block py-2 px-3 {{ Request::routeIs('history') ? 'text-white bg-blue-700 rounded' : 'text-gray-900 rounded hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700' }}">History</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main content -->
<div class="flex flex-col flex-1">
<!-- Sensor Cards Bar -->
<div class="sticky top-0 z-10 flex-shrink-0 bg-white shadow">
<!-- Toggle button untuk sensor cards di mobile -->
<div class="flex items-center justify-between bg-white p-3 border-t border-gray-200 md:hidden">
<h3 class="text-sm font-medium text-gray-700">Status Sensor</h3>
<button id="toggleSensorCards" class="flex items-center bg-gray-100 hover:bg-gray-200 rounded-md px-3 py-1 text-xs text-gray-700 transition-all">
<span id="toggleSensorText">Lihat Detail</span>
<svg id="toggleSensorIcon" class="w-4 h-4 ml-1 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- Sensor Cards Bar -->
<div id="sensorCardsContainer" class="hidden md:grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 border-t border-gray-200">
<!-- Card Suhu -->
<div class="bg-white p-3 sm:p-4 rounded-lg shadow-sm border border-gray-200 transition-all duration-300 hover:shadow-md">
<div class="flex items-start sm:items-center">
<div class="flex-shrink-0 bg-red-100 rounded-full p-2 sm:p-3 mr-3 sm:mr-4">
<svg class="h-5 w-5 sm:h-6 sm:w-6 text-red-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="flex-1">
<div class="text-xs font-medium text-gray-500 uppercase">Suhu</div>
<div class="text-xl sm:text-2xl font-semibold text-gray-800 transition-all duration-300" id="nilai-suhu">--°C</div>
<div class="flex flex-col sm:flex-row sm:justify-between mt-1 sm:mt-2">
<div class="mb-1 sm:mb-0">
<span class="text-xs font-medium text-gray-500">Status:</span>
<span class="text-xs sm:text-sm font-medium ml-1" id="status-suhu">--</span>
</div>
<div>
<span class="text-xs font-medium text-gray-500">Aksi:</span>
<span class="text-xs sm:text-sm font-medium ml-1" id="aksi-suhu">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Card Kelembaban -->
<div class="bg-white p-3 sm:p-4 rounded-lg shadow-sm border border-gray-200 transition-all duration-300 hover:shadow-md">
<div class="flex items-start sm:items-center">
<div class="flex-shrink-0 bg-blue-100 rounded-full p-2 sm:p-3 mr-3 sm:mr-4">
<svg class="h-5 w-5 sm:h-6 sm:w-6 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<div class="flex-1">
<div class="text-xs font-medium text-gray-500 uppercase">Kelembapan Tanah</div>
<div class="text-xl sm:text-2xl font-semibold text-gray-800 transition-all duration-300" id="nilai-kelembaban">--%</div>
<div class="flex flex-col sm:flex-row sm:justify-between mt-1 sm:mt-2">
<div class="mb-1 sm:mb-0">
<span class="text-xs font-medium text-gray-500">Status:</span>
<span class="text-xs sm:text-sm font-medium ml-1" id="status-kelembaban">--</span>
</div>
<div>
<span class="text-xs font-medium text-gray-500">Aksi:</span>
<span class="text-xs sm:text-sm font-medium ml-1" id="aksi-kelembaban">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Card Cahaya -->
<div class="bg-white p-3 sm:p-4 rounded-lg shadow-sm border border-gray-200 transition-all duration-300 hover:shadow-md">
<div class="flex items-start sm:items-center">
<div class="flex-shrink-0 bg-yellow-100 rounded-full p-2 sm:p-3 mr-3 sm:mr-4">
<svg class="h-5 w-5 sm:h-6 sm:w-6 text-yellow-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="flex-1">
<div class="text-xs font-medium text-gray-500 uppercase">Cahaya</div>
<div class="text-xl sm:text-2xl font-semibold text-gray-800 transition-all duration-300" id="nilai-cahaya">-- lux</div>
<div class="flex flex-col sm:flex-row sm:justify-between mt-1 sm:mt-2">
<div class="mb-1 sm:mb-0">
<span class="text-xs font-medium text-gray-500">Status:</span>
<span class="text-xs sm:text-sm font-medium ml-1" id="status-cahaya">--</span>
</div>
<div>
<span class="text-xs font-medium text-gray-500">Aksi:</span>
<span class="text-xs sm:text-sm font-medium ml-1" id="aksi-cahaya">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Card Status ESP8266 -->
<div class="bg-white p-3 sm:p-4 rounded-lg shadow-sm border border-gray-200 transition-all duration-300 hover:shadow-md">
<div class="flex items-start sm:items-center">
<div class="flex-shrink-0 bg-gray-100 rounded-full p-2 sm:p-3 mr-3 sm:mr-4">
<svg class="h-5 w-5 sm:h-6 sm:w-6 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<div class="flex-1">
<div class="text-xs font-medium text-gray-500 uppercase">Status ESP8266</div>
<div class="flex items-center">
<span class="h-2.5 w-2.5 sm:h-3 sm:w-3 rounded-full mr-1 sm:mr-2 bg-gray-300 transition-all duration-300" id="status-dot"></span>
<span class="text-sm sm:text-lg font-semibold text-gray-800 transition-all duration-300" id="status-text">Menghubungkan...</span>
</div>
<div class="mt-2 flex items-center">
<button id="restart-esp" class="p-1.5 sm:p-2 bg-blue-500 text-white rounded-full hover:bg-blue-600 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="h-3.5 w-3.5 sm:h-4 sm:w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span id="restart-status" class="text-xs text-gray-500 ml-2 flex-grow truncate"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<main class="flex-1">
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
@yield('content')
</div>
</div>
</main>
</div>
</div>
<!-- Script untuk Firebase Realtime -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Referensi ke path data sensor
const sensorRef = database.ref('sensor');
// Referensi ke status ESP8266
const statusRef = database.ref('status/connected');
// Referensi untuk restart
const restartRef = database.ref('system/restart');
// Tombol restart ESP8266
const restartButton = document.getElementById('restart-esp');
const restartStatus = document.getElementById('restart-status');
// Timestamp saat terakhir update
let lastUpdateTime = Date.now();
// Token CSRF untuk request API
let csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Tambahkan variabel untuk menyimpan status terakhir
let lastStatus = {
suhu: null,
kelembaban: null,
cahaya: null
};
// Menyimpan nilai sensor terakhir untuk dibandingkan
let lastSensorValues = {
suhu: null,
kelembapan_tanah: null,
cahaya: null
};
// Tambahkan variabel untuk menyimpan status terakhir yang dikirim ke history
let lastSentStatus = {
suhu: null,
kelembaban: null,
cahaya: null
};
// Flag untuk tracking status pengiriman data ke server
let isDataSending = false;
// Listener untuk data sensor (menggunakan snapshot)
sensorRef.on('value', (snapshot) => {
const data = snapshot.val();
if (data) {
// Suhu
if (data.suhu !== undefined && data.suhu !== lastSensorValues.suhu) {
saveSensorHistory('suhu', lastSensorValues.suhu, data.suhu);
lastSensorValues.suhu = data.suhu;
}
updateSensorValue('nilai-suhu', data.suhu ? `${data.suhu}°C` : '--°C');
if (data.suhu !== undefined) evaluateSuhu(data.suhu);
// Kelembapan Tanah
if (data.kelembapan_tanah !== undefined && data.kelembapan_tanah !== lastSensorValues.kelembapan_tanah) {
saveSensorHistory('kelembapan_tanah', lastSensorValues.kelembapan_tanah, data.kelembapan_tanah);
lastSensorValues.kelembapan_tanah = data.kelembapan_tanah;
}
updateSensorValue('nilai-kelembaban', data.kelembapan_tanah ? `${data.kelembapan_tanah}%` : '--%');
if (data.kelembapan_tanah !== undefined) evaluateKelembaban(data.kelembapan_tanah);
// Cahaya
if (data.cahaya !== undefined && data.cahaya !== lastSensorValues.cahaya) {
saveSensorHistory('cahaya', lastSensorValues.cahaya, data.cahaya);
lastSensorValues.cahaya = data.cahaya;
}
updateSensorValue('nilai-cahaya', data.cahaya ? `${data.cahaya} lux` : '-- lux');
if (data.cahaya !== undefined) evaluateCahaya(data.cahaya);
lastUpdateTime = Date.now();
}
});
// Fungsi untuk menyimpan riwayat perubahan sensor ke server API
async function saveSensorHistory(sensorType, oldValue, newValue) {
// Hindari pengiriman berulang jika masih dalam proses pengiriman
if (isDataSending) return;
// Cek jika nilainya kosong atau null
if (newValue === null || newValue === undefined) return;
// Jika tidak ada perubahan nilai, tidak perlu mengirim data
if (oldValue === newValue) return;
try {
// Tentukan status dan aksi berdasarkan nilai baru
let currentStatus = '';
let currentAction = '';
if (sensorType === 'suhu') {
if (newValue < 24) {
currentStatus = 'Rendah';
currentAction = 'Kipas Off';
} else if (newValue >= 25 && newValue <= 27) {
currentStatus = 'Normal';
currentAction = 'Kipas Off';
} else if (newValue >= 28) {
currentStatus = 'Tinggi';
currentAction = 'Kipas On';
}
} else if (sensorType === 'kelembapan_tanah') {
if (newValue < 40) {
currentStatus = 'Kering';
currentAction = 'Pompa Air On';
} else if (newValue >= 40 && newValue < 50) {
currentStatus = 'Cukup';
currentAction = 'Pompa Air Off';
} else if (newValue >= 50 && newValue < 60) {
currentStatus = 'Optimal';
currentAction = 'Pompa Air Off';
} else if (newValue >= 60 && newValue <= 70) {
currentStatus = 'Lembap';
currentAction = 'Pompa Air Off';
} else if (newValue > 70) {
currentStatus = 'Sangat Lembap';
currentAction = 'Pompa Air Off';
}
} else if (sensorType === 'cahaya') {
if (newValue < 8000) {
currentStatus = 'Rendah';
currentAction = 'Lampu On';
} else if (newValue >= 8001 && newValue <= 11000) {
currentStatus = 'Normal';
currentAction = 'Lampu Off';
} else if (newValue > 11001) {
currentStatus = 'Tinggi';
currentAction = 'Lampu Off';
}
}
// Siapkan data untuk dikirim ke server
const sensorData = {
sensor: sensorType === 'kelembapan_tanah' ? 'kelembapan_tanah' : sensorType,
oldValue: oldValue,
newValue: newValue,
timestamp: new Date().toISOString()
};
console.log('Mengirim data sensor ke API:', sensorData);
// Set flag pengiriman data
isDataSending = true;
// Kirim data ke API
const response = await fetch('/api/history', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(sensorData)
});
// Reset flag pengiriman data
isDataSending = false;
if (!response.ok) {
throw new Error('Gagal mengirim data ke server');
}
// Update status terakhir yang dikirim
lastSentStatus[sensorType] = currentStatus;
console.log('Data sensor berhasil dikirim ke API');
} catch (error) {
console.error('Error mengirim data sensor ke API:', error);
isDataSending = false;
}
}
// Event listener untuk tombol restart
restartButton.addEventListener('click', function() {
if (confirm('Apakah Anda yakin ingin merestart ESP8266?')) {
// Nonaktifkan tombol saat proses restart
restartButton.disabled = true;
restartButton.classList.add('bg-gray-400');
restartButton.classList.remove('bg-blue-500', 'hover:bg-blue-600');
restartStatus.textContent = 'Mengirim perintah restart...';
// Set nilai restart ke true
database.ref('system/restart').set(true)
.then(() => {
restartStatus.textContent = 'Perintah restart dikirim';
restartStatus.className = 'text-xs text-green-600 ml-2';
// Setelah 3 detik, reset ke false
setTimeout(() => {
database.ref('system/restart').set(false)
.catch(() => {
console.log('Reset restart flag gagal, tetapi ESP mungkin telah membacanya');
});
}, 3000);
// Setelah 5 detik, aktifkan kembali tombol
setTimeout(() => {
restartButton.disabled = false;
restartButton.classList.remove('bg-gray-400');
restartButton.classList.add('bg-blue-500', 'hover:bg-blue-600');
restartStatus.textContent = '';
}, 5000);
})
.catch((error) => {
restartStatus.textContent = 'Gagal mengirim restart. Perlu cek aturan Firebase.';
restartStatus.className = 'text-xs text-red-600 ml-2';
console.error('Gagal mengirim restart:', error);
// Aktifkan kembali tombol
restartButton.disabled = false;
restartButton.classList.remove('bg-gray-400');
restartButton.classList.add('bg-blue-500', 'hover:bg-blue-600');
});
}
});
// Listener untuk status koneksi ESP8266
statusRef.on('value', (snapshot) => {
const isConnected = snapshot.val();
if (isConnected === true) {
lastUpdateTime = Date.now(); // Update waktu terakhir terlihat
}
updateConnectionStatus();
});
// Juga pantau timestamp last_seen untuk deteksi koneksi yang lebih akurat
const lastSeenRef = database.ref('status/last_seen');
lastSeenRef.on('value', (snapshot) => {
const lastSeenTimestamp = snapshot.val();
if (lastSeenTimestamp) {
// Parse timestamp (format: "DD-MM-YYYY HH:MM:SS")
try {
const lastSeen = parseFirebaseTimestamp(lastSeenTimestamp);
if (!isNaN(lastSeen.getTime())) {
// Hanya update jika timestamp valid
lastUpdateTime = Date.now();
updateConnectionStatus();
}
} catch (e) {
console.error("Error parsing timestamp:", e);
}
}
});
// Fungsi untuk memperbarui status koneksi
function updateConnectionStatus() {
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
// Jika terakhir update kurang dari 60 detik, anggap terhubung
const isConnected = (Date.now() - lastUpdateTime) < 60000;
if (isConnected) {
statusDot.className = 'h-3 w-3 rounded-full mr-2 bg-green-500 transition-all duration-300';
statusText.textContent = 'Terhubung';
statusText.className = 'text-lg font-semibold text-green-600 transition-all duration-300';
} else {
statusDot.className = 'h-3 w-3 rounded-full mr-2 bg-red-500 transition-all duration-300';
statusText.textContent = 'Terputus';
statusText.className = 'text-lg font-semibold text-red-600 transition-all duration-300';
}
}
// Fungsi untuk mengubah string timestamp Firebase menjadi objek Date
function parseFirebaseTimestamp(timestampStr) {
// Format: "DD-MM-YYYY HH:MM:SS"
const [datePart, timePart] = timestampStr.split(' ');
const [day, month, year] = datePart.split('-');
const [hour, minute, second] = timePart.split(':');
return new Date(year, month-1, day, hour, minute, second);
}
// Cek koneksi setiap 5 detik untuk memperbarui status UI
setInterval(updateConnectionStatus, 5000);
// Fungsi untuk update nilai dengan animasi smooth
function updateSensorValue(elementId, newValue) {
const element = document.getElementById(elementId);
// Tambahkan kelas untuk animasi
element.classList.add('scale-110', 'text-indigo-600');
element.textContent = newValue;
// Hapus kelas setelah animasi selesai
setTimeout(() => {
element.classList.remove('scale-110', 'text-indigo-600');
}, 300);
}
// Fungsi untuk mengevaluasi status dan aksi sensor suhu
function evaluateSuhu(suhu) {
let status = '--';
let aksi = '--';
let statusClass = '';
if (suhu < 24) {
status = 'Rendah';
aksi = 'Kipas Off';
statusClass = 'text-blue-600';
} else if (suhu >= 25 && suhu <= 27) {
status = 'Normal';
aksi = 'Kipas Off';
statusClass = 'text-green-600';
} else if (suhu >= 28) {
status = 'Tinggi';
aksi = 'Kipas On';
statusClass = 'text-red-600';
}
const statusElement = document.getElementById('status-suhu');
const aksiElement = document.getElementById('aksi-suhu');
statusElement.textContent = status;
statusElement.className = `text-sm font-medium ml-1 ${statusClass}`;
aksiElement.textContent = aksi;
aksiElement.className = `text-sm font-medium ml-1 ${aksi.includes('On') ? 'text-green-600' : 'text-gray-600'}`;
}
// Fungsi untuk mengevaluasi status dan aksi sensor kelembapan tanah
function evaluateKelembaban(kelembapanTanah) {
let status = '--';
let aksi = '--';
let statusClass = '';
if (kelembapanTanah < 40) {
status = 'Kering';
aksi = 'Pompa Air On';
statusClass = 'text-yellow-600';
} else if (kelembapanTanah >= 40 && kelembapanTanah < 50) {
status = 'Cukup';
aksi = 'Pompa Air Off';
statusClass = 'text-green-600';
} else if (kelembapanTanah >= 50 && kelembapanTanah < 60) {
status = 'Optimal';
aksi = 'Pompa Air Off';
statusClass = 'text-green-600';
} else if (kelembapanTanah >= 60 && kelembapanTanah <= 70) {
status = 'Lembap';
aksi = 'Pompa Air Off';
statusClass = 'text-blue-600';
} else if (kelembapanTanah > 70) {
status = 'Sangat Lembap';
aksi = 'Pompa Air Off';
statusClass = 'text-blue-400';
}
const statusElement = document.getElementById('status-kelembaban');
const aksiElement = document.getElementById('aksi-kelembaban');
statusElement.textContent = status;
statusElement.className = `text-sm font-medium ml-1 ${statusClass}`;
aksiElement.textContent = aksi;
aksiElement.className = `text-sm font-medium ml-1 ${aksi === 'Pompa Air On' ? 'text-green-600' : 'text-gray-600'}`;
}
// Fungsi untuk mengevaluasi status dan aksi sensor cahaya
function evaluateCahaya(cahaya) {
let status = '--';
let aksi = '--';
let statusClass = '';
if (cahaya < 8000) {
status = 'Rendah';
aksi = 'Lampu On';
statusClass = 'text-blue-600';
} else if (cahaya >= 8001 && cahaya <= 11000) {
status = 'Normal';
aksi = 'Lampu Off';
statusClass = 'text-green-600';
} else if (cahaya > 11001) {
status = 'Tinggi';
aksi = 'Lampu Off';
statusClass = 'text-yellow-600';
}
const statusElement = document.getElementById('status-cahaya');
const aksiElement = document.getElementById('aksi-cahaya');
statusElement.textContent = status;
statusElement.className = `text-xs sm:text-sm font-medium ml-1 ${statusClass}`;
aksiElement.textContent = aksi;
aksiElement.className = `text-xs sm:text-sm font-medium ml-1 ${aksi.includes('On') ? 'text-green-600' : 'text-gray-600'}`;
}
});
</script>
<!-- Script untuk Flowbite (dropdown, dll) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.0/flowbite.min.js"></script>
<!-- Script untuk User Dropdown dan Sensor Cards Toggle -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const userMenuButton = document.getElementById('user-menu-button');
const userDropdown = document.getElementById('user-dropdown');
const navbarToggle = document.getElementById('navbar-toggle');
const navbarUser = document.getElementById('navbar-user');
// Toggle Sensor Cards pada Mobile
const toggleSensorCards = document.getElementById('toggleSensorCards');
const sensorCardsContainer = document.getElementById('sensorCardsContainer');
const toggleSensorIcon = document.getElementById('toggleSensorIcon');
const toggleSensorText = document.getElementById('toggleSensorText');
if (toggleSensorCards && sensorCardsContainer) {
toggleSensorCards.addEventListener('click', function() {
sensorCardsContainer.classList.toggle('hidden');
toggleSensorIcon.classList.toggle('rotate-180');
toggleSensorText.textContent = sensorCardsContainer.classList.contains('hidden') ? 'Lihat Detail' : 'Sembunyikan';
});
// Restore state dari localStorage jika ada
const sensorCardsState = localStorage.getItem('sensorCardsVisible');
if (sensorCardsState === 'true') {
sensorCardsContainer.classList.remove('hidden');
toggleSensorIcon.classList.add('rotate-180');
toggleSensorText.textContent = 'Sembunyikan';
}
// Save state ke localStorage saat toggle
toggleSensorCards.addEventListener('click', function() {
localStorage.setItem('sensorCardsVisible', !sensorCardsContainer.classList.contains('hidden'));
});
}
// Fallback jika flowbite tidak berfungsi
if (userMenuButton && userDropdown && typeof flowbite === 'undefined') {
// Toggle dropdown saat tombol profil diklik
userMenuButton.addEventListener('click', function() {
userDropdown.classList.toggle('hidden');
});
// Tutup dropdown saat klik di luar dropdown
document.addEventListener('click', function(e) {
if (!userMenuButton.contains(e.target) && !userDropdown.contains(e.target)) {
userDropdown.classList.add('hidden');
}
});
}
if (navbarToggle && navbarUser) {
// Toggle navbar mobile
navbarToggle.addEventListener('click', function() {
navbarUser.classList.toggle('hidden');
});
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,264 @@
@extends('layouts.app')
@section('title', 'Semua Foto')
@section('content')
<div class="py-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">Semua Foto ESP32-CAM</h2>
<a href="{{ route('dashboard') }}" class="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600 transition duration-300">
Kembali ke Dashboard
</a>
</div>
@if(count($photos) > 0)
<div id="photos-grid" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@foreach($photos as $photo)
<div class="bg-gray-50 rounded-lg overflow-hidden shadow-sm hover:shadow-md transition duration-300 photo-item">
<div class="relative pt-[75%]">
<img src="{{ asset($photo['path']) }}"
alt="{{ $photo['name'] }}"
class="absolute inset-0 w-full h-full object-cover"
onclick="openPhotoModal('{{ asset($photo['path']) }}', '{{ $photo['time'] }}', '{{ $photo['size'] }}', '{{ $photo['name'] }}')">
<div class="absolute top-2 right-2">
<button class="delete-photo p-1.5 bg-red-500 text-white rounded-full hover:bg-red-600 focus:outline-none"
data-filename="{{ $photo['name'] }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<div class="p-3">
<p class="text-gray-600 text-sm mb-1">
<span class="font-semibold">Waktu:</span> {{ $photo['time'] }}
</p>
<p class="text-gray-600 text-sm">
<span class="font-semibold">Ukuran:</span> {{ $photo['size'] }}
</p>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-12 bg-gray-50 rounded-lg">
<svg class="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p class="text-gray-500 text-lg">Belum ada foto yang tersedia</p>
</div>
@endif
</div>
</div>
</div>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// Konfigurasi SweetAlert2 untuk menggunakan warna Tailwind
const mySwal = Swal.mixin({
customClass: {
confirmButton: 'bg-blue-600 text-white font-medium px-4 py-2 rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 mr-2',
cancelButton: 'bg-red-500 text-white font-medium px-4 py-2 rounded-md shadow-sm hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500'
},
buttonsStyling: false
});
// Tambahkan event listener untuk tombol hapus
document.querySelectorAll('.delete-photo').forEach(button => {
button.addEventListener('click', function() {
const filename = this.getAttribute('data-filename');
const photoElement = this.closest('.photo-item');
mySwal.fire({
title: 'Apakah Anda yakin?',
text: "Foto yang dihapus tidak dapat dikembalikan!",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Ya, hapus!',
cancelButtonText: 'Batal'
}).then((result) => {
if (result.isConfirmed) {
// Tampilkan loading
mySwal.fire({
title: 'Menghapus...',
html: 'Mohon tunggu sebentar...',
allowOutsideClick: false,
didOpen: () => {
mySwal.showLoading();
}
});
// Kirim permintaan AJAX untuk menghapus foto
fetch('/api/esp32cam/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
filename: filename,
delete_storage: true // Tambahkan flag untuk menghapus file di storage
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// Tambahkan animasi sebelum menghapus
photoElement.style.transition = 'all 0.5s ease';
photoElement.style.opacity = '0';
photoElement.style.transform = 'scale(0.8)';
setTimeout(() => {
photoElement.remove();
// Periksa apakah masih ada foto
if (document.querySelectorAll('.photo-item').length === 0) {
const photosGrid = document.getElementById('photos-grid');
photosGrid.innerHTML = `
<div class="text-center py-12 bg-gray-50 rounded-lg col-span-full">
<svg class="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p class="text-gray-500 text-lg">Belum ada foto yang tersedia</p>
</div>
`;
}
mySwal.fire({
title: 'Terhapus!',
text: 'Foto berhasil dihapus.',
icon: 'success',
timer: 1500,
showConfirmButton: false
});
}, 500);
} else {
mySwal.fire({
title: 'Error!',
text: 'Gagal menghapus foto: ' + data.message,
icon: 'error'
});
}
})
.catch(error => {
console.error('Error:', error);
mySwal.fire({
title: 'Error!',
text: 'Terjadi kesalahan saat menghapus foto.',
icon: 'error'
});
});
}
});
});
});
});
// Variabel untuk zoom
let currentScale = 1;
// Fungsi zoom
function zoomIn() {
currentScale += 0.25;
document.getElementById('modal-photo').style.transform = `scale(${currentScale})`;
}
function zoomOut() {
if (currentScale > 0.5) {
currentScale -= 0.25;
document.getElementById('modal-photo').style.transform = `scale(${currentScale})`;
}
}
function zoomReset() {
currentScale = 1;
document.getElementById('modal-photo').style.transform = 'scale(1)';
}
// Fungsi untuk modal foto
function openPhotoModal(src, timestamp, filesize, filename) {
const modal = document.getElementById('photoModal');
const modalPhoto = document.getElementById('modal-photo');
const modalTimestamp = document.getElementById('modal-timestamp');
const modalFilesize = document.getElementById('modal-filesize');
const modalFilename = document.getElementById('modal-filename');
modalPhoto.src = src;
modalTimestamp.textContent = timestamp;
modalFilesize.textContent = filesize;
modalFilename.textContent = filename;
// Reset zoom setiap kali membuka modal
currentScale = 1;
modalPhoto.style.transform = 'scale(1)';
modal.classList.remove('hidden');
}
function closePhotoModal() {
const modal = document.getElementById('photoModal');
modal.classList.add('hidden');
}
// Tutup modal dengan escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closePhotoModal();
}
});
// Tutup modal dengan click di luar content
document.getElementById('photoModal')?.addEventListener('click', function(e) {
if (e.target === this) {
closePhotoModal();
}
});
</script>
@endpush
<!-- Modal untuk Photo Zoom -->
<div id="photoModal" class="fixed inset-0 z-50 hidden overflow-auto bg-black bg-opacity-80 flex items-center justify-center p-4">
<div class="relative bg-white rounded-lg shadow-xl max-w-4xl w-full">
<div class="flex justify-between items-center p-4 border-b">
<h3 class="text-lg font-semibold text-gray-900" id="modal-title">Detail Foto</h3>
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center" onclick="closePhotoModal()">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<div class="p-4 space-y-6">
<div class="relative">
<div id="zoom-container" class="overflow-hidden relative">
<img id="modal-photo" src="" alt="Photo" class="w-full transform transition-transform duration-300">
</div>
<div class="flex justify-center mt-4 space-x-2">
<button id="zoom-in" class="bg-gray-200 p-2 rounded-full hover:bg-gray-300 transition-colors" onclick="zoomIn()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
</button>
<button id="zoom-out" class="bg-gray-200 p-2 rounded-full hover:bg-gray-300 transition-colors" onclick="zoomOut()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z" clip-rule="evenodd" />
</svg>
</button>
<button id="zoom-reset" class="bg-gray-200 p-2 rounded-full hover:bg-gray-300 transition-colors" onclick="zoomReset()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<div class="mt-4">
<p class="text-sm text-gray-600"><span class="font-medium">Nama File:</span> <span id="modal-filename"></span></p>
<p class="text-sm text-gray-600"><span class="font-medium">Waktu Pengambilan:</span> <span id="modal-timestamp"></span></p>
<p class="text-sm text-gray-600"><span class="font-medium">Ukuran File:</span> <span id="modal-filesize"></span></p>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1 @@

72
routes/api.php Normal file
View File

@ -0,0 +1,72 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// use App\Http\Controllers\PhotoController;
use App\Http\Controllers\SensorController;
use App\Http\Controllers\ESP32CamController;
use App\Http\Controllers\HistoryController;
use Illuminate\Support\Facades\Storage;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
// Route untuk ESP32Cam dan foto
Route::post('/esp32cam/upload', [ESP32CamController::class, 'upload']);
Route::get('/esp32cam/cleanup', [ESP32CamController::class, 'cleanup']);
Route::get('/esp32cam/latest', [ESP32CamController::class, 'latest']);
Route::post('/esp32cam/delete', [ESP32CamController::class, 'delete'])->name('api.photos.delete');
Route::post('/esp32cam/fetch-from-camera', [ESP32CamController::class, 'fetchFromCamera'])->name('api.esp32cam.fetch');
// Route untuk proxy kamera ESP32-CAM
Route::get('/proxy-camera/status', [ESP32CamController::class, 'proxyStatus'])->name('api.proxy.status');
Route::get('/proxy-camera/stream', [ESP32CamController::class, 'proxyStream'])->name('api.proxy.stream');
Route::get('/proxy-camera/stopstream', [ESP32CamController::class, 'proxyStopStream'])->name('api.proxy.stopstream');
Route::get('/proxy-camera/settings', [ESP32CamController::class, 'proxySettings'])->name('api.proxy.settings');
Route::get('/proxy-camera/capture', [ESP32CamController::class, 'proxyCapture'])->name('api.proxy.capture');
// Route test untuk ESP32CAM
Route::get('/esp32cam/test', function() {
return response()->json(['status' => 'success', 'message' => 'ESP32CAM test endpoint berfungsi!']);
});
// Route untuk sensor data
Route::post('/sensor-update', [SensorController::class, 'update']);
Route::get('/sensor-data', [SensorController::class, 'latest']);
Route::get('/sensor-history', [SensorController::class, 'history']);
// Routes untuk history
Route::get('/history', [HistoryController::class, 'getAll']);
Route::post('/history', [HistoryController::class, 'store']);
Route::post('/history/clear', [HistoryController::class, 'clear']);
Route::post('/history/delete', [HistoryController::class, 'delete']);
Route::get('/history/firebase', [HistoryController::class, 'getFirebaseData']);
Route::post('/history/sync-firebase', [HistoryController::class, 'syncFromFirebase']);
Route::get('/history/current-sensor-data', [HistoryController::class, 'getCurrentSensorData']);
// Route untuk testing file report.json
Route::get('/test-report-json', function() {
$reportFilePath = 'public/reports/report.json';
$exists = Storage::exists($reportFilePath);
$data = $exists ? json_decode(Storage::get($reportFilePath), true) : null;
$size = $exists ? Storage::size($reportFilePath) : 0;
return response()->json([
'file_exists' => $exists,
'file_size' => $size,
'data_count' => $data ? count($data) : 0,
'data' => $data
]);
});

18
routes/channels.php Normal file
View File

@ -0,0 +1,18 @@
<?php
use Illuminate\Support\Facades\Broadcast;
/*
|--------------------------------------------------------------------------
| Broadcast Channels
|--------------------------------------------------------------------------
|
| Here you may register all of the event broadcasting channels that your
| application supports. The given channel authorization callbacks are
| used to check if an authenticated user can listen to the channel.
|
*/
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});

19
routes/console.php Normal file
View File

@ -0,0 +1,19 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
/*
|--------------------------------------------------------------------------
| Console Routes
|--------------------------------------------------------------------------
|
| This file is where you may define all of your Closure based console
| commands. Each Closure is bound to a command instance allowing a
| simple approach to interacting with each command's IO methods.
|
*/
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

46
routes/web.php Normal file
View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ESP32CamController;
use App\Http\Controllers\PhotoController;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/
// Rute untuk autentikasi
Route::get('/login', [AuthController::class, 'showLoginForm'])->name('login');
Route::post('/login/process', [AuthController::class, 'processLogin'])->name('login.process');
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
Route::get('/check-session', [AuthController::class, 'checkSession'])->name('check.session');
// Rute yang dilindungi auth
Route::middleware(['firebase.auth'])->group(function () {
Route::get('/', function () {
return redirect()->route('dashboard');
});
Route::get('/dashboard', function () {
return view('dashboard');
})->name('dashboard');
Route::get('/history', function () {
return view('history');
})->name('history');
// Tambahkan route untuk melihat semua foto
Route::get('/photos', [ESP32CamController::class, 'all'])->name('photos.all');
// Tambahkan route untuk pengaturan ESP32CAM
Route::get('/esp32cam/settings', function () {
return view('esp32cam.settings');
})->name('esp32cam.settings');
});

View File

@ -0,0 +1,41 @@
{
"command": {
"restart": "restart-1745686702005"
},
"devices": {
"kipas": false,
"lampu": true,
"lampu_reason": "Intensitas cahaya rendah",
"pump": false,
"pump_reason": "Kelembapan tanah sudah Optimal",
"pump_timestamp": "07:02:25"
},
"logs": {
"bh1750": {
"message": "Perangkat terhubung",
"status": "Terhubung"
},
"dht11": {
"message": "Perangkat terhubung"
},
"soil_moisture": {
"message": "Perangkat terhubung"
},
"system": "System starting up"
},
"sensor": {
"cahaya": 357.5,
"kelembaban": 62,
"kelembapan_tanah": 48,
"status_cahaya": "Rendah",
"status_kelembapan_tanah": "Cukup",
"suhu": 26
},
"status": {
"connected": true,
"last_seen": "09:54:10"
},
"system": {
"restart": false
}
}

3
storage/app/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*
!public/
!.gitignore

2
storage/app/public/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

9
storage/framework/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json

3
storage/framework/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*
!data/
!.gitignore

View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/sessions/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/testing/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/views/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/logs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show More