Initial commit
This commit is contained in:
commit
f09a282153
|
|
@ -0,0 +1,623 @@
|
|||
/*
|
||||
* ======================================================
|
||||
* SMART COLLAR FIRMWARE
|
||||
* ------------------------------------------------------
|
||||
* Kirim data sensor (suhu, gerak, GPS, sinyal) ke backend
|
||||
* tiap 1 detik. Prioritas kirim lewat SIM800L (GPRS),
|
||||
* fallback otomatis ke WiFi kalau SIM800L gagal.
|
||||
* ======================================================
|
||||
*/
|
||||
|
||||
#define TINY_GSM_MODEM_SIM800
|
||||
|
||||
#include <Wire.h>
|
||||
#include <Adafruit_MLX90614.h>
|
||||
#include <Adafruit_MPU6050.h>
|
||||
#include <Adafruit_Sensor.h>
|
||||
#include <TinyGPS++.h>
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include <TinyGsmClient.h>
|
||||
#include <ArduinoHttpClient.h>
|
||||
|
||||
// Install lewat Library Manager: "ArduinoJson" by Benoit Blanchon
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// ======================================================
|
||||
// PIN CONFIG
|
||||
// ======================================================
|
||||
#define SDA_PIN 21
|
||||
#define SCL_PIN 22
|
||||
|
||||
#define GPS_RX 16
|
||||
#define GPS_TX 17
|
||||
|
||||
#define SIM_RX 13
|
||||
#define SIM_TX 12
|
||||
|
||||
// ======================================================
|
||||
// WIFI CONFIG
|
||||
// ======================================================
|
||||
const char* WIFI_SSID = "oke";
|
||||
const char* WIFI_PASSWORD = "12345678";
|
||||
|
||||
// ======================================================
|
||||
// GPRS CONFIG
|
||||
// ======================================================
|
||||
const char APN[] = "internet";
|
||||
|
||||
// ======================================================
|
||||
// IDENTITAS DEVICE
|
||||
// Harus sama persis dengan yang terdaftar di backend.
|
||||
// ======================================================
|
||||
const char* SERIAL_NUMBER = "SC001";
|
||||
const char* DEVICE_SECRET = "eefda1ae-ceb8-47da-b89e-c8be0d6543f2";
|
||||
const char* FIRMWARE_VERSION = "1.0.0";
|
||||
const char* HARDWARE_VERSION = "1.0.0";
|
||||
|
||||
// ======================================================
|
||||
// BACKEND CONFIG
|
||||
// ======================================================
|
||||
const char* WIFI_BASE_URL = "https://api.smartcollar.my.id";
|
||||
const char* MONITORING_PATH = "/api/v1/monitoring";
|
||||
|
||||
// Host SIM800L TANPA "http://"/"https://".
|
||||
// Domain KHUSUS untuk jalur SIM800L (backend terpisah dari WiFi),
|
||||
// jadi harus support plain HTTP di port 80 -- SIM800L via
|
||||
// TinyGsmClient/HttpClient tidak bisa TLS/HTTPS.
|
||||
const char GSM_SERVER[] = "sim.smartcollar.my.id";
|
||||
const int GSM_PORT = 80;
|
||||
|
||||
// ======================================================
|
||||
// KALIBRASI SENSOR
|
||||
// ======================================================
|
||||
const float TEMP_OFFSET = 2.0;
|
||||
|
||||
// ======================================================
|
||||
// NTP CONFIG (fallback kalau device sedang pakai WiFi)
|
||||
// ======================================================
|
||||
const char* NTP_SERVER_1 = "pool.ntp.org";
|
||||
const char* NTP_SERVER_2 = "time.google.com";
|
||||
const long GMT_OFFSET_SEC = 7 * 3600; // WIB = UTC+7
|
||||
const int DAYLIGHT_OFFSET_SEC = 0;
|
||||
const unsigned long NTP_SYNC_TIMEOUT_MS = 10000;
|
||||
|
||||
// ======================================================
|
||||
// TIMING
|
||||
// ======================================================
|
||||
const unsigned long SEND_INTERVAL_MS = 1000; // kirim tiap 1 detik
|
||||
const unsigned long WIFI_CONNECT_TIMEOUT_MS = 15000;
|
||||
const unsigned long WIFI_RECONNECT_COOLDOWN_MS = 5000;
|
||||
|
||||
// ======================================================
|
||||
// GLOBAL OBJECTS
|
||||
// ======================================================
|
||||
Adafruit_MLX90614 mlx;
|
||||
Adafruit_MPU6050 mpu;
|
||||
TinyGPSPlus gps;
|
||||
|
||||
HardwareSerial SerialGPS(2);
|
||||
HardwareSerial SerialSIM(1);
|
||||
|
||||
TinyGsm modem(SerialSIM);
|
||||
TinyGsmClient gsmClient(modem);
|
||||
HttpClient gsmHttp(gsmClient, GSM_SERVER, GSM_PORT);
|
||||
|
||||
const unsigned long WIFI_HTTP_TIMEOUT_MS = 10000;
|
||||
|
||||
// ======================================================
|
||||
// GLOBAL STATE
|
||||
// ======================================================
|
||||
float baseAccelX = 0;
|
||||
bool simConnected = false;
|
||||
|
||||
unsigned long lastSendMillis = 0;
|
||||
unsigned long lastWifiReconnectAttempt = 0;
|
||||
|
||||
// ======================================================
|
||||
// STRUCT DATA SENSOR (biar gampang dibaca & di-passing)
|
||||
// ======================================================
|
||||
struct SensorReading {
|
||||
float temperature;
|
||||
float accelX;
|
||||
double latitude;
|
||||
double longitude;
|
||||
String linkMaps;
|
||||
int signalStrength;
|
||||
};
|
||||
|
||||
// ======================================================
|
||||
// WIFI
|
||||
// ======================================================
|
||||
void connectWiFi() {
|
||||
|
||||
Serial.println();
|
||||
Serial.println("CONNECT WIFI");
|
||||
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setSleep(false); // cegah ESP32 disconnect random akibat modem-sleep
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||
|
||||
unsigned long startAttempt = millis();
|
||||
|
||||
while (WiFi.status() != WL_CONNECTED &&
|
||||
millis() - startAttempt < WIFI_CONNECT_TIMEOUT_MS) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
}
|
||||
|
||||
Serial.println();
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.print("WIFI CONNECTED, IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
} else {
|
||||
Serial.println("WIFI FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect non-blocking, dipanggil tiap loop() supaya WiFi yang
|
||||
// putus di tengah jalan langsung dicoba nyambung lagi di background
|
||||
// tanpa nge-block pembacaan sensor/GPS.
|
||||
void reconnectWiFiIfNeeded() {
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned long now = millis();
|
||||
|
||||
if (now - lastWifiReconnectAttempt < WIFI_RECONNECT_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWifiReconnectAttempt = now;
|
||||
|
||||
Serial.println();
|
||||
Serial.println("WIFI DISCONNECTED -> RECONNECT (background)");
|
||||
|
||||
WiFi.reconnect();
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// SIM800L / GPRS
|
||||
// ======================================================
|
||||
bool connectSIM800() {
|
||||
|
||||
Serial.println();
|
||||
Serial.println("INIT SIM800L");
|
||||
|
||||
SerialSIM.begin(9600, SERIAL_8N1, SIM_RX, SIM_TX);
|
||||
delay(3000);
|
||||
|
||||
if (!modem.restart()) {
|
||||
Serial.println("SIM800 NOT RESPOND");
|
||||
return false;
|
||||
}
|
||||
Serial.println("SIM800 OK");
|
||||
|
||||
if (!modem.waitForNetwork()) {
|
||||
Serial.println("NETWORK FAILED");
|
||||
return false;
|
||||
}
|
||||
Serial.println("NETWORK OK");
|
||||
|
||||
if (!modem.gprsConnect(APN, "", "")) {
|
||||
Serial.println("GPRS FAILED");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.print("GPRS CONNECTED, IP: ");
|
||||
Serial.println(modem.localIP());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void reconnectSIM800() {
|
||||
|
||||
if (!modem.isNetworkConnected()) {
|
||||
|
||||
Serial.println();
|
||||
Serial.println("NETWORK LOST");
|
||||
|
||||
if (modem.waitForNetwork()) {
|
||||
|
||||
Serial.println("NETWORK RECONNECTED");
|
||||
|
||||
} else {
|
||||
|
||||
Serial.println("NETWORK FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
if (!modem.isGprsConnected()) {
|
||||
|
||||
Serial.println();
|
||||
Serial.println("GPRS LOST");
|
||||
|
||||
if (modem.gprsConnect(APN, "", "")) {
|
||||
|
||||
Serial.println("GPRS RECONNECTED");
|
||||
|
||||
simConnected = true;
|
||||
|
||||
} else {
|
||||
|
||||
Serial.println("GPRS FAILED");
|
||||
|
||||
simConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// SYNC WAKTU (biar field "uptime" isinya Unix timestamp asli,
|
||||
// sama seperti Date.now() di simulator -- bukan sekadar detik
|
||||
// sejak boot / millis())
|
||||
//
|
||||
// CATATAN: sync waktu dari modem (AT+CCLK) SEMENTARA DINONAKTIFKAN
|
||||
// karena gak semua provider SIM support & berisiko bikin proses
|
||||
// nyangkut kalau modem/network gak respon sesuai harapan. Uptime
|
||||
// akan fallback ke millis() kalau device cuma pakai SIM800L tanpa
|
||||
// WiFi sama sekali -- gak ideal, tapi jauh lebih aman/stabil dulu
|
||||
// sampai koneksi SIM800L ke backend beres.
|
||||
// ======================================================
|
||||
|
||||
// Sync lewat NTP (butuh WiFi aktif)
|
||||
bool syncTimeFromNTP() {
|
||||
|
||||
Serial.println();
|
||||
Serial.println("SYNC WAKTU (NTP)...");
|
||||
|
||||
configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);
|
||||
|
||||
unsigned long startAttempt = millis();
|
||||
time_t now = time(nullptr);
|
||||
|
||||
while (now < 1700000000 && millis() - startAttempt < NTP_SYNC_TIMEOUT_MS) {
|
||||
delay(300);
|
||||
Serial.print(".");
|
||||
now = time(nullptr);
|
||||
}
|
||||
|
||||
Serial.println();
|
||||
|
||||
if (now >= 1700000000) {
|
||||
Serial.print("WAKTU TERSINKRON DARI NTP: ");
|
||||
Serial.println(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.println("NTP SYNC GAGAL");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Coba sync waktu lewat NTP kalau WiFi aktif. Kalau device murni
|
||||
// pakai SIM800L tanpa WiFi, uptime fallback ke millis() (lihat
|
||||
// getUnixTimestamp() di bawah).
|
||||
void syncTimeIfPossible() {
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
syncTimeFromNTP();
|
||||
} else {
|
||||
Serial.println();
|
||||
Serial.println("WIFI TIDAK AKTIF -- uptime pakai fallback millis()");
|
||||
}
|
||||
}
|
||||
|
||||
// Ambil Unix timestamp (detik sejak epoch), sama format dengan
|
||||
// Math.floor(Date.now() / 1000) di simulator.
|
||||
unsigned long getUnixTimestamp() {
|
||||
|
||||
time_t now = time(nullptr);
|
||||
|
||||
if (now >= 1700000000) {
|
||||
return (unsigned long)now;
|
||||
}
|
||||
|
||||
// Fallback kalau waktu belum/gagal sync sama sekali
|
||||
return millis() / 1000;
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// SENSOR SETUP
|
||||
// ======================================================
|
||||
void setupMPU() {
|
||||
mpu.setAccelerometerRange(MPU6050_RANGE_2_G);
|
||||
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
|
||||
}
|
||||
|
||||
void calibrateMPU() {
|
||||
sensors_event_t a, g, t;
|
||||
mpu.getEvent(&a, &g, &t);
|
||||
baseAccelX = a.acceleration.x;
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// SIGNAL STRENGTH
|
||||
// ======================================================
|
||||
int getSignalStrength() {
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
return WiFi.RSSI();
|
||||
}
|
||||
|
||||
if (simConnected) {
|
||||
int csq = modem.getSignalQuality();
|
||||
return (csq == 99) ? -113 : (-113 + csq * 2);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// BACA SEMUA SENSOR
|
||||
// ======================================================
|
||||
SensorReading readSensors() {
|
||||
|
||||
SensorReading reading;
|
||||
|
||||
// Suhu
|
||||
float rawTemp = mlx.readObjectTempC();
|
||||
reading.temperature = isnan(rawTemp) ? 0 : rawTemp + TEMP_OFFSET;
|
||||
|
||||
// Gerak (accelX mentah, status dihitung di backend)
|
||||
sensors_event_t a, g, t;
|
||||
mpu.getEvent(&a, &g, &t);
|
||||
reading.accelX = a.acceleration.x - baseAccelX;
|
||||
|
||||
// GPS
|
||||
reading.latitude = 0;
|
||||
reading.longitude = 0;
|
||||
reading.linkMaps = "-";
|
||||
|
||||
if (gps.location.isValid()) {
|
||||
reading.latitude = gps.location.lat();
|
||||
reading.longitude = gps.location.lng();
|
||||
reading.linkMaps = "https://www.google.com/maps?q=" +
|
||||
String(reading.latitude, 6) + "," +
|
||||
String(reading.longitude, 6);
|
||||
}
|
||||
|
||||
// Sinyal
|
||||
reading.signalStrength = getSignalStrength();
|
||||
|
||||
return reading;
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// BANGUN PAYLOAD JSON
|
||||
// Struktur & nama field disamakan persis dengan simulator.
|
||||
// ======================================================
|
||||
String buildPayload(const SensorReading& reading) {
|
||||
|
||||
StaticJsonDocument<512> doc;
|
||||
|
||||
doc["serialNumber"] = SERIAL_NUMBER;
|
||||
doc["deviceSecret"] = DEVICE_SECRET;
|
||||
|
||||
JsonObject device = doc.createNestedObject("device");
|
||||
device["signal"] = reading.signalStrength;
|
||||
device["firmwareVersion"] = FIRMWARE_VERSION;
|
||||
device["hardwareVersion"] = HARDWARE_VERSION;
|
||||
device["uptime"] = getUnixTimestamp();
|
||||
|
||||
JsonObject sensor = doc.createNestedObject("sensor");
|
||||
sensor["temperature"] = round(reading.temperature * 100) / 100.0;
|
||||
|
||||
JsonObject gpsObj = sensor.createNestedObject("gps");
|
||||
gpsObj["latitude"] = reading.latitude;
|
||||
gpsObj["longitude"] = reading.longitude;
|
||||
gpsObj["linkMaps"] = reading.linkMaps;
|
||||
|
||||
JsonObject movement = sensor.createNestedObject("movement");
|
||||
movement["accelX"] = round(reading.accelX * 100) / 100.0;
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
if (doc.overflowed()) {
|
||||
Serial.println();
|
||||
Serial.println("WARNING: JSON BUFFER OVERFLOW! Payload mungkin kepotong, perbesar StaticJsonDocument.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// KIRIM VIA SIM800L
|
||||
// return true kalau berhasil (statusCode > 0)
|
||||
// ======================================================
|
||||
bool sendViaSIM800(const String& payload) {
|
||||
|
||||
Serial.println();
|
||||
Serial.println("---- KIRIM VIA SIM800L ----");
|
||||
Serial.print("Host : ");
|
||||
Serial.println(GSM_SERVER);
|
||||
Serial.print("Path : ");
|
||||
Serial.println(MONITORING_PATH);
|
||||
Serial.print("Payload: ");
|
||||
Serial.println(payload);
|
||||
|
||||
gsmHttp.beginRequest();
|
||||
gsmHttp.post(MONITORING_PATH);
|
||||
gsmHttp.sendHeader("Content-Type", "application/json");
|
||||
gsmHttp.sendHeader("Content-Length", payload.length());
|
||||
gsmHttp.beginBody();
|
||||
gsmHttp.print(payload);
|
||||
gsmHttp.endRequest();
|
||||
|
||||
int statusCode = gsmHttp.responseStatusCode();
|
||||
String response = gsmHttp.responseBody();
|
||||
|
||||
Serial.print("Status Code : ");
|
||||
Serial.println(statusCode);
|
||||
Serial.print("Response : ");
|
||||
Serial.println(response.length() > 0 ? response : "(kosong)");
|
||||
|
||||
return statusCode > 0;
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// KIRIM VIA WIFI (HTTPS)
|
||||
// ======================================================
|
||||
bool sendViaWiFi(const String& payload) {
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
connectWiFi();
|
||||
}
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
Serial.println();
|
||||
Serial.println("NO INTERNET (WiFi gagal connect)");
|
||||
return false;
|
||||
}
|
||||
|
||||
String url = String(WIFI_BASE_URL) + String(MONITORING_PATH);
|
||||
|
||||
Serial.println();
|
||||
Serial.println("---- KIRIM VIA WIFI ----");
|
||||
Serial.print("URL : ");
|
||||
Serial.println(url);
|
||||
Serial.print("Payload: ");
|
||||
Serial.println(payload);
|
||||
|
||||
// ESP32 HTTPClient wajib pakai WiFiClientSecure untuk https,
|
||||
// kalau tidak, request bisa gagal diam-diam (httpCode negatif).
|
||||
WiFiClientSecure secureClient;
|
||||
secureClient.setInsecure();
|
||||
secureClient.setTimeout(WIFI_HTTP_TIMEOUT_MS);
|
||||
|
||||
HTTPClient http;
|
||||
http.setTimeout(WIFI_HTTP_TIMEOUT_MS);
|
||||
http.setConnectTimeout(WIFI_HTTP_TIMEOUT_MS);
|
||||
http.begin(secureClient, url);
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
|
||||
int httpCode = http.POST(payload);
|
||||
|
||||
Serial.print("HTTP Code : ");
|
||||
Serial.println(httpCode);
|
||||
|
||||
bool success = httpCode > 0 && httpCode < 400;
|
||||
|
||||
if (httpCode > 0) {
|
||||
String body = http.getString();
|
||||
Serial.print("Response : ");
|
||||
Serial.println(body.length() > 0 ? body : "(kosong)");
|
||||
} else {
|
||||
Serial.print("HTTP Error: ");
|
||||
Serial.println(http.errorToString(httpCode));
|
||||
}
|
||||
|
||||
http.end();
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// KIRIM DATA — PRIORITAS SIM800L, FALLBACK WIFI
|
||||
// ======================================================
|
||||
void sendToBackend(const String& payload) {
|
||||
|
||||
bool success = false;
|
||||
|
||||
if (simConnected) {
|
||||
success = sendViaSIM800(payload);
|
||||
|
||||
if (!success) {
|
||||
Serial.println();
|
||||
Serial.println("SIM800L GAGAL -> COBA WIFI");
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
success = sendViaWiFi(payload);
|
||||
}
|
||||
|
||||
Serial.println();
|
||||
if (success) {
|
||||
Serial.println("===== DATA BERHASIL TERKIRIM =====");
|
||||
} else {
|
||||
Serial.println("===== DATA GAGAL TERKIRIM (cek koneksi/backend) =====");
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// SETUP
|
||||
// ======================================================
|
||||
void setup() {
|
||||
|
||||
Serial.begin(115200);
|
||||
delay(3000);
|
||||
|
||||
Serial.println();
|
||||
Serial.println("SMART COLLAR START");
|
||||
Serial.print("Serial Number : ");
|
||||
Serial.println(SERIAL_NUMBER);
|
||||
|
||||
// Koneksi: prioritas SIM800L, fallback WiFi
|
||||
simConnected = connectSIM800();
|
||||
|
||||
if (!simConnected) {
|
||||
connectWiFi();
|
||||
}
|
||||
|
||||
syncTimeIfPossible();
|
||||
|
||||
// I2C + sensor
|
||||
Wire.begin(SDA_PIN, SCL_PIN);
|
||||
|
||||
Serial.println(mlx.begin() ? "MLX OK" : "MLX FAILED");
|
||||
|
||||
if (mpu.begin()) {
|
||||
Serial.println("MPU OK");
|
||||
setupMPU();
|
||||
calibrateMPU();
|
||||
} else {
|
||||
Serial.println("MPU FAILED");
|
||||
}
|
||||
|
||||
// GPS
|
||||
SerialGPS.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);
|
||||
Serial.println("GPS READY");
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// LOOP
|
||||
// ======================================================
|
||||
void loop() {
|
||||
|
||||
reconnectSIM800();
|
||||
|
||||
if (!simConnected) {
|
||||
reconnectWiFiIfNeeded();
|
||||
}
|
||||
|
||||
// Update data GPS terus-menerus (non-blocking)
|
||||
while (SerialGPS.available()) {
|
||||
gps.encode(SerialGPS.read());
|
||||
}
|
||||
|
||||
unsigned long currentMillis = millis();
|
||||
|
||||
if (currentMillis - lastSendMillis < SEND_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSendMillis = currentMillis;
|
||||
|
||||
SensorReading reading = readSensors();
|
||||
String payload = buildPayload(reading);
|
||||
|
||||
Serial.println();
|
||||
Serial.println(payload);
|
||||
|
||||
sendToBackend(payload);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Server
|
||||
PORT=3000
|
||||
|
||||
# Firebase
|
||||
FIREBASE_PROJECT_ID=monitoring-7cb63
|
||||
FIREBASE_CLIENT_EMAIL=your-service-account-email@.iam.gserviceaccount.com
|
||||
FIREBASE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----
|
||||
|
||||
# JWT (still kept for compatibility but Firebase auth is primary)
|
||||
JWT_SECRET=
|
||||
|
||||
# Fonte
|
||||
FONTE_TOKEN=
|
||||
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
|
||||
|
||||
IDLE_ALERT_THRESHOLD_HOURS :
|
||||
TEMP_ALERT_COOLDOWN_HOURS :
|
||||
IDLE_ALERT_COOLDOWN_HOURS :
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Firebase
|
||||
firebase-debug.log
|
||||
|
||||
# Temporary
|
||||
*.tmp
|
||||
*.temp
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## v0.1.0
|
||||
|
||||
### Added
|
||||
|
||||
* Project initialization
|
||||
* Firebase configuration
|
||||
* Authentication module
|
||||
* Cow management module
|
||||
* Smart Collar management module
|
||||
|
||||
## Upcoming
|
||||
|
||||
### v0.2.0
|
||||
|
||||
* Assignment module
|
||||
* Monitoring module
|
||||
* ESP32 integration
|
||||
|
||||
### v0.3.0
|
||||
|
||||
* WhatsApp notification
|
||||
* Alert system
|
||||
* Threshold configuration
|
||||
|
||||
### v1.0.0
|
||||
|
||||
* Production release
|
||||
* VPS deployment
|
||||
* Complete documentation
|
||||
* Stable REST API
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Contributing Guide
|
||||
|
||||
Thank you for your interest in contributing to Smart Collar Backend.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
* `main` → Production
|
||||
* `develop` → Integration branch
|
||||
* `feature/*` → New features
|
||||
* `hotfix/*` → Urgent fixes
|
||||
|
||||
## Commit Convention
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
feat: implement assignment module
|
||||
fix: resolve authentication issue
|
||||
refactor: simplify monitoring service
|
||||
docs: update README
|
||||
style: format source code
|
||||
test: add unit tests
|
||||
chore: update dependencies
|
||||
```
|
||||
|
||||
## Pull Request
|
||||
|
||||
* Create a feature branch from `develop`
|
||||
* Keep pull requests focused on a single feature
|
||||
* Ensure the application builds successfully
|
||||
* Update documentation when necessary
|
||||
|
||||
## Coding Standards
|
||||
|
||||
* Use ES Modules
|
||||
* Use async/await
|
||||
* Keep controllers thin
|
||||
* Business logic belongs in services
|
||||
* Database access belongs in repositories
|
||||
* Follow existing project structure
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
# Smart Collar Backend
|
||||
|
||||
Backend API untuk sistem **Smart Collar**, sebuah platform IoT yang digunakan untuk monitoring kesehatan dan aktivitas ternak secara real-time menggunakan perangkat Smart Collar berbasis ESP32.
|
||||
|
||||
## Features
|
||||
|
||||
* Firebase Authentication
|
||||
* Cow Management
|
||||
* Smart Collar Management
|
||||
* Assignment Management
|
||||
* Real-time Monitoring
|
||||
* WhatsApp Notification (Fonte)
|
||||
* REST API
|
||||
* Firebase Realtime Database
|
||||
|
||||
## Tech Stack
|
||||
|
||||
* Node.js
|
||||
* Express.js
|
||||
* Firebase Admin SDK
|
||||
* Firebase Realtime Database
|
||||
* Firebase Authentication
|
||||
* Zod
|
||||
* JWT
|
||||
* Fonte API
|
||||
* ES Modules
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src
|
||||
├── config
|
||||
├── core
|
||||
├── middlewares
|
||||
├── modules
|
||||
│ ├── auth
|
||||
│ ├── cow
|
||||
│ ├── collar
|
||||
│ ├── assignment
|
||||
│ ├── monitoring
|
||||
│ ├── notification
|
||||
│ └── settings
|
||||
├── routes
|
||||
├── shared
|
||||
├── app.js
|
||||
└── server.js
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Clone repository.
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
```
|
||||
|
||||
Install dependencies.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Copy environment file.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Run development server.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run production server.
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example`.
|
||||
|
||||
## API Version
|
||||
|
||||
Current Version:
|
||||
|
||||
```
|
||||
v1
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
* Create feature branch from `develop`
|
||||
* Implement feature
|
||||
* Test locally
|
||||
* Commit using Conventional Commits
|
||||
* Open Pull Request into `develop`
|
||||
* Merge into `main` only after testing
|
||||
|
||||
## Roadmap
|
||||
|
||||
* Authentication
|
||||
* Cow Management
|
||||
* Smart Collar Management
|
||||
* Assignment Module
|
||||
* Monitoring Module
|
||||
* WhatsApp Notification
|
||||
* Dashboard & Analytics
|
||||
* OTA Firmware Support
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "smartcollar-be",
|
||||
"version": "1.0.0",
|
||||
"description": "Smart Collar Backend API",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/server.js",
|
||||
"start": "node src/server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.18.1",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"firebase": "^12.16.0",
|
||||
"firebase-admin": "^14.1.0",
|
||||
"helmet": "^8.2.0",
|
||||
"morgan": "^1.11.0",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import morgan from "morgan";
|
||||
|
||||
import env from "./config/env.js";
|
||||
|
||||
import router from "./routes/index.js";
|
||||
|
||||
import notFoundMiddleware from "./middlewares/notFound.middleware.js";
|
||||
import errorMiddleware from "./middlewares/error.middleware.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Security
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
// CORS configuration for development (ports 3000 and 5500)
|
||||
app.use(cors({
|
||||
origin: function (origin, callback) {
|
||||
// Allow requests with no origin (like mobile apps or curl requests)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Allow localhost ports
|
||||
const allowedOrigins = [
|
||||
'http://localhost:3000', // Backend itself
|
||||
'http://localhost:5500', // Go Live frontend
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:5500',
|
||||
'http://localhost:8000'
|
||||
];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
console.log('✅ CORS allowed for origin:', origin);
|
||||
return callback(null, true);
|
||||
} else {
|
||||
console.log('❌ CORS blocked for origin:', origin);
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Body Parser
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use(
|
||||
express.urlencoded({
|
||||
extended: true,
|
||||
})
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Logger
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
app.use(morgan("dev"));
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
app.use(env.app.apiPrefix, router);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 404
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
app.use(notFoundMiddleware);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Error
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
app.use(errorMiddleware);
|
||||
|
||||
export default app;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const env = {
|
||||
app: {
|
||||
name: process.env.APP_NAME || "Smart Collar API",
|
||||
env: process.env.NODE_ENV || "development",
|
||||
port: Number(process.env.PORT) || 3000,
|
||||
apiPrefix: process.env.API_PREFIX || "/api/v1",
|
||||
},
|
||||
|
||||
firebase: {
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
|
||||
databaseURL: process.env.FIREBASE_DATABASE_URL,
|
||||
},
|
||||
|
||||
fonnte: {
|
||||
token: process.env.FONNTE_TOKEN,
|
||||
},
|
||||
|
||||
monitoring: {
|
||||
temperatureMin: Number(process.env.DEFAULT_TEMPERATURE_MIN) || 37,
|
||||
temperatureMax: Number(process.env.DEFAULT_TEMPERATURE_MAX) || 39.5,
|
||||
batteryLow: Number(process.env.DEFAULT_BATTERY_LOW) || 20,
|
||||
offlineMinute: Number(process.env.DEVICE_TIMEOUT) || 5,
|
||||
},
|
||||
};
|
||||
|
||||
export default env;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { initializeApp, cert, getApps } from "firebase-admin/app";
|
||||
import { getDatabase } from "firebase-admin/database";
|
||||
import { getAuth } from "firebase-admin/auth";
|
||||
|
||||
import env from "./env.js";
|
||||
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({
|
||||
credential: cert({
|
||||
projectId: env.firebase.projectId,
|
||||
clientEmail: env.firebase.clientEmail,
|
||||
privateKey: env.firebase.privateKey,
|
||||
}),
|
||||
databaseURL: env.firebase.databaseURL,
|
||||
});
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const auth = getAuth();
|
||||
|
||||
export { db, auth };
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import axios from "axios";
|
||||
import env from "./env.js";
|
||||
|
||||
const fonnte = axios.create({
|
||||
baseURL: "https://api.fonnte.com",
|
||||
headers: {
|
||||
Authorization: env.fonnte.token,
|
||||
},
|
||||
});
|
||||
|
||||
export default fonnte;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export const ROLE = {
|
||||
ADMIN: "admin",
|
||||
FARMER: "farmer",
|
||||
};
|
||||
|
||||
export const DEVICE_STATUS = {
|
||||
ONLINE: "online",
|
||||
OFFLINE: "offline",
|
||||
};
|
||||
|
||||
export const ASSIGNMENT_STATUS = {
|
||||
ACTIVE: "active",
|
||||
INACTIVE: "inactive",
|
||||
};
|
||||
|
||||
export const MOVEMENT = {
|
||||
IDLE: "idle",
|
||||
WALKING: "walking",
|
||||
RUNNING: "running",
|
||||
UNKNOWN: "unknown",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_TYPE = {
|
||||
TEMPERATURE: "temperature",
|
||||
BATTERY: "battery",
|
||||
OFFLINE: "offline",
|
||||
MOVEMENT: "movement",
|
||||
};
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
class AppError extends Error {
|
||||
constructor(message, statusCode = 500, errors = null) {
|
||||
super(message);
|
||||
|
||||
this.name = "AppError";
|
||||
|
||||
this.statusCode = statusCode;
|
||||
|
||||
this.errors = errors;
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export default AppError;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
const getCurrentTime = () => {
|
||||
return new Date().toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
});
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info(message, data = null) {
|
||||
console.log(
|
||||
`[INFO] ${getCurrentTime()} - ${message}`,
|
||||
data ?? ""
|
||||
);
|
||||
},
|
||||
|
||||
warn(message, data = null) {
|
||||
console.warn(
|
||||
`[WARN] ${getCurrentTime()} - ${message}`,
|
||||
data ?? ""
|
||||
);
|
||||
},
|
||||
|
||||
error(message, error = null) {
|
||||
console.error(
|
||||
`[ERROR] ${getCurrentTime()} - ${message}`,
|
||||
error ?? ""
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default logger;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
export const successResponse = (
|
||||
res,
|
||||
data = null,
|
||||
message = "Success",
|
||||
statusCode = 200
|
||||
) => {
|
||||
return res.status(statusCode).json({
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const errorResponse = (
|
||||
res,
|
||||
message = "Internal Server Error",
|
||||
statusCode = 500,
|
||||
errors = null
|
||||
) => {
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
message,
|
||||
errors,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import AppError from "../exceptions/AppError.js";
|
||||
|
||||
export const validate = (schema, data) => {
|
||||
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
throw new AppError(
|
||||
"Validation Error",
|
||||
400,
|
||||
result.error.issues
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { auth } from "../config/firebase.js";
|
||||
import AppError from "../core/exceptions/AppError.js";
|
||||
|
||||
const authMiddleware = async (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
try {
|
||||
const authorization =
|
||||
req.headers.authorization;
|
||||
|
||||
if (!authorization) {
|
||||
throw new AppError(
|
||||
"Unauthorized",
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const token =
|
||||
authorization.replace(
|
||||
"Bearer ",
|
||||
""
|
||||
);
|
||||
|
||||
console.log('[AUTH] Token received:', token ? token.substring(0, 20) + '...' : 'NONE');
|
||||
|
||||
// Allow debug token for testing
|
||||
if (token === 'debug-token-for-testing') {
|
||||
console.log('[AUTH] Debug token accepted for testing');
|
||||
req.user = {
|
||||
uid: 'debug-user',
|
||||
ownerId: 'lRAPbzLWIHMhD70rfP0DBWE79eo1'
|
||||
};
|
||||
} else {
|
||||
// Verify the Firebase ID token
|
||||
const decoded = await auth.verifyIdToken(token).catch((error) => {
|
||||
console.error('[AUTH] Firebase token verification failed:', error);
|
||||
throw new AppError(
|
||||
"Invalid authentication token: " + error.message,
|
||||
401
|
||||
);
|
||||
});
|
||||
|
||||
req.user = decoded;
|
||||
req.user.ownerId = decoded.ownerId ?? decoded.uid;
|
||||
console.log('[AUTH] Token verified successfully for uid:', decoded.uid, 'ownerId:', req.user.ownerId);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('[AUTH] Token verification failed:', error.message);
|
||||
next(
|
||||
new AppError(
|
||||
"Invalid authentication token",
|
||||
401
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default authMiddleware;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import AppError from "../core/exceptions/AppError.js";
|
||||
import logger from "../core/logger/logger.js";
|
||||
import { errorResponse } from "../core/response/response.js";
|
||||
|
||||
const errorMiddleware = (err, req, res, next) => {
|
||||
logger.error(err.message, err);
|
||||
|
||||
if (err instanceof AppError) {
|
||||
return errorResponse(
|
||||
res,
|
||||
err.message,
|
||||
err.statusCode,
|
||||
err.errors
|
||||
);
|
||||
}
|
||||
|
||||
return errorResponse(
|
||||
res,
|
||||
"Internal Server Error",
|
||||
500
|
||||
);
|
||||
};
|
||||
|
||||
export default errorMiddleware;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { errorResponse } from "../core/response/response.js";
|
||||
|
||||
const notFoundMiddleware = (req, res) => {
|
||||
return errorResponse(
|
||||
res,
|
||||
`Route ${req.originalUrl} not found`,
|
||||
404
|
||||
);
|
||||
};
|
||||
|
||||
export default notFoundMiddleware;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { validate } from "../core/validator/validator.js";
|
||||
|
||||
const validationMiddleware = (schema) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
req.body = validate(schema, req.body);
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default validationMiddleware;
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { successResponse } from "../../core/response/response.js";
|
||||
|
||||
import * as assignmentService from "./assignment.service.js";
|
||||
|
||||
/**
|
||||
* GET /assignments
|
||||
*/
|
||||
export const getAll = async (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
|
||||
try {
|
||||
|
||||
const data =
|
||||
await assignmentService.getAll(
|
||||
req.user.uid
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
data,
|
||||
"Assignment retrieved successfully"
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
next(error);
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /assignments/attach
|
||||
*/
|
||||
export const attach = async (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
|
||||
try {
|
||||
|
||||
const data =
|
||||
await assignmentService.attach(
|
||||
req.user.uid,
|
||||
req.body
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
data,
|
||||
"Smart Collar attached successfully",
|
||||
201
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
next(error);
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /assignments/detach
|
||||
*/
|
||||
export const detach = async (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
|
||||
try {
|
||||
|
||||
const data =
|
||||
await assignmentService.detach(
|
||||
req.user.uid,
|
||||
req.body.cowId
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
data,
|
||||
"Smart Collar detached successfully"
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
next(error);
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { db } from "../../config/firebase.js";
|
||||
|
||||
import {
|
||||
buildOwnerCow,
|
||||
buildOwnerCollar,
|
||||
} from "../../shared/utils/owner-key.js";
|
||||
|
||||
const COLLECTION = "assignments";
|
||||
|
||||
/**
|
||||
* Semua assignment
|
||||
*/
|
||||
export const findAll = async (
|
||||
ownerId
|
||||
) => {
|
||||
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("ownerId")
|
||||
.equalTo(ownerId)
|
||||
.once("value");
|
||||
|
||||
const data = snapshot.val();
|
||||
|
||||
return data
|
||||
? Object.values(data)
|
||||
: [];
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Detail assignment
|
||||
*/
|
||||
export const findById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const snapshot = await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.once("value");
|
||||
|
||||
if (!snapshot.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignment =
|
||||
snapshot.val();
|
||||
|
||||
if (
|
||||
assignment.ownerId !== ownerId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return assignment;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Assignment aktif berdasarkan sapi
|
||||
*/
|
||||
export const findActiveByCow = async (
|
||||
ownerId,
|
||||
cowId
|
||||
) => {
|
||||
|
||||
const ownerCow =
|
||||
buildOwnerCow(
|
||||
ownerId,
|
||||
cowId
|
||||
);
|
||||
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("ownerCow")
|
||||
.equalTo(ownerCow)
|
||||
.once("value");
|
||||
|
||||
const data =
|
||||
snapshot.val();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignment =
|
||||
Object.values(data)
|
||||
.find(
|
||||
(item) =>
|
||||
item.active
|
||||
);
|
||||
|
||||
return assignment || null;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Assignment aktif berdasarkan collar
|
||||
*/
|
||||
export const findActiveByCollar = async (
|
||||
ownerId,
|
||||
collarId
|
||||
) => {
|
||||
|
||||
const ownerCollar =
|
||||
buildOwnerCollar(
|
||||
ownerId,
|
||||
collarId
|
||||
);
|
||||
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("ownerCollar")
|
||||
.equalTo(ownerCollar)
|
||||
.once("value");
|
||||
|
||||
const data =
|
||||
snapshot.val();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignment =
|
||||
Object.values(data)
|
||||
.find(
|
||||
(item) =>
|
||||
item.active
|
||||
);
|
||||
|
||||
return assignment || null;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Simpan
|
||||
*/
|
||||
export const create = async (
|
||||
data
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${data.id}`)
|
||||
.set(data);
|
||||
|
||||
return data;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Update
|
||||
*/
|
||||
export const update = async (
|
||||
ownerId,
|
||||
id,
|
||||
data
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.update(data);
|
||||
|
||||
return await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Router } from "express";
|
||||
|
||||
import authMiddleware from "../../middlewares/auth.middleware.js";
|
||||
|
||||
import validationMiddleware from "../../middlewares/validation.middleware.js";
|
||||
|
||||
import {
|
||||
attachSchema,
|
||||
detachSchema
|
||||
} from "./assignment.schema.js";
|
||||
|
||||
import * as controller from "./assignment.controller.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
controller.getAll
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/attach",
|
||||
validationMiddleware(
|
||||
attachSchema
|
||||
),
|
||||
controller.attach
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/detach",
|
||||
validationMiddleware(
|
||||
detachSchema
|
||||
),
|
||||
controller.detach
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const attachSchema = z.object({
|
||||
|
||||
cowId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1),
|
||||
|
||||
collarId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
|
||||
});
|
||||
|
||||
export const detachSchema = z.object({
|
||||
|
||||
cowId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import AppError from "../../core/exceptions/AppError.js";
|
||||
|
||||
import { generateId } from "../../shared/id/generateId.js";
|
||||
|
||||
import { now } from "../../shared/utils/timestamp.js";
|
||||
|
||||
import {
|
||||
buildOwnerCow,
|
||||
buildOwnerCollar,
|
||||
} from "../../shared/utils/owner-key.js";
|
||||
|
||||
import * as repository from "./assignment.repository.js";
|
||||
|
||||
import * as cowRepository from "../cow/cow.repository.js";
|
||||
|
||||
import * as collarRepository from "../collar/collar.repository.js";
|
||||
|
||||
/**
|
||||
* Semua assignment
|
||||
*/
|
||||
export const getAll = async (
|
||||
ownerId
|
||||
) => {
|
||||
|
||||
return await repository.findAll(
|
||||
ownerId
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Pasang Smart Collar
|
||||
*/
|
||||
export const attach = async (
|
||||
ownerId,
|
||||
body
|
||||
) => {
|
||||
|
||||
// ==========================
|
||||
// VALIDASI SAPI
|
||||
// ==========================
|
||||
|
||||
const cow =
|
||||
await cowRepository.findById(
|
||||
ownerId,
|
||||
body.cowId
|
||||
);
|
||||
|
||||
if (!cow) {
|
||||
|
||||
throw new AppError(
|
||||
"Cow not found",
|
||||
404
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// VALIDASI COLLAR
|
||||
// ==========================
|
||||
|
||||
const collar =
|
||||
await collarRepository.findById(
|
||||
ownerId,
|
||||
body.collarId
|
||||
);
|
||||
|
||||
if (!collar) {
|
||||
|
||||
throw new AppError(
|
||||
"Smart Collar not found",
|
||||
404
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// CEK SAPI
|
||||
// ==========================
|
||||
|
||||
const cowAssignment =
|
||||
await repository.findActiveByCow(
|
||||
ownerId,
|
||||
body.cowId
|
||||
);
|
||||
|
||||
if (cowAssignment) {
|
||||
|
||||
throw new AppError(
|
||||
"Cow already has Smart Collar",
|
||||
400
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// CEK COLLAR
|
||||
// ==========================
|
||||
|
||||
const collarAssignment =
|
||||
await repository.findActiveByCollar(
|
||||
ownerId,
|
||||
body.collarId
|
||||
);
|
||||
|
||||
if (collarAssignment) {
|
||||
|
||||
throw new AppError(
|
||||
"Smart Collar already assigned",
|
||||
400
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
now();
|
||||
|
||||
const assignment = {
|
||||
|
||||
id:
|
||||
generateId(
|
||||
"assignment"
|
||||
),
|
||||
|
||||
ownerId,
|
||||
|
||||
ownerCow:
|
||||
buildOwnerCow(
|
||||
ownerId,
|
||||
cow.id
|
||||
),
|
||||
|
||||
ownerCollar:
|
||||
buildOwnerCollar(
|
||||
ownerId,
|
||||
collar.id
|
||||
),
|
||||
|
||||
cowId:
|
||||
cow.id,
|
||||
|
||||
cowCode:
|
||||
cow.code,
|
||||
|
||||
cowName:
|
||||
cow.name,
|
||||
|
||||
collarId:
|
||||
collar.id,
|
||||
|
||||
serialNumber:
|
||||
collar.serialNumber,
|
||||
|
||||
deviceName:
|
||||
collar.deviceName,
|
||||
|
||||
active:true,
|
||||
|
||||
assignedAt:
|
||||
timestamp,
|
||||
|
||||
unassignedAt:null,
|
||||
|
||||
createdAt:
|
||||
timestamp,
|
||||
|
||||
updatedAt:
|
||||
timestamp
|
||||
|
||||
};
|
||||
|
||||
await repository.create(
|
||||
assignment
|
||||
);
|
||||
|
||||
await collarRepository.update(
|
||||
|
||||
ownerId,
|
||||
|
||||
collar.id,
|
||||
|
||||
{
|
||||
|
||||
assigned:true,
|
||||
|
||||
status:"assigned",
|
||||
|
||||
currentCowId:
|
||||
cow.id,
|
||||
|
||||
currentCowCode:
|
||||
cow.code,
|
||||
|
||||
currentCowName:
|
||||
cow.name,
|
||||
|
||||
currentAssignmentId:
|
||||
assignment.id,
|
||||
|
||||
updatedAt:timestamp
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
return assignment;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Lepas Smart Collar
|
||||
*/
|
||||
export const detach = async (
|
||||
ownerId,
|
||||
cowId
|
||||
) => {
|
||||
|
||||
const assignment =
|
||||
await repository.findActiveByCow(
|
||||
ownerId,
|
||||
cowId
|
||||
);
|
||||
|
||||
if (!assignment) {
|
||||
|
||||
throw new AppError(
|
||||
"Assignment not found",
|
||||
404
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
now();
|
||||
|
||||
await repository.update(
|
||||
|
||||
ownerId,
|
||||
|
||||
assignment.id,
|
||||
|
||||
{
|
||||
|
||||
active:false,
|
||||
|
||||
unassignedAt:
|
||||
timestamp,
|
||||
|
||||
updatedAt:
|
||||
timestamp
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
await collarRepository.update(
|
||||
|
||||
ownerId,
|
||||
|
||||
assignment.collarId,
|
||||
|
||||
{
|
||||
|
||||
assigned:false,
|
||||
|
||||
status:"available",
|
||||
|
||||
currentCowId:null,
|
||||
|
||||
currentCowCode:null,
|
||||
|
||||
currentCowName:null,
|
||||
|
||||
currentAssignmentId:null,
|
||||
|
||||
updatedAt:timestamp
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
message:
|
||||
"Smart Collar detached successfully"
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { successResponse } from "../../core/response/response.js";
|
||||
|
||||
import {
|
||||
syncUser,
|
||||
getProfile,
|
||||
} from "./auth.service.js";
|
||||
|
||||
export const sync = async (req, res, next) => {
|
||||
try {
|
||||
const user = await syncUser(req.user, req.body);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
user,
|
||||
"User synchronized successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const me = async (req, res, next) => {
|
||||
try {
|
||||
const user = await getProfile(req.user.uid);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
user,
|
||||
"Profile fetched successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { db } from "../../config/firebase.js";
|
||||
|
||||
const USER_COLLECTION = "users";
|
||||
|
||||
export const findUserById = async (uid) => {
|
||||
const snapshot = await db
|
||||
.ref(`${USER_COLLECTION}/${uid}`)
|
||||
.once("value");
|
||||
|
||||
return snapshot.val();
|
||||
};
|
||||
|
||||
export const createUser = async (uid, data) => {
|
||||
await db.ref(`${USER_COLLECTION}/${uid}`).set(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateUser = async (uid, data) => {
|
||||
await db.ref(`${USER_COLLECTION}/${uid}`).update(data);
|
||||
|
||||
const snapshot = await db
|
||||
.ref(`${USER_COLLECTION}/${uid}`)
|
||||
.once("value");
|
||||
|
||||
return snapshot.val();
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Router } from "express";
|
||||
|
||||
import authMiddleware from "../../middlewares/auth.middleware.js";
|
||||
import validationMiddleware from "../../middlewares/validation.middleware.js";
|
||||
|
||||
import { syncUserSchema } from "./auth.schema.js";
|
||||
|
||||
import {
|
||||
sync,
|
||||
me,
|
||||
} from "./auth.controller.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
"/sync",
|
||||
authMiddleware,
|
||||
validationMiddleware(syncUserSchema),
|
||||
sync
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/me",
|
||||
authMiddleware,
|
||||
me
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const syncUserSchema = z.object({
|
||||
name: z.string().min(3),
|
||||
phone: z.string().min(10),
|
||||
address: z.string().min(3),
|
||||
photoUrl: z.string().optional().default(""),
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import AppError from "../../core/exceptions/AppError.js";
|
||||
|
||||
import {
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUser,
|
||||
} from "./auth.repository.js";
|
||||
|
||||
export const syncUser = async (firebaseUser, body) => {
|
||||
const now = Date.now();
|
||||
|
||||
const existingUser = await findUserById(firebaseUser.uid);
|
||||
|
||||
if (!existingUser) {
|
||||
const user = {
|
||||
uid: firebaseUser.uid,
|
||||
email: firebaseUser.email,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
address: body.address,
|
||||
photoUrl: body.photoUrl || "",
|
||||
role: "farmer",
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return await createUser(firebaseUser.uid, user);
|
||||
}
|
||||
|
||||
return await updateUser(firebaseUser.uid, {
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
address: body.address,
|
||||
photoUrl: body.photoUrl || "",
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
|
||||
export const getProfile = async (uid) => {
|
||||
const user = await findUserById(uid);
|
||||
|
||||
if (!user) {
|
||||
throw new AppError("User not found", 404);
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { successResponse } from "../../core/response/response.js";
|
||||
|
||||
import * as collarService from "./collar.service.js";
|
||||
|
||||
/**
|
||||
* GET /collars
|
||||
*/
|
||||
export const getAll = async (req, res, next) => {
|
||||
try {
|
||||
const collars = await collarService.getAll(
|
||||
req.user.uid
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
collars,
|
||||
"Smart Collar list retrieved successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /collars/:id
|
||||
*/
|
||||
export const getById = async (req, res, next) => {
|
||||
try {
|
||||
const collar = await collarService.getById(
|
||||
req.user.uid,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
collar,
|
||||
"Smart Collar retrieved successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /collars
|
||||
*/
|
||||
export const create = async (req, res, next) => {
|
||||
try {
|
||||
const collar = await collarService.create(
|
||||
req.user.uid,
|
||||
req.body
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
collar,
|
||||
"Smart Collar created successfully",
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /collars/:id
|
||||
*/
|
||||
export const update = async (req, res, next) => {
|
||||
try {
|
||||
const collar = await collarService.update(
|
||||
req.user.uid,
|
||||
req.params.id,
|
||||
req.body
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
collar,
|
||||
"Smart Collar updated successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /collars/:id
|
||||
*/
|
||||
export const deleteById = async (req, res, next) => {
|
||||
try {
|
||||
await collarService.deleteById(
|
||||
req.user.uid,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
null,
|
||||
"Smart Collar deleted successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
import { db } from "../../config/firebase.js";
|
||||
|
||||
import {
|
||||
buildOwnerSerial,
|
||||
buildSerialSecret,
|
||||
} from "../../shared/utils/owner-key.js";
|
||||
|
||||
const COLLECTION = "collars";
|
||||
|
||||
/**
|
||||
* Ambil semua Smart Collar milik owner
|
||||
*/
|
||||
export const findAll = async (ownerId) => {
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("ownerId")
|
||||
.equalTo(ownerId)
|
||||
.once("value");
|
||||
|
||||
const data = snapshot.val();
|
||||
|
||||
return data ? Object.values(data) : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cari Smart Collar berdasarkan ID
|
||||
*/
|
||||
export const findById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const snapshot = await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.once("value");
|
||||
|
||||
if (!snapshot.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collar = snapshot.val();
|
||||
|
||||
if (collar.ownerId !== ownerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cari berdasarkan serial number
|
||||
*/
|
||||
export const findBySerial = async (
|
||||
ownerId,
|
||||
serialNumber
|
||||
) => {
|
||||
|
||||
const ownerSerial =
|
||||
buildOwnerSerial(
|
||||
ownerId,
|
||||
serialNumber
|
||||
);
|
||||
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("ownerSerial")
|
||||
.equalTo(ownerSerial)
|
||||
.once("value");
|
||||
|
||||
const data = snapshot.val();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.values(data)[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Digunakan ESP32
|
||||
*/
|
||||
export const findByDevice = async (
|
||||
serialNumber,
|
||||
deviceSecret
|
||||
) => {
|
||||
|
||||
const serialSecret =
|
||||
buildSerialSecret(
|
||||
serialNumber,
|
||||
deviceSecret
|
||||
);
|
||||
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("serialSecret")
|
||||
.equalTo(serialSecret)
|
||||
.once("value");
|
||||
|
||||
const data = snapshot.val();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.values(data)[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cek apakah Smart Collar ada
|
||||
*/
|
||||
export const exists = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const collar =
|
||||
await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
return collar !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cek apakah sedang digunakan
|
||||
*/
|
||||
export const isAssigned = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const collar =
|
||||
await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
if (!collar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return collar.assigned;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tambah Smart Collar
|
||||
*/
|
||||
export const create = async (
|
||||
data
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${data.id}`)
|
||||
.set(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update data Smart Collar
|
||||
*/
|
||||
export const update = async (
|
||||
ownerId,
|
||||
id,
|
||||
data
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.update(data);
|
||||
|
||||
return await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update status device
|
||||
* Dipakai Monitoring
|
||||
*/
|
||||
export const updateStatus = async (
|
||||
ownerId,
|
||||
id,
|
||||
status
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.update(status);
|
||||
|
||||
return await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update heartbeat device
|
||||
* Dipanggil setiap ESP32 mengirim data
|
||||
*/
|
||||
export const touchOnline = async (
|
||||
ownerId,
|
||||
id,
|
||||
payload
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.update({
|
||||
|
||||
signal:
|
||||
payload.signal,
|
||||
|
||||
firmwareVersion:
|
||||
payload.firmwareVersion,
|
||||
|
||||
hardwareVersion:
|
||||
payload.hardwareVersion,
|
||||
|
||||
online: true,
|
||||
|
||||
lastOnline:
|
||||
payload.lastOnline,
|
||||
|
||||
lastSync:
|
||||
payload.lastSync,
|
||||
|
||||
updatedAt:
|
||||
payload.lastSync,
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Set offline
|
||||
* Dipakai scheduler
|
||||
*/
|
||||
export const setOffline = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.update({
|
||||
|
||||
online: false,
|
||||
|
||||
updatedAt:
|
||||
Date.now()
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Hapus Smart Collar
|
||||
*/
|
||||
export const deleteById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const exists =
|
||||
await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.remove();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Router } from "express";
|
||||
|
||||
import authMiddleware from "../../middlewares/auth.middleware.js";
|
||||
import validationMiddleware from "../../middlewares/validation.middleware.js";
|
||||
|
||||
import {
|
||||
createCollarSchema,
|
||||
updateCollarSchema,
|
||||
} from "./collar.schema.js";
|
||||
|
||||
import * as controller from "./collar.controller.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get("/", controller.getAll);
|
||||
|
||||
router.get("/:id", controller.getById);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
validationMiddleware(createCollarSchema),
|
||||
controller.create
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/:id",
|
||||
validationMiddleware(updateCollarSchema),
|
||||
controller.update
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
controller.deleteById
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const createCollarSchema = z.object({
|
||||
serialNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.transform((v) => v.toUpperCase()),
|
||||
|
||||
deviceName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(100),
|
||||
|
||||
hardwareVersion: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1),
|
||||
|
||||
firmwareVersion: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1),
|
||||
|
||||
macAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1),
|
||||
|
||||
simNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default("")
|
||||
});
|
||||
|
||||
export const updateCollarSchema =
|
||||
createCollarSchema.partial();
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import AppError from "../../core/exceptions/AppError.js";
|
||||
|
||||
import { generateId } from "../../shared/id/generateId.js";
|
||||
|
||||
import { generateDeviceSecret } from "../../shared/utils/device.js";
|
||||
|
||||
import {
|
||||
buildOwnerSerial,
|
||||
buildSerialSecret,
|
||||
} from "../../shared/utils/owner-key.js";
|
||||
|
||||
import { now } from "../../shared/utils/timestamp.js";
|
||||
|
||||
import { COLLAR_STATUS as collarStatus } from "../../shared/constants/collar-status.js";
|
||||
|
||||
import * as repository from "./collar.repository.js";
|
||||
|
||||
/**
|
||||
* Ambil semua Smart Collar
|
||||
*/
|
||||
export const getAll = async (ownerId) => {
|
||||
return await repository.findAll(ownerId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detail Smart Collar
|
||||
*/
|
||||
export const getById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
const collar = await repository.findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
if (!collar) {
|
||||
throw new AppError(
|
||||
"Smart Collar not found",
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
return collar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tambah Smart Collar
|
||||
*/
|
||||
export const create = async (
|
||||
ownerId,
|
||||
body
|
||||
) => {
|
||||
const duplicate =
|
||||
await repository.findBySerial(
|
||||
ownerId,
|
||||
body.serialNumber
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
throw new AppError(
|
||||
"Serial number already exists",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const serial =
|
||||
body.serialNumber.toUpperCase();
|
||||
|
||||
const secret =
|
||||
generateDeviceSecret();
|
||||
|
||||
|
||||
|
||||
const timestamp = now();
|
||||
|
||||
const collar = {
|
||||
|
||||
id: generateId("collar"),
|
||||
|
||||
ownerId,
|
||||
|
||||
ownerSerial: buildOwnerSerial(
|
||||
ownerId,
|
||||
serial
|
||||
),
|
||||
|
||||
serialNumber: serial,
|
||||
|
||||
deviceSecret: secret,
|
||||
|
||||
serialSecret: buildSerialSecret(
|
||||
serial,
|
||||
secret
|
||||
),
|
||||
|
||||
deviceName:
|
||||
body.deviceName?.trim(),
|
||||
|
||||
hardwareVersion:
|
||||
body.hardwareVersion ?? null,
|
||||
|
||||
firmwareVersion:
|
||||
body.firmwareVersion ?? null,
|
||||
|
||||
macAddress:
|
||||
body.macAddress?.toUpperCase() ?? null,
|
||||
|
||||
simNumber:
|
||||
body.simNumber ?? null,
|
||||
|
||||
signal: null,
|
||||
|
||||
online: false,
|
||||
|
||||
assigned: false,
|
||||
|
||||
currentCowId: null,
|
||||
|
||||
currentCowCode: null,
|
||||
|
||||
currentCowName: null,
|
||||
|
||||
currentAssignmentId: null,
|
||||
|
||||
status: collarStatus.AVAILABLE,
|
||||
|
||||
lastOnline: null,
|
||||
|
||||
lastSync: null,
|
||||
|
||||
createdAt: timestamp,
|
||||
|
||||
updatedAt: timestamp,
|
||||
|
||||
};
|
||||
|
||||
return await repository.create(
|
||||
collar
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Smart Collar
|
||||
*/
|
||||
export const update = async (
|
||||
ownerId,
|
||||
id,
|
||||
body
|
||||
) => {
|
||||
const collar =
|
||||
await getById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
if (body.serialNumber) {
|
||||
const duplicate =
|
||||
await repository.findBySerial(
|
||||
ownerId,
|
||||
body.serialNumber
|
||||
);
|
||||
|
||||
if (
|
||||
duplicate &&
|
||||
duplicate.id !== id
|
||||
) {
|
||||
throw new AppError(
|
||||
"Serial number already exists",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
body.serialNumber =
|
||||
body.serialNumber.toUpperCase();
|
||||
|
||||
body.ownerSerial =
|
||||
buildOwnerSerial(
|
||||
ownerId,
|
||||
body.serialNumber
|
||||
);
|
||||
|
||||
body.serialSecret =
|
||||
buildSerialSecret(
|
||||
body.serialNumber,
|
||||
collar.deviceSecret
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
...body,
|
||||
updatedAt: now(),
|
||||
};
|
||||
|
||||
return await repository.update(
|
||||
ownerId,
|
||||
id,
|
||||
updateData
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hapus Smart Collar
|
||||
*/
|
||||
export const deleteById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
const collar =
|
||||
await getById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
await repository.deleteById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
return collar;
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { successResponse } from "../../core/response/response.js";
|
||||
import * as cowService from "./cow.service.js";
|
||||
|
||||
export const getAll = async (req, res, next) => {
|
||||
try {
|
||||
const cows = await cowService.getAll(req.user.uid);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
cows,
|
||||
"Cow list retrieved successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getById = async (req, res, next) => {
|
||||
try {
|
||||
const cow = await cowService.getById(
|
||||
req.user.uid,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
cow,
|
||||
"Cow retrieved successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const create = async (req, res, next) => {
|
||||
try {
|
||||
const cow = await cowService.create(
|
||||
req.user.uid,
|
||||
req.body
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
cow,
|
||||
"Cow created successfully",
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const update = async (req, res, next) => {
|
||||
try {
|
||||
const cow = await cowService.update(
|
||||
req.user.uid,
|
||||
req.params.id,
|
||||
req.body
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
cow,
|
||||
"Cow updated successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (req, res, next) => {
|
||||
try {
|
||||
await cowService.deleteById(
|
||||
req.user.uid,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
return successResponse(
|
||||
res,
|
||||
null,
|
||||
"Cow deleted successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { db } from "../../config/firebase.js";
|
||||
|
||||
const COLLECTION = "cows";
|
||||
|
||||
/**
|
||||
* Ambil seluruh sapi milik owner
|
||||
*/
|
||||
export const findAll = async (ownerId) => {
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("ownerId")
|
||||
.equalTo(ownerId)
|
||||
.once("value");
|
||||
|
||||
const data = snapshot.val();
|
||||
|
||||
return data ? Object.values(data) : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cari sapi berdasarkan owner + id
|
||||
*/
|
||||
export const findById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const snapshot = await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.once("value");
|
||||
|
||||
if (!snapshot.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cow = snapshot.val();
|
||||
|
||||
if (cow.ownerId !== ownerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cow;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cari sapi berdasarkan owner + code
|
||||
*/
|
||||
export const findByOwnerCode = async (
|
||||
ownerId,
|
||||
code
|
||||
) => {
|
||||
|
||||
const ownerCode =
|
||||
`${ownerId}_${code.toUpperCase()}`;
|
||||
|
||||
const snapshot = await db
|
||||
.ref(COLLECTION)
|
||||
.orderByChild("ownerCode")
|
||||
.equalTo(ownerCode)
|
||||
.once("value");
|
||||
|
||||
const data = snapshot.val();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.values(data)[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cek apakah sapi ada
|
||||
*/
|
||||
export const exists = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const cow = await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
return cow !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tambah sapi
|
||||
*/
|
||||
export const create = async (
|
||||
data
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${data.id}`)
|
||||
.set(data);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update sapi
|
||||
*/
|
||||
export const update = async (
|
||||
ownerId,
|
||||
id,
|
||||
data
|
||||
) => {
|
||||
|
||||
console.log("================================");
|
||||
console.log("UPDATE DATA");
|
||||
console.log("================================");
|
||||
console.log(data);
|
||||
console.log(Array.isArray(data));
|
||||
|
||||
const cow = await findById(ownerId, id);
|
||||
|
||||
if (!cow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.update(data);
|
||||
|
||||
return await findById(ownerId, id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hapus sapi
|
||||
*/
|
||||
export const deleteById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
|
||||
const cow =
|
||||
await findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
if (!cow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db
|
||||
.ref(`${COLLECTION}/${id}`)
|
||||
.remove();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Router } from "express";
|
||||
|
||||
import authMiddleware from "../../middlewares/auth.middleware.js";
|
||||
import validationMiddleware from "../../middlewares/validation.middleware.js";
|
||||
|
||||
import {
|
||||
createCowSchema,
|
||||
updateCowSchema,
|
||||
} from "./cow.schema.js";
|
||||
|
||||
import * as controller from "./cow.controller.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get("/", controller.getAll);
|
||||
|
||||
router.get("/:id", controller.getById);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
validationMiddleware(createCowSchema),
|
||||
controller.create
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/:id",
|
||||
validationMiddleware(updateCowSchema),
|
||||
controller.update
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
controller.remove
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const createCowSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Code minimal 2 karakter")
|
||||
.max(20),
|
||||
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Nama minimal 2 karakter")
|
||||
.max(100),
|
||||
|
||||
breed: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Breed wajib diisi"),
|
||||
|
||||
gender: z.enum(["male", "female"]),
|
||||
|
||||
birthDate: z.string(),
|
||||
|
||||
weight: z
|
||||
.number()
|
||||
.positive(),
|
||||
|
||||
color: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default(""),
|
||||
|
||||
photoUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.default(""),
|
||||
|
||||
note: z
|
||||
.string()
|
||||
.optional()
|
||||
.default(""),
|
||||
|
||||
status: z.enum([
|
||||
"healthy",
|
||||
"sick",
|
||||
"pregnant"
|
||||
])
|
||||
});
|
||||
|
||||
export const updateCowSchema = createCowSchema.partial();
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import AppError from "../../core/exceptions/AppError.js";
|
||||
|
||||
import { generateId } from "../../shared/id/generateId.js";
|
||||
|
||||
import {
|
||||
buildOwnerCode,
|
||||
} from "../../shared/utils/owner-key.js";
|
||||
|
||||
import {
|
||||
now,
|
||||
} from "../../shared/utils/timestamp.js";
|
||||
|
||||
import * as repository from "./cow.repository.js";
|
||||
|
||||
/**
|
||||
* Ambil semua sapi milik user
|
||||
*/
|
||||
export const getAll = async (ownerId) => {
|
||||
return await repository.findAll(ownerId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detail sapi
|
||||
*/
|
||||
export const getById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
const cow = await repository.findById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
if (!cow) {
|
||||
throw new AppError(
|
||||
"Cow not found",
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
return cow;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tambah sapi
|
||||
*/
|
||||
export const create = async (
|
||||
ownerId,
|
||||
body
|
||||
) => {
|
||||
const duplicate =
|
||||
await repository.findByOwnerCode(
|
||||
ownerId,
|
||||
body.code
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
throw new AppError(
|
||||
"Cow code already exists",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = now();
|
||||
|
||||
const cow = {
|
||||
id: generateId("cow"),
|
||||
|
||||
ownerId,
|
||||
|
||||
ownerCode: buildOwnerCode(
|
||||
ownerId,
|
||||
body.code
|
||||
),
|
||||
|
||||
...body,
|
||||
|
||||
code: body.code.toUpperCase(),
|
||||
|
||||
createdAt: timestamp,
|
||||
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
return await repository.create(cow);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update sapi
|
||||
*/
|
||||
export const update = async (
|
||||
ownerId,
|
||||
id,
|
||||
body
|
||||
) => {
|
||||
await getById(ownerId, id);
|
||||
|
||||
if (body.code) {
|
||||
const duplicate =
|
||||
await repository.findByOwnerCode(
|
||||
ownerId,
|
||||
body.code
|
||||
);
|
||||
|
||||
if (
|
||||
duplicate &&
|
||||
duplicate.id !== id
|
||||
) {
|
||||
throw new AppError(
|
||||
"Cow code already exists",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
body.code =
|
||||
body.code.toUpperCase();
|
||||
|
||||
body.ownerCode =
|
||||
buildOwnerCode(
|
||||
ownerId,
|
||||
body.code
|
||||
);
|
||||
}
|
||||
|
||||
body.updatedAt = now();
|
||||
|
||||
return await repository.update(
|
||||
ownerId,
|
||||
id,
|
||||
body
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hapus sapi
|
||||
*/
|
||||
export const deleteById = async (
|
||||
ownerId,
|
||||
id
|
||||
) => {
|
||||
const cow = await getById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
await repository.deleteById(
|
||||
ownerId,
|
||||
id
|
||||
);
|
||||
|
||||
return cow;
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { successResponse } from "../../core/response/response.js";
|
||||
|
||||
import * as monitoringService from "./monitoring.service.js";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| POST /api/v1/monitoring
|
||||
| Digunakan oleh ESP32
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const receive = async (req, res, next) => {
|
||||
try {
|
||||
const monitoring = await monitoringService.receiveMonitoring(req.body);
|
||||
return successResponse(res, monitoring, "Monitoring received successfully");
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET /api/v1/monitoring/latest
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getLatest = async (req, res, next) => {
|
||||
try {
|
||||
const monitoring = await monitoringService.getLatest(req.user.ownerId);
|
||||
res.set({
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
Pragma: "no-cache",
|
||||
Expires: "0",
|
||||
"Surrogate-Control": "no-store"
|
||||
});
|
||||
return successResponse(res, monitoring, "Latest monitoring retrieved successfully");
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET /api/v1/monitoring/latest/:cowId
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getLatestByCow = async (req, res, next) => {
|
||||
try {
|
||||
const monitoring = await monitoringService.getLatestByCow(req.user.ownerId, req.params.cowId);
|
||||
return successResponse(res, monitoring, "Monitoring retrieved successfully");
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET /api/v1/monitoring/history/:cowId
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getHistory = async (req, res, next) => {
|
||||
try {
|
||||
const history = await monitoringService.getHistory(req.user.ownerId, req.params.cowId, req.query);
|
||||
return successResponse(res, history, "Monitoring history retrieved successfully");
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET /api/v1/monitoring/debug/latest
|
||||
| Debug endpoint - no authentication required
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getLatestDebug = async (req, res, next) => {
|
||||
try {
|
||||
console.log('[DEBUG] Debug endpoint called - skipping authentication');
|
||||
// Use the same owner ID as the test data
|
||||
const monitoring = await monitoringService.getLatest('lRAPbzLWIHMhD70rfP0DBWE79eo1');
|
||||
return successResponse(res, monitoring, "Debug monitoring data retrieved successfully");
|
||||
} catch (error) {
|
||||
console.error('[DEBUG] Error in debug endpoint:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { db } from "../../config/firebase.js";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SAVE LATEST
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const saveLatest = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
data
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(
|
||||
`monitoring/latest/${ownerId}/${cowId}`
|
||||
)
|
||||
.set(data);
|
||||
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SAVE HISTORY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simpan history monitoring
|
||||
*/
|
||||
export const saveHistory = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
data
|
||||
) => {
|
||||
|
||||
const date = new Date(data.timestamp);
|
||||
|
||||
const year = String(date.getFullYear());
|
||||
|
||||
const month = String(
|
||||
date.getMonth() + 1
|
||||
).padStart(2, "0");
|
||||
|
||||
const day = String(
|
||||
date.getDate()
|
||||
).padStart(2, "0");
|
||||
|
||||
await db
|
||||
.ref(
|
||||
`monitoring/history/${ownerId}/${cowId}/${year}/${month}/${day}`
|
||||
)
|
||||
.push(data);
|
||||
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET LATEST
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getLatest = async (
|
||||
ownerId
|
||||
) => {
|
||||
|
||||
const snapshot =
|
||||
await db
|
||||
|
||||
.ref(
|
||||
`monitoring/latest/${ownerId}`
|
||||
)
|
||||
|
||||
.once("value");
|
||||
|
||||
return snapshot.val();
|
||||
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET LATEST BY COW
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getLatestByCow =
|
||||
async (
|
||||
|
||||
ownerId,
|
||||
|
||||
cowId
|
||||
|
||||
) => {
|
||||
|
||||
const snapshot =
|
||||
await db
|
||||
|
||||
.ref(
|
||||
|
||||
`monitoring/latest/${ownerId}/${cowId}`
|
||||
|
||||
)
|
||||
|
||||
.once("value");
|
||||
|
||||
return snapshot.exists()
|
||||
|
||||
? snapshot.val()
|
||||
|
||||
: null;
|
||||
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET HISTORY DAY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getHistoryByDay = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
year,
|
||||
month,
|
||||
day
|
||||
) => {
|
||||
|
||||
const snapshot = await db
|
||||
.ref(
|
||||
`monitoring/history/${ownerId}/${cowId}/${year}/${month}/${day}`
|
||||
)
|
||||
.once("value");
|
||||
|
||||
const data = snapshot.val();
|
||||
|
||||
return data
|
||||
? Object.values(data)
|
||||
: [];
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET HISTORY MONTH
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const getHistoryByMonth = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
year,
|
||||
month
|
||||
) => {
|
||||
|
||||
const snapshot = await db
|
||||
.ref(
|
||||
`monitoring/history/${ownerId}/${cowId}/${year}/${month}`
|
||||
)
|
||||
.once("value");
|
||||
|
||||
return snapshot.val();
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DELETE HISTORY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const deleteHistory = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
year,
|
||||
month,
|
||||
day
|
||||
) => {
|
||||
|
||||
await db
|
||||
.ref(
|
||||
`monitoring/history/${ownerId}/${cowId}/${year}/${month}/${day}`
|
||||
)
|
||||
.remove();
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { Router } from "express";
|
||||
|
||||
import * as controller from "./monitoring.controller.js";
|
||||
|
||||
import authMiddleware from "../../middlewares/auth.middleware.js";
|
||||
import validationMiddleware from "../../middlewares/validation.middleware.js";
|
||||
|
||||
import {
|
||||
createMonitoringSchema
|
||||
} from "./monitoring.schema.js";
|
||||
|
||||
const router=Router();
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ESP32
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
validationMiddleware(createMonitoringSchema),
|
||||
controller.receive
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mobile
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
router.get(
|
||||
"/latest",
|
||||
authMiddleware,
|
||||
controller.getLatest
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/latest/:cowId",
|
||||
authMiddleware,
|
||||
controller.getLatestByCow
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/history/:cowId",
|
||||
authMiddleware,
|
||||
controller.getHistory
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Debug endpoint - no authentication required
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
router.get(
|
||||
"/debug/latest",
|
||||
controller.getLatestDebug
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const createMonitoringSchema = z.object({
|
||||
|
||||
serialNumber:
|
||||
z.string(),
|
||||
|
||||
deviceSecret:
|
||||
z.string(),
|
||||
|
||||
device: z.object({
|
||||
|
||||
signal:
|
||||
z.number(),
|
||||
|
||||
firmwareVersion:
|
||||
z.string().optional(),
|
||||
|
||||
hardwareVersion:
|
||||
z.string().optional(),
|
||||
|
||||
uptime:
|
||||
z.number().optional(),
|
||||
|
||||
}),
|
||||
|
||||
sensor: z.object({
|
||||
|
||||
temperature:
|
||||
z.number(),
|
||||
|
||||
gps: z.object({
|
||||
|
||||
latitude:
|
||||
z.number(),
|
||||
|
||||
longitude:
|
||||
z.number(),
|
||||
|
||||
linkMaps:
|
||||
z.string(),
|
||||
|
||||
}),
|
||||
|
||||
movement: z.object({
|
||||
|
||||
accelX:
|
||||
z.number(),
|
||||
|
||||
}),
|
||||
|
||||
}),
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,550 @@
|
|||
import AppError from "../../core/exceptions/AppError.js";
|
||||
|
||||
import * as collarRepository from "../collar/collar.repository.js";
|
||||
import * as assignmentRepository from "../assignment/assignment.repository.js";
|
||||
import * as monitoringRepository from "./monitoring.repository.js";
|
||||
import * as authRepository from "../auth/auth.repository.js";
|
||||
import notificationService from "../notification/notification.service.js";
|
||||
|
||||
import { buildMonitoring } from "../../shared/builders/monitoring.builder.js";
|
||||
|
||||
const MOVEMENT_LABELS = {
|
||||
0: "Diam",
|
||||
1: "Gerak",
|
||||
2: "Lari",
|
||||
};
|
||||
|
||||
/**
|
||||
* Label aktivitas yang aman untuk nilai status apa pun.
|
||||
* Konsisten dengan logika di frontend (lokasi.js/dashboard.js):
|
||||
* status 0 = Diam, selain itu = Bergerak (dengan label lebih spesifik kalau dikenal).
|
||||
*/
|
||||
const getMovementLabel = (status) => {
|
||||
if (status === 0 || typeof status === "undefined" || status === null) {
|
||||
return MOVEMENT_LABELS[0];
|
||||
}
|
||||
|
||||
return MOVEMENT_LABELS[status] ?? "Bergerak";
|
||||
};
|
||||
|
||||
const TEMPERATURE_LABELS = {
|
||||
0: "Normal",
|
||||
1: "Peringatan",
|
||||
2: "Bahaya",
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Konfigurasi Alert (bisa di-override lewat ENV, ada default)
|
||||
|--------------------------------------------------------------------------
|
||||
| IDLE_ALERT_THRESHOLD_HOURS : lama diam minimal sebelum dianggap tidak wajar
|
||||
| TEMP_ALERT_COOLDOWN_HOURS : jarak minimal antar notif suhu bahaya
|
||||
| (selama suhu masih bahaya terus-menerus)
|
||||
| IDLE_ALERT_COOLDOWN_HOURS : jarak minimal antar notif diam
|
||||
| (selama sapi masih diam terus-menerus)
|
||||
*/
|
||||
|
||||
const hoursToMs = (hours) => hours * 60 * 60 * 1000;
|
||||
|
||||
const IDLE_ALERT_THRESHOLD_MS = hoursToMs(
|
||||
Number(process.env.IDLE_ALERT_THRESHOLD_HOURS) || 24
|
||||
);
|
||||
|
||||
// Jarak minimal antar penyimpanan ke history (menit), terpisah dari
|
||||
// saveLatest() yang tetap realtime tiap request masuk dari device.
|
||||
const HISTORY_INTERVAL_MS =
|
||||
(Number(process.env.HISTORY_INTERVAL_MINUTES) || 5) * 60 * 1000;
|
||||
|
||||
const ALERT_COOLDOWN_MS = {
|
||||
temperature: hoursToMs(Number(process.env.TEMP_ALERT_COOLDOWN_HOURS) || 3),
|
||||
immobility: hoursToMs(Number(process.env.IDLE_ALERT_COOLDOWN_HOURS) || 6),
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
hour12: false,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms) => {
|
||||
const totalMinutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours <= 0) {
|
||||
return `${minutes} menit`;
|
||||
}
|
||||
|
||||
return minutes > 0 ? `${hours} jam ${minutes} menit` : `${hours} jam`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hanya 2 kondisi yang bisa memicu notifikasi:
|
||||
* 1) Suhu tubuh sangat tinggi (status "Bahaya")
|
||||
* 2) Sapi diam (tidak bergerak) >= IDLE_ALERT_THRESHOLD_MS berturut-turut,
|
||||
* sementara collar online (request ini datang langsung dari device).
|
||||
*
|
||||
* Catatan: fungsi ini hanya mengecek apakah KONDISI-nya terjadi.
|
||||
* Keputusan apakah notif benar-benar dikirim (biar tidak spam) ada di
|
||||
* sendMonitoringAlerts lewat pengecekan cooldown per tipe.
|
||||
*/
|
||||
const buildMonitoringAlerts = (monitoring, idleDurationMs) => {
|
||||
const events = [];
|
||||
|
||||
if (monitoring.temperatureStatus === 2) {
|
||||
events.push({
|
||||
type: "temperature",
|
||||
level: "Bahaya",
|
||||
message: `Suhu tubuh sangat tinggi: ${monitoring.temperature}°C. Segera periksa kondisi sapi.`,
|
||||
});
|
||||
}
|
||||
|
||||
const isIdle = (monitoring.movement?.status ?? 0) === 0;
|
||||
|
||||
if (isIdle && typeof idleDurationMs === "number" && idleDurationMs >= IDLE_ALERT_THRESHOLD_MS) {
|
||||
events.push({
|
||||
type: "immobility",
|
||||
level: "Peringatan",
|
||||
message: `Tidak ada gerakan selama ${formatDuration(idleDurationMs)} berturut-turut, padahal collar dalam status online.`,
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
|
||||
const buildAlertMessage = (owner, monitoring, events) => {
|
||||
const time = formatTimestamp(monitoring.timestamp);
|
||||
|
||||
const location = monitoring.gps?.linkMaps
|
||||
|| (monitoring.gps?.latitude && monitoring.gps?.longitude
|
||||
? `https://www.google.com/maps/search/?api=1&query=${monitoring.gps.latitude},${monitoring.gps.longitude}`
|
||||
: null);
|
||||
|
||||
const isDanger = events.some((event) => event.level === "Bahaya");
|
||||
const severityIcon = isDanger ? "🔴" : "🟠";
|
||||
|
||||
const ownerName = owner?.nama || owner?.name || owner?.fullName;
|
||||
const greeting = ownerName ? `Halo ${ownerName},` : "Halo,";
|
||||
|
||||
const eventLines = events
|
||||
.map((event) => `${event.type === "temperature" ? "🌡️🔥" : "🐄⚠️"} *${event.level}* — ${event.message}`)
|
||||
.join("\n");
|
||||
|
||||
const sensorLines = [
|
||||
`🌡️ Suhu tubuh: *${monitoring.temperature}°C* (${TEMPERATURE_LABELS[monitoring.temperatureStatus] ?? "Tidak diketahui"})`,
|
||||
`🏃 Aktivitas: *${getMovementLabel(monitoring.movement?.status)}*`,
|
||||
`📶 Sinyal Collar: ${monitoring.signal ?? "-"}`,
|
||||
`🔧 Device: ${monitoring.deviceName ?? "-"} (SN: ${monitoring.serialNumber ?? "-"})`,
|
||||
].join("\n");
|
||||
|
||||
const locationBlock = location
|
||||
? `📍 *Lokasi Terakhir:*\n${location}`
|
||||
: `📍 *Lokasi Terakhir:*\nTidak tersedia`;
|
||||
|
||||
return (
|
||||
`${severityIcon} *SMART COLLAR ALERT*\n` +
|
||||
`${greeting} ada kondisi yang perlu perhatianmu.\n\n` +
|
||||
`🐄 *Sapi:* ${monitoring.cowName} (${monitoring.cowCode ?? "-"})\n` +
|
||||
`🆔 *Collar ID:* ${monitoring.collarId ?? "-"}\n\n` +
|
||||
`*Peringatan Terdeteksi:*\n${eventLines}\n\n` +
|
||||
`*Ringkasan Sensor Saat Ini:*\n${sensorLines}\n\n` +
|
||||
`${locationBlock}\n\n` +
|
||||
`⏰ *Waktu Kejadian:* ${time}\n\n` +
|
||||
`Mohon segera dicek langsung ke lapangan untuk memastikan keamanan sapi. 🙏`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Kirim notifikasi WA kalau ada event yang lolos cooldown.
|
||||
*
|
||||
* Anti-spam: memanfaatkan notificationService.shouldSendAlert() yang SUDAH
|
||||
* menyimpan waktu alert terakhir per (ownerId, cowId, alertType) di
|
||||
* notificationRepository — cukup kirim cooldownMs khusus per tipe
|
||||
* (ALERT_COOLDOWN_MS), bukan default 10 menit bawaannya.
|
||||
*/
|
||||
const sendMonitoringAlerts = async (ownerId, monitoring, idleDurationMs) => {
|
||||
const user = await authRepository.findUserById(ownerId);
|
||||
|
||||
if (!user || !user.phone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const events = buildMonitoringAlerts(monitoring, idleDurationMs);
|
||||
|
||||
if (events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedEvents = [];
|
||||
const suppressedEvents = [];
|
||||
|
||||
for (const event of events) {
|
||||
const cooldownMs = ALERT_COOLDOWN_MS[event.type] ?? undefined;
|
||||
|
||||
const canSend = await notificationService.shouldSendAlert(
|
||||
ownerId,
|
||||
monitoring.cowId,
|
||||
event.type,
|
||||
cooldownMs
|
||||
);
|
||||
|
||||
if (canSend) {
|
||||
allowedEvents.push(event);
|
||||
} else {
|
||||
suppressedEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedEvents.length === 0) {
|
||||
for (const event of suppressedEvents) {
|
||||
await notificationService.logNotification(
|
||||
ownerId,
|
||||
monitoring.cowId,
|
||||
event.type,
|
||||
user.phone,
|
||||
buildAlertMessage(user, monitoring, [event]),
|
||||
false,
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = buildAlertMessage(user, monitoring, allowedEvents);
|
||||
|
||||
try {
|
||||
const result = await notificationService.sendWhatsApp(user.phone, message);
|
||||
|
||||
for (const event of allowedEvents) {
|
||||
await notificationService.saveAlertSent(
|
||||
ownerId,
|
||||
monitoring.cowId,
|
||||
event.type,
|
||||
buildAlertMessage(user, monitoring, [event])
|
||||
);
|
||||
await notificationService.logNotification(
|
||||
ownerId,
|
||||
monitoring.cowId,
|
||||
event.type,
|
||||
user.phone,
|
||||
buildAlertMessage(user, monitoring, [event]),
|
||||
true,
|
||||
null,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
for (const event of suppressedEvents) {
|
||||
await notificationService.logNotification(
|
||||
ownerId,
|
||||
monitoring.cowId,
|
||||
event.type,
|
||||
user.phone,
|
||||
buildAlertMessage(user, monitoring, [event]),
|
||||
false,
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION] Failed to send WhatsApp alert:", err.message || err);
|
||||
|
||||
for (const event of allowedEvents) {
|
||||
await notificationService.logNotification(
|
||||
ownerId,
|
||||
monitoring.cowId,
|
||||
event.type,
|
||||
user.phone,
|
||||
buildAlertMessage(user, monitoring, [event]),
|
||||
false,
|
||||
err?.message || err,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Monitoring dari ESP32
|
||||
*/
|
||||
export const receiveMonitoring = async (body) => {
|
||||
const {
|
||||
|
||||
serialNumber,
|
||||
|
||||
deviceSecret,
|
||||
|
||||
device,
|
||||
|
||||
sensor,
|
||||
|
||||
} = body;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Device Authentication
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const collar = await collarRepository.findByDevice(
|
||||
serialNumber,
|
||||
deviceSecret
|
||||
);
|
||||
|
||||
if (!collar) {
|
||||
throw new AppError(
|
||||
"Smart Collar not found",
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resolve cow assignment
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
let currentCowId = collar.currentCowId;
|
||||
let currentCowCode = collar.currentCowCode;
|
||||
let currentCowName = collar.currentCowName;
|
||||
let currentAssignmentId = collar.currentAssignmentId;
|
||||
|
||||
if (!currentCowId) {
|
||||
const assignment = await assignmentRepository.findActiveByCollar(
|
||||
collar.ownerId,
|
||||
collar.id
|
||||
);
|
||||
|
||||
if (!assignment) {
|
||||
throw new AppError(
|
||||
"Smart Collar is not assigned",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
currentCowId = assignment.cowId;
|
||||
currentCowCode = assignment.cowCode;
|
||||
currentCowName = assignment.cowName;
|
||||
currentAssignmentId = assignment.id;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Timestamp
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Update Device Status
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await collarRepository.touchOnline(
|
||||
collar.ownerId,
|
||||
collar.id,
|
||||
{
|
||||
signal: device.signal,
|
||||
|
||||
firmwareVersion:
|
||||
device.firmwareVersion ?? null,
|
||||
|
||||
hardwareVersion:
|
||||
device.hardwareVersion ?? null,
|
||||
|
||||
lastOnline: timestamp,
|
||||
|
||||
lastSync: timestamp,
|
||||
}
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Build Monitoring Snapshot
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const monitoring = buildMonitoring(
|
||||
{
|
||||
...collar,
|
||||
currentCowId,
|
||||
currentCowCode,
|
||||
currentCowName,
|
||||
currentAssignmentId,
|
||||
},
|
||||
sensor,
|
||||
device,
|
||||
timestamp
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Track Idle Duration (untuk alert "diam 24 jam")
|
||||
|--------------------------------------------------------------------------
|
||||
| movement.status: 0 = Diam, 1 = Gerak
|
||||
| lastMovementAt di-update ke waktu sekarang setiap kali sapi bergerak.
|
||||
| Kalau belum pernah tercatat (collar baru), anggap baru saja bergerak
|
||||
| supaya tidak langsung false-alert saat pertama kali online.
|
||||
*/
|
||||
|
||||
const isMovingNow = (monitoring.movement?.status ?? 0) !== 0;
|
||||
const previousLastMovementAt = collar.lastMovementAt ?? timestamp;
|
||||
const lastMovementAt = isMovingNow ? timestamp : previousLastMovementAt;
|
||||
const idleDurationMs = timestamp - lastMovementAt;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Save Latest (REALTIME — selalu update tiap request masuk dari device)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await monitoringRepository.saveLatest(
|
||||
collar.ownerId,
|
||||
currentCowId,
|
||||
monitoring
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Save History (DI-THROTTLE — hanya disimpan tiap HISTORY_INTERVAL_MS,
|
||||
| supaya tabel history tidak membanjir tiap 5 detik dari ESP32)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const previousLastHistoryAt = collar.lastHistoryAt ?? 0;
|
||||
const shouldSaveHistory = (timestamp - previousLastHistoryAt) >= HISTORY_INTERVAL_MS;
|
||||
|
||||
if (shouldSaveHistory) {
|
||||
await monitoringRepository.saveHistory(
|
||||
collar.ownerId,
|
||||
currentCowId,
|
||||
monitoring
|
||||
);
|
||||
}
|
||||
|
||||
const lastHistoryAt = shouldSaveHistory ? timestamp : previousLastHistoryAt;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Persist state collar (assignment, lastMovementAt)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await collarRepository.update(
|
||||
collar.ownerId,
|
||||
collar.id,
|
||||
{
|
||||
currentCowId,
|
||||
currentCowCode,
|
||||
currentCowName,
|
||||
currentAssignmentId,
|
||||
lastMovementAt,
|
||||
lastHistoryAt,
|
||||
}
|
||||
);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Alert (anti-spam per tipe ditangani oleh notificationService, lihat
|
||||
| cooldownMs khusus per tipe di ALERT_COOLDOWN_MS)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await sendMonitoringAlerts(
|
||||
collar.ownerId,
|
||||
monitoring,
|
||||
idleDurationMs
|
||||
);
|
||||
|
||||
return monitoring;
|
||||
};
|
||||
|
||||
/**
|
||||
* Monitoring realtime semua sapi
|
||||
*/
|
||||
export const getLatest = async (ownerId) => {
|
||||
|
||||
const latest =
|
||||
await monitoringRepository.getLatest(ownerId);
|
||||
|
||||
if (!latest) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return latest;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Monitoring realtime satu sapi
|
||||
*/
|
||||
export const getLatestByCow = async (
|
||||
ownerId,
|
||||
cowId
|
||||
) => {
|
||||
|
||||
let monitoring =
|
||||
await monitoringRepository.getLatestByCow(
|
||||
ownerId,
|
||||
cowId
|
||||
);
|
||||
|
||||
if (!monitoring) {
|
||||
const latest =
|
||||
await monitoringRepository.getLatest(ownerId);
|
||||
|
||||
if (latest && typeof latest === "object") {
|
||||
monitoring = Object.values(latest).find(
|
||||
(item) => item && item.cowId === cowId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!monitoring) {
|
||||
throw new AppError(
|
||||
"Monitoring not found",
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
return monitoring;
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* History Monitoring
|
||||
*/
|
||||
export const getHistory = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
query
|
||||
) => {
|
||||
|
||||
const {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
} = query;
|
||||
|
||||
return await monitoringRepository.getHistoryByDay(
|
||||
ownerId,
|
||||
cowId,
|
||||
year,
|
||||
month,
|
||||
day
|
||||
);
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import notificationService from "./notification.service.js";
|
||||
|
||||
export const sendWhatsApp = async (req, res, next) => {
|
||||
try {
|
||||
const { phone, message, template } = req.body;
|
||||
const result = await notificationService.sendWhatsApp(phone, message, { template });
|
||||
|
||||
return res.status(200).json({ success: true, message: "Pesan terkirim", data: result });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
sendWhatsApp,
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { db } from "../../config/firebase.js";
|
||||
|
||||
const COLLECTION = "notifications";
|
||||
|
||||
export const getLastAlert = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
alertType
|
||||
) => {
|
||||
const snapshot = await db
|
||||
.ref(`${COLLECTION}/last/${ownerId}/${cowId}/${alertType}`)
|
||||
.once("value");
|
||||
|
||||
return snapshot.val();
|
||||
};
|
||||
|
||||
export const saveLastAlert = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
alertType,
|
||||
data
|
||||
) => {
|
||||
await db
|
||||
.ref(`${COLLECTION}/last/${ownerId}/${cowId}/${alertType}`)
|
||||
.set(data);
|
||||
};
|
||||
|
||||
export const saveNotificationLog = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
data
|
||||
) => {
|
||||
const timestamp = Date.now();
|
||||
await db
|
||||
.ref(`${COLLECTION}/logs/${ownerId}/${cowId}/${timestamp}`)
|
||||
.set({
|
||||
...data,
|
||||
createdAt: timestamp,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Router } from "express";
|
||||
import * as controller from "./notification.controller.js";
|
||||
import testController from "./notification.test.controller.js";
|
||||
import authMiddleware from "../../middlewares/auth.middleware.js";
|
||||
import validationMiddleware from "../../middlewares/validation.middleware.js";
|
||||
import {
|
||||
sendWhatsAppSchema,
|
||||
sendTestWhatsAppSchema,
|
||||
} from "./notification.schema.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
"/whatsapp",
|
||||
authMiddleware,
|
||||
validationMiddleware(sendWhatsAppSchema),
|
||||
controller.sendWhatsApp
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/whatsapp/test",
|
||||
authMiddleware,
|
||||
validationMiddleware(sendTestWhatsAppSchema),
|
||||
testController.sendTestWhatsApp
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const sendWhatsAppSchema = z.object({
|
||||
phone: z.string().min(5, "Nomor telepon wajib diisi"),
|
||||
message: z.string().min(1, "Pesan wajib diisi"),
|
||||
template: z.string().optional(),
|
||||
});
|
||||
|
||||
export const sendTestWhatsAppSchema = z.object({
|
||||
phone: z.string().min(5, "Nomor telepon wajib diisi"),
|
||||
message: z.string().min(1, "Pesan wajib diisi"),
|
||||
});
|
||||
|
||||
export default sendWhatsAppSchema;
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import fonnte from "../../config/fonnte.js";
|
||||
import * as notificationRepository from "./notification.repository.js";
|
||||
|
||||
const DEFAULT_COOLDOWN_MS = 1000 * 60 * 10; // 10 menit
|
||||
|
||||
const sendWhatsApp = async (phone, message, opts = {}) => {
|
||||
const payload = {
|
||||
target: phone,
|
||||
message,
|
||||
...(opts.template ? { template: opts.template } : {}),
|
||||
};
|
||||
|
||||
const res = await fonnte.post("/send", payload);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const shouldSendAlert = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
alertType,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS
|
||||
) => {
|
||||
const lastAlert = await notificationRepository.getLastAlert(
|
||||
ownerId,
|
||||
cowId,
|
||||
alertType
|
||||
);
|
||||
|
||||
if (!lastAlert || !lastAlert.timestamp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - lastAlert.timestamp;
|
||||
return elapsed > cooldownMs;
|
||||
};
|
||||
|
||||
const saveAlertSent = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
alertType,
|
||||
message
|
||||
) => {
|
||||
await notificationRepository.saveLastAlert(ownerId, cowId, alertType, {
|
||||
timestamp: Date.now(),
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
const logNotification = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
alertType,
|
||||
phone,
|
||||
message,
|
||||
sent,
|
||||
error = null,
|
||||
suppressed = false
|
||||
) => {
|
||||
await notificationRepository.saveNotificationLog(ownerId, cowId, {
|
||||
alertType,
|
||||
phone,
|
||||
message,
|
||||
sent,
|
||||
suppressed,
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
const sendAlertIfAllowed = async (
|
||||
ownerId,
|
||||
cowId,
|
||||
alertType,
|
||||
phone,
|
||||
message,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS
|
||||
) => {
|
||||
const canSend = await shouldSendAlert(ownerId, cowId, alertType, cooldownMs);
|
||||
|
||||
if (!canSend) {
|
||||
await logNotification(ownerId, cowId, alertType, phone, message, false, null, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendWhatsApp(phone, message);
|
||||
await saveAlertSent(ownerId, cowId, alertType, message);
|
||||
await logNotification(ownerId, cowId, alertType, phone, message, true, null, false);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await logNotification(ownerId, cowId, alertType, phone, message, false, error?.message || error, false);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
sendWhatsApp,
|
||||
shouldSendAlert,
|
||||
saveAlertSent,
|
||||
logNotification,
|
||||
sendAlertIfAllowed,
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import notificationService from "./notification.service.js";
|
||||
|
||||
export const sendTestWhatsApp = async (req, res, next) => {
|
||||
try {
|
||||
const { phone, message } = req.body;
|
||||
|
||||
if (!phone || !message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "phone dan message wajib diisi untuk testing",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await notificationService.sendWhatsApp(phone, message);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Test pesan WhatsApp terkirim",
|
||||
data: result,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
sendTestWhatsApp,
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Router } from "express";
|
||||
import authRoute from "../modules/auth/auth.route.js";
|
||||
import cowRoute from "../modules/cow/cow.route.js";
|
||||
import collarRoute from "../modules/collar/collar.route.js";
|
||||
import assignmentRoute from "../modules/assignment/assignment.route.js";
|
||||
import monitoringRoute from "../modules/monitoring/monitoring.route.js";
|
||||
import notificationRoute from "../modules/notification/notification.route.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Health Check
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Smart Collar API v1",
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Modules
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
|
||||
router.use("/auth", authRoute);
|
||||
router.use("/cows", cowRoute);
|
||||
router.use("/collars", collarRoute);
|
||||
router.use("/assignments", assignmentRoute);
|
||||
router.use("/monitoring", monitoringRoute);
|
||||
router.use("/notifications", notificationRoute);
|
||||
// router.use("/settings", settingRoute);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import app from "./app.js";
|
||||
|
||||
import env from "./config/env.js";
|
||||
|
||||
import logger from "./core/logger/logger.js";
|
||||
|
||||
const PORT = env.app.port;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`${env.app.name} running on port ${PORT}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import {
|
||||
calculateTemperatureStatus,
|
||||
} from "../calculators/temperature.calculator.js";
|
||||
|
||||
import {
|
||||
calculateMovementStatus,
|
||||
} from "../calculators/movement.calculator.js";
|
||||
|
||||
export const buildMonitoring = (
|
||||
collar,
|
||||
sensor,
|
||||
device,
|
||||
timestamp
|
||||
) => {
|
||||
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return {
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Owner
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
ownerId: collar.ownerId,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
cowId: collar.currentCowId,
|
||||
cowCode: collar.currentCowCode,
|
||||
cowName: collar.currentCowName,
|
||||
|
||||
assignmentId:
|
||||
collar.currentAssignmentId,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Collar
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
collarId: collar.id,
|
||||
|
||||
serialNumber:
|
||||
collar.serialNumber,
|
||||
|
||||
deviceName:
|
||||
collar.deviceName,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Device
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
signal:
|
||||
device.signal,
|
||||
|
||||
firmwareVersion:
|
||||
device.firmwareVersion ?? null,
|
||||
|
||||
hardwareVersion:
|
||||
device.hardwareVersion ?? null,
|
||||
|
||||
uptime:
|
||||
device.uptime ?? null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sensor
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
temperature:
|
||||
sensor.temperature,
|
||||
|
||||
temperatureStatus:
|
||||
calculateTemperatureStatus(
|
||||
sensor.temperature
|
||||
),
|
||||
|
||||
gps: {
|
||||
|
||||
latitude:
|
||||
sensor.gps.latitude,
|
||||
|
||||
longitude:
|
||||
sensor.gps.longitude,
|
||||
|
||||
linkMaps:
|
||||
sensor.gps.linkMaps,
|
||||
|
||||
},
|
||||
|
||||
movement: {
|
||||
|
||||
accelX:
|
||||
sensor.movement.accelX,
|
||||
|
||||
status:
|
||||
calculateMovementStatus(
|
||||
sensor.movement.accelX
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Time
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
year:
|
||||
date.getFullYear(),
|
||||
|
||||
month:
|
||||
String(
|
||||
date.getMonth() + 1
|
||||
).padStart(2, "0"),
|
||||
|
||||
day:
|
||||
String(
|
||||
date.getDate()
|
||||
).padStart(2, "0"),
|
||||
|
||||
hour:
|
||||
String(
|
||||
date.getHours()
|
||||
).padStart(2, "0"),
|
||||
|
||||
minute:
|
||||
String(
|
||||
date.getMinutes()
|
||||
).padStart(2, "0"),
|
||||
|
||||
second:
|
||||
String(
|
||||
date.getSeconds()
|
||||
).padStart(2, "0"),
|
||||
|
||||
timestamp,
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
export const MOVEMENT_STATUS = {
|
||||
REST: 0,
|
||||
WALK: 1,
|
||||
RUN: 2,
|
||||
};
|
||||
|
||||
// Range "diam" berdasarkan observasi nyata di lapangan -- device yang
|
||||
// benar-benar diam tetap menghasilkan noise sensor di kisaran ini
|
||||
// (asimetris karena sedikit gravitasi/kemiringan bocor ke sumbu X).
|
||||
const ACCEL_REST_MIN = -0.5;
|
||||
const ACCEL_REST_MAX = 0.2;
|
||||
|
||||
// Ambang batas RUN, dihitung dari besaran (magnitude) di luar range REST.
|
||||
const ACCEL_RUN_THRESHOLD = 1.0;
|
||||
|
||||
export const calculateMovementStatus = (
|
||||
accelX
|
||||
) => {
|
||||
|
||||
const isResting =
|
||||
accelX >= ACCEL_REST_MIN &&
|
||||
accelX <= ACCEL_REST_MAX;
|
||||
|
||||
if (isResting) {
|
||||
return MOVEMENT_STATUS.REST;
|
||||
}
|
||||
|
||||
const magnitude = Math.abs(accelX);
|
||||
|
||||
if (magnitude < ACCEL_RUN_THRESHOLD) {
|
||||
return MOVEMENT_STATUS.WALK;
|
||||
}
|
||||
|
||||
return MOVEMENT_STATUS.RUN;
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
export const TEMPERATURE_STATUS = {
|
||||
NORMAL: 0,
|
||||
WARNING: 1,
|
||||
DANGER: 2,
|
||||
};
|
||||
|
||||
export const calculateTemperatureStatus = (
|
||||
temperature
|
||||
) => {
|
||||
|
||||
if (temperature <= 39.5) {
|
||||
return TEMPERATURE_STATUS.NORMAL;
|
||||
}
|
||||
|
||||
if (temperature <= 40.5) {
|
||||
return TEMPERATURE_STATUS.WARNING;
|
||||
}
|
||||
|
||||
return TEMPERATURE_STATUS.DANGER;
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export const COLLAR_STATUS = {
|
||||
AVAILABLE: "available",
|
||||
ASSIGNED: "assigned",
|
||||
OFFLINE: "offline",
|
||||
MAINTENANCE: "maintenance",
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import crypto from "crypto";
|
||||
|
||||
export const generateId = (prefix) => {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Generate device secret
|
||||
*/
|
||||
export const generateDeviceSecret = () => {
|
||||
return crypto.randomUUID();
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Generate ownerCode
|
||||
*/
|
||||
export const buildOwnerCode = (
|
||||
ownerId,
|
||||
code
|
||||
) => {
|
||||
return `${ownerId}_${code.toUpperCase()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate ownerSerial
|
||||
*/
|
||||
export const buildOwnerSerial = (
|
||||
ownerId,
|
||||
serialNumber
|
||||
) => {
|
||||
return `${ownerId}_${serialNumber.toUpperCase()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate serialSecret
|
||||
*/
|
||||
export const buildSerialSecret = (
|
||||
serialNumber,
|
||||
secret
|
||||
) => {
|
||||
return `${serialNumber.toUpperCase()}_${secret}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate ownerCow
|
||||
*/
|
||||
export const buildOwnerCow = (
|
||||
ownerId,
|
||||
cowId
|
||||
) => {
|
||||
return `${ownerId}_${cowId}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate ownerCollar
|
||||
*/
|
||||
export const buildOwnerCollar = (
|
||||
ownerId,
|
||||
collarId
|
||||
) => {
|
||||
return `${ownerId}_${collarId}`;
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const now = () => Date.now();
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#########################################
|
||||
# macOS
|
||||
#########################################
|
||||
|
||||
.DS_Store
|
||||
|
||||
#########################################
|
||||
# VSCode
|
||||
#########################################
|
||||
|
||||
.vscode/
|
||||
|
||||
#########################################
|
||||
# JetBrains
|
||||
#########################################
|
||||
|
||||
.idea/
|
||||
|
||||
#########################################
|
||||
# Composer
|
||||
#########################################
|
||||
|
||||
vendor/
|
||||
|
||||
#########################################
|
||||
# Environment
|
||||
#########################################
|
||||
|
||||
.env
|
||||
|
||||
.env.local
|
||||
|
||||
#########################################
|
||||
# Logs
|
||||
#########################################
|
||||
|
||||
logs/
|
||||
|
||||
*.log
|
||||
|
||||
#########################################
|
||||
# Cache
|
||||
#########################################
|
||||
|
||||
.cache/
|
||||
|
||||
tmp/
|
||||
|
||||
#########################################
|
||||
# Upload
|
||||
#########################################
|
||||
|
||||
uploads/
|
||||
|
||||
#########################################
|
||||
# Node
|
||||
#########################################
|
||||
|
||||
node_modules/
|
||||
|
||||
#########################################
|
||||
# OS
|
||||
#########################################
|
||||
|
||||
Thumbs.db
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Firebase Authentication
|
||||
- PHP Session Login
|
||||
- Dashboard
|
||||
- Cow Management
|
||||
- Smart Collar Management
|
||||
- Assignment Module
|
||||
- Monitoring Module
|
||||
- REST API Integration
|
||||
|
||||
---
|
||||
|
||||
## Upcoming
|
||||
|
||||
- Monitoring History
|
||||
- WhatsApp Notification
|
||||
- Export PDF
|
||||
- Export Excel
|
||||
- Device Analytics
|
||||
- OTA Update
|
||||
- Geofence
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Muhammad Iqbal
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
# 🐄 Smart Collar Web
|
||||
|
||||
Smart Collar Web adalah aplikasi berbasis **PHP Native** yang digunakan untuk memonitor sapi secara realtime menggunakan perangkat IoT Smart Collar.
|
||||
|
||||
Project ini terhubung dengan **Smart Collar Backend (Express.js)** melalui REST API dan menggunakan Firebase Authentication serta Firebase Realtime Database.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Firebase Authentication
|
||||
- Dashboard Monitoring
|
||||
- Cow Management
|
||||
- Smart Collar Management
|
||||
- Assignment Management
|
||||
- Realtime Monitoring
|
||||
- Google Maps
|
||||
- Monitoring History
|
||||
- WhatsApp Notification (Planned)
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
- PHP Native
|
||||
- Bootstrap 5
|
||||
- JavaScript
|
||||
- Fetch API
|
||||
|
||||
### Backend
|
||||
|
||||
- Node.js
|
||||
- Express.js
|
||||
|
||||
### Database
|
||||
|
||||
- Firebase Realtime Database
|
||||
|
||||
### Authentication
|
||||
|
||||
- Firebase Authentication
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
smartcollar-web/
|
||||
|
||||
├── ajax/
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ ├── img/
|
||||
│ └── js/
|
||||
│
|
||||
├── config/
|
||||
├── includes/
|
||||
├── pages/
|
||||
├── services/
|
||||
│
|
||||
├── index.php
|
||||
├── login.php
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
├── CHANGELOG.md
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Clone repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/smartcollar-web.git
|
||||
```
|
||||
|
||||
Masuk ke project
|
||||
|
||||
```bash
|
||||
cd smartcollar-web
|
||||
```
|
||||
|
||||
Install dependency
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit
|
||||
|
||||
```
|
||||
config/api.php
|
||||
```
|
||||
|
||||
```php
|
||||
define(
|
||||
"API_URL",
|
||||
"http://localhost:3000/api/v1"
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
PHP Built-in Server
|
||||
|
||||
```bash
|
||||
php -S localhost:8080
|
||||
```
|
||||
|
||||
atau menggunakan XAMPP
|
||||
|
||||
```
|
||||
http://localhost/smartcollar-web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Flow
|
||||
|
||||
```
|
||||
ESP32
|
||||
│
|
||||
▼
|
||||
REST API
|
||||
│
|
||||
▼
|
||||
Express Backend
|
||||
│
|
||||
▼
|
||||
Firebase
|
||||
│
|
||||
▼
|
||||
PHP Dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Status
|
||||
|
||||
| Module | Status |
|
||||
|----------|--------|
|
||||
| Authentication | ✅ |
|
||||
| Dashboard | ✅ |
|
||||
| Cow Management | ✅ |
|
||||
| Smart Collar | ✅ |
|
||||
| Assignment | ✅ |
|
||||
| Monitoring | ✅|
|
||||
| History | ✅ |
|
||||
| WhatsApp Notification | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
## Author
|
||||
|
||||
Muhammad Iqbal
|
||||
|
||||
Software Engineer
|
||||
|
||||
DevGo
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
require_once "../config/auth.php";
|
||||
require_once "../services/assignment.service.php";
|
||||
require_once "../services/cow.service.php";
|
||||
require_once "../services/collar.service.php";
|
||||
|
||||
requireLogin();
|
||||
|
||||
header("Content-Type: application/json");
|
||||
|
||||
$action = $_POST["action"] ?? "";
|
||||
|
||||
switch ($action) {
|
||||
|
||||
case "list":
|
||||
$assignments = getAllAssignment();
|
||||
echo json_encode(["success" => true, "data" => $assignments]);
|
||||
break;
|
||||
|
||||
case "attach":
|
||||
$cowId = $_POST["cowId"] ?? null;
|
||||
$collarId = $_POST["collarId"] ?? null;
|
||||
|
||||
if (!$cowId || !$collarId) {
|
||||
echo json_encode(["success" => false, "message" => "cowId and collarId are required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$response = attachAssignment(["cowId" => $cowId, "collarId" => $collarId]);
|
||||
echo json_encode($response);
|
||||
break;
|
||||
|
||||
case "detach":
|
||||
$cowId = $_POST["cowId"] ?? null;
|
||||
|
||||
if (!$cowId) {
|
||||
echo json_encode(["success" => false, "message" => "cowId is required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$response = detachAssignment(["cowId" => $cowId]);
|
||||
echo json_encode($response);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(["success" => false, "message" => "Invalid action"]);
|
||||
break;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
require_once "../config/auth.php";
|
||||
require_once "../config/api.php";
|
||||
|
||||
requireLogin();
|
||||
|
||||
header("Content-Type: application/json");
|
||||
|
||||
$token = getToken();
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$input) {
|
||||
echo json_encode(["success" => false, "message" => "Invalid request payload"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$name = trim($input['name'] ?? '');
|
||||
$phone = trim($input['phone'] ?? '');
|
||||
$address = trim($input['address'] ?? '');
|
||||
$photoUrl = trim($input['photoUrl'] ?? '');
|
||||
|
||||
if (!$name || !$phone || !$address) {
|
||||
echo json_encode(["success" => false, "message" => "Semua field user wajib diisi"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [
|
||||
"name" => $name,
|
||||
"phone" => $phone,
|
||||
"address" => $address,
|
||||
"photoUrl" => $photoUrl,
|
||||
];
|
||||
|
||||
$result = apiPost("/auth/sync", $data, $token);
|
||||
|
||||
echo json_encode($result);
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
require_once "../config/auth.php";
|
||||
require_once "../services/collar.service.php";
|
||||
|
||||
requireLogin();
|
||||
|
||||
header("Content-Type: application/json");
|
||||
|
||||
$action = $_POST["action"] ?? "";
|
||||
|
||||
switch ($action) {
|
||||
|
||||
case "get":
|
||||
|
||||
$id = $_POST["id"] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"message" => "Collar ID is required"
|
||||
]);
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
$collar = getCollarById($id);
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"data" => $collar
|
||||
]);
|
||||
|
||||
break;
|
||||
|
||||
case "create":
|
||||
|
||||
$data = [
|
||||
"serialNumber" => trim($_POST["serialNumber"] ?? ""),
|
||||
"deviceName" => trim($_POST["deviceName"] ?? ""),
|
||||
"hardwareVersion" => trim($_POST["hardwareVersion"] ?? ""),
|
||||
"firmwareVersion" => trim($_POST["firmwareVersion"] ?? ""),
|
||||
"macAddress" => trim($_POST["macAddress"] ?? ""),
|
||||
"simNumber" => trim($_POST["simNumber"] ?? "")
|
||||
];
|
||||
|
||||
$response = createCollar($data);
|
||||
|
||||
echo json_encode($response);
|
||||
|
||||
break;
|
||||
|
||||
case "update":
|
||||
|
||||
$id = $_POST["id"] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"message" => "Collar ID is required"
|
||||
]);
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [
|
||||
"serialNumber" => trim($_POST["serialNumber"] ?? ""),
|
||||
"deviceName" => trim($_POST["deviceName"] ?? ""),
|
||||
"hardwareVersion" => trim($_POST["hardwareVersion"] ?? ""),
|
||||
"firmwareVersion" => trim($_POST["firmwareVersion"] ?? ""),
|
||||
"macAddress" => trim($_POST["macAddress"] ?? ""),
|
||||
"simNumber" => trim($_POST["simNumber"] ?? "")
|
||||
];
|
||||
|
||||
$response = updateCollar($id, $data);
|
||||
|
||||
echo json_encode($response);
|
||||
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
|
||||
$id = $_POST["id"] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"message" => "Collar ID is required"
|
||||
]);
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
$response = deleteCollar($id);
|
||||
|
||||
echo json_encode($response);
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"message" => "Invalid action"
|
||||
]);
|
||||
|
||||
break;
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
require_once "../config/auth.php";
|
||||
require_once "../services/cow.service.php";
|
||||
|
||||
requireLogin();
|
||||
|
||||
header("Content-Type: application/json");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Action
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
$action = $_POST["action"] ?? "";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Switch Action
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
switch ($action) {
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Get Cow By ID
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
case "get":
|
||||
|
||||
$id = $_POST["id"] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"message" => "Cow ID is required"
|
||||
]);
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
$cow = getCowById($id);
|
||||
|
||||
// Map backend field names to frontend expectations
|
||||
if (is_array($cow)) {
|
||||
$cow["description"] = $cow["note"] ?? "";
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"data" => $cow
|
||||
]);
|
||||
|
||||
break;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Create Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
case "create":
|
||||
|
||||
// Map frontend fields to backend API schema
|
||||
$gender = strtolower(trim($_POST["gender"] ?? ""));
|
||||
|
||||
$weight = (float) ($_POST["weight"] ?? 0);
|
||||
if ($weight <= 0) {
|
||||
$weight = 1; // default to 1 to satisfy positive validation
|
||||
}
|
||||
|
||||
$data = [
|
||||
"code" => trim($_POST["code"] ?? ""),
|
||||
"name" => trim($_POST["name"] ?? ""),
|
||||
"breed" => trim($_POST["breed"] ?? ""),
|
||||
"gender" => $gender,
|
||||
"birthDate" => trim($_POST["birthDate"] ?? ""),
|
||||
"weight" => $weight,
|
||||
"note" => trim($_POST["description"] ?? ""),
|
||||
"color" => trim($_POST["color"] ?? ""),
|
||||
"photoUrl" => trim($_POST["photoUrl"] ?? ""),
|
||||
"status" => trim($_POST["status"] ?? "healthy")
|
||||
];
|
||||
|
||||
$response = createCow($data);
|
||||
|
||||
echo json_encode($response);
|
||||
|
||||
break;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Update Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
case "update":
|
||||
|
||||
$id = $_POST["id"] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"message" => "Cow ID is required"
|
||||
]);
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
$gender = strtolower(trim($_POST["gender"] ?? ""));
|
||||
|
||||
$weight = (float) ($_POST["weight"] ?? 0);
|
||||
if ($weight <= 0) {
|
||||
$weight = 1;
|
||||
}
|
||||
|
||||
$data = [
|
||||
"code" => trim($_POST["code"] ?? ""),
|
||||
"name" => trim($_POST["name"] ?? ""),
|
||||
"breed" => trim($_POST["breed"] ?? ""),
|
||||
"gender" => $gender,
|
||||
"birthDate" => trim($_POST["birthDate"] ?? ""),
|
||||
"weight" => $weight,
|
||||
"note" => trim($_POST["description"] ?? ""),
|
||||
"color" => trim($_POST["color"] ?? ""),
|
||||
"photoUrl" => trim($_POST["photoUrl"] ?? ""),
|
||||
"status" => trim($_POST["status"] ?? "healthy")
|
||||
];
|
||||
|
||||
$response = updateCow(
|
||||
$id,
|
||||
$data
|
||||
);
|
||||
|
||||
echo json_encode($response);
|
||||
|
||||
break;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Delete Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
case "delete":
|
||||
|
||||
$id = $_POST["id"] ?? null;
|
||||
|
||||
if (!$id) {
|
||||
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"message" => "Cow ID is required"
|
||||
]);
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
$response = deleteCow($id);
|
||||
|
||||
echo json_encode($response);
|
||||
|
||||
break;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
default:
|
||||
|
||||
echo json_encode([
|
||||
|
||||
"success" => false,
|
||||
|
||||
"message" => "Invalid action"
|
||||
|
||||
]);
|
||||
|
||||
break;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
require_once "../config/auth.php";
|
||||
require_once "../config/api.php";
|
||||
|
||||
requireLogin();
|
||||
|
||||
header("Content-Type: application/json");
|
||||
|
||||
$token =
|
||||
getToken();
|
||||
|
||||
$cowId =
|
||||
$_GET["cowId"] ?? "";
|
||||
|
||||
$data =
|
||||
apiGet(
|
||||
"/monitoring/latest/" . $cowId,
|
||||
$token
|
||||
);
|
||||
|
||||
echo json_encode($data);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
require_once "../config/auth.php";
|
||||
require_once "../config/api.php";
|
||||
|
||||
requireLogin();
|
||||
|
||||
header("Content-Type: application/json");
|
||||
|
||||
$token = getToken();
|
||||
|
||||
$cowId = $_GET["cowId"] ?? "";
|
||||
$date = $_GET["date"] ?? null; // expected YYYY-MM-DD
|
||||
|
||||
if (!$cowId) {
|
||||
echo json_encode(["success" => false, "message" => "cowId required"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$year = $month = $day = null;
|
||||
if ($date) {
|
||||
$parts = explode('-', $date);
|
||||
if (count($parts) === 3) {
|
||||
$year = $parts[0]; $month = $parts[1]; $day = $parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
$endpoint = "/monitoring/history/" . $cowId;
|
||||
if ($year && $month && $day) {
|
||||
$endpoint .= "?year={$year}&month={$month}&day={$day}";
|
||||
}
|
||||
|
||||
$result = apiGet($endpoint, $token);
|
||||
|
||||
echo json_encode($result);
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,379 @@
|
|||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Monitoring Aktivitas Real-time (accelX)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const cowSelect = document.getElementById("cowSelect");
|
||||
let currentCow = cowSelect && cowSelect.value ? cowSelect.value : "";
|
||||
|
||||
const MAX_POINTS = 30; // jumlah titik chart yang disimpan
|
||||
const CHART_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.4/chart.umd.min.js";
|
||||
|
||||
let activityChart;
|
||||
let historyData = []; // { timestamp, accelX }[]
|
||||
let lastPlottedTimestamp = null; // hanya push ke chart kalau timestamp data berubah (data baru masuk)
|
||||
let chartReady = null; // promise, biar renderChart() tidak dipanggil sebelum library siap
|
||||
let resizeTimer = null;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Load Chart.js (dynamic, hindari race condition)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureChartJs() {
|
||||
|
||||
if (chartReady) {
|
||||
return chartReady;
|
||||
}
|
||||
|
||||
if (typeof Chart !== "undefined") {
|
||||
chartReady = Promise.resolve();
|
||||
return chartReady;
|
||||
}
|
||||
|
||||
chartReady = loadScript(CHART_JS_URL).catch((error) => {
|
||||
console.error("Gagal memuat Chart.js dari CDN. Periksa koneksi internet / ad-blocker.", error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return chartReady;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helper: deteksi layar mobile
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function isMobileScreen() {
|
||||
return window.innerWidth < 576;
|
||||
}
|
||||
|
||||
function isTabletScreen() {
|
||||
return window.innerWidth < 768;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Render Chart
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function renderChart(data) {
|
||||
|
||||
const ctx = document.getElementById("activityChart");
|
||||
if (!ctx || typeof Chart === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Urutkan lama -> baru khusus untuk chart biar tren terbaca alami
|
||||
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
|
||||
const mobile = isMobileScreen();
|
||||
const tablet = isTabletScreen();
|
||||
|
||||
const labels = sorted.map(d => {
|
||||
const t = new Date(d.timestamp);
|
||||
if (isNaN(t.getTime())) return "";
|
||||
return mobile
|
||||
? t.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
|
||||
: t.toLocaleString(undefined, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
});
|
||||
const values = sorted.map(d => parseFloat(d.accelX) || 0);
|
||||
|
||||
if (activityChart) activityChart.destroy();
|
||||
|
||||
const tickFont = mobile ? 10 : 12;
|
||||
const legendFont = mobile ? 11 : 13;
|
||||
const maxTicks = mobile ? 4 : (tablet ? 6 : 8);
|
||||
const pointRadius = mobile ? 1.5 : 2;
|
||||
|
||||
activityChart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: "Aktivitas - accelX",
|
||||
data: values,
|
||||
borderColor: "#34d399",
|
||||
backgroundColor: "rgba(52, 211, 153, 0.15)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
borderWidth: mobile ? 1.5 : 2,
|
||||
pointRadius: pointRadius,
|
||||
pointBackgroundColor: "#10b981"
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
layout: {
|
||||
padding: mobile ? 4 : 8
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: !mobile,
|
||||
labels: { color: "#e2e8f0", font: { size: legendFont } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: "#94a3b8", maxTicksLimit: maxTicks, font: { size: tickFont } },
|
||||
grid: { color: "rgba(255,255,255,0.05)" }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: "#94a3b8", font: { size: tickFont } },
|
||||
grid: { color: "rgba(255,255,255,0.05)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function resetChart() {
|
||||
|
||||
historyData = [];
|
||||
lastPlottedTimestamp = null;
|
||||
|
||||
if (activityChart) {
|
||||
activityChart.destroy();
|
||||
activityChart = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resize handling (debounced)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
|
||||
clearTimeout(resizeTimer);
|
||||
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (historyData.length > 0) {
|
||||
renderChart(historyData);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Change Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (cowSelect) {
|
||||
|
||||
cowSelect.addEventListener(
|
||||
|
||||
"change",
|
||||
|
||||
function () {
|
||||
|
||||
currentCow = this.value;
|
||||
|
||||
resetChart();
|
||||
loadAktivitas();
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Load Data Aktivitas
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function loadAktivitas() {
|
||||
|
||||
if (!currentCow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
|
||||
"../ajax/dashboard.php?cowId=" +
|
||||
|
||||
currentCow
|
||||
|
||||
);
|
||||
|
||||
const result =
|
||||
await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
console.log(result.message);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
console.log("Data aktivitas tidak tersedia untuk sapi ini.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateAktivitas(data);
|
||||
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
|
||||
console.log(error);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ambil Nilai accelX
|
||||
|--------------------------------------------------------------------------
|
||||
| Sesuai skema BE (createMonitoringSchema): sensor.movement hanya berisi
|
||||
| field accelX (number). Tidak ada accelY di data yang dikirim device.
|
||||
*/
|
||||
|
||||
function extractAccelX(movement) {
|
||||
|
||||
if (!movement || typeof movement !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (typeof movement.accelX !== "undefined" && movement.accelX !== null)
|
||||
? movement.accelX
|
||||
: null;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Update Card & Chart
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function updateAktivitas(data) {
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ambil nilai sensor accelX dari data.movement (sesuai skema BE).
|
||||
const movement = (typeof data.movement === "object" && data.movement !== null) ? data.movement : null;
|
||||
const accelX = extractAccelX(movement);
|
||||
|
||||
const movementStatus = movement && typeof movement.status !== "undefined" ? movement.status : null;
|
||||
|
||||
// Card nilai aktivitas (accelX)
|
||||
const valAktivitas = document.getElementById("val-aktivitas");
|
||||
if (valAktivitas) {
|
||||
valAktivitas.innerText = (accelX !== null && typeof accelX !== "undefined") ? accelX : "-";
|
||||
}
|
||||
|
||||
// Badge Diam / Bergerak (berdasarkan movement.status: 0 = Diam, selain itu Bergerak)
|
||||
const valGerak = document.getElementById("val-gerak");
|
||||
if (valGerak && movementStatus !== null && typeof movementStatus !== "undefined") {
|
||||
const status = Number(movementStatus);
|
||||
if (status === 0) {
|
||||
valGerak.className = "badge bg-light text-dark px-3 py-1 rounded-pill";
|
||||
valGerak.innerText = "DIAM";
|
||||
} else {
|
||||
valGerak.className = "badge bg-success px-3 py-1 rounded-pill";
|
||||
valGerak.innerText = "BERGERAK";
|
||||
}
|
||||
}
|
||||
|
||||
// Status online/offline collar
|
||||
const lastUpdate = data.timestamp ? new Date(data.timestamp) : null;
|
||||
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
|
||||
const isOnline = age < 30000;
|
||||
|
||||
const status = document.getElementById("status");
|
||||
if (status) {
|
||||
status.innerText = isOnline ? "ONLINE" : "OFFLINE";
|
||||
status.className = "value-text " + (isOnline ? "text-success" : "text-danger");
|
||||
}
|
||||
|
||||
const elLast = document.getElementById("lastUpdate");
|
||||
if (elLast && lastUpdate) {
|
||||
elLast.innerText = lastUpdate.toLocaleString();
|
||||
}
|
||||
|
||||
// Chart: hanya tambah titik & render ulang kalau ada data BARU dari
|
||||
// device (timestamp berbeda dari titik terakhir yang sudah diplot).
|
||||
if (accelX !== null && typeof accelX !== "undefined" && data.timestamp) {
|
||||
|
||||
const isNewData = data.timestamp !== lastPlottedTimestamp;
|
||||
|
||||
if (isNewData) {
|
||||
|
||||
lastPlottedTimestamp = data.timestamp;
|
||||
|
||||
historyData.push({ timestamp: data.timestamp, accelX: accelX });
|
||||
|
||||
if (historyData.length > MAX_POINTS) {
|
||||
historyData.shift();
|
||||
}
|
||||
|
||||
renderChart(historyData);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Init & Refresh
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function start() {
|
||||
|
||||
try {
|
||||
await ensureChartJs();
|
||||
} catch (error) {
|
||||
// Chart.js gagal dimuat (mis. offline / CDN diblokir).
|
||||
// Card aktivitas tetap jalan normal, hanya grafik yang tidak tampil.
|
||||
const cardChart = document.getElementById("activityChart");
|
||||
if (cardChart && cardChart.parentElement) {
|
||||
cardChart.parentElement.innerHTML =
|
||||
'<p class="text-muted mb-0">Grafik tidak dapat dimuat (Chart.js gagal diakses). Data aktivitas tetap diperbarui pada card di atas.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
loadAktivitas();
|
||||
|
||||
setInterval(loadAktivitas, 2000);
|
||||
|
||||
}
|
||||
|
||||
start();
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { auth } from "./firebase.js";
|
||||
|
||||
const refreshSessionToken = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken(true);
|
||||
await fetch("/session.php", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token, uid: user.uid, email: user.email }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AUTH] Failed to refresh token:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleTokenRefresh = () => {
|
||||
setInterval(() => {
|
||||
refreshSessionToken();
|
||||
}, 1000 * 60 * 4); // refresh setiap 4 menit
|
||||
};
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
auth.onAuthStateChanged((user) => {
|
||||
if (user) {
|
||||
refreshSessionToken();
|
||||
scheduleTokenRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("focus", () => {
|
||||
refreshSessionToken();
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
refreshSessionToken();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const assignForm = document.getElementById('assignForm');
|
||||
const assignTableBody = document.getElementById('assignmentTableBody');
|
||||
const cowSelect = document.querySelector('select[name="cowId"]');
|
||||
const collarSelect = document.querySelector('select[name="collarId"]');
|
||||
|
||||
const initialCowOptions = cowSelect
|
||||
? Array.from(cowSelect.options)
|
||||
.filter(option => option.value)
|
||||
.map(option => ({ value: option.value, text: option.text }))
|
||||
: [];
|
||||
const initialCollarOptions = collarSelect
|
||||
? Array.from(collarSelect.options)
|
||||
.filter(option => option.value)
|
||||
.map(option => ({ value: option.value, text: option.text }))
|
||||
: [];
|
||||
|
||||
function refreshDropdowns(assignedCowIds, assignedCollarIds) {
|
||||
if (cowSelect) {
|
||||
cowSelect.innerHTML = '';
|
||||
cowSelect.appendChild(new Option('-- Select Cow --', ''));
|
||||
for (const option of initialCowOptions) {
|
||||
if (assignedCowIds.has(option.value)) continue;
|
||||
cowSelect.appendChild(new Option(option.text, option.value));
|
||||
}
|
||||
}
|
||||
|
||||
if (collarSelect) {
|
||||
collarSelect.innerHTML = '';
|
||||
collarSelect.appendChild(new Option('-- Select Collar --', ''));
|
||||
for (const option of initialCollarOptions) {
|
||||
if (assignedCollarIds.has(option.value)) continue;
|
||||
collarSelect.appendChild(new Option(option.text, option.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssignments() {
|
||||
if (!assignTableBody) {
|
||||
console.error('Assignment table body element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('action', 'list');
|
||||
const res = await fetch('../ajax/assignment.php', { method: 'POST', body: form });
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
alert(json.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeAssignments = Array.isArray(json.data)
|
||||
? json.data.filter(a => a.active)
|
||||
: [];
|
||||
|
||||
const assignedCowIds = new Set(activeAssignments.map(a => a.cowId).filter(Boolean));
|
||||
const assignedCollarIds = new Set(activeAssignments.map(a => a.collarId).filter(Boolean));
|
||||
refreshDropdowns(assignedCowIds, assignedCollarIds);
|
||||
|
||||
assignTableBody.innerHTML = '';
|
||||
if (activeAssignments.length === 0) {
|
||||
const emptyRow = document.createElement('tr');
|
||||
emptyRow.innerHTML = '<td colspan="6" class="text-center">No active assignments</td>';
|
||||
assignTableBody.appendChild(emptyRow);
|
||||
} else {
|
||||
for (const a of activeAssignments) {
|
||||
const status = a.active ? 'Assigned' : 'Inactive';
|
||||
const cowLabel = a.cowName ? a.cowName : a.cowId ?? '';
|
||||
const deviceLabel = a.deviceName ? a.deviceName : a.collarId ?? '';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${a.id}</td>
|
||||
<td>${cowLabel}</td>
|
||||
<td>${deviceLabel}</td>
|
||||
<td>${status}</td>
|
||||
<td>${a.assignedAt ?? ''}</td>
|
||||
<td><button class="btn btn-sm btn-danger detachBtn" data-cowid="${a.cowId}">Detach</button></td>
|
||||
`;
|
||||
assignTableBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.detachBtn').forEach(btn => {
|
||||
btn.addEventListener('click', async function () {
|
||||
const cowId = this.dataset.cowid;
|
||||
if (!confirm('Detach this assignment?')) return;
|
||||
const form = new FormData();
|
||||
form.append('action', 'detach');
|
||||
form.append('cowId', cowId);
|
||||
const res = await fetch('../ajax/assignment.php', { method: 'POST', body: form });
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
alert('Detached');
|
||||
loadAssignments();
|
||||
} else {
|
||||
alert(j.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (assignForm) {
|
||||
assignForm.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
const cowId = formData.get('cowId');
|
||||
const collarId = formData.get('collarId');
|
||||
if (!cowId || !collarId) {
|
||||
alert('Select cow and collar');
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('action', 'attach');
|
||||
form.append('cowId', cowId);
|
||||
form.append('collarId', collarId);
|
||||
const res = await fetch('../ajax/assignment.php', { method: 'POST', body: form });
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
alert('Assigned');
|
||||
loadAssignments();
|
||||
} else {
|
||||
alert(j.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadAssignments();
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
auth
|
||||
} from "./firebase.js";
|
||||
|
||||
import {
|
||||
signInWithEmailAndPassword
|
||||
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-auth.js";
|
||||
|
||||
const form =
|
||||
document.getElementById("loginForm");
|
||||
|
||||
if (form) {
|
||||
|
||||
form.addEventListener(
|
||||
"submit",
|
||||
async function (e) {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const email =
|
||||
document.getElementById("email").value;
|
||||
|
||||
const password =
|
||||
document.getElementById("password").value;
|
||||
|
||||
try {
|
||||
|
||||
const credential =
|
||||
await signInWithEmailAndPassword(
|
||||
|
||||
auth,
|
||||
|
||||
email,
|
||||
|
||||
password
|
||||
|
||||
);
|
||||
|
||||
const token =
|
||||
await credential.user.getIdToken();
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
"/session.php",
|
||||
{
|
||||
|
||||
method: "POST",
|
||||
|
||||
headers: {
|
||||
"Content-Type":
|
||||
"application/json"
|
||||
},
|
||||
|
||||
body: JSON.stringify({
|
||||
|
||||
token,
|
||||
|
||||
uid:
|
||||
credential.user.uid,
|
||||
|
||||
email:
|
||||
credential.user.email
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
const result =
|
||||
await response.json();
|
||||
|
||||
if (result.success) {
|
||||
|
||||
window.location =
|
||||
"/pages/dashboard.php";
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
|
||||
alert(error.message);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
if (typeof bootstrap === "undefined") {
|
||||
console.error("Bootstrap JS belum dimuat.");
|
||||
return;
|
||||
}
|
||||
|
||||
const createModalEl = document.getElementById("createCollarModal");
|
||||
const editModalEl = document.getElementById("editCollarModal");
|
||||
const deleteModalEl = document.getElementById("deleteCollarModal");
|
||||
|
||||
const createModal = createModalEl ? new bootstrap.Modal(createModalEl) : null;
|
||||
const editModal = editModalEl ? new bootstrap.Modal(editModalEl) : null;
|
||||
const deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
|
||||
/* CREATE */
|
||||
const createForm = document.getElementById("createCollarForm");
|
||||
if (createForm) {
|
||||
createForm.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = {};
|
||||
formData.forEach((v, k) => data[k] = v);
|
||||
|
||||
const form = new FormData();
|
||||
for (const k in data) form.append(k, data[k]);
|
||||
form.append("action", "create");
|
||||
|
||||
const response = await fetch("../ajax/collar.php", { method: "POST", body: form });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert("Collar berhasil ditambahkan.");
|
||||
createModal.hide();
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* EDIT BUTTON */
|
||||
document.querySelectorAll('.editCollarBtn').forEach(btn => {
|
||||
btn.addEventListener('click', async function () {
|
||||
const id = this.dataset.id;
|
||||
const form = new FormData();
|
||||
form.append('action', 'get');
|
||||
form.append('id', id);
|
||||
|
||||
const response = await fetch('../ajax/collar.php', { method: 'POST', body: form });
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) { alert(result.message); return; }
|
||||
|
||||
const collar = result.data;
|
||||
document.querySelector('#editCollarForm input[name=id]').value = collar.id;
|
||||
document.querySelector('#editCollarForm input[name=serialNumber]').value = collar.serialNumber ?? '';
|
||||
document.querySelector('#editCollarForm input[name=deviceName]').value = collar.deviceName ?? '';
|
||||
document.querySelector('#editCollarForm input[name=hardwareVersion]').value = collar.hardwareVersion ?? '';
|
||||
document.querySelector('#editCollarForm input[name=firmwareVersion]').value = collar.firmwareVersion ?? '';
|
||||
document.querySelector('#editCollarForm input[name=macAddress]').value = collar.macAddress ?? '';
|
||||
document.querySelector('#editCollarForm input[name=simNumber]').value = collar.simNumber ?? '';
|
||||
|
||||
editModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
/* UPDATE */
|
||||
const editForm = document.getElementById('editCollarForm');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
const data = {};
|
||||
formData.forEach((v,k)=> data[k]=v);
|
||||
|
||||
const form = new FormData();
|
||||
for (const k in data) form.append(k, data[k]);
|
||||
form.append('action','update');
|
||||
|
||||
const response = await fetch('../ajax/collar.php', { method: 'POST', body: form });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('Collar berhasil diupdate.');
|
||||
editModal.hide();
|
||||
location.reload();
|
||||
} else { alert(result.message); }
|
||||
});
|
||||
}
|
||||
|
||||
/* SECRET ACTIONS */
|
||||
document.querySelectorAll('.toggle-secret-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const group = this.closest('.input-group');
|
||||
const input = group.querySelector('.secret-input');
|
||||
if (!input) return;
|
||||
const isPassword = input.type === 'password';
|
||||
input.type = isPassword ? 'text' : 'password';
|
||||
this.textContent = isPassword ? 'Hide' : 'Show';
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.copy-secret-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function () {
|
||||
const group = this.closest('.input-group');
|
||||
const input = group.querySelector('.secret-input');
|
||||
if (!input) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(input.value);
|
||||
alert('Device secret copied to clipboard.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Unable to copy device secret.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* DELETE */
|
||||
document.querySelectorAll('.deleteCollarBtn').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = this.dataset.id;
|
||||
const form = document.querySelector('#deleteCollarForm');
|
||||
form.querySelector('input[name=id]').value = id;
|
||||
});
|
||||
});
|
||||
|
||||
const deleteForm = document.getElementById('deleteCollarForm');
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
formData.append('action','delete');
|
||||
|
||||
const response = await fetch('../ajax/collar.php', { method: 'POST', body: formData });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('Collar berhasil dihapus.');
|
||||
deleteModal.hide();
|
||||
location.reload();
|
||||
} else { alert(result.message); }
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bootstrap Check
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (typeof bootstrap === "undefined") {
|
||||
console.error("Bootstrap JS belum dimuat.");
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Toast Notification (pengganti alert())
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function showToast(message, type = "success") {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast-glass ${type}`;
|
||||
const icon = type === "success" ? "fa-circle-check" : "fa-circle-exclamation";
|
||||
toast.innerHTML = `<i class="fas ${icon}"></i><span>${message}</span>`;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add("show"));
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
setTimeout(() => toast.remove(), 350);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Modal
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const createCowModalElement = document.getElementById("createCowModal");
|
||||
const editCowModalElement = document.getElementById("editCowModal");
|
||||
const deleteCowModalElement = document.getElementById("deleteCowModal");
|
||||
|
||||
const createModal = createCowModalElement ? new bootstrap.Modal(createCowModalElement) : null;
|
||||
const editModal = editCowModalElement ? new bootstrap.Modal(editCowModalElement) : null;
|
||||
const deleteModal = deleteCowModalElement ? new bootstrap.Modal(deleteCowModalElement) : null;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CREATE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const createForm = document.getElementById("createCowForm");
|
||||
|
||||
if (createForm) {
|
||||
createForm.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
formData.append("action", "create");
|
||||
|
||||
const response = await fetch("../ajax/cow.php", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast("Cow berhasil ditambahkan.", "success");
|
||||
createModal.hide();
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} else {
|
||||
showToast(result.message || "Gagal menambahkan cow.", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| EDIT BUTTON
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
document.querySelectorAll(".editBtn").forEach(button => {
|
||||
button.addEventListener("click", async function () {
|
||||
const id = this.dataset.id;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("action", "get");
|
||||
form.append("id", id);
|
||||
|
||||
const response = await fetch("../ajax/cow.php", {
|
||||
method: "POST",
|
||||
body: form
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
showToast(result.message || "Gagal memuat data cow.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const cow = result.data;
|
||||
|
||||
document.querySelector("#editCowForm input[name=id]").value = cow.id;
|
||||
|
||||
document.getElementById("editBody").innerHTML = `
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Code</label>
|
||||
<input class="form-control" name="code" value="${cow.code}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" name="name" value="${cow.name}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Breed</label>
|
||||
<input class="form-control" name="breed" value="${cow.breed ?? ""}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Gender</label>
|
||||
<select class="form-select" name="gender">
|
||||
<option value="male" ${cow.gender == "male" ? "selected" : ""}>Male</option>
|
||||
<option value="female" ${cow.gender == "female" ? "selected" : ""}>Female</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Weight</label>
|
||||
<input type="number" class="form-control" name="weight" value="${cow.weight ?? 0}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Birth Date</label>
|
||||
<input type="date" class="form-control" name="birthDate" value="${cow.birthDate ?? ""}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Color</label>
|
||||
<input type="text" class="form-control" name="color" value="${cow.color ?? ""}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="healthy" ${cow.status == "healthy" ? "selected" : ""}>Healthy</option>
|
||||
<option value="sick" ${cow.status == "sick" ? "selected" : ""}>Sick</option>
|
||||
<option value="pregnant" ${cow.status == "pregnant" ? "selected" : ""}>Pregnant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="description">${cow.note ?? ""}</textarea>
|
||||
</div>
|
||||
|
||||
`;
|
||||
|
||||
editModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| UPDATE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const editForm = document.getElementById("editCowForm");
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
formData.append("action", "update");
|
||||
|
||||
const response = await fetch("../ajax/cow.php", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast("Cow berhasil diupdate.", "success");
|
||||
editModal.hide();
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} else {
|
||||
showToast(result.message || "Gagal update cow.", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DELETE BUTTON (buka modal konfirmasi, TIDAK langsung hapus)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
document.querySelectorAll(".deleteBtn").forEach(button => {
|
||||
button.addEventListener("click", function () {
|
||||
const id = this.dataset.id;
|
||||
document.querySelector("#deleteCowForm input[name=id]").value = id;
|
||||
deleteModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DELETE (dieksekusi setelah user konfirmasi di modal)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const deleteForm = document.getElementById("deleteCowForm");
|
||||
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
formData.append("action", "delete");
|
||||
|
||||
const response = await fetch("../ajax/cow.php", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast("Cow berhasil dihapus.", "success");
|
||||
deleteModal.hide();
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} else {
|
||||
showToast(result.message || "Gagal menghapus cow.", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Dashboard Monitoring
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MAP
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
let map;
|
||||
let marker;
|
||||
|
||||
const cowSelect =
|
||||
document.getElementById("cowSelect");
|
||||
|
||||
let currentCow =
|
||||
cowSelect && cowSelect.value ? cowSelect.value : "";
|
||||
const googleMapsApiKey = typeof GOOGLE_MAPS_API_KEY !== "undefined" ? GOOGLE_MAPS_API_KEY : "YOUR_GOOGLE_MAPS_API_KEY";
|
||||
const useGoogleMaps = googleMapsApiKey && googleMapsApiKey !== "YOUR_GOOGLE_MAPS_API_KEY";
|
||||
let mapProvider = useGoogleMaps ? "google" : "leaflet";
|
||||
let mapAssetsLoaded = false;
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCss(href) {
|
||||
if (document.querySelector(`link[href="${href}"]`)) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
async function loadMapAssets() {
|
||||
if (mapAssetsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapProvider === "google") {
|
||||
try {
|
||||
await loadScript(`https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}`);
|
||||
} catch (error) {
|
||||
console.error("Google Maps failed to load, falling back to Leaflet.", error);
|
||||
mapProvider = "leaflet";
|
||||
}
|
||||
}
|
||||
|
||||
if (mapProvider === "leaflet") {
|
||||
loadCss("https://unpkg.com/leaflet/dist/leaflet.css");
|
||||
await loadScript("https://unpkg.com/leaflet/dist/leaflet.js");
|
||||
}
|
||||
|
||||
mapAssetsLoaded = true;
|
||||
}
|
||||
|
||||
function initGoogleMap(position) {
|
||||
if (!window.google || !window.google.maps) {
|
||||
console.error("Google Maps API not loaded");
|
||||
return;
|
||||
}
|
||||
map = new google.maps.Map(document.getElementById("map"), {
|
||||
center: position,
|
||||
zoom: 18,
|
||||
disableDefaultUI: false,
|
||||
streetViewControl: false,
|
||||
mapTypeControl: false,
|
||||
});
|
||||
}
|
||||
|
||||
function initLeafletMap(position) {
|
||||
if (typeof L === "undefined") {
|
||||
console.error("Leaflet not loaded");
|
||||
return;
|
||||
}
|
||||
map = L.map("map").setView([position.lat, position.lng], 18);
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
async function ensureMap(position) {
|
||||
await loadMapAssets();
|
||||
if (!map) {
|
||||
if (mapProvider === "google") {
|
||||
initGoogleMap(position);
|
||||
} else {
|
||||
initLeafletMap(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Change Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (cowSelect) {
|
||||
|
||||
cowSelect.addEventListener(
|
||||
|
||||
"change",
|
||||
|
||||
function () {
|
||||
|
||||
currentCow = this.value;
|
||||
|
||||
loadMonitoring();
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Load Monitoring
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function loadMonitoring() {
|
||||
|
||||
if (!currentCow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
|
||||
"../ajax/dashboard.php?cowId=" +
|
||||
|
||||
currentCow
|
||||
|
||||
);
|
||||
|
||||
const result =
|
||||
await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
console.log(result.message);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
console.log('No monitoring data available for selected cow.');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateDashboard(data);
|
||||
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
|
||||
console.log(error);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Update Dashboard
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function updateDashboard(data) {
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const lastUpdate = new Date(data.timestamp);
|
||||
const age = Date.now() - lastUpdate.getTime();
|
||||
const isOnline = age < 30000;
|
||||
|
||||
if (data.gps && typeof data.gps.latitude !== "undefined" && typeof data.gps.longitude !== "undefined") {
|
||||
const lat = data.gps.latitude;
|
||||
const lng = data.gps.longitude;
|
||||
const position = { lat, lng };
|
||||
|
||||
await ensureMap(position);
|
||||
|
||||
if (mapProvider === "google") {
|
||||
if (!marker) {
|
||||
marker = new google.maps.Marker({
|
||||
position,
|
||||
map,
|
||||
title: "Smart Collar"
|
||||
});
|
||||
} else {
|
||||
marker.setPosition(position);
|
||||
}
|
||||
map.panTo(position);
|
||||
} else if (mapProvider === "leaflet") {
|
||||
if (!marker) {
|
||||
marker = L.marker([position.lat, position.lng]).addTo(map);
|
||||
} else {
|
||||
marker.setLatLng([position.lat, position.lng]);
|
||||
}
|
||||
map.panTo([position.lat, position.lng]);
|
||||
}
|
||||
}
|
||||
|
||||
// Render device cards (support single object or array)
|
||||
const cardsContainer = document.getElementById("deviceCards");
|
||||
if (cardsContainer) {
|
||||
const devices = Array.isArray(data) ? data : [data];
|
||||
renderDeviceCards(devices, cardsContainer);
|
||||
}
|
||||
|
||||
// Safely update any legacy elements if present (page may have removed them)
|
||||
const elTemp = document.getElementById("temperature");
|
||||
if (elTemp && typeof data.temperature !== 'undefined') elTemp.innerHTML = (typeof data.temperature === 'object' && data.temperature.value ? data.temperature.value : data.temperature) + " °C";
|
||||
|
||||
// Update new dashboard stat fields if present
|
||||
const valSuhu = document.getElementById('val-suhu');
|
||||
if (valSuhu && typeof data.temperature !== 'undefined') {
|
||||
const t = (typeof data.temperature === 'object' && data.temperature.value ? data.temperature.value : data.temperature);
|
||||
valSuhu.innerText = t !== null && typeof t !== 'undefined' ? t : '-';
|
||||
}
|
||||
|
||||
const elSignal = document.getElementById("signal");
|
||||
if (elSignal && typeof data.signal !== 'undefined') elSignal.innerHTML = data.signal + " dBm";
|
||||
|
||||
const valAkt = document.getElementById('val-aktivitas');
|
||||
if (valAkt) {
|
||||
// prefer movement status from backend (Diam/Bergerak)
|
||||
if (data.movement && typeof data.movement.status !== 'undefined') {
|
||||
const status = data.movement.status;
|
||||
// Map numeric status to Indonesian labels: 0=Diam, 1-2=Bergerak
|
||||
const statusLabel = status === 0 ? 'DIAM' : 'BERGERAK';
|
||||
valAkt.innerText = statusLabel;
|
||||
} else if (typeof data.activity !== 'undefined') {
|
||||
valAkt.innerText = data.activity;
|
||||
} else {
|
||||
valAkt.innerText = '-';
|
||||
}
|
||||
}
|
||||
|
||||
if (status) { status.innerHTML = isOnline ? "ONLINE" : "OFFLINE"; status.className = isOnline ? "text-success" : "text-danger"; }
|
||||
|
||||
const elLat = document.getElementById("latitude");
|
||||
if (elLat && data.gps && typeof data.gps.latitude !== 'undefined') elLat.innerHTML = data.gps.latitude;
|
||||
const elLng = document.getElementById("longitude");
|
||||
if (elLng && data.gps && typeof data.gps.longitude !== 'undefined') elLng.innerHTML = data.gps.longitude;
|
||||
|
||||
const vLat = document.getElementById('val-lat');
|
||||
const vLng = document.getElementById('val-long');
|
||||
if (vLat && data.gps && typeof data.gps.latitude !== 'undefined') vLat.innerText = data.gps.latitude;
|
||||
if (vLng && data.gps && typeof data.gps.longitude !== 'undefined') vLng.innerText = data.gps.longitude;
|
||||
|
||||
const elLast = document.getElementById("lastUpdate");
|
||||
if (elLast && lastUpdate) elLast.innerHTML = lastUpdate.toLocaleString();
|
||||
|
||||
// Update register badge condition if present
|
||||
const valKond = document.getElementById('val-kondisi');
|
||||
if (valKond && typeof data.temperature !== 'undefined') {
|
||||
const t = (typeof data.temperature === 'object' && data.temperature.value ? data.temperature.value : data.temperature);
|
||||
if (t !== null && typeof t !== 'undefined') {
|
||||
if (t >= 40) { valKond.className = 'badge bg-danger px-3 py-1 rounded-pill'; valKond.innerText = 'BAHAYA'; }
|
||||
else if (t >= 39.5) { valKond.className = 'badge bg-warning text-dark px-3 py-1 rounded-pill'; valKond.innerText = 'WASPADA'; }
|
||||
else { valKond.className = 'badge bg-light text-dark px-3 py-1 rounded-pill'; valKond.innerText = 'NORMAL'; }
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Maps
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
document
|
||||
.getElementById("maps")
|
||||
.href =
|
||||
data.gps.linkMaps;
|
||||
|
||||
}
|
||||
|
||||
function renderDeviceCards(devices, container) {
|
||||
container.innerHTML = "";
|
||||
devices.forEach((d) => {
|
||||
const id = d.deviceId || d.collarId || d.id || currentCow || "-";
|
||||
const tempVal = (typeof d.temperature !== 'undefined') ? d.temperature : null;
|
||||
const temp = tempVal !== null ? tempVal + ' °C' : '-';
|
||||
const rawMovement = (typeof d.movement !== 'undefined') ? d.movement : (typeof d.movementValue !== 'undefined' ? d.movementValue : null);
|
||||
let movementVal = null;
|
||||
// backend provides movement as object { accelX, status }
|
||||
if (rawMovement !== null && typeof rawMovement === 'number') movementVal = rawMovement;
|
||||
else if (rawMovement !== null && typeof rawMovement === 'object') {
|
||||
if (typeof rawMovement.accelX === 'number') movementVal = rawMovement.accelX;
|
||||
else if (typeof rawMovement.value === 'number') movementVal = rawMovement.value;
|
||||
}
|
||||
// prefer movement status string for display when available
|
||||
const movementStatusStr = (rawMovement && typeof rawMovement === 'object' && rawMovement.status) ? rawMovement.status : null;
|
||||
const activity = movementVal !== null ? movementVal : (movementStatusStr !== null ? movementStatusStr : (d.activity || d.motion || d.activityState || '-'));
|
||||
const lat = d.gps && typeof d.gps.latitude !== 'undefined' ? d.gps.latitude : '-';
|
||||
const lng = d.gps && typeof d.gps.longitude !== 'undefined' ? d.gps.longitude : '-';
|
||||
const lastUpdate = d.timestamp ? new Date(d.timestamp) : null;
|
||||
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
|
||||
const status = age < 30000 ? 'ONLINE' : 'OFFLINE';
|
||||
const statusClass = age < 30000 ? 'text-success' : 'text-danger';
|
||||
|
||||
// Temperature status: threshold — adjust as needed
|
||||
let tempStatus = 'Normal';
|
||||
let tempStatusClass = 'text-success';
|
||||
if (tempVal !== null) {
|
||||
if (tempVal >= 40) { tempStatus = 'Bahaya'; tempStatusClass = 'text-danger'; }
|
||||
else if (tempVal >= 39.5) { tempStatus = 'Waspada'; tempStatusClass = 'text-warning'; }
|
||||
else { tempStatus = 'Normal'; tempStatusClass = 'text-success'; }
|
||||
} else {
|
||||
tempStatus = '-'; tempStatusClass = 'text-muted';
|
||||
}
|
||||
|
||||
// Use movement status from backend (Diam/Bergerak)
|
||||
let actShort = '-';
|
||||
if (movementStatusStr !== null && typeof movementStatusStr === 'number') {
|
||||
const status = Number(movementStatusStr);
|
||||
// Map numeric status to Indonesian labels: 0=Diam, 1-2=Bergerak
|
||||
actShort = status === 0 ? 'Diam' : 'Bergerak';
|
||||
} else if (activity) {
|
||||
// Fallback to activity string if status not available
|
||||
const actLower = ('' + activity).toLowerCase();
|
||||
actShort = (actLower.includes('move') || actLower.includes('moving') || actLower.includes('walk') || actLower.includes('run')) ? 'Bergerak' : (actLower.includes('sleep') || actLower.includes('rest') || actLower.includes('idle') ? 'Diam' : (activity === '-' ? '-' : activity));
|
||||
}
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'd-flex flex-row gap-3 mb-3 align-items-stretch';
|
||||
|
||||
// Temp card
|
||||
const tempCard = document.createElement('div');
|
||||
tempCard.className = 'card text-center shadow-sm device-card bg-gradient-suhu';
|
||||
tempCard.style.flex = '1 1 32%';
|
||||
tempCard.innerHTML = `
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Suhu Tubuh</h6>
|
||||
<div class="h2 mb-1">${temp}</div>
|
||||
<small class="${tempStatusClass}">${tempStatus}</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Activity card
|
||||
const actCard = document.createElement('div');
|
||||
actCard.className = 'card text-center shadow-sm device-card bg-gradient-aktivitas';
|
||||
actCard.style.flex = '1 1 32%';
|
||||
actCard.innerHTML = `
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Aktivitas</h6>
|
||||
<div class="h3 mb-1">${actShort}</div>
|
||||
<small class="text-muted">${fmt(movementVal !== null ? movementVal : activity)}</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// GPS card
|
||||
const gpsCard = document.createElement('div');
|
||||
gpsCard.className = 'card text-center shadow-sm device-card bg-gradient-lokasi';
|
||||
gpsCard.style.flex = '1 1 32%';
|
||||
const mapsHref = d.gps && d.gps.linkMaps ? d.gps.linkMaps : `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
|
||||
gpsCard.innerHTML = `
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Koordinat GPS</h6>
|
||||
<div class="mb-1">Lat: ${lat}</div>
|
||||
<div class="mb-2">Lng: ${lng}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Status card
|
||||
const statusCard = document.createElement('div');
|
||||
statusCard.className = 'card text-center shadow-sm device-card';
|
||||
statusCard.style.flex = '1 1 20%';
|
||||
statusCard.innerHTML = `
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Status</h6>
|
||||
<div class="h5 mb-1 ${statusClass}">${status}</div>
|
||||
<small class="text-muted">${lastUpdate ? lastUpdate.toLocaleString() : '-'}</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
row.appendChild(tempCard);
|
||||
row.appendChild(actCard);
|
||||
row.appendChild(gpsCard);
|
||||
row.appendChild(statusCard);
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
// small utility to format objects safely
|
||||
function fmt(v) {
|
||||
if (v === null || typeof v === 'undefined') return '-';
|
||||
if (typeof v === 'object') {
|
||||
if (typeof v.value !== 'undefined') return v.value;
|
||||
if (typeof v.movement !== 'undefined') return v.movement;
|
||||
if (typeof v.type !== 'undefined') return v.type;
|
||||
try { return JSON.stringify(v); } catch (e) { return String(v); }
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Refresh
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
loadMonitoring();
|
||||
|
||||
setInterval(
|
||||
|
||||
loadMonitoring,
|
||||
|
||||
2000
|
||||
|
||||
);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import {
|
||||
initializeApp
|
||||
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-app.js";
|
||||
|
||||
import {
|
||||
getAuth
|
||||
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-auth.js";
|
||||
|
||||
console.log("APP =", window.APP);
|
||||
console.log("Firebase =", window.APP?.firebase);
|
||||
|
||||
const app = initializeApp(window.APP.firebase);
|
||||
|
||||
export const auth = getAuth(app);
|
||||
|
|
@ -0,0 +1,821 @@
|
|||
/* History page script */
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const loadBtn = document.getElementById('loadHistory');
|
||||
const cowSelect = document.getElementById('cowSelect');
|
||||
const dateStart = document.getElementById('dateStart');
|
||||
const dateEnd = document.getElementById('dateEnd');
|
||||
const historyBody = document.getElementById('historyBody');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const paginationControls = document.getElementById('paginationControls');
|
||||
const paginationInfo = document.getElementById('paginationInfo');
|
||||
const exportCsvBtn = document.getElementById('exportCsvBtn');
|
||||
const exportPdfBtn = document.getElementById('exportPdfBtn');
|
||||
|
||||
let allData = [];
|
||||
let filteredData = [];
|
||||
let currentPage = 1;
|
||||
const perPage = 10;
|
||||
let tempChart = null;
|
||||
let cowDetails = null; // Store cow details
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Quick Date Presets
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
document.querySelectorAll('.btn-preset').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
const today = new Date();
|
||||
const range = this.dataset.range;
|
||||
|
||||
if (range === 'today') {
|
||||
dateStart.value = formatDate(today);
|
||||
dateEnd.value = formatDate(today);
|
||||
} else {
|
||||
const days = parseInt(range, 10);
|
||||
const past = new Date();
|
||||
past.setDate(today.getDate() - (days - 1));
|
||||
dateStart.value = formatDate(past);
|
||||
dateEnd.value = formatDate(today);
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
});
|
||||
});
|
||||
|
||||
function formatDate(d) {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bikin daftar tanggal antara start - end (inklusif)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function getDateRange(start, end) {
|
||||
const dates = [];
|
||||
let current = new Date(start);
|
||||
const last = new Date(end);
|
||||
|
||||
while (current <= last) {
|
||||
dates.push(formatDate(current));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ambil satu hari data dari API asli (GET ?cowId=..&date=..)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function fetchCowDetails(cowId) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("action", "get");
|
||||
formData.append("id", cowId);
|
||||
|
||||
const resp = await fetch("../ajax/cow.php", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('Gagal memuat detail sapi:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOneDay(cowId, date) {
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL_HISTORY}?cowId=${encodeURIComponent(cowId)}&date=${encodeURIComponent(date)}`);
|
||||
const result = await resp.json();
|
||||
if (!result.success || !Array.isArray(result.data)) return [];
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
console.error(`Gagal memuat data tanggal ${date}:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Load Data (loop tiap tanggal dalam rentang, lalu digabung)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function loadHistory() {
|
||||
const cowId = cowSelect.value;
|
||||
|
||||
if (!cowId) {
|
||||
alert('Pilih sapi terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dateStart.value || !dateEnd.value) {
|
||||
alert('Pilih rentang tanggal terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dateStart.value > dateEnd.value) {
|
||||
alert('Tanggal awal tidak boleh lebih besar dari tanggal akhir');
|
||||
return;
|
||||
}
|
||||
|
||||
renderLoadingState();
|
||||
|
||||
const dates = getDateRange(dateStart.value, dateEnd.value);
|
||||
|
||||
// Ambil detail sapi terlebih dahulu
|
||||
cowDetails = await fetchCowDetails(cowId);
|
||||
|
||||
// Ambil data tiap tanggal secara paralel
|
||||
const results = await Promise.all(dates.map(date => fetchOneDay(cowId, date)));
|
||||
allData = results.flat();
|
||||
|
||||
// Urutkan berdasarkan waktu terbaru dulu
|
||||
allData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
currentPage = 1;
|
||||
applySearch();
|
||||
updateSummary(allData);
|
||||
renderChart(allData);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helper: ambil label movement dari struktur asli (object/string)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function getMovementLabel(movement) {
|
||||
// Simplified logic: only show "Diam" or "Aktif"
|
||||
if (!movement || !movement.status) return 'Diam';
|
||||
|
||||
return movement.status === 0 ? 'Diam' : 'Gerak';
|
||||
}
|
||||
|
||||
function isMovementActive(movement) {
|
||||
if (!movement || !movement.status) return false;
|
||||
return movement.status !== 0;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Search / Filter Client-side
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function calculateAge(birthDate) {
|
||||
if (!birthDate) return '-';
|
||||
const today = new Date();
|
||||
const birth = new Date(birthDate);
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age + ' tahun';
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
const keyword = (searchInput.value || '').toLowerCase().trim();
|
||||
|
||||
if (!keyword) {
|
||||
filteredData = [...allData];
|
||||
} else {
|
||||
filteredData = allData.filter(item => {
|
||||
const movementLabel = getMovementLabel(item.movement);
|
||||
const movementText = isMovementActive(item.movement) ? 'Gerak' : 'Diam';
|
||||
const cowInfo = cowDetails ? [
|
||||
cowDetails.name ?? '',
|
||||
cowDetails.code ?? '',
|
||||
cowDetails.breed ?? '',
|
||||
cowDetails.gender ?? '',
|
||||
cowDetails.weight ?? '',
|
||||
'normal',
|
||||
movementText
|
||||
].join(' ') : '';
|
||||
const haystack = [
|
||||
item.timestamp ?? '',
|
||||
item.temperature ?? '',
|
||||
cowInfo,
|
||||
item.gps?.latitude ?? '',
|
||||
item.gps?.longitude ?? ''
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
currentPage = 1;
|
||||
applySearch();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Render Table + Pagination
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function renderTable() {
|
||||
if (filteredData.length === 0) {
|
||||
historyBody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-4">Tidak ada data</td></tr>';
|
||||
paginationControls.innerHTML = '';
|
||||
paginationInfo.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / perPage);
|
||||
if (currentPage > totalPages) currentPage = totalPages;
|
||||
|
||||
const startIdx = (currentPage - 1) * perPage;
|
||||
const pageData = filteredData.slice(startIdx, startIdx + perPage);
|
||||
|
||||
const rows = pageData.map(item => {
|
||||
const t = new Date(item.timestamp);
|
||||
const time = isNaN(t.getTime()) ? '-' : t.toLocaleString();
|
||||
const temp = (item.temperature !== undefined && item.temperature !== null) ? item.temperature + ' °C' : '-';
|
||||
const movementLabel = getMovementLabel(item.movement);
|
||||
const movementClass = isMovementActive(item.movement) ? 'badge-movement-active' : 'badge-movement-idle';
|
||||
const gps = item.gps ? `${item.gps.latitude || '-'}, ${item.gps.longitude || '-'}` : '-';
|
||||
|
||||
// Cow details
|
||||
const cowName = cowDetails ? cowDetails.name : '-';
|
||||
const cowCode = cowDetails ? cowDetails.code : '-';
|
||||
const cowBreed = cowDetails ? cowDetails.breed : '-';
|
||||
const cowGender = cowDetails ? cowDetails.gender : '-';
|
||||
const cowWeight = cowDetails ? cowDetails.weight + ' kg' : '-';
|
||||
const cowAge = cowDetails ? calculateAge(cowDetails.birthDate) : '-';
|
||||
const cowCondition = 'Normal';
|
||||
|
||||
return `<tr>
|
||||
<td>${time}</td>
|
||||
<td>${temp}</td>
|
||||
<td>${cowCode}</td>
|
||||
<td>${cowName}</td>
|
||||
<td>${cowBreed}</td>
|
||||
<td>${cowGender}</td>
|
||||
<td>${cowAge}</td>
|
||||
<td>${cowWeight}</td>
|
||||
<td>${cowCondition}</td>
|
||||
<td><span class="${movementClass}">${movementLabel}</span></td>
|
||||
<td>${gps}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
historyBody.innerHTML = rows;
|
||||
|
||||
paginationInfo.textContent = `Menampilkan ${startIdx + 1}-${Math.min(startIdx + perPage, filteredData.length)} dari ${filteredData.length} data`;
|
||||
renderPagination(totalPages);
|
||||
}
|
||||
|
||||
function renderPagination(totalPages) {
|
||||
paginationControls.innerHTML = '';
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const createPageItem = (label, page, disabled = false, active = false) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = `page-item ${disabled ? 'disabled' : ''} ${active ? 'active' : ''}`;
|
||||
const a = document.createElement('a');
|
||||
a.className = 'page-link';
|
||||
a.href = '#';
|
||||
a.textContent = label;
|
||||
a.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (disabled) return;
|
||||
currentPage = page;
|
||||
renderTable();
|
||||
});
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
};
|
||||
|
||||
paginationControls.appendChild(createPageItem('«', currentPage - 1, currentPage === 1));
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
paginationControls.appendChild(createPageItem(i, i, false, i === currentPage));
|
||||
}
|
||||
paginationControls.appendChild(createPageItem('»', currentPage + 1, currentPage === totalPages));
|
||||
}
|
||||
|
||||
function renderLoadingState() {
|
||||
historyBody.innerHTML = `
|
||||
<tr><td colspan="11" class="table-loading">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>Memuat data...
|
||||
</td></tr>
|
||||
`;
|
||||
paginationControls.innerHTML = '';
|
||||
paginationInfo.textContent = '';
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Summary Cards
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function updateSummary(data) {
|
||||
const temps = data.map(d => parseFloat(d.temperature)).filter(t => !isNaN(t));
|
||||
|
||||
document.getElementById('totalRecords').textContent = data.length;
|
||||
|
||||
if (temps.length === 0) {
|
||||
document.getElementById('avgTemp').textContent = '-';
|
||||
document.getElementById('maxTemp').textContent = '-';
|
||||
document.getElementById('minTemp').textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
const avg = (temps.reduce((a, b) => a + b, 0) / temps.length).toFixed(1);
|
||||
const max = Math.max(...temps).toFixed(1);
|
||||
const min = Math.min(...temps).toFixed(1);
|
||||
|
||||
document.getElementById('avgTemp').textContent = avg + '°C';
|
||||
document.getElementById('maxTemp').textContent = max + '°C';
|
||||
document.getElementById('minTemp').textContent = min + '°C';
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chart Tren Suhu (Chart.js)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function renderChart(data) {
|
||||
const ctx = document.getElementById('tempChart');
|
||||
if (!ctx) return;
|
||||
|
||||
// Urutkan lama -> baru khusus untuk chart biar tren terbaca alami
|
||||
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
|
||||
const labels = sorted.map(d => {
|
||||
const t = new Date(d.timestamp);
|
||||
return isNaN(t.getTime()) ? '' : t.toLocaleString(undefined, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
const temps = sorted.map(d => parseFloat(d.temperature) || null);
|
||||
|
||||
if (tempChart) tempChart.destroy();
|
||||
|
||||
tempChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Suhu (°C)',
|
||||
data: temps,
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.15)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 2,
|
||||
pointBackgroundColor: '#38bdf8'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#e2e8f0' } }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#94a3b8', maxTicksLimit: 8 },
|
||||
grid: { color: 'rgba(255,255,255,0.05)' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(255,255,255,0.05)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helper: statistik ringkas untuk kop laporan (CSV & PDF)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function calculateStats(data) {
|
||||
const temps = data.map(d => parseFloat(d.temperature)).filter(t => !isNaN(t));
|
||||
const activeCount = data.filter(d => isMovementActive(d.movement)).length;
|
||||
|
||||
return {
|
||||
total: data.length,
|
||||
avg: temps.length ? (temps.reduce((a, b) => a + b, 0) / temps.length).toFixed(1) : '-',
|
||||
max: temps.length ? Math.max(...temps).toFixed(1) : '-',
|
||||
min: temps.length ? Math.min(...temps).toFixed(1) : '-',
|
||||
activeCount,
|
||||
idleCount: data.length - activeCount
|
||||
};
|
||||
}
|
||||
|
||||
function generateReportNumber() {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const rand = String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0');
|
||||
return `SC/RPT/${y}${m}${d}/${rand}`;
|
||||
}
|
||||
|
||||
function formatLongDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Export CSV — format surat laporan terstruktur
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
exportCsvBtn.addEventListener('click', () => {
|
||||
if (filteredData.length === 0) {
|
||||
alert('Tidak ada data untuk diexport.');
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = calculateStats(filteredData);
|
||||
const reportNo = generateReportNumber();
|
||||
const now = new Date();
|
||||
const rows = [];
|
||||
|
||||
const addRow = (...cols) => rows.push(cols);
|
||||
const csvEscape = (val) => `"${String(val ?? '').replace(/"/g, '""')}"`;
|
||||
const blankRow = () => rows.push(['']);
|
||||
|
||||
// ---------- KOP LAPORAN ----------
|
||||
addRow('SMART COLLAR - LAPORAN RIWAYAT MONITORING TERNAK SAPI');
|
||||
addRow('Sistem Monitoring Kesehatan Ternak Sapi');
|
||||
blankRow();
|
||||
addRow('Nomor Laporan', ':', reportNo);
|
||||
addRow('Tanggal Dicetak', ':', now.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' }) + ' ' + now.toLocaleTimeString('id-ID'));
|
||||
addRow('Periode Monitoring', ':', `${formatLongDate(dateStart.value)} s/d ${formatLongDate(dateEnd.value)}`);
|
||||
blankRow();
|
||||
|
||||
// ---------- INFORMASI SAPI ----------
|
||||
addRow('DATA IDENTITAS SAPI');
|
||||
if (cowDetails) {
|
||||
addRow('Kode Sapi', ':', cowDetails.code ?? '-');
|
||||
addRow('Nama Sapi', ':', cowDetails.name ?? '-');
|
||||
addRow('Breed', ':', cowDetails.breed ?? '-');
|
||||
addRow('Gender', ':', cowDetails.gender ?? '-');
|
||||
addRow('Umur', ':', calculateAge(cowDetails.birthDate));
|
||||
addRow('Berat', ':', cowDetails.weight ? cowDetails.weight + ' kg' : '-');
|
||||
} else {
|
||||
addRow('Data sapi tidak tersedia', '', '');
|
||||
}
|
||||
blankRow();
|
||||
|
||||
// ---------- RINGKASAN STATISTIK ----------
|
||||
addRow('RINGKASAN HASIL MONITORING');
|
||||
addRow('Total Catatan Data', ':', stats.total);
|
||||
addRow('Suhu Rata-rata', ':', stats.avg !== '-' ? stats.avg + ' °C' : '-');
|
||||
addRow('Suhu Tertinggi', ':', stats.max !== '-' ? stats.max + ' °C' : '-');
|
||||
addRow('Suhu Terendah', ':', stats.min !== '-' ? stats.min + ' °C' : '-');
|
||||
addRow('Jumlah Catatan Aktif Bergerak', ':', stats.activeCount);
|
||||
addRow('Jumlah Catatan Diam', ':', stats.idleCount);
|
||||
blankRow();
|
||||
|
||||
// ---------- TABEL DATA ----------
|
||||
// Kolom identitas sapi tidak diulang di sini karena sudah tercantum sekali
|
||||
// pada bagian "Data Identitas Sapi" di atas (satu laporan = satu sapi).
|
||||
addRow('DETAIL DATA MONITORING');
|
||||
addRow('Waktu', 'Suhu', 'Kondisi', 'Aktivitas', 'Latitude', 'Longitude');
|
||||
|
||||
filteredData.forEach(item => {
|
||||
const t = new Date(item.timestamp);
|
||||
const time = isNaN(t.getTime()) ? '-' : t.toLocaleString('id-ID');
|
||||
const movementText = isMovementActive(item.movement) ? 'Gerak' : 'Diam';
|
||||
addRow(
|
||||
time,
|
||||
item.temperature ?? '',
|
||||
'Normal',
|
||||
movementText,
|
||||
item.gps?.latitude ?? '',
|
||||
item.gps?.longitude ?? ''
|
||||
);
|
||||
});
|
||||
|
||||
blankRow();
|
||||
addRow(`Laporan ini dibuat secara otomatis oleh Smart Collar System pada ${now.toLocaleString('id-ID')}.`);
|
||||
|
||||
let csvContent = rows.map(row => row.map(csvEscape).join(',')).join('\n');
|
||||
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `laporan-riwayat-monitoring-${dateStart.value}_${dateEnd.value}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
exportPdfBtn.addEventListener('click', () => {
|
||||
if (filteredData.length === 0) {
|
||||
alert('Tidak ada data untuk diexport.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { jsPDF } = window.jspdf;
|
||||
const doc = new jsPDF('p', 'mm', 'a4');
|
||||
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 18;
|
||||
const contentWidth = pageWidth - (2 * margin);
|
||||
|
||||
const stats = calculateStats(filteredData);
|
||||
const reportNo = generateReportNumber();
|
||||
const now = new Date();
|
||||
|
||||
const COLOR_PRIMARY = [37, 99, 153]; // biru kop/aksen
|
||||
const COLOR_DARK = [30, 30, 30];
|
||||
const COLOR_MUTED = [110, 110, 110];
|
||||
const COLOR_LINE = [190, 190, 190];
|
||||
const COLOR_ROW_ALT = [244, 247, 250];
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* KOP SURAT / LETTERHEAD (dicetak di setiap halaman)
|
||||
* ---------------------------------------------------------------- */
|
||||
function drawLetterhead() {
|
||||
doc.setFillColor(...COLOR_PRIMARY);
|
||||
doc.rect(0, 0, pageWidth, 24, 'F');
|
||||
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFontSize(14);
|
||||
doc.text('SMART COLLAR MONITORING SYSTEM', margin, 11);
|
||||
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.text('Sistem Monitoring Kesehatan & Aktivitas Ternak Sapi', margin, 17);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.text(`No. Laporan: ${reportNo}`, pageWidth - margin, 11, { align: 'right' });
|
||||
doc.text(now.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' }), pageWidth - margin, 17, { align: 'right' });
|
||||
|
||||
doc.setDrawColor(...COLOR_PRIMARY);
|
||||
doc.setLineWidth(0.8);
|
||||
doc.line(0, 24, pageWidth, 24);
|
||||
}
|
||||
|
||||
function drawFooter(pageNum, totalPages) {
|
||||
const footerY = pageHeight - 12;
|
||||
doc.setDrawColor(...COLOR_LINE);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(margin, footerY - 5, pageWidth - margin, footerY - 5);
|
||||
|
||||
doc.setFontSize(7.5);
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setTextColor(...COLOR_MUTED);
|
||||
doc.text('Dokumen ini dihasilkan otomatis oleh Smart Collar System dan sah tanpa tanda tangan basah.', margin, footerY);
|
||||
doc.text(`Halaman ${pageNum} dari ${totalPages}`, pageWidth - margin, footerY, { align: 'right' });
|
||||
}
|
||||
|
||||
drawLetterhead();
|
||||
let y = 34;
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* JUDUL LAPORAN & PERIHAL (gaya surat resmi)
|
||||
* ---------------------------------------------------------------- */
|
||||
doc.setTextColor(...COLOR_DARK);
|
||||
doc.setFontSize(13);
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.text('LAPORAN RIWAYAT MONITORING TERNAK SAPI', pageWidth / 2, y, { align: 'center' });
|
||||
y += 7;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.text(`Perihal : Rekapitulasi data suhu tubuh dan aktivitas gerak ternak`, margin, y);
|
||||
y += 5;
|
||||
doc.text(`Periode : ${formatLongDate(dateStart.value)} s/d ${formatLongDate(dateEnd.value)}`, margin, y);
|
||||
y += 8;
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* KOTAK INFORMASI IDENTITAS SAPI
|
||||
* ---------------------------------------------------------------- */
|
||||
const infoBoxHeight = 28;
|
||||
doc.setDrawColor(...COLOR_LINE);
|
||||
doc.setFillColor(250, 250, 251);
|
||||
doc.roundedRect(margin, y, contentWidth, infoBoxHeight, 1.5, 1.5, 'FD');
|
||||
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFontSize(9.5);
|
||||
doc.setTextColor(...COLOR_PRIMARY);
|
||||
doc.text('DATA IDENTITAS SAPI', margin + 4, y + 6);
|
||||
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(...COLOR_DARK);
|
||||
|
||||
const colA = margin + 4;
|
||||
const colB = margin + contentWidth / 2 + 2;
|
||||
const labelValue = (label, value, x, yy) => {
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.text(label, x, yy);
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.text(String(value ?? '-'), x + 24, yy);
|
||||
};
|
||||
|
||||
if (cowDetails) {
|
||||
labelValue('Kode Sapi', cowDetails.code, colA, y + 13);
|
||||
labelValue('Nama Sapi', cowDetails.name, colB, y + 13);
|
||||
labelValue('Breed', cowDetails.breed, colA, y + 19);
|
||||
labelValue('Gender', cowDetails.gender, colB, y + 19);
|
||||
labelValue('Umur', calculateAge(cowDetails.birthDate), colA, y + 25);
|
||||
labelValue('Berat', (cowDetails.weight ? cowDetails.weight + ' kg' : '-'), colB, y + 25);
|
||||
} else {
|
||||
doc.text('Data identitas sapi tidak tersedia.', colA, y + 15);
|
||||
}
|
||||
|
||||
y += infoBoxHeight + 6;
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* KOTAK RINGKASAN STATISTIK
|
||||
* ---------------------------------------------------------------- */
|
||||
const summaryBoxHeight = 22;
|
||||
doc.setDrawColor(...COLOR_LINE);
|
||||
doc.setFillColor(250, 250, 251);
|
||||
doc.roundedRect(margin, y, contentWidth, summaryBoxHeight, 1.5, 1.5, 'FD');
|
||||
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFontSize(9.5);
|
||||
doc.setTextColor(...COLOR_PRIMARY);
|
||||
doc.text('RINGKASAN HASIL MONITORING', margin + 4, y + 6);
|
||||
|
||||
const summaryItems = [
|
||||
['Total Data', stats.total],
|
||||
['Suhu Rata-rata', stats.avg !== '-' ? stats.avg + ' °C' : '-'],
|
||||
['Suhu Tertinggi', stats.max !== '-' ? stats.max + ' °C' : '-'],
|
||||
['Suhu Terendah', stats.min !== '-' ? stats.min + ' °C' : '-'],
|
||||
['Aktif Bergerak', stats.activeCount],
|
||||
['Diam', stats.idleCount]
|
||||
];
|
||||
const cellW = contentWidth / summaryItems.length;
|
||||
summaryItems.forEach((sitem, i) => {
|
||||
const cx = margin + cellW * i + cellW / 2;
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setFontSize(7.5);
|
||||
doc.setTextColor(...COLOR_MUTED);
|
||||
doc.text(sitem[0], cx, y + 13, { align: 'center' });
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFontSize(10.5);
|
||||
doc.setTextColor(...COLOR_DARK);
|
||||
doc.text(String(sitem[1]), cx, y + 19, { align: 'center' });
|
||||
});
|
||||
|
||||
y += summaryBoxHeight + 8;
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* TABEL DETAIL DATA
|
||||
* ---------------------------------------------------------------- */
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFontSize(9.5);
|
||||
doc.setTextColor(...COLOR_PRIMARY);
|
||||
doc.text('DETAIL DATA MONITORING', margin, y);
|
||||
y += 4;
|
||||
|
||||
// Kolom identitas sapi (kode/nama/breed/gender/berat) tidak diulang di sini
|
||||
// karena sudah ditampilkan sekali di kotak "Data Identitas Sapi" di atas
|
||||
// (satu laporan = satu sapi, nilainya sama untuk semua baris).
|
||||
// Lebar kolom dijumlah persis = contentWidth agar tidak meluber ke samping.
|
||||
const colWidths = [40, 24, 30, 30, 50]; // total = 174 = contentWidth
|
||||
const headers = ['Waktu', 'Suhu', 'Kondisi', 'Aktivitas', 'Lokasi GPS'];
|
||||
const xPos = colWidths.map((w, i) => margin + colWidths.slice(0, i).reduce((a, b) => a + b, 0));
|
||||
const rowHeight = 6.5;
|
||||
|
||||
function drawTableHeader(yy) {
|
||||
doc.setFillColor(...COLOR_PRIMARY);
|
||||
doc.rect(margin, yy, contentWidth, rowHeight, 'F');
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.setFontSize(7.8);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
headers.forEach((h, i) => doc.text(h, xPos[i] + 1.5, yy + rowHeight - 2));
|
||||
// garis vertikal pemisah kolom pada header
|
||||
doc.setDrawColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
xPos.forEach(x => doc.line(x, yy, x, yy + rowHeight));
|
||||
return yy + rowHeight;
|
||||
}
|
||||
|
||||
let tableTop = y;
|
||||
let cursorY = drawTableHeader(tableTop);
|
||||
const bottomLimit = pageHeight - 22;
|
||||
|
||||
function newPage() {
|
||||
doc.addPage();
|
||||
drawLetterhead();
|
||||
cursorY = drawTableHeader(34);
|
||||
}
|
||||
|
||||
filteredData.forEach((item) => {
|
||||
if (cursorY + rowHeight > bottomLimit) {
|
||||
newPage();
|
||||
}
|
||||
|
||||
const t = new Date(item.timestamp);
|
||||
const time = isNaN(t.getTime()) ? '-' : t.toLocaleString('id-ID', { dateStyle: 'short', timeStyle: 'short' });
|
||||
|
||||
const gpsText = item.gps ? `${item.gps.latitude ?? '-'}, ${item.gps.longitude ?? '-'}` : '-';
|
||||
|
||||
const rowData = [
|
||||
time,
|
||||
item.temperature ? item.temperature + '°C' : '-',
|
||||
'Normal',
|
||||
getMovementLabel(item.movement),
|
||||
gpsText
|
||||
];
|
||||
|
||||
const isEven = Math.round((cursorY - tableTop) / rowHeight) % 2 === 0;
|
||||
if (isEven) {
|
||||
doc.setFillColor(...COLOR_ROW_ALT);
|
||||
doc.rect(margin, cursorY, contentWidth, rowHeight, 'F');
|
||||
}
|
||||
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setFontSize(7.5);
|
||||
doc.setTextColor(...COLOR_DARK);
|
||||
rowData.forEach((val, i) => {
|
||||
doc.text(String(val), xPos[i] + 1.5, cursorY + rowHeight - 2, { maxWidth: colWidths[i] - 2 });
|
||||
});
|
||||
|
||||
// garis horizontal & vertikal (grid tabel penuh)
|
||||
doc.setDrawColor(...COLOR_LINE);
|
||||
doc.setLineWidth(0.15);
|
||||
doc.line(margin, cursorY, margin + contentWidth, cursorY);
|
||||
xPos.forEach(x => doc.line(x, cursorY, x, cursorY + rowHeight));
|
||||
doc.line(margin + contentWidth, cursorY, margin + contentWidth, cursorY + rowHeight);
|
||||
|
||||
cursorY += rowHeight;
|
||||
});
|
||||
// garis penutup bawah tabel
|
||||
doc.setDrawColor(...COLOR_LINE);
|
||||
doc.line(margin, cursorY, margin + contentWidth, cursorY);
|
||||
|
||||
/* ---------------------------------------------------------------- *
|
||||
* BLOK PENUTUP & TANDA TANGAN
|
||||
* ---------------------------------------------------------------- */
|
||||
const signatureBlockHeight = 38;
|
||||
if (cursorY + signatureBlockHeight > bottomLimit) {
|
||||
newPage();
|
||||
}
|
||||
cursorY += 10;
|
||||
|
||||
doc.setFont(undefined, 'normal');
|
||||
doc.setFontSize(8.5);
|
||||
doc.setTextColor(...COLOR_DARK);
|
||||
doc.text(
|
||||
'Demikian laporan riwayat monitoring ini dibuat berdasarkan data yang tercatat pada sistem Smart Collar',
|
||||
margin, cursorY
|
||||
);
|
||||
doc.text('untuk digunakan sebagaimana mestinya.', margin, cursorY + 5);
|
||||
|
||||
cursorY += 16;
|
||||
const signCol = pageWidth - margin - 60;
|
||||
doc.text(`Padalarang, ${formatLongDate(now.toISOString().split('T')[0])}`, signCol, cursorY);
|
||||
doc.text('Petugas Monitoring,', signCol, cursorY + 5);
|
||||
cursorY += 28;
|
||||
doc.setFont(undefined, 'bold');
|
||||
doc.text('( ......................................... )', signCol, cursorY);
|
||||
|
||||
// Cetak footer + nomor halaman pada setiap halaman setelah semua konten selesai
|
||||
const totalPageCount = doc.internal.getNumberOfPages();
|
||||
for (let p = 1; p <= totalPageCount; p++) {
|
||||
doc.setPage(p);
|
||||
drawFooter(p, totalPageCount);
|
||||
}
|
||||
|
||||
const fileName = `laporan-riwayat-monitoring-${dateStart.value}_${dateEnd.value}.pdf`;
|
||||
doc.save(fileName);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Event Listener Utama
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
loadBtn.addEventListener('click', loadHistory);
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Monitoring Lokasi (GPS) Real-time — Leaflet (tampilan ala Google Maps)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const cowSelect = document.getElementById("cowSelect");
|
||||
let currentCow = cowSelect && cowSelect.value ? cowSelect.value : "";
|
||||
|
||||
let lastPlottedTimestamp = null; // hanya update peta kalau timestamp data berubah (data baru masuk)
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Map setup (Leaflet + tile bergaya Google Maps)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
let map;
|
||||
let marker;
|
||||
let mapAssetsLoaded = false;
|
||||
|
||||
// Tile "Voyager" dari CARTO — palet warna paling mendekati Google Maps
|
||||
// (jalan putih/kuning, air biru muda, area hijau lembut), gratis & tanpa API key.
|
||||
const TILE_URL = "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
|
||||
const TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCss(href) {
|
||||
if (document.querySelector(`link[href="${href}"]`)) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
async function loadMapAssets() {
|
||||
|
||||
if (mapAssetsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadCss("https://unpkg.com/leaflet/dist/leaflet.css");
|
||||
|
||||
try {
|
||||
await loadScript("https://unpkg.com/leaflet/dist/leaflet.js");
|
||||
} catch (error) {
|
||||
console.error("Leaflet gagal dimuat dari CDN.", error);
|
||||
const mapEl = document.getElementById("map");
|
||||
if (mapEl) {
|
||||
mapEl.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-muted">Peta tidak dapat dimuat (CDN diblokir). Koordinat tetap tersedia pada card di atas.</div>';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
mapAssetsLoaded = true;
|
||||
|
||||
}
|
||||
|
||||
function getGoogleStylePin() {
|
||||
// Icon marker ala pin merah Google Maps.
|
||||
// Dibuat setelah Leaflet siap karena butuh L.divIcon.
|
||||
return L.divIcon({
|
||||
className: "gmap-style-pin",
|
||||
html: `
|
||||
<div style="
|
||||
width: 30px; height: 42px;
|
||||
display:flex; align-items:flex-start; justify-content:center;
|
||||
">
|
||||
<svg width="30" height="42" viewBox="0 0 30 42" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 0C6.7 0 0 6.7 0 15c0 10.5 12.4 24.6 14 26.4a1.3 1.3 0 0 0 2 0C17.6 39.6 30 25.5 30 15 30 6.7 23.3 0 15 0z" fill="#EA4335"/>
|
||||
<circle cx="15" cy="15" r="6.5" fill="#ffffff"/>
|
||||
</svg>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [30, 42],
|
||||
iconAnchor: [15, 42],
|
||||
});
|
||||
}
|
||||
|
||||
function initLeafletMap(position) {
|
||||
if (typeof L === "undefined") {
|
||||
console.error("Leaflet belum siap");
|
||||
return;
|
||||
}
|
||||
|
||||
map = L.map("map", {
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
}).setView([position.lat, position.lng], 17);
|
||||
|
||||
L.tileLayer(TILE_URL, {
|
||||
maxZoom: 20,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
}).addTo(map);
|
||||
|
||||
// Posisi kontrol zoom dipindah ke kanan bawah, seperti Google Maps
|
||||
map.zoomControl.setPosition("bottomright");
|
||||
}
|
||||
|
||||
async function ensureMap(position) {
|
||||
|
||||
await loadMapAssets();
|
||||
|
||||
if (!map) {
|
||||
initLeafletMap(position);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function updateMarker(position) {
|
||||
|
||||
if (!marker) {
|
||||
marker = L.marker([position.lat, position.lng], { icon: getGoogleStylePin() }).addTo(map);
|
||||
} else {
|
||||
marker.setLatLng([position.lat, position.lng]);
|
||||
}
|
||||
map.panTo([position.lat, position.lng]);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Change Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (cowSelect) {
|
||||
|
||||
cowSelect.addEventListener(
|
||||
|
||||
"change",
|
||||
|
||||
function () {
|
||||
|
||||
currentCow = this.value;
|
||||
|
||||
lastPlottedTimestamp = null;
|
||||
|
||||
loadLokasi();
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Load Data Lokasi
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function loadLokasi() {
|
||||
|
||||
if (!currentCow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
|
||||
"../ajax/dashboard.php?cowId=" +
|
||||
|
||||
currentCow
|
||||
|
||||
);
|
||||
|
||||
const result =
|
||||
await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
console.log(result.message);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
console.log("Data lokasi tidak tersedia untuk sapi ini.");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateLokasi(data);
|
||||
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
|
||||
console.log(error);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Update Card & Peta
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function updateLokasi(data) {
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Status online/offline collar
|
||||
const lastUpdate = data.timestamp ? new Date(data.timestamp) : null;
|
||||
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
|
||||
const isOnline = age < 30000;
|
||||
|
||||
const status = document.getElementById("status");
|
||||
if (status) {
|
||||
status.innerText = isOnline ? "ONLINE" : "OFFLINE";
|
||||
status.className = "value-text " + (isOnline ? "text-success" : "text-danger");
|
||||
}
|
||||
|
||||
const elLast = document.getElementById("lastUpdate");
|
||||
if (elLast && lastUpdate) {
|
||||
elLast.innerText = lastUpdate.toLocaleString();
|
||||
}
|
||||
|
||||
if (!data.gps || typeof data.gps.latitude === "undefined" || typeof data.gps.longitude === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Card koordinat selalu ditampilkan sesuai data terkini
|
||||
const elLat = document.getElementById("latitude");
|
||||
if (elLat) elLat.innerText = data.gps.latitude;
|
||||
|
||||
const elLng = document.getElementById("longitude");
|
||||
if (elLng) elLng.innerText = data.gps.longitude;
|
||||
|
||||
const mapsLink = document.getElementById("maps");
|
||||
if (mapsLink) {
|
||||
mapsLink.href = data.gps.linkMaps || `https://www.google.com/maps?q=${data.gps.latitude},${data.gps.longitude}`;
|
||||
}
|
||||
|
||||
// Peta hanya di-update (marker digeser) kalau ada data BARU dari
|
||||
// device (timestamp berbeda dari titik terakhir).
|
||||
const isNewData = data.timestamp !== lastPlottedTimestamp;
|
||||
|
||||
if (!isNewData) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastPlottedTimestamp = data.timestamp;
|
||||
|
||||
const position = { lat: data.gps.latitude, lng: data.gps.longitude };
|
||||
|
||||
try {
|
||||
await ensureMap(position);
|
||||
updateMarker(position);
|
||||
} catch (error) {
|
||||
// sudah ditangani pesan fallback di loadMapAssets()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Init & Refresh
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
loadLokasi();
|
||||
|
||||
setInterval(
|
||||
|
||||
loadLokasi,
|
||||
|
||||
2000
|
||||
|
||||
);
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
auth
|
||||
} from "./firebase.js";
|
||||
|
||||
import {
|
||||
createUserWithEmailAndPassword
|
||||
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-auth.js";
|
||||
|
||||
const form = document.getElementById("registerForm");
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = document.getElementById("name").value.trim();
|
||||
const email = document.getElementById("email").value.trim();
|
||||
const rawPhone = document.getElementById("phone").value.trim();
|
||||
const address = document.getElementById("address").value.trim();
|
||||
const password = document.getElementById("password").value;
|
||||
const alertCont = document.getElementById("alert-container");
|
||||
|
||||
const showError = (message) => {
|
||||
if (alertCont) {
|
||||
alertCont.innerHTML = `
|
||||
<div class="alert alert-danger animate__animated animate__shakeX">
|
||||
<i class="fas fa-circle-exclamation me-2"></i>${message}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (!name || !email || !rawPhone || !address || !password) {
|
||||
showError("Semua field user wajib diisi.");
|
||||
return;
|
||||
}
|
||||
|
||||
const formatPhone = (value) => {
|
||||
if (!value) return "";
|
||||
if (value.startsWith("+62")) return value;
|
||||
if (value.startsWith("62")) return "+" + value;
|
||||
if (value.startsWith("08")) return "+62" + value.slice(1);
|
||||
return value;
|
||||
};
|
||||
|
||||
const phone = formatPhone(rawPhone);
|
||||
|
||||
if (!phone.startsWith("+62")) {
|
||||
alert("Nomor WA harus diawali dengan +62, misal +628123...");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await createUserWithEmailAndPassword(
|
||||
auth,
|
||||
email,
|
||||
password
|
||||
);
|
||||
|
||||
const token = await credential.user.getIdToken();
|
||||
|
||||
const response = await fetch("/session.php", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
uid: credential.user.uid,
|
||||
email: credential.user.email,
|
||||
name,
|
||||
phone,
|
||||
address
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Gagal menyimpan session");
|
||||
}
|
||||
|
||||
const syncResponse = await fetch("/ajax/auth_sync.php", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
phone,
|
||||
address,
|
||||
photoUrl: ""
|
||||
})
|
||||
});
|
||||
|
||||
const syncResult = await syncResponse.json();
|
||||
if (!syncResult.success) {
|
||||
throw new Error(syncResult.message || "Gagal sinkron user");
|
||||
}
|
||||
|
||||
window.location = "/pages/dashboard.php";
|
||||
} catch (error) {
|
||||
let message = error.message || "Terjadi kesalahan, silakan coba lagi.";
|
||||
if (error.code) {
|
||||
switch (error.code) {
|
||||
case "auth/email-already-in-use":
|
||||
message = "Email sudah terdaftar. Silakan gunakan email lain atau login.";
|
||||
break;
|
||||
case "auth/weak-password":
|
||||
message = "Password terlalu lemah. Gunakan minimal 6 karakter.";
|
||||
break;
|
||||
case "auth/invalid-email":
|
||||
message = "Format email tidak valid.";
|
||||
break;
|
||||
case "auth/network-request-failed":
|
||||
message = "Koneksi internet bermasalah. Coba lagi.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showError(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Monitoring Suhu Real-time
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const cowSelect = document.getElementById("cowSelect");
|
||||
let currentCow = cowSelect && cowSelect.value ? cowSelect.value : "";
|
||||
|
||||
const MAX_POINTS = 30; // jumlah titik chart yang disimpan (± 1 menit @ interval 2 detik)
|
||||
const CHART_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.4/chart.umd.min.js";
|
||||
|
||||
let tempChart;
|
||||
let historyData = []; // { timestamp, temperature }[]
|
||||
let lastPlottedTimestamp = null; // hanya push ke chart kalau timestamp data berubah (data baru masuk)
|
||||
let chartReady = null; // promise, biar renderChart() tidak dipanggil sebelum library siap
|
||||
let resizeTimer = null;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Load Chart.js (dynamic, hindari race condition)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureChartJs() {
|
||||
|
||||
if (chartReady) {
|
||||
return chartReady;
|
||||
}
|
||||
|
||||
if (typeof Chart !== "undefined") {
|
||||
chartReady = Promise.resolve();
|
||||
return chartReady;
|
||||
}
|
||||
|
||||
chartReady = loadScript(CHART_JS_URL).catch((error) => {
|
||||
console.error("Gagal memuat Chart.js dari CDN. Periksa koneksi internet / ad-blocker.", error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return chartReady;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helper: deteksi layar mobile
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function isMobileScreen() {
|
||||
return window.innerWidth < 576;
|
||||
}
|
||||
|
||||
function isTabletScreen() {
|
||||
return window.innerWidth < 768;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Render Chart
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function renderChart(data) {
|
||||
|
||||
const ctx = document.getElementById("tempChart");
|
||||
if (!ctx || typeof Chart === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Urutkan lama -> baru khusus untuk chart biar tren terbaca alami
|
||||
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
|
||||
const mobile = isMobileScreen();
|
||||
const tablet = isTabletScreen();
|
||||
|
||||
const labels = sorted.map(d => {
|
||||
const t = new Date(d.timestamp);
|
||||
if (isNaN(t.getTime())) return "";
|
||||
// Di layar sempit tampilkan jam:menit saja biar tidak penuh
|
||||
return mobile
|
||||
? t.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
|
||||
: t.toLocaleString(undefined, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
});
|
||||
const temps = sorted.map(d => parseFloat(d.temperature) || null);
|
||||
|
||||
if (tempChart) tempChart.destroy();
|
||||
|
||||
const tickFont = mobile ? 10 : 12;
|
||||
const legendFont = mobile ? 11 : 13;
|
||||
const maxTicks = mobile ? 4 : (tablet ? 6 : 8);
|
||||
const pointRadius = mobile ? 1.5 : 2;
|
||||
|
||||
tempChart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: "Suhu (°C)",
|
||||
data: temps,
|
||||
borderColor: "#60a5fa",
|
||||
backgroundColor: "rgba(96, 165, 250, 0.15)",
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
borderWidth: mobile ? 1.5 : 2,
|
||||
pointRadius: pointRadius,
|
||||
pointBackgroundColor: "#38bdf8"
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
layout: {
|
||||
padding: mobile ? 4 : 8
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: !mobile, // sembunyikan legend di HP biar hemat ruang
|
||||
labels: { color: "#e2e8f0", font: { size: legendFont } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: "#94a3b8", maxTicksLimit: maxTicks, font: { size: tickFont } },
|
||||
grid: { color: "rgba(255,255,255,0.05)" }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: "#94a3b8", font: { size: tickFont } },
|
||||
grid: { color: "rgba(255,255,255,0.05)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function resetChart() {
|
||||
|
||||
historyData = [];
|
||||
lastPlottedTimestamp = null;
|
||||
|
||||
if (tempChart) {
|
||||
tempChart.destroy();
|
||||
tempChart = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resize handling
|
||||
|--------------------------------------------------------------------------
|
||||
| Chart.js sendiri sudah responsive (auto resize saat container berubah),
|
||||
| tapi label/font/ticks perlu di-render ulang penuh saat breakpoint mobile
|
||||
| <-> desktop berubah (mis. rotasi layar HP). Di-debounce biar tidak
|
||||
| render ulang berkali-kali saat drag resize.
|
||||
*/
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
|
||||
clearTimeout(resizeTimer);
|
||||
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (historyData.length > 0) {
|
||||
renderChart(historyData);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Change Cow
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (cowSelect) {
|
||||
|
||||
cowSelect.addEventListener(
|
||||
|
||||
"change",
|
||||
|
||||
function () {
|
||||
|
||||
currentCow = this.value;
|
||||
|
||||
resetChart();
|
||||
loadSuhu();
|
||||
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Load Data Suhu
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function loadSuhu() {
|
||||
|
||||
if (!currentCow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const response =
|
||||
await fetch(
|
||||
|
||||
"../ajax/dashboard.php?cowId=" +
|
||||
|
||||
currentCow
|
||||
|
||||
);
|
||||
|
||||
const result =
|
||||
await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
console.log(result.message);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
console.log("Data suhu tidak tersedia untuk sapi ini.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateSuhu(data);
|
||||
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
|
||||
console.log(error);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Update Card & Chart
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function updateSuhu(data) {
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const t = (typeof data.temperature === "object" && data.temperature !== null)
|
||||
? data.temperature.value
|
||||
: data.temperature;
|
||||
|
||||
// Card nilai suhu
|
||||
const valSuhu = document.getElementById("val-suhu");
|
||||
if (valSuhu) {
|
||||
valSuhu.innerText = (t !== null && typeof t !== "undefined") ? t : "-";
|
||||
}
|
||||
|
||||
// Badge kondisi
|
||||
const valKondisi = document.getElementById("val-kondisi");
|
||||
if (valKondisi && t !== null && typeof t !== "undefined") {
|
||||
if (t >= 40) {
|
||||
valKondisi.className = "badge bg-danger px-3 py-1 rounded-pill";
|
||||
valKondisi.innerText = "BAHAYA";
|
||||
} else if (t >= 39.5) {
|
||||
valKondisi.className = "badge bg-warning text-dark px-3 py-1 rounded-pill";
|
||||
valKondisi.innerText = "WASPADA";
|
||||
} else {
|
||||
valKondisi.className = "badge bg-light text-dark px-3 py-1 rounded-pill";
|
||||
valKondisi.innerText = "NORMAL";
|
||||
}
|
||||
}
|
||||
|
||||
// Status online/offline collar
|
||||
const lastUpdate = data.timestamp ? new Date(data.timestamp) : null;
|
||||
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
|
||||
const isOnline = age < 30000;
|
||||
|
||||
const status = document.getElementById("status");
|
||||
if (status) {
|
||||
status.innerText = isOnline ? "ONLINE" : "OFFLINE";
|
||||
status.className = "value-text " + (isOnline ? "text-success" : "text-danger");
|
||||
}
|
||||
|
||||
const elLast = document.getElementById("lastUpdate");
|
||||
if (elLast && lastUpdate) {
|
||||
elLast.innerText = lastUpdate.toLocaleString();
|
||||
}
|
||||
|
||||
// Chart: hanya tambah titik & render ulang kalau ada data BARU dari
|
||||
// device (timestamp berbeda dari titik terakhir yang sudah diplot).
|
||||
// Bukan setiap kali polling 2 detik meskipun datanya masih sama.
|
||||
if (t !== null && typeof t !== "undefined" && data.timestamp) {
|
||||
|
||||
const isNewData = data.timestamp !== lastPlottedTimestamp;
|
||||
|
||||
if (isNewData) {
|
||||
|
||||
lastPlottedTimestamp = data.timestamp;
|
||||
|
||||
historyData.push({ timestamp: data.timestamp, temperature: t });
|
||||
|
||||
if (historyData.length > MAX_POINTS) {
|
||||
historyData.shift();
|
||||
}
|
||||
|
||||
renderChart(historyData);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Init & Refresh
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function start() {
|
||||
|
||||
try {
|
||||
await ensureChartJs();
|
||||
} catch (error) {
|
||||
// Chart.js gagal dimuat (mis. offline / CDN diblokir).
|
||||
// Card suhu tetap jalan normal, hanya grafik yang tidak tampil.
|
||||
const cardChart = document.getElementById("tempChart");
|
||||
if (cardChart && cardChart.parentElement) {
|
||||
cardChart.parentElement.innerHTML =
|
||||
'<p class="text-muted mb-0">Grafik tidak dapat dimuat (Chart.js gagal diakses). Data suhu tetap diperbarui pada card di atas.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
loadSuhu();
|
||||
|
||||
setInterval(loadSuhu, 2000);
|
||||
|
||||
}
|
||||
|
||||
start();
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"require": {
|
||||
"vlucas/phpdotenv": "^5.6"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,492 @@
|
|||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "108be68e4e2b97fed51d36a10eed0849",
|
||||
"packages": [
|
||||
{
|
||||
"name": "graham-campbell/result-type",
|
||||
"version": "v1.1.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/GrahamCampbell/Result-Type.git",
|
||||
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
|
||||
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"phpoption/phpoption": "^1.9.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GrahamCampbell\\ResultType\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
}
|
||||
],
|
||||
"description": "An Implementation Of The Result Type",
|
||||
"keywords": [
|
||||
"Graham Campbell",
|
||||
"GrahamCampbell",
|
||||
"Result Type",
|
||||
"Result-Type",
|
||||
"result"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
|
||||
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-27T19:43:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/schmittjoh/php-option.git",
|
||||
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
|
||||
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOption\\": "src/PhpOption/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Johannes M. Schmitt",
|
||||
"email": "schmittjoh@gmail.com",
|
||||
"homepage": "https://github.com/schmittjoh"
|
||||
},
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
}
|
||||
],
|
||||
"description": "Option Type for PHP",
|
||||
"keywords": [
|
||||
"language",
|
||||
"option",
|
||||
"php",
|
||||
"type"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/schmittjoh/php-option/issues",
|
||||
"source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-27T19:41:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||
"reference": "141046a8f9477948ff284fa65be2095baafb94f2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
|
||||
"reference": "141046a8f9477948ff284fa65be2095baafb94f2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-ctype": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-ctype": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Ctype\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Gert de Pagter",
|
||||
"email": "BackEndTea@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for ctype functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"ctype",
|
||||
"polyfill",
|
||||
"portable"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.38.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for the Mbstring extension",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"mbstring",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-27T06:59:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php80",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php80.git",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php80\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ion Bazan",
|
||||
"email": "ion.bazan@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "vlucas/phpdotenv",
|
||||
"version": "v5.6.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vlucas/phpdotenv.git",
|
||||
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b",
|
||||
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-pcre": "*",
|
||||
"graham-campbell/result-type": "^1.1.4",
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"phpoption/phpoption": "^1.9.5",
|
||||
"symfony/polyfill-ctype": "^1.26",
|
||||
"symfony/polyfill-mbstring": "^1.26",
|
||||
"symfony/polyfill-php80": "^1.26"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"ext-filter": "*",
|
||||
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-filter": "Required to use the boolean validator."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "5.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dotenv\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Vance Lucas",
|
||||
"email": "vance@vancelucas.com",
|
||||
"homepage": "https://github.com/vlucas"
|
||||
}
|
||||
],
|
||||
"description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
|
||||
"keywords": [
|
||||
"dotenv",
|
||||
"env",
|
||||
"environment"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/vlucas/phpdotenv/issues",
|
||||
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-06T19:11:50+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
require_once __DIR__ . "/../config/app.php";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Request
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function apiRequest(
|
||||
$method,
|
||||
$endpoint,
|
||||
$data = null,
|
||||
$token = null
|
||||
) {
|
||||
|
||||
$url = API_URL . $endpoint;
|
||||
|
||||
$curl = curl_init($url);
|
||||
|
||||
$headers = [
|
||||
"Content-Type: application/json"
|
||||
];
|
||||
|
||||
if ($token) {
|
||||
$headers[] = "Authorization: Bearer {$token}";
|
||||
}
|
||||
|
||||
curl_setopt_array(
|
||||
$curl,
|
||||
[
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => strtoupper($method),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CONNECTTIMEOUT => API_CONNECT_TIMEOUT,
|
||||
CURLOPT_TIMEOUT => API_TIMEOUT,
|
||||
]
|
||||
);
|
||||
|
||||
if ($data !== null) {
|
||||
|
||||
curl_setopt(
|
||||
$curl,
|
||||
CURLOPT_POSTFIELDS,
|
||||
json_encode($data)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
$response = curl_exec($curl);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CURL Error
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
if (curl_errno($curl)) {
|
||||
|
||||
$message = curl_error($curl);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
return [
|
||||
"success" => false,
|
||||
"status" => 500,
|
||||
"message" => $message
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Status
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
$status = curl_getinfo(
|
||||
$curl,
|
||||
CURLINFO_HTTP_CODE
|
||||
);
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Decode JSON
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
$result = json_decode(
|
||||
$response,
|
||||
true
|
||||
);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
|
||||
return [
|
||||
"success" => false,
|
||||
"status" => $status,
|
||||
"message" => "Invalid JSON response",
|
||||
"raw" => $response
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
$result["status"] = $status;
|
||||
|
||||
return $result;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| GET
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function apiGet(
|
||||
$endpoint,
|
||||
$token = null
|
||||
) {
|
||||
|
||||
return apiRequest(
|
||||
"GET",
|
||||
$endpoint,
|
||||
null,
|
||||
$token
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| POST
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function apiPost(
|
||||
$endpoint,
|
||||
$data,
|
||||
$token = null
|
||||
) {
|
||||
|
||||
return apiRequest(
|
||||
"POST",
|
||||
$endpoint,
|
||||
$data,
|
||||
$token
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| PUT
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function apiPut(
|
||||
$endpoint,
|
||||
$data,
|
||||
$token = null
|
||||
) {
|
||||
|
||||
return apiRequest(
|
||||
"PUT",
|
||||
$endpoint,
|
||||
$data,
|
||||
$token
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DELETE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function apiDelete(
|
||||
$endpoint,
|
||||
$token = null
|
||||
) {
|
||||
|
||||
return apiRequest(
|
||||
"DELETE",
|
||||
$endpoint,
|
||||
null,
|
||||
$token
|
||||
);
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue