update 25 juni 2k25
This commit is contained in:
commit
fe6c81940e
|
@ -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
|
|
@ -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}"
|
|
@ -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
|
|
@ -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
|
|
@ -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).
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
//
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 = [
|
||||
//
|
||||
];
|
||||
}
|
|
@ -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 = [
|
||||
//
|
||||
];
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
];
|
||||
}
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
];
|
||||
}
|
|
@ -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/*',
|
||||
];
|
||||
}
|
|
@ -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',
|
||||
];
|
||||
}
|
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -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
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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(),
|
||||
|
||||
];
|
|
@ -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,
|
||||
|
||||
];
|
|
@ -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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
|
@ -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_'),
|
||||
|
||||
];
|
|
@ -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,
|
||||
|
||||
];
|
|
@ -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'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
|
@ -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'),
|
||||
],
|
||||
|
||||
];
|
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
|
@ -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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -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',
|
||||
],
|
||||
|
||||
];
|
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
|
@ -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'),
|
||||
],
|
||||
|
||||
];
|
|
@ -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,
|
||||
|
||||
];
|
|
@ -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'))
|
||||
),
|
||||
|
||||
];
|
|
@ -0,0 +1 @@
|
|||
*.sqlite*
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -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',
|
||||
// ]);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
|
@ -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>
|
Binary file not shown.
After Width: | Height: | Size: 67 KiB |
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 |
|
@ -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);
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
|
@ -0,0 +1 @@
|
|||
import './bootstrap';
|
|
@ -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'],
|
||||
// });
|
|
@ -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
|
@ -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
|
|
@ -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">
|
||||
<
|
||||
</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">
|
||||
>
|
||||
</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
|
|
@ -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>
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -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
|
||||
]);
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -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');
|
|
@ -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');
|
||||
});
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!public/
|
||||
!.gitignore
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -0,0 +1,9 @@
|
|||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!data/
|
||||
!.gitignore
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue