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