Initial commit: Rancang Bangun UPS DC Berbasis IoT (Web Dashboard & Firmware ESP-12F)
|
|
@ -0,0 +1,13 @@
|
|||
# Node & Next.js
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS & build
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.exe
|
||||
*.o
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Rancang Bangun Perangkat Uninterruptible Power Supply (UPS) DC Berbasis IoT dengan Menggunakan ESP-12F
|
||||
|
||||
**Repositori Tugas Akhir (TKK) — Politeknik Negeri Jember (2026)**
|
||||
* **Oleh**: Rio Ahmad Fadilah (NIM: E32230469)
|
||||
* **Program Studi**: Teknik Komputer - Jurusan Teknologi Informasi
|
||||
|
||||
---
|
||||
|
||||
## Struktur Repositori (Monorepo)
|
||||
|
||||
Repositori ini memuat 2 bagian utama dalam sistem Smart DC UPS:
|
||||
|
||||
### 1. `web_ups/` — Website Dashboard (Next.js & Supabase)
|
||||
Aplikasi antarmuka berbasis web untuk pemantauan parameter tegangan dan kontrol daya jarak jauh (*remote control*) secara *real-time* menggunakan koneksi WebSocket MQTT dan cloud database Supabase.
|
||||
|
||||
### 2. `firmware_iot_esp12f/` — Firmware Mikrokontroler IoT (ESP-12F / Arduino C++)
|
||||
Kode program mikrokontroler ESP-12F yang bertugas membaca sensor tegangan, mengendalikan sistem proteksi baterai (menggunakan P-Channel MOSFET SUD45P03 dan modul XL4015), serta berkomunikasi dua arah via protokol MQTT (`broker.emqx.io`).
|
||||
|
||||
---
|
||||
|
||||
## Panduan Cepat Menjalankan Website Secara Lokal
|
||||
|
||||
```bash
|
||||
cd web_ups
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Buka browser di `http://localhost:3000`. Gunakan kredensial login resmi:
|
||||
* **Username**: `admin`
|
||||
* **Password**: `atmindatang`
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
#include <Arduino.h>
|
||||
#include <FS.h>
|
||||
#include <LittleFS.h>
|
||||
|
||||
void listFiles() {
|
||||
Serial.println("\nListing files in LittleFS:");
|
||||
Dir dir = LittleFS.openDir("/");
|
||||
while (dir.next()) {
|
||||
Serial.printf(" %s (%d bytes)\n", dir.fileName().c_str(), dir.fileSize());
|
||||
}
|
||||
}
|
||||
|
||||
void readFile(const char *path) {
|
||||
File file = LittleFS.open(path, "r");
|
||||
if (!file) {
|
||||
Serial.printf("Failed to open file: %s\n", path);
|
||||
return;
|
||||
}
|
||||
Serial.printf("Contents of %s:\n", path);
|
||||
while (file.available()) {
|
||||
Serial.write(file.read());
|
||||
}
|
||||
Serial.println();
|
||||
file.close();
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
|
||||
if (!LittleFS.begin()) {
|
||||
Serial.println("LittleFS mount failed! Did you upload data folder?");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println("LittleFS mounted successfully.");
|
||||
listFiles();
|
||||
|
||||
// Read demo files
|
||||
readFile("/info.txt");
|
||||
readFile("/config.json");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"wifi_ssid": "YourNetwork",
|
||||
"wifi_pass": "YourPassword",
|
||||
"mode": "demo"
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
Wemos D1 Mini LittleFS Demo
|
||||
Uploaded from Arduino IDE using LittleFS Data Upload plugin.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# LittleFS Demo
|
||||
This is a sample file stored in internal flash using LittleFS.
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
//LiFePO4 4S battery soc and level calculation library
|
||||
|
||||
//todo:
|
||||
//- battery level will forced after 60-seconds if not passing battPercentageLevelThreshold (optional)
|
||||
|
||||
static uint8_t battPercentageLevelThreshold = 5; //threshold for switch discrete level 0-4
|
||||
static uint8_t battLastLevel = 0;
|
||||
static uint8_t battLastPercentageForLevel = 0;
|
||||
|
||||
//variables
|
||||
static float battVoltageFullChargeMinimal = 14.4; //used for longevity charge
|
||||
static float battVoltageFullChargeMaximal = 14.6; //3.60V - 3.65V * 4S = 14.4V - 14.6V (can be used for "topped-off" charge mode, )
|
||||
static float battVoltageFullRest = 13.6; //~3.40V - 3.50V * 4S = ~13.6V - 13.7V (can be used for "optimal" charge mode, preferred for longevity)
|
||||
static float battVoltageUpperZone = 13.3; //Also can be used for "Storage/Long-term Maintenance" charge mode, or after long usage without load ~13.2V - 13.3V (50-60% state of charge)
|
||||
static float battVoltageLowerZone = 12.9;
|
||||
//float battVoltageHalf = 13.1; //~3.25V - 3.28V * 4S = ~13.0V - 13.1V, known as nomimal
|
||||
static float battVoltageEmpty = 10.0; //~2.50V - 2.80V * 4S = ~10.0V - 11.2V
|
||||
static float battVoltageCritical = 12.0; //Default ~10% => 3.0V * 4S = 12V
|
||||
static float battVoltageForceShutdown = 11.2; //Default ~5% => 2.8V * 4S = 11.2V
|
||||
static float battVoltageResumeChargingDefault = 13.4; //Default ~90% = ~3.35V *4S = 13.4V
|
||||
|
||||
//error condition
|
||||
static float battOverVoltage = 15.3; //3.75V - 3.80V * 4S = 15V - 15.3V
|
||||
static float battVoltageDetected = 8;
|
||||
static float battVoltageNotDetected = 7;
|
||||
|
||||
static void resetBatteryLevel() {
|
||||
battLastLevel = 0;
|
||||
battLastPercentageForLevel = 0;
|
||||
}
|
||||
|
||||
static void setBatteryPercentage(uint8_t soc) {
|
||||
if (soc <= 0) {
|
||||
battLastLevel = 0;
|
||||
battLastPercentageForLevel = 0;
|
||||
return;
|
||||
} else if (soc >= 100) {
|
||||
battLastLevel = 4;
|
||||
battLastPercentageForLevel = 100;
|
||||
return;
|
||||
}
|
||||
|
||||
if (soc > battLastPercentageForLevel) {
|
||||
//charging
|
||||
if (soc - battLastPercentageForLevel > battPercentageLevelThreshold) {
|
||||
battLastPercentageForLevel = soc;
|
||||
if (battLastPercentageForLevel > 75) {
|
||||
battLastLevel = 4;
|
||||
} else if (battLastPercentageForLevel > 50) {
|
||||
battLastLevel = 3;
|
||||
} else if (battLastPercentageForLevel > 25) {
|
||||
battLastLevel = 2;
|
||||
} else if (battLastPercentageForLevel > 10) {
|
||||
battLastLevel = 1;
|
||||
} else {
|
||||
battLastLevel = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//depleted or equal
|
||||
if (battLastPercentageForLevel - soc > battPercentageLevelThreshold) {
|
||||
battLastPercentageForLevel = soc;
|
||||
if (battLastPercentageForLevel < 10) {
|
||||
battLastLevel = 0;
|
||||
} else if (battLastPercentageForLevel < 25) {
|
||||
battLastLevel = 1;
|
||||
} else if (battLastPercentageForLevel < 50) {
|
||||
battLastLevel = 2;
|
||||
} else if (battLastPercentageForLevel < 75) {
|
||||
battLastLevel = 3;
|
||||
} else {
|
||||
battLastLevel = 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t getBatteryPercentage(float voltage) {
|
||||
float soc = 0;
|
||||
if (voltage > battVoltageUpperZone) {
|
||||
soc = 90.0 + (10.0 * ((voltage - battVoltageUpperZone) / (battVoltageFullRest - battVoltageUpperZone)));
|
||||
if (soc > 100) soc = 100;
|
||||
} else if (voltage > battVoltageLowerZone && voltage <= battVoltageUpperZone) {
|
||||
soc = 20.0 + (70.0 * ((voltage - battVoltageLowerZone) / (battVoltageUpperZone - battVoltageLowerZone)));
|
||||
} else if (voltage > battVoltageEmpty) {
|
||||
soc = (20.0 * ((voltage - battVoltageEmpty) / (battVoltageLowerZone - battVoltageEmpty)));
|
||||
}
|
||||
setBatteryPercentage(soc);
|
||||
return (uint8_t) soc;
|
||||
}
|
||||
|
||||
static uint8_t getRawBatteryLevelFromPercentage(uint8_t soc) {
|
||||
if (soc <= 0) {
|
||||
return 0;
|
||||
} else if (soc >= 100) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (soc > 75) {
|
||||
return 4;
|
||||
} else if (soc > 50) {
|
||||
return 3;
|
||||
} else if (soc > 25) {
|
||||
return 2;
|
||||
} else if (soc > 5) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uint8_t getBatteryLevel() {
|
||||
return battLastLevel;
|
||||
}
|
||||
|
||||
static uint8_t getBatteryLevel(float voltage) {
|
||||
getBatteryPercentage(voltage);
|
||||
return battLastLevel;
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
static const unsigned char PROGMEM splash_logo_bitmap[512] = {
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0xE0,0xFF,0xE7,0x01,0x80,0xEF,0x03,0xF0,0x39,0xE0,0xFF,0x07,0x00,0x00,
|
||||
0x00,0x00,0xF8,0xFF,0xEF,0x03,0x80,0xC7,0x07,0xF8,0x78,0xF8,0xFF,0x1F,0x00,0x00,
|
||||
0x00,0x00,0xFC,0xFF,0xCF,0x03,0xC0,0x87,0x0F,0x7C,0x78,0xFC,0xFF,0x3F,0x00,0x00,
|
||||
0x00,0x00,0xFC,0xFF,0xC7,0x07,0xC0,0x83,0x1F,0x3E,0x78,0xFC,0xFF,0x3F,0x00,0x00,
|
||||
0x00,0x00,0x3E,0x00,0x80,0x07,0xE0,0x03,0x1F,0x1F,0x78,0x3E,0x00,0x3C,0x00,0x00,
|
||||
0x00,0x00,0x1E,0x00,0x80,0x0F,0xF0,0x01,0xBE,0x0F,0x78,0x1E,0x00,0x3C,0x00,0x00,
|
||||
0x00,0x00,0x1E,0x00,0x00,0x0F,0xF0,0x00,0xFC,0x07,0x78,0x1E,0x00,0x7C,0x00,0x00,
|
||||
0x00,0x00,0xFE,0xFF,0x07,0x1F,0xF8,0x00,0xF8,0x03,0x78,0x1E,0x00,0x7C,0x00,0x00,
|
||||
0x00,0x00,0xFE,0xFF,0x07,0x1E,0x78,0x00,0xF0,0x03,0x78,0x1E,0x00,0x7C,0x00,0x00,
|
||||
0x00,0x00,0xFE,0xFF,0x07,0x3E,0x7C,0x00,0xF8,0x03,0x78,0x1E,0x00,0x7C,0x00,0x00,
|
||||
0x00,0x00,0x1E,0x00,0x00,0x3C,0x3C,0x00,0xFC,0x07,0x78,0x1E,0x00,0x7C,0x00,0x00,
|
||||
0x00,0x00,0x1E,0x00,0x00,0x78,0x3E,0x00,0xBE,0x0F,0x78,0x1E,0x00,0x3C,0x00,0x00,
|
||||
0x00,0x00,0x3E,0x00,0x00,0xF8,0x1E,0x00,0x1F,0x1F,0x78,0x3E,0x00,0x3C,0x00,0x00,
|
||||
0x00,0x00,0xFC,0xFF,0x07,0xF0,0x1F,0x80,0x0F,0x3E,0x78,0xFC,0xFF,0x3F,0x00,0x00,
|
||||
0x00,0x00,0xFC,0xFF,0x07,0xF0,0x0F,0xC0,0x07,0x7C,0x78,0xFC,0xFF,0x3F,0x00,0x00,
|
||||
0x00,0x00,0xF8,0xFF,0x0F,0xE0,0x07,0xE0,0x03,0xF8,0x78,0xF8,0xFF,0x1F,0x00,0x00,
|
||||
0x00,0x00,0xF0,0xFF,0x07,0xC0,0x07,0xF0,0x01,0xF0,0x79,0xF0,0xFF,0x07,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0xC0,0x9F,0x83,0x83,0xC1,0x3F,0xFF,0xC1,0x61,0xFE,0xFE,0x83,0x81,0xFC,0xF1,0x07,
|
||||
0xE0,0xDF,0xC3,0xC3,0xC3,0x7F,0xFF,0xE1,0x61,0xFF,0xFF,0x83,0xC1,0xFE,0xFB,0x07,
|
||||
0x60,0xC0,0xC7,0xC3,0xC7,0x60,0x18,0xE0,0x63,0x03,0x60,0x80,0xC1,0x0E,0x1B,0x00,
|
||||
0xE0,0xCF,0xC6,0x63,0xC6,0x60,0x18,0x60,0x67,0xFF,0x60,0x80,0xC1,0x0E,0xFB,0x03,
|
||||
0xE0,0xDF,0x6E,0x63,0xCC,0x7F,0x18,0x60,0x6E,0xFF,0x60,0x80,0xC1,0xFE,0xF3,0x0F,
|
||||
0x00,0xD8,0x6C,0xF3,0xCF,0x3F,0x18,0x60,0x7C,0x03,0x60,0x80,0xC1,0xFE,0x01,0x0C,
|
||||
0xE0,0xDF,0x3C,0xFB,0xDF,0x30,0x18,0x60,0x78,0xFF,0x60,0x80,0xFF,0x0E,0xF8,0x0F,
|
||||
0xE0,0xDF,0x38,0x1B,0xD8,0x60,0x18,0x60,0x70,0xFE,0x61,0x00,0xFF,0x0E,0xF8,0x07,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM splash_logo_bitmap_inverted[512] = {
|
||||
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
|
||||
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
|
||||
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
|
||||
0xFF,0xFF,0x1F,0x00,0x18,0xFE,0x7F,0x10,0xFC,0x0F,0xC6,0x1F,0x00,0xF8,0xFF,0xFF,
|
||||
0xFF,0xFF,0x07,0x00,0x10,0xFC,0x7F,0x38,0xF8,0x07,0x87,0x07,0x00,0xE0,0xFF,0xFF,
|
||||
0xFF,0xFF,0x03,0x00,0x30,0xFC,0x3F,0x78,0xF0,0x83,0x87,0x03,0x00,0xC0,0xFF,0xFF,
|
||||
0xFF,0xFF,0x03,0x00,0x38,0xF8,0x3F,0x7C,0xE0,0xC1,0x87,0x03,0x00,0xC0,0xFF,0xFF,
|
||||
0xFF,0xFF,0xC1,0xFF,0x7F,0xF8,0x1F,0xFC,0xE0,0xE0,0x87,0xC1,0xFF,0xC3,0xFF,0xFF,
|
||||
0xFF,0xFF,0xE1,0xFF,0x7F,0xF0,0x0F,0xFE,0x41,0xF0,0x87,0xE1,0xFF,0xC3,0xFF,0xFF,
|
||||
0xFF,0xFF,0xE1,0xFF,0xFF,0xF0,0x0F,0xFF,0x03,0xF8,0x87,0xE1,0xFF,0x83,0xFF,0xFF,
|
||||
0xFF,0xFF,0x01,0x00,0xF8,0xE0,0x07,0xFF,0x07,0xFC,0x87,0xE1,0xFF,0x83,0xFF,0xFF,
|
||||
0xFF,0xFF,0x01,0x00,0xF8,0xE1,0x87,0xFF,0x0F,0xFC,0x87,0xE1,0xFF,0x83,0xFF,0xFF,
|
||||
0xFF,0xFF,0x01,0x00,0xF8,0xC1,0x83,0xFF,0x07,0xFC,0x87,0xE1,0xFF,0x83,0xFF,0xFF,
|
||||
0xFF,0xFF,0xE1,0xFF,0xFF,0xC3,0xC3,0xFF,0x03,0xF8,0x87,0xE1,0xFF,0x83,0xFF,0xFF,
|
||||
0xFF,0xFF,0xE1,0xFF,0xFF,0x87,0xC1,0xFF,0x41,0xF0,0x87,0xE1,0xFF,0xC3,0xFF,0xFF,
|
||||
0xFF,0xFF,0xC1,0xFF,0xFF,0x07,0xE1,0xFF,0xE0,0xE0,0x87,0xC1,0xFF,0xC3,0xFF,0xFF,
|
||||
0xFF,0xFF,0x03,0x00,0xF8,0x0F,0xE0,0x7F,0xF0,0xC1,0x87,0x03,0x00,0xC0,0xFF,0xFF,
|
||||
0xFF,0xFF,0x03,0x00,0xF8,0x0F,0xF0,0x3F,0xF8,0x83,0x87,0x03,0x00,0xC0,0xFF,0xFF,
|
||||
0xFF,0xFF,0x07,0x00,0xF0,0x1F,0xF8,0x1F,0xFC,0x07,0x87,0x07,0x00,0xE0,0xFF,0xFF,
|
||||
0xFF,0xFF,0x0F,0x00,0xF8,0x3F,0xF8,0x0F,0xFE,0x0F,0x86,0x0F,0x00,0xF8,0xFF,0xFF,
|
||||
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
|
||||
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
|
||||
0x3F,0x60,0x7C,0x7C,0x3E,0xC0,0x00,0x3E,0x9E,0x01,0x01,0x7C,0x7E,0x03,0x0E,0xF8,
|
||||
0x1F,0x20,0x3C,0x3C,0x3C,0x80,0x00,0x1E,0x9E,0x00,0x00,0x7C,0x3E,0x01,0x04,0xF8,
|
||||
0x9F,0x3F,0x38,0x3C,0x38,0x9F,0xE7,0x1F,0x9C,0xFC,0x9F,0x7F,0x3E,0xF1,0xE4,0xFF,
|
||||
0x1F,0x30,0x39,0x9C,0x39,0x9F,0xE7,0x9F,0x98,0x00,0x9F,0x7F,0x3E,0xF1,0x04,0xFC,
|
||||
0x1F,0x20,0x91,0x9C,0x33,0x80,0xE7,0x9F,0x91,0x00,0x9F,0x7F,0x3E,0x01,0x0C,0xF0,
|
||||
0xFF,0x27,0x93,0x0C,0x30,0xC0,0xE7,0x9F,0x83,0xFC,0x9F,0x7F,0x3E,0x01,0xFE,0xF3,
|
||||
0x1F,0x20,0xC3,0x04,0x20,0xCF,0xE7,0x9F,0x87,0x00,0x9F,0x7F,0x00,0xF1,0x07,0xF0,
|
||||
0x1F,0x20,0xC7,0xE4,0x27,0x9F,0xE7,0x9F,0x8F,0x01,0x9E,0xFF,0x00,0xF1,0x07,0xF8,
|
||||
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
|
||||
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM active_data_2x4_in_bitmap[4] = {
|
||||
0x02,
|
||||
0x02,
|
||||
0x03,
|
||||
0x02
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM active_data_2x4_out_bitmap[4] = {
|
||||
0x01,
|
||||
0x03,
|
||||
0x01,
|
||||
0x01
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM power_bitmap[32] = {
|
||||
0x80,0x01,
|
||||
0x80,0x01,
|
||||
0x90,0x09,
|
||||
0xB8,0x1D,
|
||||
0x9C,0x39,
|
||||
0x8E,0x71,
|
||||
0x86,0x61,
|
||||
0x86,0x61,
|
||||
0x86,0x61,
|
||||
0x06,0x60,
|
||||
0x06,0x60,
|
||||
0x0E,0x70,
|
||||
0x1C,0x38,
|
||||
0x78,0x1E,
|
||||
0xF0,0x0F,
|
||||
0xE0,0x07
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM wifi2_lv1_bitmap[32] = {
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x80,0x01,
|
||||
0xE0,0x07,
|
||||
0x70,0x0E,
|
||||
0x00,0x00,
|
||||
0x80,0x01,
|
||||
0x80,0x01,
|
||||
0x00,0x00,
|
||||
0x00,0x00
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM wifi2_lv2_bitmap[32] = {
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0xC0,0x03,
|
||||
0xF0,0x0F,
|
||||
0x3C,0x3C,
|
||||
0x8C,0x31,
|
||||
0xE0,0x07,
|
||||
0x70,0x0E,
|
||||
0x00,0x00,
|
||||
0x80,0x01,
|
||||
0x80,0x01,
|
||||
0x00,0x00,
|
||||
0x00,0x00
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM wifi2_lv3_bitmap[32] = {
|
||||
0x00,0x00,
|
||||
0x00,0x00,
|
||||
0xF0,0x0F,
|
||||
0xFC,0x3F,
|
||||
0x1E,0x78,
|
||||
0xC7,0xE3,
|
||||
0xF0,0x0F,
|
||||
0x3C,0x3C,
|
||||
0x8C,0x31,
|
||||
0xE0,0x07,
|
||||
0x70,0x0E,
|
||||
0x00,0x00,
|
||||
0x80,0x01,
|
||||
0x80,0x01,
|
||||
0x00,0x00,
|
||||
0x00,0x00
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM bolt_16x16_bitmap[32] = {
|
||||
0x00,0x00,
|
||||
0x00,0x01,
|
||||
0x00,0x01,
|
||||
0x80,0x01,
|
||||
0xC0,0x01,
|
||||
0xC0,0x01,
|
||||
0xE0,0x1F,
|
||||
0xE0,0x0F,
|
||||
0xF0,0x0F,
|
||||
0xF8,0x07,
|
||||
0x80,0x03,
|
||||
0x80,0x03,
|
||||
0x80,0x01,
|
||||
0x80,0x01,
|
||||
0x80,0x00,
|
||||
0x00,0x00
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM charging_7x8_bitmap[8] = {
|
||||
0x3E,
|
||||
0x41,
|
||||
0x5D,
|
||||
0x45,
|
||||
0x45,
|
||||
0x5D,
|
||||
0x41,
|
||||
0x3E
|
||||
};
|
||||
|
||||
|
||||
static const unsigned char PROGMEM charge_fast_7x8_bitmap[8] = {
|
||||
0x3E,
|
||||
0x7F,
|
||||
0x63,
|
||||
0x7B,
|
||||
0x7B,
|
||||
0x63,
|
||||
0x7F,
|
||||
0x3E
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM warn_16x16_bitmap[32] = {
|
||||
0x00,0x00,
|
||||
0x80,0x01,
|
||||
0xC0,0x03,
|
||||
0xC0,0x07,
|
||||
0xE0,0x07,
|
||||
0x70,0x0E,
|
||||
0x70,0x0E,
|
||||
0x78,0x1E,
|
||||
0x78,0x1E,
|
||||
0x7C,0x3E,
|
||||
0xFC,0x3F,
|
||||
0x7E,0x7E,
|
||||
0x7E,0x7E,
|
||||
0xFF,0xFF,
|
||||
0xFF,0xFF,
|
||||
0xFF,0xFF
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM error_16x16_bitmap[32] = {
|
||||
0xF0,0x0F,
|
||||
0xF8,0x1F,
|
||||
0xFC,0x3F,
|
||||
0x7E,0x7E,
|
||||
0x7F,0xFE,
|
||||
0x7F,0xFE,
|
||||
0x7F,0xFE,
|
||||
0x7F,0xFE,
|
||||
0x7F,0xFE,
|
||||
0x7F,0xFE,
|
||||
0xFF,0xFF,
|
||||
0x7F,0xFE,
|
||||
0x7E,0x7E,
|
||||
0xFC,0x3F,
|
||||
0xF8,0x1F,
|
||||
0xF0,0x0F
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM fatal_16x16_bitmap[32] = {
|
||||
0xF0,0x0F,
|
||||
0xF8,0x1F,
|
||||
0xFC,0x3F,
|
||||
0xFE,0x7F,
|
||||
0xDF,0xFB,
|
||||
0x8F,0xF1,
|
||||
0x1F,0xF8,
|
||||
0x3F,0xFC,
|
||||
0x3F,0xFC,
|
||||
0x1F,0xF8,
|
||||
0x8F,0xF1,
|
||||
0xDF,0xFB,
|
||||
0xFE,0x7F,
|
||||
0xFC,0x3F,
|
||||
0xF8,0x1F,
|
||||
0xF0,0x0F
|
||||
};
|
||||
|
||||
static const unsigned char PROGMEM timer_16x16_bitmap[32] = {
|
||||
0x80,0x01,
|
||||
0x80,0x19,
|
||||
0x00,0x38,
|
||||
0x08,0x70,
|
||||
0x10,0x60,
|
||||
0x73,0xC0,
|
||||
0xE3,0xC0,
|
||||
0xC3,0xC1,
|
||||
0x83,0xC1,
|
||||
0x03,0xC0,
|
||||
0x03,0xC0,
|
||||
0x06,0x60,
|
||||
0x06,0x60,
|
||||
0x1C,0x38,
|
||||
0xF8,0x1F,
|
||||
0xE0,0x07
|
||||
};
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
|
@ -0,0 +1,829 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UPS Dashboard</title>
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
margin-top: -20px;
|
||||
background-color: #121212; /* Dark background */
|
||||
color: #e0e0e0; /* Light text color */
|
||||
}
|
||||
hr {
|
||||
margin-bottom: 20px;
|
||||
border-color: #333; /* Darker border color */
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
color: #ffffff; /* White text for headings */
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
button {
|
||||
background-color: #333; /* Dark button background */
|
||||
color: #e0e0e0; /* Light text color */
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #444; /* Slightly lighter on hover */
|
||||
}
|
||||
button.disabled {
|
||||
background-color: #0046a3; /* Gray color for disabled button */
|
||||
color: #a0a0a0;
|
||||
cursor: default;
|
||||
pointer-events: none; /* Prevents hover effect */
|
||||
transition: none; /* Removes transition effect on hover */
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
background-color: #333; /* Gray color for disabled button */
|
||||
color: #555;
|
||||
cursor: default;
|
||||
pointer-events: none; /* Prevents hover effect */
|
||||
transition: none; /* Removes transition effect on hover */
|
||||
}
|
||||
button[disabled]:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #e0e0e0; /* Light text color for labels */
|
||||
}
|
||||
|
||||
/* for styled on off using checkbox */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .2s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: .2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: #0078d7;
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
/* Disabled state */
|
||||
input:disabled + .slider {
|
||||
background-color: #e6e6e6;
|
||||
cursor: default;
|
||||
pointer-events: none; /* Prevents hover effect */
|
||||
}
|
||||
input:disabled:checked + .slider {
|
||||
background-color: #a0d1f6;
|
||||
}
|
||||
input:disabled + .slider:before {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
/* Optional: Add focus style */
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px #0078d7;
|
||||
}
|
||||
|
||||
/* Selec/dropdown style */
|
||||
select {
|
||||
/* width: calc(100% - 22px); */
|
||||
background-color: #333333;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Login Overlay Styles */
|
||||
#login-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(18, 18, 18, 0.95);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-box {
|
||||
background-color: #242424;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
}
|
||||
.login-box input {
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
width: 80%;
|
||||
border: 1px solid #444;
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.login-box h2 {
|
||||
margin-top: 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Overlay -->
|
||||
<div id="login-overlay">
|
||||
<div class="login-box" onkeydown="if(event.key==='Enter')attemptLogin()">
|
||||
<h2>UPS Authentication</h2>
|
||||
<input type="text" id="login-username" placeholder="Username (admin)" value="admin">
|
||||
<input type="password" id="login-password" placeholder="Password">
|
||||
<br>
|
||||
<button onclick="attemptLogin()" style="width: 80%; padding: 10px;">Login</button>
|
||||
<p id="login-error" style="color: #ff5555; display: none; margin-top: 15px; font-size: 14px;">Invalid username or password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>
|
||||
<label>Smart Network Controlled UPS V1.1</label>
|
||||
<!-- ⭕Hollow Red Circle⚫Black Circle⚪White Circle · 🟢Green Circle🔵Blue Circle🟡Yellow Circle🟠Orange Circle🟤Brown Circle 🔴Red Circle 🟣 Purple Circle -->
|
||||
<label id="connect-state-label" title="Unavailable"> ⚫</label>
|
||||
<button id="logoutBtn" onclick="logout()" style="position: absolute; right: 20px; top: 20px; padding: 4px 12px; font-size: 12px;">Logout</button>
|
||||
</h1>
|
||||
|
||||
<hr>
|
||||
<h1>Power Control:</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Auto ON</label>
|
||||
<!-- <button id="toggleAutoBtn"
|
||||
onclick="toggleAutoOn()"
|
||||
style="position: absolute;
|
||||
left: 140px;width:50px;">OFF</button> -->
|
||||
<select id="auto-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">Line Powered</option>
|
||||
<option value="2">Persistent on Line</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- <div class="form-group">
|
||||
<label>State</label>
|
||||
<button id="switchBtn"
|
||||
onclick="toggleSwitch()"
|
||||
style="position: absolute;
|
||||
left: 140px;width:50px;">OFF</button>
|
||||
</div> -->
|
||||
|
||||
<div class="form-group">
|
||||
<label>Power</label>
|
||||
<label class="toggle-switch" style="position: absolute; left: 150px;">
|
||||
<input type="checkbox" id="switchToggle" disabled >
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Shutdown Cast</label>
|
||||
<select id="shutcast-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">On Battery</option>
|
||||
<option value="2">≤75% Battery</option>
|
||||
<option value="3">≤50% Battery</option>
|
||||
<option value="4">≤25% Battery</option>
|
||||
<option value="5">Battery Critical</option>
|
||||
</select>
|
||||
<label id="shutdown-cast-label" style="position: absolute; left: 310px;width: 50px;"> </label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<div class="container">
|
||||
<label style="position: absolute; align-items: center;" title="RestAPI: htttp://<IP>/state?shutdown=<seconds>" >Shutdown</label>
|
||||
<label id="shutdown-countdown" style="position: absolute; left: 150px; width: 150px;">999</label>
|
||||
</div>
|
||||
|
||||
<div class="container" id="shutdown-form" style="position: absolute; left: 150px; align-items: center;">
|
||||
<input type="number" id="shut-timer-input" placeholder="0-999" value="120" min="10" max="999" step="1" style="position: absolute; left: 0px; width: 50px; align-items: center;" maxlength="3">
|
||||
<label style="position: absolute; left: 65px; width: 50px; align-items: center;">sec(s)</label>
|
||||
<button id="shutdownBtn"
|
||||
onclick="shutdownTriggered()"
|
||||
style="position: absolute;
|
||||
left: 120px;width:64px; align-items:center; padding: 0px 0px; height: 22px; margin: -1px -0px">START</button>
|
||||
</div>
|
||||
|
||||
<!-- <div class="container" id="shutdown-countdown" style="position: absolute; left: 150px; align-items: center;">
|
||||
<label style="position: absolute; left: 0px; width: 50px;"></label>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h1>Charge Control:</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Charge Mode</label>
|
||||
<select id="charge-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Always Charge</option>
|
||||
<option value="1">90-100%</option>
|
||||
<option value="2">85-100%</option>
|
||||
<option value="3">80-100%</option>
|
||||
<option value="4">90-95%</option>
|
||||
<option value="5">85-95%</option>
|
||||
<option value="6">80-95%</option>
|
||||
<option value="7">85-90%</option>
|
||||
<option value="8">80-90%</option>
|
||||
<option value="9">80-85%</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label title="Automatic Voltage Reducer">AVR</label>
|
||||
<label class="toggle-switch" style="position: absolute; left: 150px;">
|
||||
<input type="checkbox" id="switchToggleAutoReducer" disabled >
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label title="Overvoltage Protection">OVP</label>
|
||||
<label class="toggle-switch" style="position: absolute; left: 150px;">
|
||||
<input type="checkbox" id="switchToggleOVP" disabled >
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label title="Full Charge on Power Lost">FCoPL</label>
|
||||
<select id="fcopl-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">Always</option>
|
||||
<option value="2">At Battery ≤95%</option>
|
||||
<option value="3">At Battery ≤90%</option>
|
||||
<option value="4">At Battery ≤85%</option>
|
||||
<option value="5">At Battery ≤80%</option>
|
||||
</select>
|
||||
<button id="resetFCoPLBtn"
|
||||
onclick="resetFCoPLTriggered()"
|
||||
style="position: absolute;
|
||||
left: 310px;width:64px; align-items:center; padding: 0px 0px; height: 22px; margin: -1px -0px">Reset</button>
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
<h1>Monitor:</h1>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<label id="status-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:400px;"
|
||||
>-</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Power Source</label>
|
||||
<label id="power-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:400px;"
|
||||
>-</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Battery Voltage</label>
|
||||
<label id="input-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:100px;"
|
||||
>-</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Battery Level</label>
|
||||
<label id="batt-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:400px;"
|
||||
>⬛⬛⬛⬛</label>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ⬛ 🟥 (red square), 🟦 (blue square), 🟩 (green square), 🟨 (yellow square), 🟧 (orange square), and 🟫 (brown square)
|
||||
// ◼️ Black Medium Square: A medium-sized black square.
|
||||
// ⬛ Black Large Square: A large black square.
|
||||
// ◾ Black Medium-Small Square: A smaller black square.
|
||||
// ▪️ Black Small Square: The smallest size of a black square.
|
||||
// 🔲 Black Square Button: A square version of the radio button emoji
|
||||
|
||||
var labelStatus = document.getElementById('status-label');
|
||||
|
||||
var labelPower = document.getElementById('power-label');
|
||||
var labelInput = document.getElementById('input-label');
|
||||
var labelBatt = document.getElementById('batt-label');
|
||||
|
||||
var dropdownAutoOnSelect = document.getElementById('auto-mode');
|
||||
var toggleSwitch = document.getElementById('switchToggle');
|
||||
|
||||
var shutTimerInput = document.getElementById('shut-timer-input');
|
||||
var shutdownForm = document.getElementById('shutdown-form');
|
||||
var shutTimer = document.getElementById('shutdown-countdown');
|
||||
|
||||
var dropdownShutLevelSelect = document.getElementById('shutcast-mode');
|
||||
var shutFlagLabel = document.getElementById('shutdown-cast-label');
|
||||
|
||||
var connectStateLabel = document.getElementById('connect-state-label');
|
||||
|
||||
var dropdownChargeModeSelect = document.getElementById('charge-mode');
|
||||
var dropdownFullChargeOnPowerLostModeSelect = document.getElementById('fcopl-mode');
|
||||
var btnResetFCOPL = document.getElementById('resetFCoPLBtn');
|
||||
var toggleAutomaticVoltageReducer = document.getElementById('switchToggleAutoReducer');
|
||||
var toggleOvervoltageProtection = document.getElementById('switchToggleOVP');
|
||||
|
||||
var lastConnectTick = 0;
|
||||
|
||||
var autoOnMode = 0;
|
||||
var stateTarget = false;
|
||||
var stateValue = false;
|
||||
var lineFrom = 0; //0 = None, 1 = AC, 2 = Battery
|
||||
|
||||
var onChange = false;
|
||||
var checkRefresh;
|
||||
|
||||
var battLevel = 0;
|
||||
var battMaxLevel = 0;
|
||||
var battPercentage = 0;
|
||||
var isChargingOrBlink = false;
|
||||
var chargeLevel = 0;
|
||||
var isBattCritical = false;
|
||||
var chargeTickTock = false;
|
||||
|
||||
var timedShutdown = false;
|
||||
var timedShutRemaining = 0;
|
||||
|
||||
var everReceiveStat = false;
|
||||
|
||||
var shutdownSuggestionLevel = 0;
|
||||
//var shutdownSuggestionLevelPrev = 0;
|
||||
var shutdownCastFlag = false;
|
||||
|
||||
var chargeStatus = 0;
|
||||
var chargingState = 0;
|
||||
var chargeMode = 0;
|
||||
var chargeAutoVoltageReducer = false;
|
||||
var chargeOvervoltageProtection = false;
|
||||
var fullChargeOnPowerLostMode = 0;
|
||||
var fullChargeOnPowerLostState = false;
|
||||
|
||||
var targetChargeMode = 0;
|
||||
var targetFCOPLMode = 0;
|
||||
var targetOvervoltageProtection = false;
|
||||
var targetAutomaticVoltageReducer = false;
|
||||
|
||||
var authHeader = localStorage.getItem('upsAuth') || "";
|
||||
|
||||
function attemptLogin() {
|
||||
var usr = document.getElementById('login-username').value;
|
||||
var pwd = document.getElementById('login-password').value;
|
||||
authHeader = "Basic " + btoa(usr + ":" + pwd);
|
||||
localStorage.setItem('upsAuth', authHeader);
|
||||
document.getElementById('login-error').style.display = 'none';
|
||||
SendState("", 0);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authHeader = "";
|
||||
localStorage.removeItem('upsAuth');
|
||||
document.getElementById('login-password').value = "";
|
||||
document.getElementById('login-error').style.display = 'none';
|
||||
document.getElementById('login-overlay').style.display = 'flex';
|
||||
fetch('/logout', { method: "GET" }).catch(function(){});
|
||||
}
|
||||
|
||||
function repeatChar(char, times) {
|
||||
return char.repeat(times);
|
||||
}
|
||||
|
||||
// Disable all elements in shutdown-form
|
||||
function disableShutdownForm() {
|
||||
const form = document.getElementById('shutdown-form');
|
||||
const elements = form.querySelectorAll('input, button, select, textarea');
|
||||
elements.forEach(element => {
|
||||
element.disabled = true;
|
||||
});
|
||||
//const btn = document.getElementById('shutdownBtn');
|
||||
//btn.disabled = true;
|
||||
}
|
||||
|
||||
// Enable all elements in shutdown-form
|
||||
function enableShutdownForm() {
|
||||
const form = document.getElementById('shutdown-form');
|
||||
const elements = form.querySelectorAll('input, button, select, textarea');
|
||||
elements.forEach(element => {
|
||||
element.disabled = false;
|
||||
});
|
||||
//const btn = document.getElementById('shutdownBtn');
|
||||
//btn.disabled = false;
|
||||
}
|
||||
|
||||
dropdownShutLevelSelect.addEventListener('change', function() {
|
||||
let targetShutdownLvMode = parseInt(this.value);
|
||||
let s = 'shutdown suggest cast: ' + targetShutdownLvMode;
|
||||
console.log(s);
|
||||
if (targetShutdownLvMode!==shutdownSuggestionLevel){
|
||||
this.disabled = true;
|
||||
SendState('shutlv', targetShutdownLvMode);
|
||||
}
|
||||
});
|
||||
|
||||
dropdownAutoOnSelect.addEventListener('change', function() {
|
||||
let targetAutoMode = parseInt(this.value);
|
||||
let s = 'auto on mode: ' + targetAutoMode;
|
||||
console.log(s);
|
||||
if (targetAutoMode!==autoOnMode){
|
||||
this.disabled = true;
|
||||
SendState('auto', targetAutoMode);
|
||||
}
|
||||
});
|
||||
|
||||
toggleSwitch.addEventListener('change', function() {
|
||||
let targetState = toggleSwitch.checked;
|
||||
let s = 'Toggle switched: ' + (targetState?'ON':'OFF');
|
||||
console.log(s);
|
||||
if (targetState!==stateValue){
|
||||
//Disabled Button
|
||||
toggleSwitch.disabled = true;
|
||||
SendState('on', targetState?'1':'0');
|
||||
}
|
||||
});
|
||||
|
||||
function shutdownTriggered(){
|
||||
//shutdownForm.style.display = 'none';
|
||||
let t = parseInt(shutTimerInput.value);
|
||||
console.log('Send timed shutdown: ' + t);
|
||||
if (stateValue){
|
||||
//url: htttp://<IP>/state?shutdown=<seconds>
|
||||
SendState('shutdown', t);
|
||||
}
|
||||
}
|
||||
|
||||
function ConnectionRefresh(){
|
||||
lastConnectTick = performance.now();
|
||||
connectStateLabel.textContent = ' 🟢';
|
||||
connectStateLabel.title = 'Connected';
|
||||
}
|
||||
|
||||
dropdownChargeModeSelect.addEventListener('change', function() {
|
||||
targetChargeMode = parseInt(this.value);
|
||||
let s = 'charge mode: ' + targetChargeMode;
|
||||
console.log(s);
|
||||
if (targetChargeMode!==chargeMode){
|
||||
this.disabled = true;
|
||||
SendState('cm', targetChargeMode);
|
||||
}
|
||||
});
|
||||
|
||||
dropdownFullChargeOnPowerLostModeSelect.addEventListener('change', function() {
|
||||
targetFCOPLMode = parseInt(this.value);
|
||||
let s = 'FCOPL mode: ' + targetFCOPLMode;
|
||||
console.log(s);
|
||||
if (targetFCOPLMode!==fullChargeOnPowerLostMode){
|
||||
this.disabled = true;
|
||||
SendState('coil', targetFCOPLMode);
|
||||
}
|
||||
});
|
||||
|
||||
function resetFCoPLTriggered(){
|
||||
btnResetFCOPL.disabled = true;
|
||||
if (fullChargeOnPowerLostState){
|
||||
SendState('rofc', 1);
|
||||
}
|
||||
}
|
||||
|
||||
toggleAutomaticVoltageReducer.addEventListener('change', function() {
|
||||
targetAutomaticVoltageReducer = toggleAutomaticVoltageReducer.checked;
|
||||
let s = 'AVR switched: ' + (targetAutomaticVoltageReducer?'ON':'OFF');
|
||||
console.log(s);
|
||||
if (targetAutomaticVoltageReducer!==chargeAutoVoltageReducer){
|
||||
//Disabled Button
|
||||
toggleAutomaticVoltageReducer.disabled = true;
|
||||
SendState('car', targetAutomaticVoltageReducer?'1':'0');
|
||||
}
|
||||
});
|
||||
|
||||
toggleOvervoltageProtection.addEventListener('change', function() {
|
||||
targetOvervoltageProtection = toggleOvervoltageProtection.checked;
|
||||
let s = 'OVP switched: ' + (targetOvervoltageProtection?'ON':'OFF');
|
||||
console.log(s);
|
||||
if (targetOvervoltageProtection!==chargeOvervoltageProtection){
|
||||
//Disabled Button
|
||||
toggleOvervoltageProtection.disabled = true;
|
||||
SendState('covp', targetOvervoltageProtection?'1':'0');
|
||||
}
|
||||
});
|
||||
|
||||
function SendState(cmd, value) {
|
||||
// Clear timeout
|
||||
clearTimeout(checkRefresh);
|
||||
|
||||
if (cmd !== "") {
|
||||
onChange = true;
|
||||
const url = '/state?' + cmd + '=' + value;
|
||||
|
||||
console.log("Fetch URL:", url);
|
||||
|
||||
fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": authHeader
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
document.getElementById('login-overlay').style.display = 'flex';
|
||||
if (authHeader !== "") document.getElementById('login-error').style.display = 'block';
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to update.");
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('login-overlay').style.display = 'none';
|
||||
console.log(`${cmd} updated:`, data);
|
||||
onChange = false;
|
||||
if (parseInt(data.success)>0) ConnectionRefresh();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error:", error);
|
||||
onChange = false;
|
||||
});
|
||||
|
||||
} else {
|
||||
if (!onChange) {
|
||||
const url = '/state';
|
||||
fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": authHeader
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
document.getElementById('login-overlay').style.display = 'flex';
|
||||
if (authHeader !== "") document.getElementById('login-error').style.display = 'block';
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch data.");
|
||||
return response.json(); // Already a JSON object, no need for JSON.parse()
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('login-overlay').style.display = 'none';
|
||||
//console.log("Fetched Data:", data);
|
||||
if (parseInt(data.success)>0) ConnectionRefresh();
|
||||
if (onChange) return;
|
||||
//sync display
|
||||
if (data.success=="2"){
|
||||
autoOnMode = parseInt(data.auto);
|
||||
let targetAutoMode = parseInt(dropdownAutoOnSelect.value);
|
||||
if (targetAutoMode!=autoOnMode){
|
||||
dropdownAutoOnSelect.value = autoOnMode.toString();
|
||||
}
|
||||
if (dropdownAutoOnSelect.disabled){
|
||||
dropdownAutoOnSelect.disabled = false;
|
||||
}
|
||||
|
||||
//toggleAutoBtn.textContent = autoOn?'ON':'OFF';
|
||||
stateTarget = parseInt(data.on) > 0;
|
||||
stateValue = parseInt(data.state) > 0;
|
||||
if (stateTarget==stateValue){
|
||||
toggleSwitch.checked = stateTarget;
|
||||
toggleSwitch.disabled = false;
|
||||
}
|
||||
//switchBtn.textContent = stateOn?'ON':'OFF';
|
||||
|
||||
lineFrom = parseInt(data.line);
|
||||
isChargingOrBlink = parseInt(data.battBlink) > 0;
|
||||
chargeLevel = parseInt(data.chargeLv); //battery level at charges
|
||||
isBattCritical = parseInt(data.battCritical) > 0;
|
||||
|
||||
//Version 1.1
|
||||
battPercentage = parseInt(data.battP);
|
||||
chargeStatus = parseInt(data.chgError);
|
||||
chargingState = parseInt(data.charging);
|
||||
chargeMode = parseInt(data.chgMode);
|
||||
chargeAutoVoltageReducer = parseInt(data.chgReducer) > 0;
|
||||
chargeOvervoltageProtection = parseInt(data.chgOVP) > 0;
|
||||
fullChargeOnPowerLostMode = parseInt(data.chgFullTrig);
|
||||
fullChargeOnPowerLostState = parseInt(data.chgOFC) > 0;
|
||||
|
||||
//let s = lineFrom==0?'OFF':(lineFrom==1 || isChargingOrBlink)?'⚡️ AC':'🔋 Battery';
|
||||
let s = lineFrom==0?'OFF':(lineFrom==1 || isChargingOrBlink)?'⚡️ Line':'🔋 Battery';
|
||||
|
||||
if (chargingState>0){
|
||||
if (chargeStatus==0){ //normal state
|
||||
if (lineFrom==1 && chargingState==2) s += " (Charging)";
|
||||
if (lineFrom==1 && chargingState==1) s += " (Reduced Charging)";
|
||||
}else{
|
||||
//charging while error (retry test)
|
||||
if (lineFrom==1 && chargingState>0) s += " (Charging)";
|
||||
}
|
||||
}
|
||||
|
||||
if (lineFrom==2 && isBattCritical) s += " (Critical)";
|
||||
labelPower.textContent = s;
|
||||
labelInput.textContent = data.v + " V";
|
||||
battLevel = parseInt(data.batt);
|
||||
battMaxLevel = parseInt(data.battMax);
|
||||
|
||||
|
||||
//display V1.1
|
||||
//charge mode:
|
||||
if (targetChargeMode!=chargeMode){
|
||||
dropdownChargeModeSelect.value = chargeMode.toString();
|
||||
}
|
||||
if (dropdownChargeModeSelect.disabled){
|
||||
dropdownChargeModeSelect.disabled = false;
|
||||
}
|
||||
//fcopl:
|
||||
if (targetFCOPLMode!=fullChargeOnPowerLostMode){
|
||||
dropdownFullChargeOnPowerLostModeSelect.value = fullChargeOnPowerLostMode.toString();
|
||||
}
|
||||
if (dropdownFullChargeOnPowerLostModeSelect.disabled){
|
||||
dropdownFullChargeOnPowerLostModeSelect.disabled = false;
|
||||
}
|
||||
//cvopl reset button:
|
||||
if (fullChargeOnPowerLostState){
|
||||
if (btnResetFCOPL.disabled || btnResetFCOPL.style.display !== "inline-block"){
|
||||
btnResetFCOPL.disabled = false;
|
||||
btnResetFCOPL.style.display = "inline-block";
|
||||
}
|
||||
}else{
|
||||
if (!btnResetFCOPL.disabled || btnResetFCOPL.style.visibility !== "none"){
|
||||
btnResetFCOPL.disabled = true;
|
||||
btnResetFCOPL.style.display = "none";
|
||||
}
|
||||
}
|
||||
//AVR:
|
||||
//if (toggleAutomaticVoltageReducer.checked!=chargeAutoVoltageReducer){
|
||||
if (toggleAutomaticVoltageReducer.disabled){
|
||||
toggleAutomaticVoltageReducer.checked = chargeAutoVoltageReducer;
|
||||
toggleAutomaticVoltageReducer.disabled = false;
|
||||
}
|
||||
//}
|
||||
//OVP:
|
||||
if (toggleOvervoltageProtection.disabled){
|
||||
toggleOvervoltageProtection.checked = chargeOvervoltageProtection;
|
||||
toggleOvervoltageProtection.disabled = false;
|
||||
}
|
||||
|
||||
|
||||
if (stateTarget==stateValue){
|
||||
if (stateValue){
|
||||
enableShutdownForm();
|
||||
}else{
|
||||
disableShutdownForm();
|
||||
}
|
||||
}else{
|
||||
disableShutdownForm();
|
||||
}
|
||||
|
||||
timedShutdown = parseInt(data.timedShutdown) > 0;
|
||||
if (timedShutdown){
|
||||
timedShutRemaining = parseInt(data.shutdownRemain);
|
||||
if (timedShutRemaining==0){
|
||||
shutTimer.textContent = 'TURNING OFF...';
|
||||
}else{
|
||||
shutTimer.textContent = timedShutRemaining.toString();
|
||||
}
|
||||
shutdownForm.style.display = 'none';
|
||||
}else{
|
||||
timedShutRemaining = 0;
|
||||
shutTimer.textContent = '';
|
||||
shutdownForm.style.display = 'block';
|
||||
}
|
||||
|
||||
if (!everReceiveStat){
|
||||
shutTimerInput.value = parseInt(data.shutLastTimer);
|
||||
}
|
||||
everReceiveStat = true;
|
||||
|
||||
|
||||
//shutdown suggestion level
|
||||
let shutLv = parseInt(data.shutdownSuggestMode);
|
||||
if (shutdownSuggestionLevel!=shutLv){
|
||||
shutdownSuggestionLevel = shutLv;
|
||||
dropdownShutLevelSelect.value = shutLv.toString();
|
||||
}
|
||||
if (dropdownShutLevelSelect.disabled){
|
||||
dropdownShutLevelSelect.disabled = false;
|
||||
}
|
||||
|
||||
shutdownCastFlag = parseInt(data.shutdownSuggest) > 0;
|
||||
shutFlagLabel.textContent = shutdownCastFlag?'✔️':' ';
|
||||
|
||||
if (chargeStatus==0){
|
||||
labelStatus.textContent = "Normal";
|
||||
}else if (chargeStatus==1){
|
||||
labelStatus.textContent = "⚠️ Battery Not Detected" + (chargingState==0?"":" (Retry charging...)");
|
||||
}else if (chargeStatus==2){
|
||||
labelStatus.textContent = "⛔ Battery Shorted" + (chargingState==0?"":" (Retry charging..)");
|
||||
}else if (chargeStatus==4){
|
||||
labelStatus.textContent = "⚠️ Charge Overvoltage" + (chargingState==0?"":" (Retry charging...)");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error:", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//charging independent animation
|
||||
function BattDisplayRoutine(){
|
||||
|
||||
//connection state update when timeout
|
||||
if (performance.now() - lastConnectTick >= 3000){
|
||||
connectStateLabel.textContent = ' ⚫';
|
||||
connectStateLabel.title = 'Unavailable';
|
||||
}
|
||||
|
||||
if (!isChargingOrBlink){
|
||||
if (isBattCritical){
|
||||
chargeTickTock = !chargeTickTock;
|
||||
let bar = chargeTickTock?0:1;
|
||||
if (chargeTickTock){
|
||||
labelBatt.textContent = repeatChar('🟧', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}else{
|
||||
labelBatt.textContent = repeatChar('🟧', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}
|
||||
}else{
|
||||
chargeTickTock = false;
|
||||
if (battMaxLevel==0){
|
||||
labelBatt.textContent = '⬛⬛⬛⬛';//'Unknown';
|
||||
}else{
|
||||
labelBatt.textContent = repeatChar('🟩', battLevel) + repeatChar('⬛', battMaxLevel-battLevel) + " " + battPercentage + "%";
|
||||
}
|
||||
}
|
||||
}else{
|
||||
chargeTickTock = !chargeTickTock;
|
||||
let bar = chargeTickTock?Math.min(chargeLevel+1, battMaxLevel):Math.min(chargeLevel, battMaxLevel);
|
||||
if (chargeTickTock){
|
||||
labelBatt.textContent = repeatChar('🟩', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}else{
|
||||
labelBatt.textContent = repeatChar('🟩', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncState(){
|
||||
//checkOnlineState();
|
||||
if (onChange){
|
||||
clearTimeout(checkRefresh);
|
||||
return;
|
||||
}
|
||||
checkRefresh = setTimeout(function() {
|
||||
SendState("", 0);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
disableShutdownForm();
|
||||
|
||||
// Set an interval to sync display
|
||||
setInterval(syncState, 500);
|
||||
|
||||
SendState("", 0);
|
||||
syncState();
|
||||
|
||||
setInterval(BattDisplayRoutine, 1000);
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
// the setup function runs once when you press reset or power the board
|
||||
|
||||
#include "upsIO.h"
|
||||
#include "upsSvr.h"
|
||||
#include "mqtt.h"
|
||||
|
||||
bool printDebug = true;
|
||||
|
||||
bool inOk = false;
|
||||
int16_t adc = 0;
|
||||
float voltage = 0;
|
||||
uint32_t infoTick = 0;
|
||||
|
||||
void setup() {
|
||||
// initialize digital pin LED_BUILTIN as an output.
|
||||
Serial.begin(115200);
|
||||
Serial.println();
|
||||
//ESP.wdtDisable();
|
||||
|
||||
//initUpsIo(true);
|
||||
initUpsIo();
|
||||
//initUpsIo(false, true);
|
||||
|
||||
upsServerInit();
|
||||
mqttInit();
|
||||
|
||||
//CharliplexDemoRoutine();
|
||||
}
|
||||
|
||||
// the loop function runs over and over again forever
|
||||
void loop() {
|
||||
if (printDebug && (millis()-infoTick>=500)){
|
||||
infoTick = millis();
|
||||
adc = analogRead(A0);//ioAdc;//analogRead(A0);//ioAdc;//
|
||||
//voltage = (16.0*((float)adc / 1024)) * 0.983; //use 1.5 MOhm series resistor
|
||||
voltage = (16.0*((float)adc / 1024)) * 1.0; //use 1.5 MOhm series resistor
|
||||
Serial.println("VBatt: " + (String(voltage, 2)) + " V");
|
||||
|
||||
//adc = ioAdcFiltered;
|
||||
voltage = batteryVoltageUnfiltered;//(16.0*((float)adc / 1024)) * 1.0; //use 1.5 MOhm series resistor
|
||||
Serial.println("VBatt (Sub-Min): " + (String(voltage, 2)) + " V");
|
||||
|
||||
//adc = adcValueMin;
|
||||
voltage = batteryVoltage;//(16.0*((float)adc / 1024)) * 1.0; //use 1.5 MOhm series resistor
|
||||
Serial.print("VBatt (Min): " + (String(voltage, 2)) + " V");
|
||||
Serial.print(" (" + (String(batteryPercentage)) + "%)");
|
||||
Serial.println(", LV: " + (String(batteryLevel)));
|
||||
|
||||
|
||||
Serial.println("Charge Error: " + (String(chargeError)));
|
||||
Serial.println("Charge Switch: " + (String(isChargeActive)));
|
||||
Serial.println("Input Under Voltage: " + (String(inputUnderVoltage)));
|
||||
Serial.println("Input Voltage Exists: " + (String(inputVoltageKeepup)));
|
||||
|
||||
//inOk = digitalRead(pinInputSense);
|
||||
//Serial.println("Vin: " + (String)(inOk?"True":"False"));
|
||||
|
||||
//buttonState = !digitalRead(pinInputButton); //inverted
|
||||
Serial.println("Button: " + (String)(buttonState?"True":"False"));
|
||||
Serial.println("Button (short): " + (String)(buttonShortPress?"True":"False"));
|
||||
Serial.println("Button (long): " + (String)(buttonLongPress?"True":"False"));
|
||||
|
||||
Serial.println("Power OUT: " + (String)(powerOutputActive?"True":"False"));
|
||||
//digitalWrite(pinLed, HIGH); // turn the LED on (HIGH is the voltage level)
|
||||
//digitalWrite(pinPowerOut, HIGH);
|
||||
|
||||
Serial.println("KEEP POWER: " + (String)(keepPowerState?"True":"False"));
|
||||
|
||||
Serial.println("Display: " + (String)(displayActive?"True":"False"));
|
||||
|
||||
Serial.println("RSSI: " + (String)(rssi));
|
||||
|
||||
Serial.println("Charge Allowed: " + (String)(chargeAllowed?"True":"False"));
|
||||
|
||||
//for persistent shutdown demo: ==============================================================
|
||||
// if (persistentShutdown){
|
||||
// Serial.println("Shutdown in: " + (String)(getPersistentShutdownTick()/1000000));
|
||||
// }else{
|
||||
// if (restartAutoPowerOn()) Serial.println("Automatic ON restarted");
|
||||
// setPersistentShutdown(10000000UL);
|
||||
// }
|
||||
//============================================================================================
|
||||
}
|
||||
|
||||
upsServerRoutine();
|
||||
mqttRoutine();
|
||||
|
||||
upsRoutineInterrupt();
|
||||
|
||||
//delay(2000); // wait for a second
|
||||
//digitalWrite(pinLed, LOW); // turn the LED off by making the voltage LOW
|
||||
//digitalWrite(pinPowerOut, LOW);
|
||||
|
||||
|
||||
//CharlieplexDemoRoutine();
|
||||
//UpsIoRoutine();
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
#ifndef MQTT_H
|
||||
#define MQTT_H
|
||||
|
||||
#include <PubSubClient.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
const char* mqtt_server = "broker.emqx.io";
|
||||
const int mqtt_port = 1883;
|
||||
|
||||
// Topics
|
||||
const char* mqtt_topic_state = "ups/dev15/state";
|
||||
const char* mqtt_topic_cmd = "ups/dev15/cmd";
|
||||
|
||||
WiFiClient espClient;
|
||||
PubSubClient mqttClient(espClient);
|
||||
|
||||
unsigned long lastMqttReconnectAttempt = 0;
|
||||
unsigned long lastMqttPublish = 0;
|
||||
const unsigned long mqttPublishInterval = 2000; // Publish state every 2 seconds
|
||||
|
||||
// Forward declaration
|
||||
void mqttPublishState();
|
||||
|
||||
void mqttCallback(char* topic, byte* payload, unsigned int length) {
|
||||
Serial.print("Message arrived on topic: ");
|
||||
Serial.print(topic);
|
||||
Serial.print(". Message: ");
|
||||
String messageTemp;
|
||||
|
||||
for (int i = 0; i < length; i++) {
|
||||
Serial.print((char)payload[i]);
|
||||
messageTemp += (char)payload[i];
|
||||
}
|
||||
Serial.println();
|
||||
|
||||
// Parse command
|
||||
if (String(topic) == mqtt_topic_cmd) {
|
||||
DynamicJsonDocument doc(256);
|
||||
DeserializationError error = deserializeJson(doc, payload, length);
|
||||
if (!error) {
|
||||
String cmd = doc["cmd"].as<String>();
|
||||
String v = doc["val"].as<String>();
|
||||
bool updated = false;
|
||||
|
||||
if (cmd == "on") {
|
||||
bool tgtState = v != "0";
|
||||
if (tgtState != powerOutputActive) {
|
||||
// This function is defined in upsSvr.h / main logic
|
||||
// Note: mqtt.h is included after or before? Need to ensure it can call setOutputPowerState
|
||||
// Actually, setOutputPowerState is declared in upsCfg.h maybe?
|
||||
// If compilation fails, we can just set the flag. Wait, let's use the function since it's global.
|
||||
setOutputPowerState(tgtState);
|
||||
updated = true;
|
||||
}
|
||||
} else if (cmd == "auto") {
|
||||
uint8_t tmpAutoOnMode = v.toInt();
|
||||
if ((tmpAutoOnMode != cfgAutoOn) && (tmpAutoOnMode <= 2)) {
|
||||
cfgAutoOn = tmpAutoOnMode;
|
||||
configChanged = true;
|
||||
updated = true;
|
||||
}
|
||||
} else if (cmd == "shutdown") {
|
||||
uint16_t tSec = v.toInt();
|
||||
if (tSec > 999) tSec = 999;
|
||||
uint32_t nanoT = ((uint32_t)tSec) * 1000000UL;
|
||||
if (nanoT < 999999) nanoT = 999999;
|
||||
bool ok = setPersistentShutdown(nanoT);
|
||||
if (cfgShutdownInSeconds != tSec && ok) {
|
||||
cfgShutdownInSeconds = tSec;
|
||||
configChanged = true;
|
||||
updated = true;
|
||||
}
|
||||
} else if (cmd == "shutlv") {
|
||||
int16_t lv = v.toInt();
|
||||
if (lv >= 0 && lv <= 5) {
|
||||
if (lv != cfgShutdownSuggestion) {
|
||||
cfgShutdownSuggestion = lv;
|
||||
configChanged = true;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
} else if (cmd == "cm") {
|
||||
int16_t i = v.toInt();
|
||||
if (i >= 0 && i <= 9) {
|
||||
cfgChargeMode = i;
|
||||
configChanged = true;
|
||||
updated = true;
|
||||
}
|
||||
} else if (cmd == "coil") {
|
||||
int16_t i = v.toInt();
|
||||
if (i >= 0 && i <= 5) {
|
||||
cfgInputLostTriggerChargeMode = i;
|
||||
configChanged = true;
|
||||
updated = true;
|
||||
}
|
||||
} else if (cmd == "covp") {
|
||||
int16_t i = v.toInt();
|
||||
if (i > 1) i = 1;
|
||||
if (i != cfgChargeOvervoltageProtection) {
|
||||
cfgChargeOvervoltageProtection = i;
|
||||
configChanged = true;
|
||||
updated = true;
|
||||
}
|
||||
} else if (cmd == "rofc") {
|
||||
int16_t i = v.toInt();
|
||||
if (i > 0 && needFullChargeOnce) {
|
||||
resetOnGoingFullChargeAfterLineLost();
|
||||
updated = true;
|
||||
}
|
||||
} else if (cmd == "car") {
|
||||
int16_t i = v.toInt();
|
||||
if (i > 1) i = 1;
|
||||
if (i != cfgChargeAutomaticReducer) {
|
||||
cfgChargeAutomaticReducer = i;
|
||||
configChanged = true;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
mqttPublishState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean mqttReconnect() {
|
||||
String clientId = "UPS_DEV15_" + String(random(0xffff), HEX);
|
||||
Serial.print("Attempting MQTT connection as ");
|
||||
Serial.println(clientId);
|
||||
if (mqttClient.connect(clientId.c_str())) {
|
||||
Serial.println("MQTT Connected");
|
||||
// Once connected, publish an announcement...
|
||||
mqttClient.publish("ups/dev15/status", "online");
|
||||
// ... and resubscribe
|
||||
mqttClient.subscribe(mqtt_topic_cmd);
|
||||
} else {
|
||||
Serial.print("failed, rc=");
|
||||
Serial.print(mqttClient.state());
|
||||
Serial.println(" try again in 5 seconds");
|
||||
}
|
||||
return mqttClient.connected();
|
||||
}
|
||||
|
||||
void mqttInit() {
|
||||
mqttClient.setServer(mqtt_server, mqtt_port);
|
||||
mqttClient.setBufferSize(1024); // Increase buffer size for large JSON payloads
|
||||
mqttClient.setCallback(mqttCallback);
|
||||
}
|
||||
|
||||
void mqttPublishState() {
|
||||
DynamicJsonDocument jsonDoc(1024);
|
||||
String jsonResponse;
|
||||
|
||||
uint8_t lineActiveState = isUpsPoweredFromBattery() ? 2 : isInputLinePowered() ? 1 : isUpsOn() ? 2 : 0;
|
||||
|
||||
jsonDoc["success"] = 2;
|
||||
jsonDoc["on"] = powerOutputActive ? 1 : 0;
|
||||
jsonDoc["state"] = isUpsOn() ? 1 : 0;
|
||||
jsonDoc["v"] = String(batteryVoltage, 2);
|
||||
jsonDoc["batt"] = batteryLevel;
|
||||
jsonDoc["battP"] = batteryPercentage;
|
||||
jsonDoc["line"] = lineActiveState;
|
||||
jsonDoc["uptime"] = millis() / 1000;
|
||||
|
||||
jsonDoc["auto"] = cfgAutoOn;
|
||||
jsonDoc["battBlink"] = isUpsBatteryIndicatorChargingOrBlink() ? 1 : 0;
|
||||
jsonDoc["battCritical"] = isUpsBatteryCritical() ? 1 : 0;
|
||||
jsonDoc["chargeLv"] = getRawBatteryLevelFromPercentage(batteryPercentage);
|
||||
jsonDoc["battMax"] = 4;
|
||||
jsonDoc["timedShutdown"] = (persistentShutdown && powerOutputActive) ? 1 : 0;
|
||||
jsonDoc["shutdownRemain"] = getPersistentShutdownTick() / 1000000;
|
||||
jsonDoc["shutdownSuggestMode"] = cfgShutdownSuggestion;
|
||||
jsonDoc["shutdownSuggest"] = getShutdownSuggestionFlag() ? 1 : 0;
|
||||
jsonDoc["shutLastTimer"] = cfgShutdownInSeconds;
|
||||
|
||||
jsonDoc["chgError"] = chargeError;
|
||||
jsonDoc["charging"] = getChargingState();
|
||||
jsonDoc["chgMode"] = cfgChargeMode;
|
||||
jsonDoc["chgOVP"] = cfgChargeOvervoltageProtection;
|
||||
jsonDoc["chgReducer"] = cfgChargeAutomaticReducer;
|
||||
jsonDoc["chgFullTrig"] = cfgInputLostTriggerChargeMode;
|
||||
jsonDoc["chgOFC"] = needFullChargeOnce ? 1 : 0;
|
||||
|
||||
serializeJson(jsonDoc, jsonResponse);
|
||||
mqttClient.publish(mqtt_topic_state, jsonResponse.c_str());
|
||||
}
|
||||
|
||||
void mqttRoutine() {
|
||||
if (!mqttClient.connected()) {
|
||||
long now = millis();
|
||||
if (now - lastMqttReconnectAttempt > 5000) {
|
||||
lastMqttReconnectAttempt = now;
|
||||
// Attempt to reconnect
|
||||
if (mqttReconnect()) {
|
||||
lastMqttReconnectAttempt = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mqttClient.loop();
|
||||
|
||||
// Publish state periodically
|
||||
long now = millis();
|
||||
if (now - lastMqttPublish > mqttPublishInterval) {
|
||||
lastMqttPublish = now;
|
||||
mqttPublishState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
#include <LittleFS.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
//shutdown suggestion level
|
||||
#define SHUTDOWN_SUGGESTION_DISABLED 0
|
||||
#define SHUTDOWN_SUGGESTION_ON_BATT 1
|
||||
#define SHUTDOWN_SUGGESTION_ON_BATT_75 2
|
||||
#define SHUTDOWN_SUGGESTION_ON_BATT_50 3
|
||||
#define SHUTDOWN_SUGGESTION_ON_BATT_25 4
|
||||
#define SHUTDOWN_SUGGESTION_ON_BATT_CRITICAL 5
|
||||
static volatile uint8_t cfgShutdownSuggestion = SHUTDOWN_SUGGESTION_ON_BATT_25;
|
||||
|
||||
#define AUTO_POWER_OFF 0
|
||||
#define AUTO_POWER_ON 1
|
||||
#define AUTO_POWER_PERSIST 2
|
||||
static volatile uint8_t cfgAutoOn = AUTO_POWER_ON;//AUTO_POWER_PERSIST;//AUTO_POWER_ON;
|
||||
|
||||
#define SHUTDOWN_DURATION_DEFAULT 120
|
||||
static volatile uint16_t cfgShutdownInSeconds = SHUTDOWN_DURATION_DEFAULT;//default time in seconds for shutdown
|
||||
|
||||
//full charge trigger:
|
||||
#define INPUT_LOST_TRIGGER_CHARGE_NONE 0
|
||||
#define INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_ALWAYS 1 //trigger full charge if below 100 percent
|
||||
#define INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_95 2 //trigger full charge if below 95 percent
|
||||
#define INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_90 3 //trigger full charge if below 90 percent
|
||||
#define INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_85 4 //trigger full charge if below 85 percent
|
||||
#define INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_80 5 //trigger full charge if below 80 percent
|
||||
static volatile uint8_t cfgInputLostTriggerChargeMode = INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_90;
|
||||
|
||||
#define CHARGE_MODE_ALWAYS 0
|
||||
#define CHARGE_MODE_RANGE_90_100 1
|
||||
#define CHARGE_MODE_RANGE_85_100 2
|
||||
#define CHARGE_MODE_RANGE_80_100 3
|
||||
#define CHARGE_MODE_RANGE_90_95 4
|
||||
#define CHARGE_MODE_RANGE_85_95 5
|
||||
#define CHARGE_MODE_RANGE_80_95 6
|
||||
#define CHARGE_MODE_RANGE_85_90 7
|
||||
#define CHARGE_MODE_RANGE_80_90 8
|
||||
#define CHARGE_MODE_RANGE_80_85 9
|
||||
static volatile uint8_t cfgChargeMode = CHARGE_MODE_RANGE_85_90;
|
||||
|
||||
static volatile uint8_t cfgChargeOvervoltageProtection = 1;
|
||||
static volatile uint8_t cfgChargeAutomaticReducer = 1;
|
||||
|
||||
bool spiffsOk = false; // flag tetap bernama spiffsOk agar kompatibel dengan kode lain
|
||||
|
||||
bool initConfigs(){
|
||||
// Setup LittleFS
|
||||
spiffsOk = LittleFS.begin();
|
||||
if (!spiffsOk){
|
||||
Serial.println("LittleFS ERROR!");
|
||||
}else{
|
||||
// DEBUG: List all files in LittleFS
|
||||
Serial.println("=== LittleFS Files ===");
|
||||
Dir dir = LittleFS.openDir("/");
|
||||
int fileCount = 0;
|
||||
while (dir.next()) {
|
||||
fileCount++;
|
||||
Serial.print(" FILE: ");
|
||||
Serial.print(dir.fileName());
|
||||
Serial.print(" [");
|
||||
Serial.print(dir.fileSize());
|
||||
Serial.println(" bytes]");
|
||||
}
|
||||
if (fileCount == 0) Serial.println(" (no files found)");
|
||||
Serial.println("=== End LittleFS ===");
|
||||
}
|
||||
return spiffsOk;
|
||||
}
|
||||
|
||||
void loadCfg(){
|
||||
if (!spiffsOk) return;
|
||||
// Open file for reading
|
||||
File file = LittleFS.open("/cfg.json", "r");
|
||||
if (!file) {
|
||||
Serial.println("Failed to open config");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse file contents as JSON
|
||||
DynamicJsonDocument jsonDoc(255);
|
||||
DeserializationError error = deserializeJson(jsonDoc, file);
|
||||
file.close();
|
||||
|
||||
if (error) {
|
||||
Serial.println("Failed to parse file settings");
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t tmpAutoOnMode = jsonDoc["auto"].as<int>();
|
||||
cfgAutoOn = tmpAutoOnMode;
|
||||
if (cfgAutoOn>AUTO_POWER_PERSIST) cfgAutoOn = AUTO_POWER_PERSIST;
|
||||
|
||||
uint8_t v = jsonDoc["shut"].as<int>();
|
||||
if (v>SHUTDOWN_SUGGESTION_ON_BATT_CRITICAL) v = SHUTDOWN_SUGGESTION_ON_BATT_25;
|
||||
cfgShutdownSuggestion = v;
|
||||
|
||||
v = jsonDoc["chgMode"].as<int>();
|
||||
if (v>CHARGE_MODE_RANGE_80_85) v = CHARGE_MODE_RANGE_90_100;
|
||||
cfgChargeMode = v;
|
||||
|
||||
v = jsonDoc["chgILTC"].as<int>();
|
||||
if (v>INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_80) v = INPUT_LOST_TRIGGER_CHARGE_NONE;
|
||||
cfgInputLostTriggerChargeMode = v;
|
||||
|
||||
v = jsonDoc["chgOVP"].as<int>();
|
||||
if (v>1) v = 1;
|
||||
cfgChargeOvervoltageProtection = v;
|
||||
|
||||
v = jsonDoc["chgAR"].as<int>();
|
||||
if (v>1) v = 1;
|
||||
cfgChargeAutomaticReducer = v;
|
||||
}
|
||||
|
||||
void saveCfg(){
|
||||
if (!spiffsOk) return;
|
||||
DynamicJsonDocument jsonDoc(255);
|
||||
jsonDoc["auto"] = cfgAutoOn;
|
||||
jsonDoc["shut"] = cfgShutdownSuggestion;
|
||||
jsonDoc["chgMode"] = cfgChargeMode;
|
||||
jsonDoc["chgILTC"] = cfgInputLostTriggerChargeMode;
|
||||
jsonDoc["chgOVP"] = cfgChargeOvervoltageProtection;
|
||||
jsonDoc["chgAR"] = cfgChargeAutomaticReducer;
|
||||
File file = LittleFS.open("/cfg.json", "w");
|
||||
if (file) {
|
||||
uint32_t sz = serializeJson(jsonDoc, file);
|
||||
file.close();
|
||||
Serial.println("Configuration updated!");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
#include <Arduino.h>
|
||||
#include <U8g2lib.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "bitmap.h"
|
||||
|
||||
#define SCREEN_WIDTH 128
|
||||
#define SCREEN_HEIGHT 32 // 0.91 inch OLED is usually 128x32
|
||||
|
||||
// Custom Pins
|
||||
#define OLED_SCL 12 // MISO = D6 = OLED_SCL
|
||||
#define OLED_SDA 13 // MOSI = D7 = OLED_SDA
|
||||
#define UNUSED_PIN 14 // SCK = D5 (reserved when need reset pin)
|
||||
#define OLED_RESET -1 // Or 0 if display has a reset pin
|
||||
U8G2_SSD1306_128X32_UNIVISION_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE, /* clock=*/ OLED_SCL, /* data=*/ OLED_SDA); // pin remapping with ESP8266 HW I2C
|
||||
|
||||
uint8_t displayContrast = 96;
|
||||
uint8_t battLevelNum = 4;
|
||||
uint8_t battBlockWidth = 15; uint8_t battBlockHeight = 14;
|
||||
uint8_t battBlockGap = 3;
|
||||
|
||||
//for demo
|
||||
uint8_t dispDemoIdx = 0;
|
||||
|
||||
void displaySplash(){
|
||||
u8g2.setContrast(255);
|
||||
//u8g2.drawXBMP(0, 0, 128, 32, splash_logo_bitmap);
|
||||
u8g2.drawXBMP(0, 0, 128, 32, splash_logo_bitmap_inverted);
|
||||
u8g2.sendBuffer();
|
||||
}
|
||||
|
||||
void drawBatteryBlocks(int8_t x, int8_t y, int8_t nFilled){
|
||||
int8_t xp = x+((battBlockWidth+battBlockGap)*battLevelNum);
|
||||
xp-=battBlockGap;
|
||||
int8_t yp = y;
|
||||
u8g2.setDrawColor(1);
|
||||
for (int8_t i=0; i<battLevelNum; i++){
|
||||
xp-=battBlockWidth;
|
||||
if (i<nFilled){
|
||||
u8g2.drawRBox(xp, yp, battBlockWidth, battBlockHeight, 2); //Draws a filled box with rounded
|
||||
}else{
|
||||
u8g2.drawRFrame(xp, yp, battBlockWidth, battBlockHeight, 2); //Draws an empty frame with rounded corners.
|
||||
}
|
||||
xp-=battBlockGap;
|
||||
}
|
||||
}
|
||||
|
||||
void drawBatteryCase(int8_t x, int8_t y, uint8_t style){
|
||||
//u8g2.drawLine(x, y, x+(battBlockWidth+battBlockGap)*battLevelNum), y);
|
||||
int8_t sideGap = 4;
|
||||
int8_t xpe = (x+((battBlockWidth+battBlockGap)*battLevelNum)) - battBlockGap;
|
||||
int8_t w = ((battBlockWidth+battBlockGap)*battLevelNum) - battBlockGap;
|
||||
u8g2.setDrawColor(1);
|
||||
//u8g2.drawLine(x-sideGap, y-sideGap, xpe+sideGap, y-sideGap);
|
||||
u8g2.drawRFrame(x-sideGap, y-sideGap, w+sideGap+sideGap, battBlockHeight+sideGap+sideGap, 3);
|
||||
//kepala batre
|
||||
int8_t headW = 5;
|
||||
int8_t headH = battBlockHeight-2;
|
||||
int8_t yc = y+(battBlockHeight/2);
|
||||
//u8g2.drawRFrame(x-(sideGap+headW), y+sideGap, headW, battBlockHeight, 3);
|
||||
if (style==0){
|
||||
u8g2.drawRFrame(x-(sideGap+headW), yc-(headH/2), headW+1, headH, 1);
|
||||
}else if (style==1){
|
||||
u8g2.drawRBox(x-(sideGap+headW), yc-(headH/2), headW+1, headH, 1);
|
||||
}else if (style==2 || style==3){
|
||||
u8g2.drawRFrame(x-(sideGap+headW), yc-(headH/2), headW+1, headH, 1);
|
||||
u8g2.setDrawColor(0);
|
||||
int8_t xp = (x-(sideGap+headW)) + headW;
|
||||
int8_t yp = yc-(headH/2);
|
||||
if (style==2) u8g2.drawLine(xp, yp+1, xp, yp+(headH-2)); //rigid
|
||||
if (style==3) u8g2.drawLine(xp, yp+0, xp, yp+(headH-1)); //curved or sleek
|
||||
}
|
||||
}
|
||||
|
||||
void drawBatteryPercentage(int8_t x, int8_t y, int8_t textWidth, int8_t percentage){
|
||||
//draw text right aligned
|
||||
String s = String(percentage) + "%";
|
||||
char char_array[6];
|
||||
s.toCharArray(char_array, s.length()+1);
|
||||
u8g2.setFont(u8g2_font_ncenB08_tr); // choose a suitable font
|
||||
int8_t w = u8g2.getStrWidth(char_array); //note: current string width calculation is still not 100% perfect as the initial offset is part of the returned width.
|
||||
int8_t xp = x + (textWidth-w);
|
||||
u8g2.setDrawColor(1);
|
||||
u8g2.drawStr(xp,y,char_array);
|
||||
}
|
||||
|
||||
void drawActiveTransmission(int8_t x, int8_t y, bool inArrow, bool outArrow){
|
||||
u8g2.setDrawColor(1);
|
||||
if (inArrow) u8g2.drawXBMP(x, y, 2, 4, active_data_2x4_in_bitmap);
|
||||
if (outArrow) u8g2.drawXBMP(x+3, y, 2, 4, active_data_2x4_out_bitmap);
|
||||
}
|
||||
|
||||
void drawWifi(int8_t x, int8_t y, uint8_t level){
|
||||
//power 0 = not drawn
|
||||
u8g2.setDrawColor(1);
|
||||
if (level==1) u8g2.drawXBMP(x, y, 16, 16, wifi2_lv1_bitmap);
|
||||
if (level==2) u8g2.drawXBMP(x, y, 16, 16, wifi2_lv2_bitmap);
|
||||
if (level>2) u8g2.drawXBMP(x, y, 16, 16, wifi2_lv3_bitmap);
|
||||
}
|
||||
|
||||
void drawWifiAndTransmission(int8_t x, int8_t y, uint8_t level, bool inArrow, bool outArrow){
|
||||
//power 0 = not drawn
|
||||
drawWifi(x, y+1, level);
|
||||
drawActiveTransmission(x+11, y+12, inArrow, outArrow);
|
||||
}
|
||||
|
||||
void drawPower(int8_t x, int8_t y, bool timed){
|
||||
u8g2.setDrawColor(1);
|
||||
if (timed){
|
||||
u8g2.drawXBMP(x, y, 16, 16, timer_16x16_bitmap);
|
||||
}else{
|
||||
u8g2.drawXBMP(x, y, 16, 16, power_bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
void drawBolt(int8_t x, int8_t y){
|
||||
u8g2.setDrawColor(1);
|
||||
u8g2.drawXBMP(x, y, 16, 16, bolt_16x16_bitmap);
|
||||
}
|
||||
|
||||
void drawCharging(int8_t x, int8_t y, bool reduced){
|
||||
u8g2.setDrawColor(1);
|
||||
if (reduced){
|
||||
u8g2.drawXBMP(x, y, 7, 8, charging_7x8_bitmap);
|
||||
}else{
|
||||
u8g2.drawXBMP(x, y, 7, 8, charge_fast_7x8_bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
void drawWarning(int8_t x, int8_t y, uint8_t mode){
|
||||
u8g2.setDrawColor(1);
|
||||
if (mode==0){
|
||||
u8g2.drawXBMP(x, y, 16, 16, warn_16x16_bitmap);
|
||||
}else if (mode==1){
|
||||
u8g2.drawXBMP(x, y, 16, 16, error_16x16_bitmap);
|
||||
}else if (mode==2){
|
||||
u8g2.drawXBMP(x, y, 16, 16, fatal_16x16_bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
void initDisplay(bool showSplash){
|
||||
pinMode(UNUSED_PIN, INPUT);
|
||||
u8g2.begin();
|
||||
u8g2.setContrast(displayContrast);
|
||||
if (showSplash){
|
||||
displaySplash();
|
||||
}else{
|
||||
u8g2.setContrast(displayContrast);
|
||||
u8g2.clearBuffer();
|
||||
u8g2.sendBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
void clearDisplay(){
|
||||
u8g2.clearBuffer();
|
||||
}
|
||||
|
||||
void sendDisplay(){
|
||||
u8g2.sendBuffer();
|
||||
}
|
||||
|
||||
void drawString(int8_t x, int8_t y, String s){
|
||||
u8g2.setFont(u8g2_font_ncenB08_tr);
|
||||
char char_array[32];
|
||||
s.toCharArray(char_array, s.length()+1);
|
||||
char_array[s.length()] = 0;
|
||||
u8g2.setDrawColor(1);
|
||||
u8g2.drawStr(x, y, char_array);
|
||||
}
|
||||
|
||||
uint8_t stringWidth(String s){
|
||||
char char_array[32];
|
||||
s.toCharArray(char_array, s.length()+1);
|
||||
char_array[s.length()] = 0;
|
||||
u8g2.setFont(u8g2_font_ncenB08_tr); // choose a suitable font
|
||||
return u8g2.getStrWidth(char_array);
|
||||
}
|
||||
|
||||
uint8_t stringCenterX(String s){
|
||||
char char_array[32];
|
||||
s.toCharArray(char_array, s.length()+1);
|
||||
u8g2.setFont(u8g2_font_ncenB08_tr); // choose a suitable font
|
||||
uint8_t w = u8g2.getStrWidth(char_array);
|
||||
return (SCREEN_WIDTH-w)/2;
|
||||
}
|
||||
|
||||
uint8_t stringRightSideX(String s){
|
||||
char char_array[32];
|
||||
s.toCharArray(char_array, s.length()+1);
|
||||
u8g2.setFont(u8g2_font_ncenB08_tr); // choose a suitable font
|
||||
uint8_t w = u8g2.getStrWidth(char_array);
|
||||
return (SCREEN_WIDTH-w);
|
||||
}
|
||||
|
||||
void displayDemoRoutine1(){
|
||||
u8g2.setContrast(displayContrast);
|
||||
u8g2.clearBuffer();
|
||||
//drawBatteryBlock(5,5, true);
|
||||
drawBatteryBlocks(55, 4, dispDemoIdx);
|
||||
drawBatteryCase(55, 4, 2);
|
||||
|
||||
//u8g2.setDrawColor(1);
|
||||
//u8g2.setFont(u8g2_font_ncenB08_tr); // choose a suitable font
|
||||
//u8g2.drawStr(100,32,"100%");
|
||||
drawBatteryPercentage(100,32,28, 25*dispDemoIdx);
|
||||
|
||||
if (dispDemoIdx<=3) drawPower(0,0, dispDemoIdx>=2);
|
||||
if (dispDemoIdx==1 || dispDemoIdx==3) drawBolt(20,0);
|
||||
|
||||
//drawWifi(0, 16, dispDemoIdx);
|
||||
//drawActiveTransmission(11,28, (dispDemoIdx==1 || dispDemoIdx==3)?true:false, (dispDemoIdx==2 || dispDemoIdx==3)?true:false);
|
||||
|
||||
drawWifiAndTransmission(0, 16, dispDemoIdx, (dispDemoIdx==1 || dispDemoIdx==3)?true:false, (dispDemoIdx==2 || dispDemoIdx==3)?true:false);
|
||||
|
||||
drawCharging(88, 24, dispDemoIdx==4);
|
||||
|
||||
drawWarning(20,16, dispDemoIdx);
|
||||
u8g2.setFont(u8g2_font_ncenB08_tr);
|
||||
if (dispDemoIdx==0) u8g2.drawStr(39, 32, "OVER.");
|
||||
if (dispDemoIdx==1) u8g2.drawStr(39, 32, "N0 BAT.");
|
||||
if (dispDemoIdx==2) u8g2.drawStr(39, 32, "SHORT");
|
||||
|
||||
dispDemoIdx++;
|
||||
if (dispDemoIdx > 4) dispDemoIdx = 0;
|
||||
u8g2.sendBuffer();
|
||||
}
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
//todo:
|
||||
// - more accurate TX/RX indication (for security concern), e.g. favicon is served static, thus not display TX/RX arrow.
|
||||
// - Login and apikey access for next version
|
||||
|
||||
#include "wifi.h"
|
||||
#include "upsCfg.h"
|
||||
|
||||
#ifndef UPSIO_H
|
||||
#include "upsIo.h"
|
||||
#endif
|
||||
|
||||
//#include <AsyncTCP.h> //for esp32
|
||||
#include <ESPAsyncTCP.h> //for esp8266
|
||||
#include <ESPAsyncWebServer.h>
|
||||
|
||||
const char* http_username = "admin";
|
||||
const char* http_password = "admin123";
|
||||
|
||||
String base64Decode(const String& input) {
|
||||
const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
String out;
|
||||
int buf = 0, bits = 0;
|
||||
for (size_t i = 0; i < input.length(); i++) {
|
||||
char c = input[i];
|
||||
if (c == '=') break;
|
||||
const char* p = strchr(b64, c);
|
||||
if (!p) continue;
|
||||
buf = (buf << 6) | (p - b64);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out += (char)((buf >> bits) & 0xFF);
|
||||
buf &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool checkAuth(AsyncWebServerRequest *request) {
|
||||
if (!request->hasHeader("Authorization")) return false;
|
||||
String auth = request->header("Authorization");
|
||||
if (!auth.startsWith("Basic ")) return false;
|
||||
String b64 = auth.substring(6);
|
||||
b64.trim();
|
||||
String decoded = base64Decode(b64);
|
||||
int sep = decoded.indexOf(':');
|
||||
if (sep < 0) return false;
|
||||
String user = decoded.substring(0, sep);
|
||||
String pass = decoded.substring(sep + 1);
|
||||
return user.equals(http_username) && pass.equals(http_password);
|
||||
}
|
||||
|
||||
int8_t wifiStatePrev = 99;
|
||||
|
||||
bool configChanged = false;
|
||||
|
||||
AsyncWebServer server(80);
|
||||
|
||||
void applyConfigs() {
|
||||
autoPowerOn = (cfgAutoOn >= AUTO_POWER_ON); //auto power on
|
||||
|
||||
//cfgChargeMode: charge mode:
|
||||
switch (cfgChargeMode) {
|
||||
case CHARGE_MODE_ALWAYS:
|
||||
{
|
||||
chargeAlwaysActive = true;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_90_100:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 100;
|
||||
chargePercentageForResume = 90;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_85_100:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 100;
|
||||
chargePercentageForResume = 85;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_80_100:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 100;
|
||||
chargePercentageForResume = 80;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_90_95:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = false;
|
||||
chargePercentageForStop = 95;
|
||||
chargePercentageForResume = 90;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_85_95:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 95;
|
||||
chargePercentageForResume = 85;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_80_95:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 95;
|
||||
chargePercentageForResume = 80;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_85_90:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 90;
|
||||
chargePercentageForResume = 85;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_80_90:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 90;
|
||||
chargePercentageForResume = 80;
|
||||
}
|
||||
break;
|
||||
case CHARGE_MODE_RANGE_80_85:
|
||||
{
|
||||
chargeAlwaysActive = false;
|
||||
chargeToFull = true;
|
||||
chargePercentageForStop = 85;
|
||||
chargePercentageForResume = 80;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
//cfgInputLostTriggerChargeMode:
|
||||
switch (cfgInputLostTriggerChargeMode) {
|
||||
case INPUT_LOST_TRIGGER_CHARGE_NONE:
|
||||
{
|
||||
chargeAfterInputLost = false;
|
||||
}
|
||||
break;
|
||||
case INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_ALWAYS:
|
||||
{
|
||||
chargeAfterInputLost = true;
|
||||
fullChargeAfterInputLost = true;
|
||||
}
|
||||
break;
|
||||
case INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_95:
|
||||
{
|
||||
chargeAfterInputLost = true;
|
||||
fullChargeAfterInputLost = false;
|
||||
triggerFullChargeAfterInputLostAtPercentage = 95;
|
||||
}
|
||||
break;
|
||||
case INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_90:
|
||||
{
|
||||
chargeAfterInputLost = true;
|
||||
fullChargeAfterInputLost = false;
|
||||
triggerFullChargeAfterInputLostAtPercentage = 90;
|
||||
}
|
||||
break;
|
||||
case INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_85:
|
||||
{
|
||||
chargeAfterInputLost = true;
|
||||
fullChargeAfterInputLost = false;
|
||||
triggerFullChargeAfterInputLostAtPercentage = 85;
|
||||
}
|
||||
break;
|
||||
case INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_80:
|
||||
{
|
||||
chargeAfterInputLost = true;
|
||||
fullChargeAfterInputLost = false;
|
||||
triggerFullChargeAfterInputLostAtPercentage = 80;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
//cfgChargeOvervoltageProtection:
|
||||
chargeOvervoltageProtection = (cfgChargeOvervoltageProtection > 0);
|
||||
//cfgChargeAutomaticReducer:
|
||||
chargeReduceAtBatteryFull = (cfgChargeAutomaticReducer > 0);
|
||||
}
|
||||
|
||||
bool getShutdownSuggestionFlag() {
|
||||
if (cfgShutdownSuggestion == SHUTDOWN_SUGGESTION_DISABLED) return false;
|
||||
if (!startupReady) return false;
|
||||
switch (cfgShutdownSuggestion) {
|
||||
case SHUTDOWN_SUGGESTION_ON_BATT:
|
||||
{
|
||||
return powerOutputActive && !inputVoltageKeepup;
|
||||
}
|
||||
break;
|
||||
case SHUTDOWN_SUGGESTION_ON_BATT_75:
|
||||
{
|
||||
return powerOutputActive && !inputVoltageKeepup && (batteryLevel <= 3);
|
||||
}
|
||||
break;
|
||||
case SHUTDOWN_SUGGESTION_ON_BATT_50:
|
||||
{
|
||||
return powerOutputActive && !inputVoltageKeepup && (batteryLevel <= 2);
|
||||
}
|
||||
break;
|
||||
case SHUTDOWN_SUGGESTION_ON_BATT_25:
|
||||
{
|
||||
return powerOutputActive && !inputVoltageKeepup && (batteryLevel <= 1);
|
||||
}
|
||||
break;
|
||||
case SHUTDOWN_SUGGESTION_ON_BATT_CRITICAL:
|
||||
{
|
||||
return powerOutputActive && !inputVoltageKeepup && (batteryLevel <= 1) && isUpsBatteryCritical();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void handleGetState(AsyncWebServerRequest *request) {
|
||||
if (!checkAuth(request)) {
|
||||
setNetworkIndicatorInTransmission(true, false);
|
||||
AsyncWebServerResponse *response = request->beginResponse(401, "application/json", "{\"success\":0}");
|
||||
// response->addHeader("WWW-Authenticate", "Basic realm=\"UPS Login\""); // Removed to prevent native browser prompt
|
||||
request->send(response);
|
||||
setNetworkIndicatorInTransmission(true, true);
|
||||
return;
|
||||
}
|
||||
setNetworkIndicatorInTransmission(true, false);
|
||||
bool isRefresh = true;
|
||||
String v = "";
|
||||
uint8_t i = 0;
|
||||
|
||||
if (request->hasParam("on")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("on")->value();
|
||||
bool tgtState = v != "0";
|
||||
Serial.print("Switch request: ");
|
||||
Serial.println(tgtState);
|
||||
if (tgtState != powerOutputActive) {
|
||||
setOutputPowerState(tgtState);
|
||||
Serial.println("Switching...");
|
||||
}
|
||||
}
|
||||
|
||||
if (request->hasParam("auto")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("auto")->value();
|
||||
uint8_t tmpAutoOnMode = v.toInt();
|
||||
if ((tmpAutoOnMode != cfgAutoOn) && (tmpAutoOnMode <= AUTO_POWER_PERSIST)) {
|
||||
cfgAutoOn = tmpAutoOnMode;
|
||||
configChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (request->hasParam("shutdown")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("shutdown")->value();
|
||||
uint16_t tSec = v.toInt();
|
||||
if (tSec > 999) tSec = 999;
|
||||
uint32_t nanoT = ((uint32_t)tSec) * 1000000UL;
|
||||
if (nanoT < 999999) nanoT = 999999;
|
||||
bool ok = setPersistentShutdown(nanoT);
|
||||
if ((cfgShutdownInSeconds != tSec && ok)) {
|
||||
cfgShutdownInSeconds = tSec;
|
||||
configChanged = true;
|
||||
}
|
||||
Serial.print("Timed Shutdown request: ");
|
||||
Serial.println(tSec);
|
||||
//Serial.println(ok);
|
||||
}
|
||||
|
||||
if (request->hasParam("shutlv")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("shutlv")->value();
|
||||
int16_t lv = v.toInt();
|
||||
if (lv >= SHUTDOWN_SUGGESTION_DISABLED && lv <= SHUTDOWN_SUGGESTION_ON_BATT_CRITICAL) {
|
||||
if (lv != cfgShutdownSuggestion) {
|
||||
cfgShutdownSuggestion = lv;
|
||||
configChanged = true;
|
||||
Serial.print("Shutdown level changed: ");
|
||||
Serial.println(lv);
|
||||
}
|
||||
} else {
|
||||
Serial.print("Invalid shutdown level: ");
|
||||
Serial.println(v);
|
||||
}
|
||||
}
|
||||
|
||||
//battery management params:
|
||||
//cfgChargeMode (charge mode):
|
||||
if (request->hasParam("cm")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("cm")->value();
|
||||
i = v.toInt();
|
||||
if (i <= CHARGE_MODE_RANGE_80_85) {
|
||||
cfgChargeMode = i;
|
||||
configChanged = true;
|
||||
Serial.print("CM: ");
|
||||
Serial.println(i);
|
||||
}
|
||||
}
|
||||
|
||||
//cfgInputLostTriggerChargeMode (charge on input lost):
|
||||
if (request->hasParam("coil")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("coil")->value();
|
||||
i = v.toInt();
|
||||
if (i <= INPUT_LOST_TRIGGER_CHARGE_ACTIVE_FULL_80) {
|
||||
cfgInputLostTriggerChargeMode = i;
|
||||
configChanged = true;
|
||||
Serial.print("COIL: ");
|
||||
Serial.println(i);
|
||||
}
|
||||
}
|
||||
|
||||
//cfgChargeOvervoltageProtection (charge overvoltage protection):
|
||||
if (request->hasParam("covp")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("covp")->value();
|
||||
i = v.toInt();
|
||||
if (i > 1) i = 1;
|
||||
if (i != cfgChargeOvervoltageProtection) {
|
||||
cfgChargeOvervoltageProtection = i;
|
||||
configChanged = true;
|
||||
Serial.print("COVP: ");
|
||||
Serial.println(i);
|
||||
}
|
||||
}
|
||||
//reset "On-Going Full Charge" triggered after input lost (reset ongoing full charge):
|
||||
if (request->hasParam("rofc")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("rofc")->value();
|
||||
i = v.toInt();
|
||||
if (i > 0 && needFullChargeOnce) {
|
||||
resetOnGoingFullChargeAfterLineLost();
|
||||
Serial.print("ROFC: ");
|
||||
Serial.println(i);
|
||||
}
|
||||
}
|
||||
|
||||
//cfgChargeAutomaticReducer (charge automatic reducer)
|
||||
if (request->hasParam("car")) {
|
||||
isRefresh = false;
|
||||
v = request->getParam("car")->value();
|
||||
i = v.toInt();
|
||||
if (i > 1) i = 1;
|
||||
if (i != cfgChargeAutomaticReducer) {
|
||||
cfgChargeAutomaticReducer = i;
|
||||
configChanged = true;
|
||||
Serial.print("CAR: ");
|
||||
Serial.println(i);
|
||||
}
|
||||
}
|
||||
|
||||
DynamicJsonDocument jsonDoc(1536);
|
||||
String jsonResponse;
|
||||
//String stateOn = isUpsOn()?"1":"0";
|
||||
//String autoOn = String(cfgAutoOn);
|
||||
//String voltageValue = String(batteryVoltage, 2);
|
||||
//2 battery, 1 Input line or AC, 0 OFF
|
||||
//String lineActive = isUpsPoweredFromBattery()?"2":isInputLinePowered()?"1":isUpsOn()?"2":"0";
|
||||
uint8_t lineActiveState = isUpsPoweredFromBattery() ? 2 : isInputLinePowered() ? 1
|
||||
: isUpsOn() ? 2
|
||||
: 0;
|
||||
//String batt = String(batteryLevel);
|
||||
//String battMax = "4";
|
||||
// String battBlink = IsUpsChargingOrBlink()?"1":"0";
|
||||
// String chargeLv = IsUpsChargingOrBlink()?(String(UpsBattMinimalLevelUnderChargingOrBlink())):(String(UpsBatteryLevel()));
|
||||
// String battCritical = IsUpsBatteryCritical()?"1":"0";
|
||||
// String isTimedShut = timedShutdownFlag?"1":"0";
|
||||
// String onShutRemaining = String(GetShutdownCountdown());
|
||||
// String shutdownSuggest = GetShutdownSuggestionFlag()?"1":"0";
|
||||
// String shutdownSuggestMode = String(cfgShutdownSuggestion);
|
||||
// String shutdownLastPeriod = String(timeShutdownLastPeriod);
|
||||
|
||||
//json vars:
|
||||
jsonDoc["success"] = isRefresh ? 2 : 1;
|
||||
jsonDoc["on"] = powerOutputActive ? 1 : 0;
|
||||
;
|
||||
jsonDoc["state"] = isUpsOn() ? 1 : 0;
|
||||
jsonDoc["v"] = String(batteryVoltage, 2);
|
||||
jsonDoc["batt"] = batteryLevel;
|
||||
jsonDoc["battP"] = batteryPercentage;
|
||||
jsonDoc["line"] = lineActiveState;
|
||||
|
||||
jsonDoc["auto"] = cfgAutoOn;
|
||||
jsonDoc["battBlink"] = isUpsBatteryIndicatorChargingOrBlink() ? 1 : 0;
|
||||
jsonDoc["battCritical"] = isUpsBatteryCritical() ? 1 : 0;
|
||||
jsonDoc["chargeLv"] = getRawBatteryLevelFromPercentage(batteryPercentage); //real level
|
||||
jsonDoc["batt"] = batteryLevel; //staged level
|
||||
jsonDoc["battMax"] = 4;
|
||||
jsonDoc["timedShutdown"] = (persistentShutdown && powerOutputActive) ? 1 : 0;
|
||||
jsonDoc["shutdownRemain"] = getPersistentShutdownTick() / 1000000;
|
||||
jsonDoc["shutdownSuggestMode"] = cfgShutdownSuggestion;
|
||||
jsonDoc["shutdownSuggest"] = getShutdownSuggestionFlag() ? 1 : 0; //internal flag raised if cfgShutdownSuggestion fullfill the condition
|
||||
jsonDoc["shutLastTimer"] = cfgShutdownInSeconds;
|
||||
//charge configuration:
|
||||
jsonDoc["chgError"] = chargeError; //error state
|
||||
jsonDoc["charging"] = getChargingState(); //on charging state
|
||||
jsonDoc["chgMode"] = cfgChargeMode;
|
||||
jsonDoc["chgOVP"] = cfgChargeOvervoltageProtection; //over voltage protection
|
||||
jsonDoc["chgReducer"] = cfgChargeAutomaticReducer;
|
||||
jsonDoc["chgFullTrig"] = cfgInputLostTriggerChargeMode;
|
||||
jsonDoc["chgOFC"] = needFullChargeOnce ? 1 : 0; //when input lost and "Ongoing Full Charge" flag triggered
|
||||
|
||||
jsonDoc["vSub"] = String(batteryVoltageUnfiltered, 2);
|
||||
jsonDoc["chgSwitch"] = isChargeActive ? 1 : 0;
|
||||
jsonDoc["inputUV"] = inputUnderVoltage ? 1 : 0;
|
||||
jsonDoc["inputVExist"] = inputVoltageKeepup ? 1 : 0;
|
||||
jsonDoc["btn"] = buttonState ? 1 : 0;
|
||||
jsonDoc["btnShort"] = buttonShortPress ? 1 : 0;
|
||||
jsonDoc["btnLong"] = buttonLongPress ? 1 : 0;
|
||||
jsonDoc["keepPower"] = keepPowerState ? 1 : 0;
|
||||
jsonDoc["display"] = displayActive ? 1 : 0;
|
||||
jsonDoc["rssi"] = rssi;
|
||||
jsonDoc["chargeAllowed"] = chargeAllowed ? 1 : 0;
|
||||
|
||||
serializeJson(jsonDoc, jsonResponse);
|
||||
request->send(200, "application/json", jsonResponse);
|
||||
|
||||
setNetworkIndicatorInTransmission(true, true);
|
||||
}
|
||||
|
||||
void onRootRequest(AsyncWebServerRequest *request) {
|
||||
setNetworkIndicatorInTransmission(true, false);
|
||||
Serial.println("onRootRequest");
|
||||
bool isOK = false;
|
||||
if (spiffsOk) {
|
||||
if (LittleFS.exists("/index.html")) {
|
||||
isOK = true;
|
||||
request->send(LittleFS, "/index.html", "text/html");
|
||||
setNetworkIndicatorInTransmission(true, true);
|
||||
}
|
||||
}
|
||||
if (!isOK) {
|
||||
request->send(404, "text/text", "404: Not Found");
|
||||
setNetworkIndicatorInTransmission(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
void upsServerInit() {
|
||||
|
||||
bool b = initConfigs();
|
||||
if (b) {
|
||||
loadCfg();
|
||||
applyConfigs();
|
||||
}
|
||||
|
||||
//transfer configuration to ups library:
|
||||
if (cfgAutoOn >= AUTO_POWER_ON) {
|
||||
autoPowerOn = true;
|
||||
restartAutoPowerOn();
|
||||
} else {
|
||||
autoPowerOn = false;
|
||||
}
|
||||
|
||||
//for favicon
|
||||
server.serveStatic("/favicon.ico", LittleFS, "/favicon.ico");
|
||||
server.serveStatic("*/favicon.ico", LittleFS, "/favicon.ico");
|
||||
|
||||
//login endpoint: validate credentials
|
||||
server.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
setNetworkIndicatorInTransmission(true, false);
|
||||
if (!checkAuth(request)) {
|
||||
request->send(401, "application/json", "{\"success\":0}");
|
||||
} else {
|
||||
request->send(200, "application/json", "{\"success\":1}");
|
||||
}
|
||||
setNetworkIndicatorInTransmission(true, true);
|
||||
});
|
||||
|
||||
//logout endpoint: clear basic auth by returning 401
|
||||
server.on("/logout", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
setNetworkIndicatorInTransmission(true, false);
|
||||
AsyncWebServerResponse *response = request->beginResponse(401, "application/json", "{\"success\":0,\"message\":\"Logged out\"}");
|
||||
request->send(response);
|
||||
setNetworkIndicatorInTransmission(true, true);
|
||||
});
|
||||
|
||||
//for RESTapi
|
||||
server.on("/state", HTTP_GET, handleGetState);
|
||||
|
||||
//root
|
||||
server.on("/", onRootRequest);
|
||||
server.on("/*", onRootRequest);
|
||||
//Redirect all not-found requests to the root
|
||||
server.onNotFound(onRootRequest);
|
||||
|
||||
server.begin();
|
||||
Serial.println("Server is running!");
|
||||
}
|
||||
|
||||
void upsServerRoutine() {
|
||||
WiFiManagerRoutine();
|
||||
|
||||
//updating wifi state:
|
||||
if (wifiCurrentState != wifiStatePrev) {
|
||||
wifiStatePrev = wifiCurrentState;
|
||||
setNetworkState(wifiStatePrev);
|
||||
}
|
||||
|
||||
//update signal:
|
||||
indicatorNetworkSignalLevel = wifiSignalLevel;
|
||||
|
||||
//manage auto on mode:
|
||||
if (cfgAutoOn == AUTO_POWER_PERSIST) {
|
||||
if (inputVoltageKeepup && inputVoltageOK) {
|
||||
if (restartAutoPowerOn()) Serial.println("Automatic ON restarted");
|
||||
}
|
||||
}
|
||||
|
||||
//if button long pressed -------------------------------------------
|
||||
if (buttonLongPressIgnore && buttonLongPress) {
|
||||
buttonLongPressIgnore = true;
|
||||
Serial.println("Long Pressed Button Action is Reserved!");
|
||||
//(not used)
|
||||
}
|
||||
//------------------------------------------------------------------
|
||||
|
||||
//saving configuration:
|
||||
if (configChanged) {
|
||||
configChanged = false;
|
||||
saveCfg();
|
||||
applyConfigs();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,832 @@
|
|||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
const char index_html[] PROGMEM = R"rawliteral(
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UPS Dashboard</title>
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
margin-top: -20px;
|
||||
background-color: #121212; /* Dark background */
|
||||
color: #e0e0e0; /* Light text color */
|
||||
}
|
||||
hr {
|
||||
margin-bottom: 20px;
|
||||
border-color: #333; /* Darker border color */
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
color: #ffffff; /* White text for headings */
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
button {
|
||||
background-color: #333; /* Dark button background */
|
||||
color: #e0e0e0; /* Light text color */
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #444; /* Slightly lighter on hover */
|
||||
}
|
||||
button.disabled {
|
||||
background-color: #0046a3; /* Gray color for disabled button */
|
||||
color: #a0a0a0;
|
||||
cursor: default;
|
||||
pointer-events: none; /* Prevents hover effect */
|
||||
transition: none; /* Removes transition effect on hover */
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
background-color: #333; /* Gray color for disabled button */
|
||||
color: #555;
|
||||
cursor: default;
|
||||
pointer-events: none; /* Prevents hover effect */
|
||||
transition: none; /* Removes transition effect on hover */
|
||||
}
|
||||
button[disabled]:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #e0e0e0; /* Light text color for labels */
|
||||
}
|
||||
|
||||
/* for styled on off using checkbox */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .2s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: .2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: #0078d7;
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
/* Disabled state */
|
||||
input:disabled + .slider {
|
||||
background-color: #e6e6e6;
|
||||
cursor: default;
|
||||
pointer-events: none; /* Prevents hover effect */
|
||||
}
|
||||
input:disabled:checked + .slider {
|
||||
background-color: #a0d1f6;
|
||||
}
|
||||
input:disabled + .slider:before {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
/* Optional: Add focus style */
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px #0078d7;
|
||||
}
|
||||
|
||||
/* Selec/dropdown style */
|
||||
select {
|
||||
/* width: calc(100% - 22px); */
|
||||
background-color: #333333;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Login Overlay Styles */
|
||||
#login-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(18, 18, 18, 0.95);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-box {
|
||||
background-color: #242424;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
}
|
||||
.login-box input {
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
width: 80%;
|
||||
border: 1px solid #444;
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.login-box h2 {
|
||||
margin-top: 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login Overlay -->
|
||||
<div id="login-overlay">
|
||||
<div class="login-box" onkeydown="if(event.key==='Enter')attemptLogin()">
|
||||
<h2>UPS Authentication</h2>
|
||||
<input type="text" id="login-username" placeholder="Username (admin)" value="admin">
|
||||
<input type="password" id="login-password" placeholder="Password">
|
||||
<br>
|
||||
<button onclick="attemptLogin()" style="width: 80%; padding: 10px;">Login</button>
|
||||
<p id="login-error" style="color: #ff5555; display: none; margin-top: 15px; font-size: 14px;">Invalid username or password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>
|
||||
<label>Smart Network Controlled UPS V1.1</label>
|
||||
<!-- ⭕Hollow Red Circle⚫Black Circle⚪White Circle · 🟢Green Circle🔵Blue Circle🟡Yellow Circle🟠Orange Circle🟤Brown Circle 🔴Red Circle 🟣 Purple Circle -->
|
||||
<label id="connect-state-label" title="Unavailable"> ⚫</label>
|
||||
<button id="logoutBtn" onclick="logout()" style="position: absolute; right: 20px; top: 20px; padding: 4px 12px; font-size: 12px;">Logout</button>
|
||||
</h1>
|
||||
|
||||
<hr>
|
||||
<h1>Power Control:</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Auto ON</label>
|
||||
<!-- <button id="toggleAutoBtn"
|
||||
onclick="toggleAutoOn()"
|
||||
style="position: absolute;
|
||||
left: 140px;width:50px;">OFF</button> -->
|
||||
<select id="auto-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">Line Powered</option>
|
||||
<option value="2">Persistent on Line</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- <div class="form-group">
|
||||
<label>State</label>
|
||||
<button id="switchBtn"
|
||||
onclick="toggleSwitch()"
|
||||
style="position: absolute;
|
||||
left: 140px;width:50px;">OFF</button>
|
||||
</div> -->
|
||||
|
||||
<div class="form-group">
|
||||
<label>Power</label>
|
||||
<label class="toggle-switch" style="position: absolute; left: 150px;">
|
||||
<input type="checkbox" id="switchToggle" disabled >
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Shutdown Cast</label>
|
||||
<select id="shutcast-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">On Battery</option>
|
||||
<option value="2">≤75% Battery</option>
|
||||
<option value="3">≤50% Battery</option>
|
||||
<option value="4">≤25% Battery</option>
|
||||
<option value="5">Battery Critical</option>
|
||||
</select>
|
||||
<label id="shutdown-cast-label" style="position: absolute; left: 310px;width: 50px;"> </label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group">
|
||||
<div class="container">
|
||||
<label style="position: absolute; align-items: center;" title="RestAPI: htttp://<IP>/state?shutdown=<seconds>" >Shutdown</label>
|
||||
<label id="shutdown-countdown" style="position: absolute; left: 150px; width: 150px;">999</label>
|
||||
</div>
|
||||
|
||||
<div class="container" id="shutdown-form" style="position: absolute; left: 150px; align-items: center;">
|
||||
<input type="number" id="shut-timer-input" placeholder="0-999" value="120" min="10" max="999" step="1" style="position: absolute; left: 0px; width: 50px; align-items: center;" maxlength="3">
|
||||
<label style="position: absolute; left: 65px; width: 50px; align-items: center;">sec(s)</label>
|
||||
<button id="shutdownBtn"
|
||||
onclick="shutdownTriggered()"
|
||||
style="position: absolute;
|
||||
left: 120px;width:64px; align-items:center; padding: 0px 0px; height: 22px; margin: -1px -0px">START</button>
|
||||
</div>
|
||||
|
||||
<!-- <div class="container" id="shutdown-countdown" style="position: absolute; left: 150px; align-items: center;">
|
||||
<label style="position: absolute; left: 0px; width: 50px;"></label>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h1>Charge Control:</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Charge Mode</label>
|
||||
<select id="charge-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Always Charge</option>
|
||||
<option value="1">90-100%</option>
|
||||
<option value="2">85-100%</option>
|
||||
<option value="3">80-100%</option>
|
||||
<option value="4">90-95%</option>
|
||||
<option value="5">85-95%</option>
|
||||
<option value="6">80-95%</option>
|
||||
<option value="7">85-90%</option>
|
||||
<option value="8">80-90%</option>
|
||||
<option value="9">80-85%</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label title="Automatic Voltage Reducer">AVR</label>
|
||||
<label class="toggle-switch" style="position: absolute; left: 150px;">
|
||||
<input type="checkbox" id="switchToggleAutoReducer" disabled >
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label title="Overvoltage Protection">OVP</label>
|
||||
<label class="toggle-switch" style="position: absolute; left: 150px;">
|
||||
<input type="checkbox" id="switchToggleOVP" disabled >
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label title="Full Charge on Power Lost">FCoPL</label>
|
||||
<select id="fcopl-mode" style="position: absolute; left: 150px;width: 150px;">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">Always</option>
|
||||
<option value="2">At Battery ≤95%</option>
|
||||
<option value="3">At Battery ≤90%</option>
|
||||
<option value="4">At Battery ≤85%</option>
|
||||
<option value="5">At Battery ≤80%</option>
|
||||
</select>
|
||||
<button id="resetFCoPLBtn"
|
||||
onclick="resetFCoPLTriggered()"
|
||||
style="position: absolute;
|
||||
left: 310px;width:64px; align-items:center; padding: 0px 0px; height: 22px; margin: -1px -0px">Reset</button>
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
<h1>Monitor:</h1>
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<label id="status-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:400px;"
|
||||
>-</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Power Source</label>
|
||||
<label id="power-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:400px;"
|
||||
>-</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Battery Voltage</label>
|
||||
<label id="input-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:100px;"
|
||||
>-</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Battery Level</label>
|
||||
<label id="batt-label"
|
||||
style="position: absolute;
|
||||
left: 150px;width:400px;"
|
||||
>⬛⬛⬛⬛</label>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ⬛ 🟥 (red square), 🟦 (blue square), 🟩 (green square), 🟨 (yellow square), 🟧 (orange square), and 🟫 (brown square)
|
||||
// ◼️ Black Medium Square: A medium-sized black square.
|
||||
// ⬛ Black Large Square: A large black square.
|
||||
// ◾ Black Medium-Small Square: A smaller black square.
|
||||
// ▪️ Black Small Square: The smallest size of a black square.
|
||||
// 🔲 Black Square Button: A square version of the radio button emoji
|
||||
|
||||
var labelStatus = document.getElementById('status-label');
|
||||
|
||||
var labelPower = document.getElementById('power-label');
|
||||
var labelInput = document.getElementById('input-label');
|
||||
var labelBatt = document.getElementById('batt-label');
|
||||
|
||||
var dropdownAutoOnSelect = document.getElementById('auto-mode');
|
||||
var toggleSwitch = document.getElementById('switchToggle');
|
||||
|
||||
var shutTimerInput = document.getElementById('shut-timer-input');
|
||||
var shutdownForm = document.getElementById('shutdown-form');
|
||||
var shutTimer = document.getElementById('shutdown-countdown');
|
||||
|
||||
var dropdownShutLevelSelect = document.getElementById('shutcast-mode');
|
||||
var shutFlagLabel = document.getElementById('shutdown-cast-label');
|
||||
|
||||
var connectStateLabel = document.getElementById('connect-state-label');
|
||||
|
||||
var dropdownChargeModeSelect = document.getElementById('charge-mode');
|
||||
var dropdownFullChargeOnPowerLostModeSelect = document.getElementById('fcopl-mode');
|
||||
var btnResetFCOPL = document.getElementById('resetFCoPLBtn');
|
||||
var toggleAutomaticVoltageReducer = document.getElementById('switchToggleAutoReducer');
|
||||
var toggleOvervoltageProtection = document.getElementById('switchToggleOVP');
|
||||
|
||||
var lastConnectTick = 0;
|
||||
|
||||
var autoOnMode = 0;
|
||||
var stateTarget = false;
|
||||
var stateValue = false;
|
||||
var lineFrom = 0; //0 = None, 1 = AC, 2 = Battery
|
||||
|
||||
var onChange = false;
|
||||
var checkRefresh;
|
||||
|
||||
var battLevel = 0;
|
||||
var battMaxLevel = 0;
|
||||
var battPercentage = 0;
|
||||
var isChargingOrBlink = false;
|
||||
var chargeLevel = 0;
|
||||
var isBattCritical = false;
|
||||
var chargeTickTock = false;
|
||||
|
||||
var timedShutdown = false;
|
||||
var timedShutRemaining = 0;
|
||||
|
||||
var everReceiveStat = false;
|
||||
|
||||
var shutdownSuggestionLevel = 0;
|
||||
//var shutdownSuggestionLevelPrev = 0;
|
||||
var shutdownCastFlag = false;
|
||||
|
||||
var chargeStatus = 0;
|
||||
var chargingState = 0;
|
||||
var chargeMode = 0;
|
||||
var chargeAutoVoltageReducer = false;
|
||||
var chargeOvervoltageProtection = false;
|
||||
var fullChargeOnPowerLostMode = 0;
|
||||
var fullChargeOnPowerLostState = false;
|
||||
|
||||
var targetChargeMode = 0;
|
||||
var targetFCOPLMode = 0;
|
||||
var targetOvervoltageProtection = false;
|
||||
var targetAutomaticVoltageReducer = false;
|
||||
|
||||
var authHeader = "";
|
||||
|
||||
function attemptLogin() {
|
||||
var usr = document.getElementById('login-username').value;
|
||||
var pwd = document.getElementById('login-password').value;
|
||||
authHeader = "Basic " + btoa(usr + ":" + pwd);
|
||||
document.getElementById('login-error').style.display = 'none';
|
||||
SendState("", 0);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authHeader = "";
|
||||
document.getElementById('login-password').value = "";
|
||||
document.getElementById('login-error').style.display = 'none';
|
||||
document.getElementById('login-overlay').style.display = 'flex';
|
||||
fetch('/logout', { method: "GET" }).catch(function(){});
|
||||
}
|
||||
|
||||
function repeatChar(char, times) {
|
||||
return char.repeat(times);
|
||||
}
|
||||
|
||||
// Disable all elements in shutdown-form
|
||||
function disableShutdownForm() {
|
||||
const form = document.getElementById('shutdown-form');
|
||||
const elements = form.querySelectorAll('input, button, select, textarea');
|
||||
elements.forEach(element => {
|
||||
element.disabled = true;
|
||||
});
|
||||
//const btn = document.getElementById('shutdownBtn');
|
||||
//btn.disabled = true;
|
||||
}
|
||||
|
||||
// Enable all elements in shutdown-form
|
||||
function enableShutdownForm() {
|
||||
const form = document.getElementById('shutdown-form');
|
||||
const elements = form.querySelectorAll('input, button, select, textarea');
|
||||
elements.forEach(element => {
|
||||
element.disabled = false;
|
||||
});
|
||||
//const btn = document.getElementById('shutdownBtn');
|
||||
//btn.disabled = false;
|
||||
}
|
||||
|
||||
dropdownShutLevelSelect.addEventListener('change', function() {
|
||||
let targetShutdownLvMode = parseInt(this.value);
|
||||
let s = 'shutdown suggest cast: ' + targetShutdownLvMode;
|
||||
console.log(s);
|
||||
if (targetShutdownLvMode!==shutdownSuggestionLevel){
|
||||
this.disabled = true;
|
||||
SendState('shutlv', targetShutdownLvMode);
|
||||
}
|
||||
});
|
||||
|
||||
dropdownAutoOnSelect.addEventListener('change', function() {
|
||||
let targetAutoMode = parseInt(this.value);
|
||||
let s = 'auto on mode: ' + targetAutoMode;
|
||||
console.log(s);
|
||||
if (targetAutoMode!==autoOnMode){
|
||||
this.disabled = true;
|
||||
SendState('auto', targetAutoMode);
|
||||
}
|
||||
});
|
||||
|
||||
toggleSwitch.addEventListener('change', function() {
|
||||
let targetState = toggleSwitch.checked;
|
||||
let s = 'Toggle switched: ' + (targetState?'ON':'OFF');
|
||||
console.log(s);
|
||||
if (targetState!==stateValue){
|
||||
//Disabled Button
|
||||
toggleSwitch.disabled = true;
|
||||
SendState('on', targetState?'1':'0');
|
||||
}
|
||||
});
|
||||
|
||||
function shutdownTriggered(){
|
||||
//shutdownForm.style.display = 'none';
|
||||
let t = parseInt(shutTimerInput.value);
|
||||
console.log('Send timed shutdown: ' + t);
|
||||
if (stateValue){
|
||||
//url: htttp://<IP>/state?shutdown=<seconds>
|
||||
SendState('shutdown', t);
|
||||
}
|
||||
}
|
||||
|
||||
function ConnectionRefresh(){
|
||||
lastConnectTick = performance.now();
|
||||
connectStateLabel.textContent = ' 🟢';
|
||||
connectStateLabel.title = 'Connected';
|
||||
}
|
||||
|
||||
dropdownChargeModeSelect.addEventListener('change', function() {
|
||||
targetChargeMode = parseInt(this.value);
|
||||
let s = 'charge mode: ' + targetChargeMode;
|
||||
console.log(s);
|
||||
if (targetChargeMode!==chargeMode){
|
||||
this.disabled = true;
|
||||
SendState('cm', targetChargeMode);
|
||||
}
|
||||
});
|
||||
|
||||
dropdownFullChargeOnPowerLostModeSelect.addEventListener('change', function() {
|
||||
targetFCOPLMode = parseInt(this.value);
|
||||
let s = 'FCOPL mode: ' + targetFCOPLMode;
|
||||
console.log(s);
|
||||
if (targetFCOPLMode!==fullChargeOnPowerLostMode){
|
||||
this.disabled = true;
|
||||
SendState('coil', targetFCOPLMode);
|
||||
}
|
||||
});
|
||||
|
||||
function resetFCoPLTriggered(){
|
||||
btnResetFCOPL.disabled = true;
|
||||
if (fullChargeOnPowerLostState){
|
||||
SendState('rofc', 1);
|
||||
}
|
||||
}
|
||||
|
||||
toggleAutomaticVoltageReducer.addEventListener('change', function() {
|
||||
targetAutomaticVoltageReducer = toggleAutomaticVoltageReducer.checked;
|
||||
let s = 'AVR switched: ' + (targetAutomaticVoltageReducer?'ON':'OFF');
|
||||
console.log(s);
|
||||
if (targetAutomaticVoltageReducer!==chargeAutoVoltageReducer){
|
||||
//Disabled Button
|
||||
toggleAutomaticVoltageReducer.disabled = true;
|
||||
SendState('car', targetAutomaticVoltageReducer?'1':'0');
|
||||
}
|
||||
});
|
||||
|
||||
toggleOvervoltageProtection.addEventListener('change', function() {
|
||||
targetOvervoltageProtection = toggleOvervoltageProtection.checked;
|
||||
let s = 'OVP switched: ' + (targetOvervoltageProtection?'ON':'OFF');
|
||||
console.log(s);
|
||||
if (targetOvervoltageProtection!==chargeOvervoltageProtection){
|
||||
//Disabled Button
|
||||
toggleOvervoltageProtection.disabled = true;
|
||||
SendState('covp', targetOvervoltageProtection?'1':'0');
|
||||
}
|
||||
});
|
||||
|
||||
function SendState(cmd, value) {
|
||||
// Clear timeout
|
||||
clearTimeout(checkRefresh);
|
||||
|
||||
if (cmd !== "") {
|
||||
onChange = true;
|
||||
const url = '/state?' + cmd + '=' + value;
|
||||
|
||||
console.log("Fetch URL:", url);
|
||||
|
||||
fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": authHeader
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
document.getElementById('login-overlay').style.display = 'flex';
|
||||
if (authHeader !== "") document.getElementById('login-error').style.display = 'block';
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to update.");
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('login-overlay').style.display = 'none';
|
||||
console.log(`${cmd} updated:`, data);
|
||||
onChange = false;
|
||||
if (parseInt(data.success)>0) ConnectionRefresh();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error:", error);
|
||||
onChange = false;
|
||||
});
|
||||
|
||||
} else {
|
||||
if (!onChange) {
|
||||
const url = '/state';
|
||||
fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": authHeader
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
document.getElementById('login-overlay').style.display = 'flex';
|
||||
if (authHeader !== "") document.getElementById('login-error').style.display = 'block';
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!response.ok) throw new Error("Failed to fetch data.");
|
||||
return response.json(); // Already a JSON object, no need for JSON.parse()
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('login-overlay').style.display = 'none';
|
||||
//console.log("Fetched Data:", data);
|
||||
if (parseInt(data.success)>0) ConnectionRefresh();
|
||||
if (onChange) return;
|
||||
//sync display
|
||||
if (data.success=="2"){
|
||||
autoOnMode = parseInt(data.auto);
|
||||
let targetAutoMode = parseInt(dropdownAutoOnSelect.value);
|
||||
if (targetAutoMode!=autoOnMode){
|
||||
dropdownAutoOnSelect.value = autoOnMode.toString();
|
||||
}
|
||||
if (dropdownAutoOnSelect.disabled){
|
||||
dropdownAutoOnSelect.disabled = false;
|
||||
}
|
||||
|
||||
//toggleAutoBtn.textContent = autoOn?'ON':'OFF';
|
||||
stateTarget = parseInt(data.on) > 0;
|
||||
stateValue = parseInt(data.state) > 0;
|
||||
if (stateTarget==stateValue){
|
||||
toggleSwitch.checked = stateTarget;
|
||||
toggleSwitch.disabled = false;
|
||||
}
|
||||
//switchBtn.textContent = stateOn?'ON':'OFF';
|
||||
|
||||
lineFrom = parseInt(data.line);
|
||||
isChargingOrBlink = parseInt(data.battBlink) > 0;
|
||||
chargeLevel = parseInt(data.chargeLv); //battery level at charges
|
||||
isBattCritical = parseInt(data.battCritical) > 0;
|
||||
|
||||
//Version 1.1
|
||||
battPercentage = parseInt(data.battP);
|
||||
chargeStatus = parseInt(data.chgError);
|
||||
chargingState = parseInt(data.charging);
|
||||
chargeMode = parseInt(data.chgMode);
|
||||
chargeAutoVoltageReducer = parseInt(data.chgReducer) > 0;
|
||||
chargeOvervoltageProtection = parseInt(data.chgOVP) > 0;
|
||||
fullChargeOnPowerLostMode = parseInt(data.chgFullTrig);
|
||||
fullChargeOnPowerLostState = parseInt(data.chgOFC) > 0;
|
||||
|
||||
//let s = lineFrom==0?'OFF':(lineFrom==1 || isChargingOrBlink)?'⚡️ AC':'🔋 Battery';
|
||||
let s = lineFrom==0?'OFF':(lineFrom==1 || isChargingOrBlink)?'⚡️ Line':'🔋 Battery';
|
||||
|
||||
if (chargingState>0){
|
||||
if (chargeStatus==0){ //normal state
|
||||
if (lineFrom==1 && chargingState==2) s += " (Charging)";
|
||||
if (lineFrom==1 && chargingState==1) s += " (Reduced Charging)";
|
||||
}else{
|
||||
//charging while error (retry test)
|
||||
if (lineFrom==1 && chargingState>0) s += " (Charging)";
|
||||
}
|
||||
}
|
||||
|
||||
if (lineFrom==2 && isBattCritical) s += " (Critical)";
|
||||
labelPower.textContent = s;
|
||||
labelInput.textContent = data.v + " V";
|
||||
battLevel = parseInt(data.batt);
|
||||
battMaxLevel = parseInt(data.battMax);
|
||||
|
||||
|
||||
//display V1.1
|
||||
//charge mode:
|
||||
if (targetChargeMode!=chargeMode){
|
||||
dropdownChargeModeSelect.value = chargeMode.toString();
|
||||
}
|
||||
if (dropdownChargeModeSelect.disabled){
|
||||
dropdownChargeModeSelect.disabled = false;
|
||||
}
|
||||
//fcopl:
|
||||
if (targetFCOPLMode!=fullChargeOnPowerLostMode){
|
||||
dropdownFullChargeOnPowerLostModeSelect.value = fullChargeOnPowerLostMode.toString();
|
||||
}
|
||||
if (dropdownFullChargeOnPowerLostModeSelect.disabled){
|
||||
dropdownFullChargeOnPowerLostModeSelect.disabled = false;
|
||||
}
|
||||
//cvopl reset button:
|
||||
if (fullChargeOnPowerLostState){
|
||||
if (btnResetFCOPL.disabled || btnResetFCOPL.style.display !== "inline-block"){
|
||||
btnResetFCOPL.disabled = false;
|
||||
btnResetFCOPL.style.display = "inline-block";
|
||||
}
|
||||
}else{
|
||||
if (!btnResetFCOPL.disabled || btnResetFCOPL.style.visibility !== "none"){
|
||||
btnResetFCOPL.disabled = true;
|
||||
btnResetFCOPL.style.display = "none";
|
||||
}
|
||||
}
|
||||
//AVR:
|
||||
//if (toggleAutomaticVoltageReducer.checked!=chargeAutoVoltageReducer){
|
||||
if (toggleAutomaticVoltageReducer.disabled){
|
||||
toggleAutomaticVoltageReducer.checked = chargeAutoVoltageReducer;
|
||||
toggleAutomaticVoltageReducer.disabled = false;
|
||||
}
|
||||
//}
|
||||
//OVP:
|
||||
if (toggleOvervoltageProtection.disabled){
|
||||
toggleOvervoltageProtection.checked = chargeOvervoltageProtection;
|
||||
toggleOvervoltageProtection.disabled = false;
|
||||
}
|
||||
|
||||
|
||||
if (stateTarget==stateValue){
|
||||
if (stateValue){
|
||||
enableShutdownForm();
|
||||
}else{
|
||||
disableShutdownForm();
|
||||
}
|
||||
}else{
|
||||
disableShutdownForm();
|
||||
}
|
||||
|
||||
timedShutdown = parseInt(data.timedShutdown) > 0;
|
||||
if (timedShutdown){
|
||||
timedShutRemaining = parseInt(data.shutdownRemain);
|
||||
if (timedShutRemaining==0){
|
||||
shutTimer.textContent = 'TURNING OFF...';
|
||||
}else{
|
||||
shutTimer.textContent = timedShutRemaining.toString();
|
||||
}
|
||||
shutdownForm.style.display = 'none';
|
||||
}else{
|
||||
timedShutRemaining = 0;
|
||||
shutTimer.textContent = '';
|
||||
shutdownForm.style.display = 'block';
|
||||
}
|
||||
|
||||
if (!everReceiveStat){
|
||||
shutTimerInput.value = parseInt(data.shutLastTimer);
|
||||
}
|
||||
everReceiveStat = true;
|
||||
|
||||
|
||||
//shutdown suggestion level
|
||||
let shutLv = parseInt(data.shutdownSuggestMode);
|
||||
if (shutdownSuggestionLevel!=shutLv){
|
||||
shutdownSuggestionLevel = shutLv;
|
||||
dropdownShutLevelSelect.value = shutLv.toString();
|
||||
}
|
||||
if (dropdownShutLevelSelect.disabled){
|
||||
dropdownShutLevelSelect.disabled = false;
|
||||
}
|
||||
|
||||
shutdownCastFlag = parseInt(data.shutdownSuggest) > 0;
|
||||
shutFlagLabel.textContent = shutdownCastFlag?'✔️':' ';
|
||||
|
||||
if (chargeStatus==0){
|
||||
labelStatus.textContent = "Normal";
|
||||
}else if (chargeStatus==1){
|
||||
labelStatus.textContent = "⚠️ Battery Not Detected" + (chargingState==0?"":" (Retry charging...)");
|
||||
}else if (chargeStatus==2){
|
||||
labelStatus.textContent = "⛔ Battery Shorted" + (chargingState==0?"":" (Retry charging..)");
|
||||
}else if (chargeStatus==4){
|
||||
labelStatus.textContent = "⚠️ Charge Overvoltage" + (chargingState==0?"":" (Retry charging...)");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error:", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//charging independent animation
|
||||
function BattDisplayRoutine(){
|
||||
|
||||
//connection state update when timeout
|
||||
if (performance.now() - lastConnectTick >= 3000){
|
||||
connectStateLabel.textContent = ' ⚫';
|
||||
connectStateLabel.title = 'Unavailable';
|
||||
}
|
||||
|
||||
if (!isChargingOrBlink){
|
||||
if (isBattCritical){
|
||||
chargeTickTock = !chargeTickTock;
|
||||
let bar = chargeTickTock?0:1;
|
||||
if (chargeTickTock){
|
||||
labelBatt.textContent = repeatChar('🟧', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}else{
|
||||
labelBatt.textContent = repeatChar('🟧', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}
|
||||
}else{
|
||||
chargeTickTock = false;
|
||||
if (battMaxLevel==0){
|
||||
labelBatt.textContent = '⬛⬛⬛⬛';//'Unknown';
|
||||
}else{
|
||||
labelBatt.textContent = repeatChar('🟩', battLevel) + repeatChar('⬛', battMaxLevel-battLevel) + " " + battPercentage + "%";
|
||||
}
|
||||
}
|
||||
}else{
|
||||
chargeTickTock = !chargeTickTock;
|
||||
let bar = chargeTickTock?Math.min(chargeLevel+1, battMaxLevel):Math.min(chargeLevel, battMaxLevel);
|
||||
if (chargeTickTock){
|
||||
labelBatt.textContent = repeatChar('🟩', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}else{
|
||||
labelBatt.textContent = repeatChar('🟩', bar) + repeatChar('⬛', battMaxLevel-bar) + " " + battPercentage + "%";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncState(){
|
||||
//checkOnlineState();
|
||||
if (onChange){
|
||||
clearTimeout(checkRefresh);
|
||||
return;
|
||||
}
|
||||
checkRefresh = setTimeout(function() {
|
||||
SendState("", 0);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
disableShutdownForm();
|
||||
|
||||
// Set an interval to sync display
|
||||
setInterval(syncState, 500);
|
||||
|
||||
SendState("", 0);
|
||||
syncState();
|
||||
|
||||
setInterval(BattDisplayRoutine, 1000);
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)rawliteral";
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
#include <ESP8266WiFi.h>
|
||||
#include <ESP8266mDNS.h>
|
||||
|
||||
bool useStaticIP = false;
|
||||
#ifndef STASSID
|
||||
#define STASSID "FADI"
|
||||
#define STAPSK "12341234"
|
||||
#endif
|
||||
|
||||
const char* ssid = STASSID;
|
||||
const char* password = STAPSK;
|
||||
String staticIP = "192.168.0.100";
|
||||
String staticGateway = "192.168.0.1";
|
||||
String staticSubnet = "255.255.255.0";
|
||||
String staticDNS = "192.168.0.1";
|
||||
String mdnsPrefix = "SMART-UPS"; //for fast access like http://smart-ups.local
|
||||
|
||||
static volatile bool wifiStarted = false;
|
||||
static volatile uint32_t wifiNextCheckTick = 0;
|
||||
static volatile bool wifiConnected = false;
|
||||
static volatile bool wifiConnecting = false;
|
||||
static volatile uint8_t wifiCurrentState = 0;
|
||||
static volatile bool setWifiConfig = false;
|
||||
|
||||
static volatile uint8_t wifiSignalLevel = 0; //0=none, 1=low, 2=medium, 2=high
|
||||
|
||||
long rssi = -999;
|
||||
|
||||
void WiFiManagerRoutine() {
|
||||
int8_t wifiState = WiFi.status();
|
||||
bool isWifiConnected = wifiState == WL_CONNECTED;
|
||||
if (isWifiConnected) wifiConnecting = false;
|
||||
if (wifiConnected != isWifiConnected){
|
||||
setWifiConfig = true; //reset config on reconnect
|
||||
wifiConnected = isWifiConnected;
|
||||
if (wifiConnected) wifiCurrentState = 2; //connected
|
||||
}
|
||||
|
||||
//wifi signal update
|
||||
rssi = WiFi.RSSI();
|
||||
if (rssi <= -80){
|
||||
wifiSignalLevel = 1;
|
||||
}else if (rssi > -80 && rssi <= -70){
|
||||
wifiSignalLevel = 2;
|
||||
}else if (rssi > -70 && rssi <= -60){
|
||||
wifiSignalLevel = 3;
|
||||
}else{
|
||||
wifiSignalLevel = 3;
|
||||
}
|
||||
|
||||
if (!isWifiConnected){
|
||||
if (wifiState==WL_NO_SSID_AVAIL || wifiState==WL_CONNECT_FAILED || wifiState==WL_CONNECTION_LOST) {
|
||||
wifiConnecting = false;
|
||||
wifiCurrentState=0;
|
||||
wifiSignalLevel = 0;
|
||||
}
|
||||
|
||||
if (!wifiStarted || (millis() - wifiNextCheckTick) >= 30000UL){ //check each 30seconds
|
||||
wifiStarted = true;
|
||||
Serial.print("Connecting to ");
|
||||
Serial.println(ssid);
|
||||
WiFi.setOutputPower(20.5);//set to max dbm for reliability (for highest RF power output, supply current ~ 80mA)
|
||||
//WiFi.setOutputPower(0);//(for lowest RF power output, supply current ~ 70mA)
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid, password);
|
||||
wifiNextCheckTick = millis(); //next 30 seconds check again
|
||||
wifiConnecting = true;
|
||||
wifiCurrentState = 1; //connecting
|
||||
}
|
||||
}else{
|
||||
if (!wifiStarted){
|
||||
wifiStarted = true;
|
||||
setWifiConfig = true;
|
||||
}
|
||||
if (setWifiConfig){
|
||||
//set connection on first time connected
|
||||
setWifiConfig = false;
|
||||
|
||||
WiFi.setAutoReconnect(true);
|
||||
WiFi.persistent(true);
|
||||
|
||||
if (useStaticIP){
|
||||
IPAddress ip, gateway, subnet, dns;
|
||||
bool b = ip.fromString(staticIP);
|
||||
b = gateway.fromString(staticGateway);
|
||||
b = subnet.fromString(staticSubnet);
|
||||
b = dns.fromString(staticDNS);
|
||||
WiFi.config(ip, gateway, subnet, dns);
|
||||
}
|
||||
|
||||
Serial.print("Connected to ");
|
||||
Serial.println(ssid);
|
||||
Serial.print("IP address: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
if (MDNS.begin(mdnsPrefix)) {
|
||||
Serial.print("MDNS responder started as ");
|
||||
Serial.print(mdnsPrefix);
|
||||
Serial.println(".local");
|
||||
}
|
||||
}
|
||||
|
||||
MDNS.update();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
# ups_dashboard
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "ups-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mqtt": "^5.15.1",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { encrypt } from '@/lib/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { username, password } = await request.json();
|
||||
|
||||
const validUsername = process.env.DASHBOARD_USERNAME || 'admin';
|
||||
const validPassword = process.env.DASHBOARD_PASSWORD || 'admin';
|
||||
|
||||
if (username === validUsername && password === validPassword) {
|
||||
// Create session
|
||||
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
const session = await encrypt({ username, expires });
|
||||
|
||||
// Save the session in a cookie
|
||||
(await cookies()).set('auth_token', session, {
|
||||
expires,
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid username or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST() {
|
||||
(await cookies()).delete('auth_token');
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
const ESP_IP = process.env.ESP_DEVICE_IP || 'http://192.168.0.100';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Bug #5: Authentication check
|
||||
const secret = process.env.CONTROL_API_SECRET;
|
||||
if (secret) {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const cmd = searchParams.get('cmd');
|
||||
const value = searchParams.get('value');
|
||||
|
||||
if (!cmd || value === null) {
|
||||
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Construct the URL exactly as expected by the ESP: http://192.168.0.100/state?cmd=value
|
||||
const targetUrl = `${ESP_IP}/state?${cmd}=${value}`;
|
||||
|
||||
// We add a timeout to fetch in case the ESP is unreachable
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const res = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'ESP returned an error' }, { status: res.status });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ success: true, data });
|
||||
} catch (error: unknown) {
|
||||
console.error('Proxy Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to reach ESP', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -0,0 +1,53 @@
|
|||
:root {
|
||||
--font-family: var(--font-inter), system-ui, sans-serif;
|
||||
--font-geist-mono: 'Geist Mono', 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
|
||||
|
||||
--bg-main: #f9fafb;
|
||||
--bg-panel: #ffffff;
|
||||
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--text-muted: #9ca3af;
|
||||
|
||||
--border-color: #f3f4f6;
|
||||
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-light: #eff6ff;
|
||||
--color-success: #10b981;
|
||||
--color-success-light: #ecfdf5;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-light: #fffbeb;
|
||||
--color-purple: #8b5cf6;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-card: 0 10px 15px -3px rgba(0, 0, 0, 0.02), 0 4px 6px -2px rgba(0, 0, 0, 0.01);
|
||||
|
||||
--border-radius-sm: 0.375rem;
|
||||
--border-radius-md: 0.5rem;
|
||||
--border-radius-lg: 1rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "IoT DC UPS Dashboard",
|
||||
description: "Monitor your UPS status real-time",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable}`} suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import Login from '@/components/Login';
|
||||
|
||||
export default function LoginPage() {
|
||||
return <Login />;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
margin-left: 260px; /* Sidebar width */
|
||||
padding: 2rem 2.5rem;
|
||||
background-color: var(--bg-main);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.gridContent {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1.5rem;
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.gridContent {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileHeader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.mainContent {
|
||||
margin-left: 0;
|
||||
padding: 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.mobileHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-panel);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mobileTitle {
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.menuBtn:active {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import mqtt from 'mqtt';
|
||||
import { Menu, Zap } from 'lucide-react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import Header from '@/components/Header';
|
||||
import StatCards from '@/components/StatCards';
|
||||
import dynamic from 'next/dynamic';
|
||||
const VoltageChart = dynamic(() => import('@/components/VoltageChart'), { ssr: false });
|
||||
const HistoryPage = dynamic(() => import('@/components/HistoryPage'), { ssr: false });
|
||||
import EventLog from '@/components/EventLog';
|
||||
import EventLogsPage from '@/components/EventLogsPage';
|
||||
import SettingsPanel from '@/components/SettingsPanel';
|
||||
import Login from '@/components/Login';
|
||||
import styles from './page.module.css';
|
||||
import { insertHistoryPoint, insertEvent, fetchHistory, fetchEvents } from '@/lib/db';
|
||||
import type { HistoryRow, EventRow } from '@/lib/db';
|
||||
|
||||
export interface UpsData {
|
||||
success: number;
|
||||
on: number;
|
||||
state: number;
|
||||
v: string;
|
||||
batt: number;
|
||||
battP: number;
|
||||
line: number;
|
||||
charging: number;
|
||||
chgError: number;
|
||||
uptime?: number;
|
||||
auto?: number;
|
||||
battBlink?: number;
|
||||
battCritical?: number;
|
||||
chargeLv?: number;
|
||||
battMax?: number;
|
||||
timedShutdown?: number;
|
||||
shutdownRemain?: number;
|
||||
shutdownSuggestMode?: number;
|
||||
shutdownSuggest?: number;
|
||||
shutLastTimer?: number;
|
||||
chgMode?: number;
|
||||
chgOVP?: number;
|
||||
chgReducer?: number;
|
||||
chgFullTrig?: number;
|
||||
chgOFC?: number;
|
||||
}
|
||||
|
||||
export interface UpsEvent {
|
||||
id: string;
|
||||
message: string;
|
||||
time: string;
|
||||
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
// Classify event severity based on message content
|
||||
function classifySeverity(msg: string): 'info' | 'success' | 'warning' | 'error' {
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('fail') || lower.includes('disconnect') || lower.includes('offline')) return 'error';
|
||||
if (lower.includes('warn') || lower.includes('critical') || lower.includes('low')) return 'warning';
|
||||
if (lower.includes('connect') || lower.includes('online') || lower.includes('subscrib')) return 'success';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
// Convert DB history row to chart point
|
||||
function rowToChartPoint(row: HistoryRow) {
|
||||
return {
|
||||
time: row.created_at
|
||||
? new Date(row.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
: '--:--:--',
|
||||
batteryPct: row.battery_pct,
|
||||
vBatt: row.v_batt,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert DB event row to UpsEvent
|
||||
function rowToEvent(row: EventRow): UpsEvent {
|
||||
return {
|
||||
id: String(row.id ?? crypto.randomUUID()),
|
||||
message: row.message,
|
||||
time: row.created_at
|
||||
? new Date(row.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
: '--:--:--',
|
||||
severity: row.severity,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'history' | 'logs' | 'settings'>('dashboard');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [upsData, setUpsData] = useState<UpsData | null>(null);
|
||||
const [mqttStatus, setMqttStatus] = useState<'Connecting...' | 'Online' | 'Offline'>('Connecting...');
|
||||
const [events, setEvents] = useState<UpsEvent[]>([]);
|
||||
const [chartData, setChartData] = useState<{ time: string; batteryPct: number; vBatt: number }[]>([]);
|
||||
const [liveChartData, setLiveChartData] = useState<{ time: string; batteryPct: number; vBatt: number }[]>([]);
|
||||
const [dbLoaded, setDbLoaded] = useState(false);
|
||||
const mqttClientRef = useRef<mqtt.MqttClient | null>(null);
|
||||
const prevUpsDataRef = useRef<UpsData | null>(null);
|
||||
const lastMqttMessageRef = useRef<number>(0);
|
||||
|
||||
// Throttle: only save to Supabase every 30 seconds
|
||||
const lastSaveRef = useRef<number>(0);
|
||||
|
||||
const addEvent = useCallback((msg: string, persist = true) => {
|
||||
const severity = classifySeverity(msg);
|
||||
const ev: UpsEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
message: msg,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
severity,
|
||||
};
|
||||
setEvents(prev => [ev, ...prev].slice(0, 200));
|
||||
|
||||
// Save to Supabase (for non-trivial messages)
|
||||
if (persist) {
|
||||
insertEvent({ message: msg, severity } as Partial<EventRow> & Pick<EventRow, 'message' | 'severity'>);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearEvents = async () => {
|
||||
const { clearAllEvents } = await import('@/lib/db');
|
||||
await clearAllEvents();
|
||||
setEvents([]);
|
||||
};
|
||||
|
||||
// Auto-clear data if no MQTT message received for 10 seconds (device offline)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (lastMqttMessageRef.current > 0) {
|
||||
const elapsed = (Date.now() - lastMqttMessageRef.current) / 1000;
|
||||
if (elapsed >= 10 && prevUpsDataRef.current !== null) {
|
||||
setUpsData(null);
|
||||
setLiveChartData([]);
|
||||
prevUpsDataRef.current = null;
|
||||
addEvent('IoT Device went Offline (No telemetry signal for >10s)', true);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [addEvent]);
|
||||
|
||||
// Initial data load on mount
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const [histRows, evRows] = await Promise.all([
|
||||
fetchHistory(100),
|
||||
fetchEvents(200)
|
||||
]);
|
||||
|
||||
if (histRows.length > 0) {
|
||||
const points = histRows.map(rowToChartPoint);
|
||||
setChartData(points);
|
||||
}
|
||||
if (evRows.length > 0) {
|
||||
const evs = evRows
|
||||
.map(rowToEvent)
|
||||
.filter(ev =>
|
||||
!ev.message.startsWith('Connected to') &&
|
||||
!ev.message.startsWith('Subscribed to') &&
|
||||
!ev.message.startsWith('Connecting to') &&
|
||||
!ev.message.startsWith('Subscription error') &&
|
||||
!ev.message.startsWith('MQTT Connection Offline')
|
||||
);
|
||||
setEvents(evs);
|
||||
}
|
||||
setDbLoaded(true);
|
||||
} catch (err) {
|
||||
console.warn('[DB] initial load warning:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
// MQTT Connection setup
|
||||
useEffect(() => {
|
||||
setMqttStatus('Connecting...');
|
||||
console.log('Connecting to MQTT Broker (wss://broker.emqx.io:8084/mqtt)...');
|
||||
|
||||
const client = mqtt.connect('wss://broker.emqx.io:8084/mqtt');
|
||||
mqttClientRef.current = client;
|
||||
|
||||
client.on('connect', () => {
|
||||
setMqttStatus('Online');
|
||||
console.log('Connected to MQTT Broker');
|
||||
client.subscribe('ups/dev15/state', (err) => {
|
||||
if (err && err.message !== 'client disconnecting') {
|
||||
console.warn(`Subscription warning: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
client.on('message', (topic, message) => {
|
||||
if (topic === 'ups/dev15/state') {
|
||||
try {
|
||||
// Sanitize the string: remove control characters like \n, \t, etc. that might break JSON parse
|
||||
const rawString = message.toString().replace(/[\x00-\x1F\x7F-\x9F]/g, "");
|
||||
const data: UpsData = JSON.parse(rawString);
|
||||
lastMqttMessageRef.current = Date.now();
|
||||
setUpsData(data);
|
||||
|
||||
// Track state changes to generate event logs
|
||||
const prevData = prevUpsDataRef.current;
|
||||
if (prevData) {
|
||||
// Power Source change
|
||||
if (prevData.line !== data.line) {
|
||||
if (data.line > 0) addEvent('AC Power Restored (Line Active)', true);
|
||||
else addEvent('AC Power Lost (Running on Battery)', true);
|
||||
}
|
||||
// Output State change
|
||||
if (prevData.on !== data.on) {
|
||||
if (data.on === 1) addEvent('UPS Output turned ON', true);
|
||||
else addEvent('UPS Output turned OFF', true);
|
||||
}
|
||||
// Battery Critical
|
||||
if (prevData.battCritical !== data.battCritical && data.battCritical === 1) {
|
||||
addEvent('WARNING: Battery level is CRITICAL', true);
|
||||
}
|
||||
// Charging state
|
||||
if (prevData.charging !== data.charging) {
|
||||
if (data.charging === 0 && data.battP >= 95) addEvent('Battery Fully Charged', true);
|
||||
else if (data.charging === 1) addEvent('Battery is now charging', false);
|
||||
}
|
||||
// Shutdown Timer
|
||||
if ((prevData.shutdownRemain ?? 0) === 0 && (data.shutdownRemain ?? 0) > 0) {
|
||||
addEvent(`Shutdown timer started (${data.shutdownRemain}s)`, true);
|
||||
} else if ((prevData.shutdownRemain ?? 0) > 0 && (data.shutdownRemain ?? 0) === 0 && data.on === 1) {
|
||||
addEvent('Shutdown timer cancelled', true);
|
||||
}
|
||||
}
|
||||
prevUpsDataRef.current = data;
|
||||
|
||||
const newPoint = {
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }),
|
||||
batteryPct: data.battP,
|
||||
vBatt: parseFloat(data.v),
|
||||
};
|
||||
|
||||
setChartData(prev => [...prev, newPoint].slice(-100));
|
||||
setLiveChartData(prev => [...prev, newPoint].slice(-15));
|
||||
|
||||
// Throttled save to Supabase (every 30 seconds)
|
||||
const now = Date.now();
|
||||
if (now - lastSaveRef.current >= 30000) {
|
||||
lastSaveRef.current = now;
|
||||
insertHistoryPoint({
|
||||
v_batt: parseFloat(data.v),
|
||||
battery_pct: data.battP,
|
||||
line_state: data.line,
|
||||
power_on: data.on === 1,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Changed to console.warn to prevent Next.js from throwing a red error overlay in dev mode
|
||||
console.warn('JSON parse error (likely malformed MQTT data):', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('offline', () => {
|
||||
setMqttStatus('Offline');
|
||||
console.log('MQTT Connection Offline');
|
||||
});
|
||||
|
||||
return () => {
|
||||
client.end();
|
||||
};
|
||||
}, [dbLoaded, addEvent]);
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
|
||||
<main className={styles.mainContent}>
|
||||
<div className={styles.mobileHeader}>
|
||||
<div className={styles.mobileTitle}>
|
||||
<Zap color="#2563eb" size={24} strokeWidth={2.5} />
|
||||
<span>UPS Monitor</span>
|
||||
</div>
|
||||
<button className={styles.menuBtn} onClick={() => setIsSidebarOpen(true)} aria-label="Open Menu">
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'dashboard' && <Header mqttStatus={upsData ? 'Online' : (mqttStatus === 'Connecting...' ? 'Connecting...' : 'Offline')} uptimeSeconds={upsData?.uptime} />}
|
||||
|
||||
{activeTab === 'dashboard' && (
|
||||
<>
|
||||
<StatCards data={upsData} />
|
||||
<div className={styles.gridContent}>
|
||||
<VoltageChart data={upsData ? liveChartData : []} />
|
||||
<EventLog events={upsData ? events.filter(ev => !ev.message.startsWith('Connected to') && !ev.message.startsWith('Subscribed to') && !ev.message.startsWith('Connecting to')) : []} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<HistoryPage data={chartData} />
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && <EventLogsPage events={events} onClear={clearEvents} />}
|
||||
|
||||
{activeTab === 'settings' && <SettingsPanel data={upsData} onSendCommand={(cmd, val) => {
|
||||
if (mqttClientRef.current && mqttClientRef.current.connected) {
|
||||
const payload = JSON.stringify({ cmd, val: String(val) });
|
||||
mqttClientRef.current.publish('ups/dev15/cmd', payload);
|
||||
addEvent(`Sent command: ${cmd}=${val}`, false);
|
||||
} else {
|
||||
alert("MQTT is not connected. Cannot send command.");
|
||||
}
|
||||
}} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
.logContainer {
|
||||
background-color: var(--bg-panel);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 460px;
|
||||
max-height: 460px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logHeader {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logList {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.logList::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.logList::-webkit-scrollbar-track {
|
||||
background: var(--bg-main);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.logList::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.logItem {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bullet {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bullet.error { background-color: #ef4444; }
|
||||
.bullet.warning { background-color: var(--color-warning); }
|
||||
.bullet.success { background-color: var(--color-success); }
|
||||
.bullet.info { background-color: var(--color-primary); }
|
||||
|
||||
.logContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.logMessage {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.logTime {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { UpsEvent } from '../app/page';
|
||||
import styles from './EventLog.module.css';
|
||||
|
||||
interface EventLogProps {
|
||||
events: UpsEvent[];
|
||||
}
|
||||
|
||||
export default function EventLog({ events }: EventLogProps) {
|
||||
return (
|
||||
<div className={styles.logContainer}>
|
||||
<div className={styles.logHeader}>
|
||||
<h2 className={styles.title}>Event Log</h2>
|
||||
<span className={styles.subtitle}>System events and alerts</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.logList}>
|
||||
{events.length === 0 ? (
|
||||
<div style={{ padding: '2rem 1rem', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted, #9ca3af)', gap: '0.4rem', textAlign: 'center', height: '100%', minHeight: '250px', flex: 1 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary, #475569)' }}>Belum Ada Aktivitas Real-time</span>
|
||||
<span style={{ fontSize: '0.78rem', maxWidth: '250px', lineHeight: '1.5' }}>Catatan kejadian akan terbit seketika saat perangkat IoT terhubung dan mendeteksi perubahan daya.</span>
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => {
|
||||
const sev = event.severity || 'info';
|
||||
return (
|
||||
<div key={event.id} className={styles.logItem}>
|
||||
<div className={`${styles.bullet} ${styles[sev]}`}></div>
|
||||
<div className={styles.logContent}>
|
||||
<span className={styles.logMessage}>{event.message}</span>
|
||||
<span className={styles.logTime}>{event.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.actionBtn:hover:not(:disabled) {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.actionBtn.danger:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.actionBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Header Meta (Subtitle & Mini Badges) */
|
||||
.headerMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metaDivider {
|
||||
color: var(--border-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.severityMini {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.sevItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.miniDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.miniDot.error { background: #ef4444; }
|
||||
.miniDot.warning { background: var(--color-warning); }
|
||||
.miniDot.success { background: var(--color-success); }
|
||||
.miniDot.info { background: var(--color-primary); }
|
||||
|
||||
/* Log Card */
|
||||
.logCard {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: var(--bg-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.5rem 0.9rem;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.resultCount {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--border-color);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.logList::-webkit-scrollbar { width: 4px; }
|
||||
.logList::-webkit-scrollbar-track { background: var(--bg-main); border-radius: 2px; }
|
||||
.logList::-webkit-scrollbar-thumb { background: var(--text-muted); border-radius: 2px; }
|
||||
|
||||
.logItem {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.logItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.logContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.dot.error { background: #ef4444; }
|
||||
.dot.warning { background: var(--color-warning); }
|
||||
.dot.success { background: var(--color-success); }
|
||||
.dot.info { background: var(--color-primary); }
|
||||
|
||||
.logMessage {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.logTime {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted);
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pageHeader {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logCard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Trash2, Download, Info } from 'lucide-react';
|
||||
import { UpsEvent } from '../app/page';
|
||||
import styles from './EventLogsPage.module.css';
|
||||
|
||||
interface EventLogsPageProps {
|
||||
events: UpsEvent[];
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function EventLogsPage({ events, onClear }: EventLogsPageProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return events;
|
||||
return events.filter(e =>
|
||||
e.message.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [events, search]);
|
||||
|
||||
const handleExport = () => {
|
||||
const rows = events.map(e => `"${e.time}","${e.message.replace(/"/g, '""')}"`).join('\n');
|
||||
const blob = new Blob([`Time,Message\n${rows}`], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ups_events_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const severityCount = useMemo(() => ({
|
||||
error: events.filter(e => (e.severity || 'info') === 'error').length,
|
||||
warning: events.filter(e => (e.severity || 'info') === 'warning').length,
|
||||
success: events.filter(e => (e.severity || 'info') === 'success').length,
|
||||
info: events.filter(e => (e.severity || 'info') === 'info').length,
|
||||
}), [events]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Page Header */}
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>Event Logs</h2>
|
||||
<div className={styles.headerMeta}>
|
||||
<span className={styles.pageSubtitle}>{events.length} total events recorded</span>
|
||||
<span className={styles.metaDivider}>•</span>
|
||||
<div className={styles.severityMini}>
|
||||
<span className={styles.sevItem}><span className={`${styles.miniDot} ${styles.error}`}/> {severityCount.error} Errors</span>
|
||||
<span className={styles.sevItem}><span className={`${styles.miniDot} ${styles.warning}`}/> {severityCount.warning} Warnings</span>
|
||||
<span className={styles.sevItem}><span className={`${styles.miniDot} ${styles.success}`}/> {severityCount.success} Connections</span>
|
||||
<span className={styles.sevItem}><span className={`${styles.miniDot} ${styles.info}`}/> {severityCount.info} Info</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.actionBtn} onClick={handleExport} disabled={!events.length}>
|
||||
<Download size={14} />
|
||||
Export
|
||||
</button>
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={onClear} disabled={!events.length}>
|
||||
<Trash2 size={14} />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + Log List */}
|
||||
<div className={styles.logCard}>
|
||||
<div className={styles.searchBar}>
|
||||
<Search size={15} className={styles.searchIcon} />
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Search events..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<span className={styles.resultCount}>{filtered.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.logList}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Info size={32} className={styles.emptyIcon} />
|
||||
<span>{events.length === 0 ? 'No events recorded yet.' : 'No events match your search.'}</span>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(event => {
|
||||
const sev = event.severity || 'info';
|
||||
return (
|
||||
<div key={event.id} className={styles.logItem}>
|
||||
<div className={`${styles.dot} ${styles[sev]}`}></div>
|
||||
<div className={styles.logContent}>
|
||||
<span className={styles.logMessage}>{event.message}</span>
|
||||
<span className={styles.logTime}>{event.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.titleArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
width: fit-content;
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
}
|
||||
|
||||
.statusArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
background-color: var(--bg-panel);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.statusItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statusItem.online {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-success);
|
||||
box-shadow: 0 0 0 2px var(--color-success-light);
|
||||
}
|
||||
|
||||
.uptime {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.themeToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggleSwitch {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggleKnob {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.statusArea {
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
// Icons removed: theme toggle not yet implemented
|
||||
import styles from './Header.module.css';
|
||||
|
||||
interface HeaderProps {
|
||||
mqttStatus: string;
|
||||
uptimeSeconds?: number;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Header({ mqttStatus, uptimeSeconds = 0 }: HeaderProps) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleArea}>
|
||||
<h1 className={styles.title}>IoT DC UPS Dashboard</h1>
|
||||
<div className={styles.badge}>UPS_DC_01</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.statusArea}>
|
||||
<div className={`${styles.statusItem} ${mqttStatus === 'Online' ? styles.online : ''}`}>
|
||||
<div className={styles.dot} style={{ backgroundColor: mqttStatus === 'Online' ? 'var(--color-success)' : 'var(--text-muted)' }}></div>
|
||||
{mqttStatus}
|
||||
</div>
|
||||
|
||||
<div className={styles.divider}></div>
|
||||
|
||||
<div className={styles.statusItem}>
|
||||
<span className={styles.uptime}>{formatUptime(uptimeSeconds)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.divider}></div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Page Header ──────────────────────────────────────────────────────────── */
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Filter Bar ───────────────────────────────────────────────────────────── */
|
||||
.filterBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.4rem 0.75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterIcon {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterBtn {
|
||||
padding: 0.35rem 0.85rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterBtn:hover:not(:disabled) {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filterBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.filterActive {
|
||||
background: var(--color-primary) !important;
|
||||
color: white !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.filterDivider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.loadingBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dbBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-success);
|
||||
background: var(--color-success-light);
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #a7f3d0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Custom Date Picker ───────────────────────────────────────────────────── */
|
||||
.customDateBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.75rem 1.25rem;
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.customLabel {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-main);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dateInput:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
padding: 0.4rem 1.1rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.applyBtn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.applyBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.exportBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.exportBtn:hover:not(:disabled) {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.exportBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Summary Grid */
|
||||
.summaryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.summaryGrid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.summaryValue.trendUp { color: var(--color-success); }
|
||||
.summaryValue.trendDown { color: #ef4444; }
|
||||
|
||||
.unit {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Chart Card */
|
||||
.chartCard {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.chartControls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modeToggle {
|
||||
display: flex;
|
||||
background: var(--bg-main);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modeBtn {
|
||||
padding: 0.35rem 0.9rem;
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius-md) - 2px);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modeBtn.active {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.rangeFilter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.rangeBtn {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.rangeBtn.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.rangeBtn:not(.active):hover {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.chartDataCount {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-main);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.emptyChart {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Table Card */
|
||||
.tableCard {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tableCount {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tableWrapper::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
.tableWrapper::-webkit-scrollbar-track { background: var(--bg-main); border-radius: 2px; }
|
||||
.tableWrapper::-webkit-scrollbar-thumb { background: var(--text-muted); border-radius: 2px; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-panel);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tr:hover td {
|
||||
background-color: var(--bg-main);
|
||||
}
|
||||
|
||||
.timeCell {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.voltCell {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.battCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.miniBar {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.miniFill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.emptyRow {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 2rem !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pageHeader {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.chartControls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modeToggle {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rangeFilter {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.customDateBar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.summaryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis,
|
||||
CartesianGrid, Tooltip, ResponsiveContainer, Legend
|
||||
} from 'recharts';
|
||||
import { Download, TrendingUp, TrendingDown, Minus, Database, RefreshCw, Calendar } from 'lucide-react';
|
||||
import styles from './HistoryPage.module.css';
|
||||
import type { HistoryRow } from '@/lib/db';
|
||||
|
||||
interface ChartPoint {
|
||||
time: string;
|
||||
batteryPct: number;
|
||||
vBatt: number;
|
||||
}
|
||||
|
||||
interface HistoryPageProps {
|
||||
data: ChartPoint[];
|
||||
}
|
||||
|
||||
type DateFilter = 'today' | 'week' | 'month' | 'custom';
|
||||
type ChartMode = 'voltage' | 'battery';
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
function startOfDay(d: Date) {
|
||||
const r = new Date(d); r.setHours(0, 0, 0, 0); return r;
|
||||
}
|
||||
function startOfWeek(d: Date) {
|
||||
const r = new Date(d);
|
||||
const day = r.getDay(); // 0=Sun
|
||||
r.setDate(r.getDate() - (day === 0 ? 6 : day - 1));
|
||||
r.setHours(0, 0, 0, 0);
|
||||
return r;
|
||||
}
|
||||
function startOfMonth(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
|
||||
}
|
||||
function toLocalDateString(d: Date) {
|
||||
// returns YYYY-MM-DD in local timezone (for <input type="date">)
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
function rowToPoint(row: HistoryRow): ChartPoint {
|
||||
return {
|
||||
time: row.created_at
|
||||
? new Date(row.created_at).toLocaleString([], {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
})
|
||||
: '--:--',
|
||||
batteryPct: row.battery_pct ?? 0,
|
||||
vBatt: row.v_batt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function HistoryPage({ data }: HistoryPageProps) {
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
const [chartMode, setChartMode] = useState<ChartMode>('voltage');
|
||||
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
|
||||
const [customFrom, setCustomFrom] = useState(toLocalDateString(startOfDay(today)));
|
||||
const [customTo, setCustomTo] = useState(toLocalDateString(today));
|
||||
|
||||
const [dbData, setDbData] = useState<ChartPoint[]>([]);
|
||||
const [loadingDb, setLoadingDb] = useState(false);
|
||||
|
||||
// ── DB fetch ────────────────────────────────────────────────────────────────
|
||||
const loadFromDatabase = useCallback(async (filter: DateFilter, fromStr?: string, toStr?: string) => {
|
||||
setLoadingDb(true);
|
||||
try {
|
||||
const now = new Date();
|
||||
let from: Date;
|
||||
let to: Date = new Date(now.getTime() + 86400000); // tomorrow to be inclusive
|
||||
|
||||
switch (filter) {
|
||||
case 'today': from = startOfDay(now); break;
|
||||
case 'week': from = startOfWeek(now); break;
|
||||
case 'month': from = startOfMonth(now); break;
|
||||
case 'custom':
|
||||
from = new Date(fromStr + 'T00:00:00');
|
||||
to = new Date(toStr + 'T23:59:59');
|
||||
break;
|
||||
default: from = startOfDay(now);
|
||||
}
|
||||
|
||||
const { fetchHistoryByRange } = await import('@/lib/db');
|
||||
const rows = await fetchHistoryByRange(from.toISOString(), to.toISOString(), 2000);
|
||||
setDbData(rows.map(rowToPoint));
|
||||
} catch (err) {
|
||||
console.error('[HistoryPage] load from DB error:', err);
|
||||
} finally {
|
||||
setLoadingDb(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-load Today on mount
|
||||
React.useEffect(() => {
|
||||
loadFromDatabase('today');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Active data selection ───────────────────────────────────────────────────
|
||||
const activeData: ChartPoint[] = useMemo(() => dbData, [dbData]);
|
||||
|
||||
// ── Summary stats ───────────────────────────────────────────────────────────
|
||||
const avgVBatt = activeData.length
|
||||
? (activeData.reduce((s, d) => s + d.vBatt, 0) / activeData.length).toFixed(2) : '—';
|
||||
const minVBatt = activeData.length
|
||||
? Math.min(...activeData.map(d => d.vBatt)).toFixed(2) : '—';
|
||||
const maxVBatt = activeData.length
|
||||
? Math.max(...activeData.map(d => d.vBatt)).toFixed(2) : '—';
|
||||
const avgBattPct = activeData.length
|
||||
? Math.round(activeData.reduce((s, d) => s + d.batteryPct, 0) / activeData.length) : 0;
|
||||
const trend = activeData.length >= 2
|
||||
? activeData[activeData.length - 1].vBatt - activeData[0].vBatt : 0;
|
||||
|
||||
// ── Export ──────────────────────────────────────────────────────────────────
|
||||
const handleExport = () => {
|
||||
if (!activeData.length) return;
|
||||
const headers = 'Time,Battery %,V-Batt\n';
|
||||
const rows = activeData.map(d =>
|
||||
`${d.time},${d.batteryPct},${d.vBatt}`
|
||||
).join('\n');
|
||||
const blob = new Blob([headers + rows], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ups_history_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ── Filter label for subtitle ───────────────────────────────────────────────
|
||||
const filterLabel: Record<DateFilter, string> = {
|
||||
today: 'Today',
|
||||
week: 'This Week',
|
||||
month: 'This Month',
|
||||
custom: `${customFrom} – ${customTo}`,
|
||||
};
|
||||
|
||||
const handleFilterClick = (f: DateFilter) => {
|
||||
setDateFilter(f);
|
||||
if (f !== 'custom') {
|
||||
loadFromDatabase(f);
|
||||
}
|
||||
// custom: wait for user to press Apply
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
|
||||
{/* ── Page Header ───────────────────────────────────────────────────── */}
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>Voltage History</h2>
|
||||
<span className={styles.pageSubtitle}>
|
||||
{activeData.length} data points · {filterLabel[dateFilter]}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.exportBtn} onClick={handleExport} disabled={!activeData.length}>
|
||||
<Download size={15} />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Date Filter Bar ───────────────────────────────────────────────── */}
|
||||
<div className={styles.filterBar}>
|
||||
<div className={styles.filterGroup}>
|
||||
<Calendar size={15} className={styles.filterIcon} />
|
||||
{(['today', 'week', 'month'] as DateFilter[]).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
className={`${styles.filterBtn} ${dateFilter === f ? styles.filterActive : ''}`}
|
||||
onClick={() => handleFilterClick(f)}
|
||||
disabled={loadingDb}
|
||||
>
|
||||
{f === 'today' ? 'Today' : f === 'week' ? 'This Week' : 'This Month'}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={`${styles.filterBtn} ${dateFilter === 'custom' ? styles.filterActive : ''}`}
|
||||
onClick={() => setDateFilter('custom')}
|
||||
disabled={loadingDb}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loadingDb && (
|
||||
<div className={styles.loadingBadge}>
|
||||
<RefreshCw size={13} style={{ animation: 'spin 0.8s linear infinite' }} />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Custom Date Picker ────────────────────────────────────────────── */}
|
||||
{dateFilter === 'custom' && (
|
||||
<div className={styles.customDateBar}>
|
||||
<span className={styles.customLabel}>From</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={customFrom}
|
||||
max={customTo}
|
||||
onChange={e => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
<span className={styles.customLabel}>To</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={customTo}
|
||||
min={customFrom}
|
||||
max={toLocalDateString(today)}
|
||||
onChange={e => setCustomTo(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={styles.applyBtn}
|
||||
onClick={() => loadFromDatabase('custom', customFrom, customTo)}
|
||||
disabled={loadingDb || !customFrom || !customTo}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Summary Cards ─────────────────────────────────────────────────── */}
|
||||
<div className={styles.summaryGrid}>
|
||||
<div className={styles.summaryCard}>
|
||||
<span className={styles.summaryLabel}>Avg V-Batt</span>
|
||||
<span className={styles.summaryValue}>{avgVBatt}<span className={styles.unit}>V</span></span>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<span className={styles.summaryLabel}>Min V-Batt</span>
|
||||
<span className={styles.summaryValue}>{minVBatt}<span className={styles.unit}>V</span></span>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<span className={styles.summaryLabel}>Max V-Batt</span>
|
||||
<span className={styles.summaryValue}>{maxVBatt}<span className={styles.unit}>V</span></span>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<span className={styles.summaryLabel}>Avg Battery</span>
|
||||
<span className={styles.summaryValue}>{avgBattPct}<span className={styles.unit}>%</span></span>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<span className={styles.summaryLabel}>Voltage Trend</span>
|
||||
<span className={`${styles.summaryValue} ${trend > 0 ? styles.trendUp : trend < 0 ? styles.trendDown : ''}`}>
|
||||
{trend > 0 ? <TrendingUp size={18} /> : trend < 0 ? <TrendingDown size={18} /> : <Minus size={18} />}
|
||||
{Math.abs(trend).toFixed(2)}V
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Chart ─────────────────────────────────────────────────────────── */}
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartControls}>
|
||||
<div className={styles.modeToggle}>
|
||||
<button
|
||||
className={`${styles.modeBtn} ${chartMode === 'voltage' ? styles.active : ''}`}
|
||||
onClick={() => setChartMode('voltage')}
|
||||
>Voltage</button>
|
||||
<button
|
||||
className={`${styles.modeBtn} ${chartMode === 'battery' ? styles.active : ''}`}
|
||||
onClick={() => setChartMode('battery')}
|
||||
>Battery %</button>
|
||||
</div>
|
||||
<span className={styles.chartDataCount}>{activeData.length} pts</span>
|
||||
</div>
|
||||
|
||||
{activeData.length === 0 ? (
|
||||
<div className={styles.emptyChart}>
|
||||
<Database size={28} style={{ color: 'var(--border-color)', marginBottom: '0.5rem' }} />
|
||||
<span>No data found for this period.</span>
|
||||
<button
|
||||
className={styles.applyBtn}
|
||||
style={{ marginTop: '0.75rem' }}
|
||||
onClick={() => loadFromDatabase(dateFilter, customFrom, customTo)}
|
||||
disabled={loadingDb}
|
||||
>
|
||||
Load from Database
|
||||
</button>
|
||||
</div>
|
||||
) : chartMode === 'voltage' ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={activeData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradVBatt" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradVIn" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.15} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border-color)" />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#9ca3af' }} dy={8} interval="preserveStartEnd" />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#9ca3af' }} tickFormatter={v => `${v}V`} domain={[0, 16]} dx={-8} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '10px', border: '1px solid var(--border-color)', boxShadow: 'var(--shadow-md)', fontSize: '13px' }}
|
||||
formatter={(val: unknown, name: unknown) => [`${(val as number).toFixed(2)} V`, name as string]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '16px' }} iconType="circle" />
|
||||
<Area type="monotone" dataKey="vBatt" name="V-Batt" stroke="#f59e0b" strokeWidth={2} fill="url(#gradVBatt)" dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={activeData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradBatt" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.25} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border-color)" />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#9ca3af' }} dy={8} interval="preserveStartEnd" />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#9ca3af' }} tickFormatter={v => `${v}%`} domain={[0, 100]} dx={-8} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '10px', border: '1px solid var(--border-color)', boxShadow: 'var(--shadow-md)', fontSize: '13px' }}
|
||||
formatter={(val: unknown) => [`${val as number}%`, 'Battery']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="batteryPct" name="Battery %" stroke="#8b5cf6" strokeWidth={2} fill="url(#gradBatt)" dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Data Table ────────────────────────────────────────────────────── */}
|
||||
<div className={styles.tableCard}>
|
||||
<h3 className={styles.tableTitle}>Data Log <span className={styles.tableCount}>({activeData.length} rows)</span></h3>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Battery %</th>
|
||||
<th>V-Batt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...activeData].reverse().map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className={styles.timeCell}>{row.time}</td>
|
||||
<td>
|
||||
<div className={styles.battCell}>
|
||||
<div className={styles.miniBar}>
|
||||
<div className={styles.miniFill} style={{
|
||||
width: `${row.batteryPct}%`,
|
||||
backgroundColor: row.batteryPct < 20 ? '#ef4444' : row.batteryPct < 50 ? '#f59e0b' : '#10b981'
|
||||
}} />
|
||||
</div>
|
||||
<span>{row.batteryPct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.voltCell}>{row.vBatt.toFixed(2)}V</td>
|
||||
</tr>
|
||||
))}
|
||||
{activeData.length === 0 && (
|
||||
<tr><td colSpan={3} className={styles.emptyRow}>No data for this period.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
/* Split-Screen Indonesian SaaS Design (Exact Match) */
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
font-family: var(--font-family), sans-serif;
|
||||
color: #0f172a;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Left Column: Clean & Minimalist Login Form */
|
||||
.leftColumn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 3.5rem 4rem;
|
||||
max-width: 550px;
|
||||
background-color: #ffffff;
|
||||
z-index: 10;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.formContainer {
|
||||
margin: auto 0;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.1rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
margin-bottom: 0.6rem;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputPrefix {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.inputSuffix {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.inputSuffix:hover {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.85rem 2.85rem 0.85rem 2.75rem;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 0.65rem;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
background: #ffffff;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #94a3b8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #3b82f6;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.input:focus~.inputPrefix {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: #ef4444 !important;
|
||||
animation: shake 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font-size: 0.825rem;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
/* Remember Me & Forgot Password Row */
|
||||
.optionsRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
|
||||
.rememberMe {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
cursor: pointer;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
|
||||
.forgotLink {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.forgotLink:hover {
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Solid Button without Glow */
|
||||
.submitBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.9rem;
|
||||
background-color: #2563eb;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 0.65rem;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: none;
|
||||
/* Tanpa efek glow / bayangan */
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.submitBtn:active {
|
||||
background-color: #1e40af;
|
||||
}
|
||||
|
||||
.submitBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
/* Right Column: Soft Pastel Blue with Exact Reference Graphics */
|
||||
.rightColumn {
|
||||
flex: 1.4;
|
||||
display: flex;
|
||||
background: linear-gradient(135deg, #f0f6ff 0%, #e8f2ff 60%, #e2efff 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
padding: 4rem 5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.showcaseContainer {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.heroRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heroText {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.subTag {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
margin-bottom: 0.75rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.showcaseHeading {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03em;
|
||||
color: #0f172a;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.showcaseHeading span {
|
||||
color: #2563eb;
|
||||
/* Kata dengan aksen biru terang */
|
||||
}
|
||||
|
||||
.titleDivider {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background-color: #2563eb;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.showcaseDesc {
|
||||
font-size: 1.025rem;
|
||||
line-height: 1.65;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* Top Right Concentric Shield Illustration */
|
||||
.shieldIllustration {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin-right: -2rem;
|
||||
}
|
||||
|
||||
.orbitRing {
|
||||
transform-origin: 140px 140px;
|
||||
animation: rotateOrbit 45s linear infinite;
|
||||
}
|
||||
|
||||
.pulseGlow {
|
||||
transform-origin: 140px 140px;
|
||||
animation: pulseConcentric 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.floatingShield {
|
||||
animation: floatShield 4.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rotateOrbit {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseConcentric {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatShield {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Feature Highlights with Soft Light Blue Icon Boxes */
|
||||
.featureList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.85rem;
|
||||
}
|
||||
|
||||
.featureItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.featureIconBox {
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.featureText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.featureDesc {
|
||||
font-size: 0.925rem;
|
||||
color: #64748b;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Background Bottom Right Grid Dots Decor */
|
||||
.bgDots {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
right: 2.5rem;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1180px) {
|
||||
.shieldIllustration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.rightColumn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
max-width: 100%;
|
||||
padding: 2.5rem 1.5rem;
|
||||
align-items: center;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.formContainer {
|
||||
max-width: 440px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { User, Lock, ArrowRight, Eye, EyeOff, Activity, ShieldCheck, Cloud } from 'lucide-react';
|
||||
import styles from './Login.module.css';
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (username.trim() === '' || password.trim() === '') return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(true);
|
||||
setTimeout(() => setError(false), 2000);
|
||||
setPassword('');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(true);
|
||||
setTimeout(() => setError(false), 2000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Kolom Kiri: Form Login Bahasa Indonesia Rapi */}
|
||||
<div className={styles.leftColumn}>
|
||||
<div /> {/* Spacer vertical alignments */}
|
||||
|
||||
<div className={styles.formContainer}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>Selamat Datang</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Masuk untuk memantau dan mengelola sistem UPS DC berbasis IoT Anda.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.fieldGroup}>
|
||||
<label className={styles.label} htmlFor="username">Username</label>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.inputPrefix}>
|
||||
<User size={18} />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Masukkan username"
|
||||
className={`${styles.input} ${error ? styles.inputError : ''}`}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGroup}>
|
||||
<label className={styles.label} htmlFor="password">Password</label>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.inputPrefix}>
|
||||
<Lock size={18} />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Masukkan password"
|
||||
className={`${styles.input} ${error ? styles.inputError : ''}`}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.inputSuffix}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <span className={styles.errorMessage}>Username atau password salah</span>}
|
||||
|
||||
{/* Opsi Ingat Saya */}
|
||||
<div className={styles.optionsRow}>
|
||||
<label className={styles.rememberMe}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.checkbox}
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<span>Ingat saya</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tombol Masuk Solid (Tanpa Glow) */}
|
||||
<button type="submit" className={styles.submitBtn} disabled={loading}>
|
||||
<span>{loading ? 'Masuk...' : 'Masuk'}</span>
|
||||
{!loading && <ArrowRight size={18} />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div /> {/* Bottom spacer for vertical alignment */}
|
||||
</div>
|
||||
|
||||
{/* Kolom Kanan: Nuansa Biru Muda Lembut dengan Fitur Bahasa Indonesia */}
|
||||
<div className={styles.rightColumn}>
|
||||
<div className={styles.showcaseContainer}>
|
||||
{/* Bagian Hero + Ilustrasi Tameng Konsentris di Kanan */}
|
||||
<div className={styles.heroRow}>
|
||||
<div className={styles.heroText}>
|
||||
<span className={styles.subTag}>Sistem UPS DC</span>
|
||||
<h2 className={styles.showcaseHeading}>
|
||||
Monitoring <span>Cerdas.</span><br />
|
||||
Perlindungan <span>Optimal.</span>
|
||||
</h2>
|
||||
<div className={styles.titleDivider} />
|
||||
<p className={styles.showcaseDesc}>
|
||||
Solusi pemantauan dan perlindungan daya DC secara real-time untuk memastikan sistem Anda selalu andal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ilustrasi Lingkaran Konsentris Tameng Petir (Sesuai Referensi) */}
|
||||
<div className={styles.shieldIllustration}>
|
||||
<svg width="260" height="260" viewBox="0 0 280 280" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Lingkaran Luar Putus-Putus & Titik Orbit (Posisi Segitiga Emas: 11:00, 04:00, 07:00) */}
|
||||
<g className={styles.orbitRing}>
|
||||
<circle cx="140" cy="140" r="130" stroke="#bfdbfe" strokeWidth="1.5" strokeDasharray="5 8" />
|
||||
<circle cx="75" cy="28" r="5" fill="#2563eb" /> {/* Pukul 11:00 */}
|
||||
<circle cx="253" cy="205" r="5" fill="#3b82f6" /> {/* Pukul 04:00 */}
|
||||
<circle cx="75" cy="252" r="4.5" fill="#60a5fa" /> {/* Pukul 07:00 */}
|
||||
</g>
|
||||
|
||||
{/* Lingkaran Menengah & Dalam (Berdenyut Aktif/Pulse) */}
|
||||
<circle cx="140" cy="140" r="100" stroke="#dbeafe" strokeWidth="1.5" />
|
||||
<g className={styles.pulseGlow}>
|
||||
<circle cx="140" cy="140" r="75" fill="#60a5fa" fillOpacity="0.18" stroke="#93c5fd" strokeWidth="2" />
|
||||
</g>
|
||||
<circle cx="140" cy="140" r="55" fill="#ffffff" fillOpacity="0.95" />
|
||||
<circle cx="140" cy="140" r="55" stroke="#eff6ff" strokeWidth="6" />
|
||||
|
||||
{/* Ikon Shield & Petir di Tengah (Melayang Elegan) */}
|
||||
<g className={styles.floatingShield}>
|
||||
<path
|
||||
d="M 140 105 L 175 118 C 175 145 162 168 140 177 C 118 168 105 145 105 118 L 140 105 Z"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#eff6ff"
|
||||
/>
|
||||
<path
|
||||
d="M 143 122 L 132 142 L 146 142 L 137 162 L 152 138 L 139 138 L 143 122 Z"
|
||||
fill="#2563eb"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daftar 3 Poin Keunggulan (Boks Ikon Biru Muda Lembut #dbeafe) */}
|
||||
<div className={styles.featureList}>
|
||||
<div className={styles.featureItem}>
|
||||
<div className={styles.featureIconBox}>
|
||||
<Activity size={26} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className={styles.featureText}>
|
||||
<span className={styles.featureTitle}>Pemantauan Real-time</span>
|
||||
<span className={styles.featureDesc}>
|
||||
Pantau tegangan, arus, dan status daya secara real-time dari mana saja.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.featureItem}>
|
||||
<div className={styles.featureIconBox}>
|
||||
<ShieldCheck size={26} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className={styles.featureText}>
|
||||
<span className={styles.featureTitle}>Perlindungan Cerdas</span>
|
||||
<span className={styles.featureDesc}>
|
||||
Sistem proteksi otomatis terhadap over-voltage, over-discharge, dan kondisi abnormal lainnya.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.featureItem}>
|
||||
<div className={styles.featureIconBox}>
|
||||
<Cloud size={26} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className={styles.featureText}>
|
||||
<span className={styles.featureTitle}>Akses Cloud IoT</span>
|
||||
<span className={styles.featureDesc}>
|
||||
Data tersimpan aman di cloud dan dapat diakses kapan saja untuk analisis dan histori.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dekorasi Dot Grid di Pojok Kiri/Kanan Bawah */}
|
||||
<div className={styles.bgDots}>
|
||||
<svg width="120" height="90" viewBox="0 0 120 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{Array.from({ length: 5 }).map((_, r) =>
|
||||
Array.from({ length: 7 }).map((_, c) => (
|
||||
<circle key={`${r}-${c}`} cx={10 + c * 16} cy={10 + r * 16} r="2" fill="#93c5fd" />
|
||||
))
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
.panelContainer {
|
||||
background-color: var(--bg-panel);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bg-main);
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.labelInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.controlArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: white;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 70px;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: white;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Toggle Switch CSS */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .2s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .2s;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
input:disabled + .slider {
|
||||
background-color: #e5e7eb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.panelContainer {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.controlArea {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import React, { useState } from 'react';
|
||||
import { UpsData } from '../app/page';
|
||||
import styles from './SettingsPanel.module.css';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
data: UpsData | null;
|
||||
onSendCommand: (cmd: string, value: string | number) => void;
|
||||
}
|
||||
|
||||
export default function SettingsPanel({ data, onSendCommand }: SettingsPanelProps) {
|
||||
const [shutTimer, setShutTimer] = useState("120");
|
||||
|
||||
const handleToggle = (cmd: string, checked: boolean) => {
|
||||
onSendCommand(cmd, checked ? 1 : 0);
|
||||
};
|
||||
|
||||
const handleSelect = (cmd: string, e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onSendCommand(cmd, e.target.value);
|
||||
};
|
||||
|
||||
const isPowerOn = data?.on === 1;
|
||||
|
||||
return (
|
||||
<div className={styles.panelContainer}>
|
||||
<div className={styles.panelHeader}>
|
||||
<h2 className={styles.title}>Device Configuration</h2>
|
||||
<span className={styles.subtitle}>Manage power states, charging logic, and automation</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{/* Left Column: Power Control */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Power Control</h3>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>Power Output</span>
|
||||
<span className={styles.description}>Main power switch</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<label className={styles.switch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPowerOn}
|
||||
disabled={!data}
|
||||
onChange={(e) => handleToggle('on', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.slider}></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>Auto ON Mode</span>
|
||||
<span className={styles.description}>Recovery behavior on AC restore</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={data?.auto ?? 0}
|
||||
disabled={!data}
|
||||
onChange={(e) => handleSelect('auto', e)}
|
||||
>
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">Line Powered</option>
|
||||
<option value="2">Persistent on Line</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>Shutdown Cast</span>
|
||||
<span className={styles.description}>Auto shutdown condition</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={data?.shutdownSuggestMode ?? 0}
|
||||
disabled={!data}
|
||||
onChange={(e) => handleSelect('shutlv', e)}
|
||||
>
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">On Battery</option>
|
||||
<option value="2">≤75% Battery</option>
|
||||
<option value="3">≤50% Battery</option>
|
||||
<option value="4">≤25% Battery</option>
|
||||
<option value="5">Battery Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>Timed Shutdown</span>
|
||||
<span className={styles.description}>Delay before power off</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={shutTimer}
|
||||
onChange={(e) => setShutTimer(e.target.value)}
|
||||
min="10" max="999"
|
||||
disabled={!isPowerOn}
|
||||
/>
|
||||
<span className={styles.description} style={{marginRight: '0.5rem'}}>sec</span>
|
||||
<button
|
||||
className={styles.button}
|
||||
disabled={!isPowerOn}
|
||||
onClick={() => onSendCommand('shutdown', shutTimer)}
|
||||
>
|
||||
START
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Charge Control */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Charge Control</h3>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>Charge Mode</span>
|
||||
<span className={styles.description}>Battery target limits</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={data?.chgMode ?? 0}
|
||||
disabled={!data}
|
||||
onChange={(e) => handleSelect('cm', e)}
|
||||
>
|
||||
<option value="0">Always Charge</option>
|
||||
<option value="1">90-100%</option>
|
||||
<option value="2">85-100%</option>
|
||||
<option value="3">80-100%</option>
|
||||
<option value="4">90-95%</option>
|
||||
<option value="5">85-95%</option>
|
||||
<option value="6">80-95%</option>
|
||||
<option value="7">85-90%</option>
|
||||
<option value="8">80-90%</option>
|
||||
<option value="9">80-85%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>AVR</span>
|
||||
<span className={styles.description}>Automatic Voltage Reducer</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<label className={styles.switch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data?.chgReducer === 1}
|
||||
disabled={!data}
|
||||
onChange={(e) => handleToggle('car', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.slider}></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>OVP</span>
|
||||
<span className={styles.description}>Overvoltage Protection</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<label className={styles.switch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data?.chgOVP === 1}
|
||||
disabled={!data}
|
||||
onChange={(e) => handleToggle('covp', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.slider}></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.labelInfo}>
|
||||
<span className={styles.label}>FCoPL</span>
|
||||
<span className={styles.description}>Full Charge on Power Lost</span>
|
||||
</div>
|
||||
<div className={styles.controlArea}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={data?.chgFullTrig ?? 0}
|
||||
disabled={!data}
|
||||
onChange={(e) => handleSelect('coil', e)}
|
||||
>
|
||||
<option value="0">Disabled</option>
|
||||
<option value="1">Always</option>
|
||||
<option value="2">At Battery ≤95%</option>
|
||||
<option value="3">At Battery ≤90%</option>
|
||||
<option value="4">At Battery ≤85%</option>
|
||||
<option value="5">At Battery ≤80%</option>
|
||||
</select>
|
||||
{data?.chgOFC === 1 && (
|
||||
<button
|
||||
className={`${styles.button} ${styles.secondary}`}
|
||||
style={{marginLeft: '0.5rem'}}
|
||||
onClick={() => onSendCommand('rofc', 1)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
.sidebar {
|
||||
width: 260px;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-panel);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.freeLogoIcon {
|
||||
color: #2563eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logoSubtitle {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menuSection {
|
||||
flex: 1;
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 0.25rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.menuItem.active {
|
||||
background-color: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.profile {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.profileInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-color: #1e1b4b;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.profileDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profileName {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profileRole {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logoutBtn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 9;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LayoutDashboard, Activity, Bell, Settings, LogOut, Zap } from 'lucide-react';
|
||||
import styles from './Sidebar.module.css';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: 'dashboard' | 'history' | 'logs' | 'settings';
|
||||
onTabChange: (tab: 'dashboard' | 'history' | 'logs' | 'settings') => void;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ activeTab, onTabChange, isOpen = false, onClose }: SidebarProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Failed to logout', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && <div className={styles.overlay} onClick={onClose} />}
|
||||
<aside className={`${styles.sidebar} ${isOpen ? styles.open : ''}`}>
|
||||
<div className={styles.logo}>
|
||||
<Zap className={styles.freeLogoIcon} size={28} strokeWidth={2.3} />
|
||||
<div className={styles.logoText}>
|
||||
<span>UPS Monitor</span>
|
||||
<span className={styles.logoSubtitle}>IOT DASHBOARD</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.menuSection}>
|
||||
<div className={styles.menuTitle}>MENU</div>
|
||||
<nav>
|
||||
<ul role="menubar">
|
||||
<li role="none">
|
||||
<button
|
||||
role="menuitem"
|
||||
className={`${styles.menuItem} ${activeTab === 'dashboard' ? styles.active : ''}`}
|
||||
onClick={() => { onTabChange('dashboard'); onClose?.(); }}
|
||||
>
|
||||
<LayoutDashboard className={styles.menuIcon} />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
role="menuitem"
|
||||
className={`${styles.menuItem} ${activeTab === 'history' ? styles.active : ''}`}
|
||||
onClick={() => { onTabChange('history'); onClose?.(); }}
|
||||
>
|
||||
<Activity className={styles.menuIcon} />
|
||||
<span>History</span>
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
role="menuitem"
|
||||
className={`${styles.menuItem} ${activeTab === 'logs' ? styles.active : ''}`}
|
||||
onClick={() => { onTabChange('logs'); onClose?.(); }}
|
||||
>
|
||||
<Bell className={styles.menuIcon} />
|
||||
<span>Event Logs</span>
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
role="menuitem"
|
||||
className={`${styles.menuItem} ${activeTab === 'settings' ? styles.active : ''}`}
|
||||
onClick={() => { onTabChange('settings'); onClose?.(); }}
|
||||
>
|
||||
<Settings className={styles.menuIcon} />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className={styles.profile}>
|
||||
<div className={styles.profileInfo}>
|
||||
<div className={styles.avatar}>N</div>
|
||||
<div className={styles.profileDetails}>
|
||||
<span className={styles.profileName}>Admin</span>
|
||||
<span className={styles.profileRole}>Administrator</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className={styles.logoutBtn} onClick={handleLogout}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
.cardsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-panel);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.valueArea {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.subtext {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.dot.error {
|
||||
background-color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 2px;
|
||||
margin-top: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background-color: var(--color-success);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.powerSourceValue {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.powerSourceIcon {
|
||||
color: #a855f7; /* Purple for power */
|
||||
}
|
||||
|
||||
.batteryIcon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.cardsGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.cardsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.25rem;
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import React from 'react';
|
||||
import { Zap, Activity, BatteryCharging, Power } from 'lucide-react';
|
||||
import { UpsData } from '../app/page';
|
||||
import styles from './StatCards.module.css';
|
||||
|
||||
interface StatCardsProps {
|
||||
data: UpsData | null;
|
||||
}
|
||||
|
||||
export default function StatCards({ data }: StatCardsProps) {
|
||||
// If no data received yet (device offline), show dashes
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={styles.cardsGrid}>
|
||||
{['Output Status', 'Charging Status', 'Battery Level', 'Power Source'].map(title => (
|
||||
<div key={title} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.valueArea}>
|
||||
<span className={styles.powerSourceValue} style={{ color: 'var(--text-muted)' }}>—</span>
|
||||
</div>
|
||||
<div className={styles.status}>
|
||||
<div className={styles.dot}></div>
|
||||
<span style={{ color: 'var(--text-muted)' }}>No signal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const outputOn = data.on === 1;
|
||||
const isCharging = data.charging === 1;
|
||||
const chgError = data.chgError === 1;
|
||||
const isFull = data.charging === 0 && (data.battP ?? 0) >= 95 && (data.line ?? 0) > 0;
|
||||
|
||||
let chargingText = "Discharging";
|
||||
if (chgError) chargingText = "Error";
|
||||
else if (isFull) chargingText = "Fully Charged";
|
||||
else if (isCharging) chargingText = "Charging";
|
||||
else if ((data.line ?? 0) > 0) chargingText = "Standby";
|
||||
|
||||
const battPercent = data.battP ?? 0;
|
||||
const battVoltage = data.v ?? "0.00";
|
||||
|
||||
const isBypass = (data.line ?? 0) > 0;
|
||||
const powerSource = isBypass ? "Adaptor" : "Battery";
|
||||
|
||||
return (
|
||||
<div className={styles.cardsGrid}>
|
||||
{/* Card 1: Output Status */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span>Output Status</span>
|
||||
<div className={styles.iconWrapper}>
|
||||
<Activity size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.valueArea}>
|
||||
<span className={styles.powerSourceValue}>{outputOn ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
<div className={styles.status}>
|
||||
<div className={`${styles.dot} ${outputOn ? styles.active : ''}`}></div>
|
||||
<span>{outputOn ? 'Powering Load' : 'Disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Charging Status */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span>Charging Status</span>
|
||||
<div className={styles.iconWrapper}>
|
||||
<Zap size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.valueArea}>
|
||||
<span className={styles.powerSourceValue}>{chargingText}</span>
|
||||
</div>
|
||||
<div className={styles.status}>
|
||||
<div className={`${styles.dot} ${isCharging || isFull ? styles.active : ''} ${chgError ? styles.error : ''}`}></div>
|
||||
<span>{chgError ? 'Check Battery' : isCharging ? 'Active' : 'Idle'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Battery Level */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span>Battery Level</span>
|
||||
<div className={`${styles.iconWrapper} ${styles.batteryIcon}`}>
|
||||
<BatteryCharging size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.valueArea}>
|
||||
<span className={styles.value}>{battPercent}</span>
|
||||
<span className={styles.unit}>%</span>
|
||||
<span className={styles.subtext}>({battVoltage}V)</span>
|
||||
</div>
|
||||
<div className={styles.progressBarContainer}>
|
||||
<div className={styles.progressBar} style={{ width: `${battPercent}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Power Source */}
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span>Power Source</span>
|
||||
<div className={`${styles.iconWrapper} ${styles.powerSourceIcon}`}>
|
||||
<Power size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.valueArea}>
|
||||
<span className={styles.powerSourceValue}>{powerSource}</span>
|
||||
</div>
|
||||
<div className={styles.status}>
|
||||
<span>Relay: {isBypass ? 'Bypass Mode' : 'Inverter Mode'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
.chartContainer {
|
||||
background-color: var(--bg-panel);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 460px;
|
||||
max-height: 460px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chartHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.titleArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.exportBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.exportBtn:hover {
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chartContent {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import styles from './VoltageChart.module.css';
|
||||
|
||||
interface ChartDataPoint {
|
||||
time: string;
|
||||
batteryPct: number;
|
||||
vBatt: number;
|
||||
}
|
||||
|
||||
interface VoltageChartProps {
|
||||
data: ChartDataPoint[];
|
||||
}
|
||||
|
||||
export default function VoltageChart({ data }: VoltageChartProps) {
|
||||
const handleExport = () => {
|
||||
if (!data.length) return;
|
||||
const headers = 'Time,Battery %,V-Batt\n';
|
||||
const rows = data.map(d =>
|
||||
`"${d.time}",${d.batteryPct},${d.vBatt}`
|
||||
).join('\n');
|
||||
const blob = new Blob([headers + rows], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ups_voltage_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartContainer}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.titleArea}>
|
||||
<h2 className={styles.title}>Voltage History</h2>
|
||||
<span className={styles.subtitle}>Real-time discharge/charge monitoring</span>
|
||||
</div>
|
||||
<button className={styles.exportBtn} onClick={handleExport} disabled={!data.length}>
|
||||
<Download size={16} />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.chartContent}>
|
||||
{data.length === 0 ? (
|
||||
<div style={{ height: '100%', minHeight: 300, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted, #9ca3af)', gap: '0.4rem', textAlign: 'center' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--text-primary, #475569)' }}>Perangkat IoT Belum Terhubung</span>
|
||||
<span style={{ fontSize: '0.8rem', maxWidth: '340px', lineHeight: '1.5' }}>Grafik akan muncul secara real-time tatkala perangkat UPS fisik Anda aktif terhubung dan mulai mengirimkan data telemetri.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#9ca3af' }} dy={10} />
|
||||
<YAxis yAxisId="left" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#9ca3af' }} dx={-10} tickFormatter={(val) => `${val}V`} domain={[0, 16]} />
|
||||
<YAxis yAxisId="right" orientation="right" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#9ca3af' }} dx={10} tickFormatter={(val) => `${val}%`} domain={[0, 100]} />
|
||||
<Tooltip contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }} />
|
||||
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '20px' }} iconType="circle" />
|
||||
|
||||
<Line yAxisId="right" type="monotone" dataKey="batteryPct" name="Battery %" stroke="#8b5cf6" strokeWidth={2} strokeDasharray="5 5" dot={false} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="vBatt" name="V-Batt" stroke="#f59e0b" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { SignJWT, jwtVerify } from 'jose';
|
||||
|
||||
const secretKey = process.env.AUTH_SECRET || 'fallback-secret-key-for-development-only';
|
||||
const encodedKey = new TextEncoder().encode(secretKey);
|
||||
|
||||
export async function encrypt(payload: any) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(encodedKey);
|
||||
}
|
||||
|
||||
export async function decrypt(session: string | undefined = '') {
|
||||
try {
|
||||
const { payload } = await jwtVerify(session, encodedKey, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
return payload;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { supabase } from './supabase';
|
||||
|
||||
// Types
|
||||
|
||||
export interface HistoryRow {
|
||||
id?: number;
|
||||
created_at?: string;
|
||||
v_batt: number;
|
||||
battery_pct: number;
|
||||
v_in?: number;
|
||||
v_out?: number;
|
||||
line_state: number;
|
||||
power_on: boolean;
|
||||
}
|
||||
|
||||
export interface EventRow {
|
||||
id?: number;
|
||||
created_at?: string;
|
||||
message: string;
|
||||
severity: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
let dbUnavailable = false;
|
||||
let warnedUnavailable = false;
|
||||
let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function isNetworkError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
return /failed to fetch|fetch failed|networkerror|load failed/i.test(message);
|
||||
}
|
||||
|
||||
function logDbIssue(operation: string, error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error ?? 'Unknown error');
|
||||
|
||||
if (isNetworkError(error)) {
|
||||
dbUnavailable = true;
|
||||
|
||||
if (!warnedUnavailable) {
|
||||
warnedUnavailable = true;
|
||||
console.warn(
|
||||
`[DB] Supabase tidak bisa dijangkau (${message}). ` +
|
||||
'Dashboard tetap berjalan tanpa sinkronisasi database. ' +
|
||||
'Periksa NEXT_PUBLIC_SUPABASE_URL dan koneksi jaringan.'
|
||||
);
|
||||
}
|
||||
// Auto-retry after 60 seconds
|
||||
if (!retryTimeoutId) {
|
||||
retryTimeoutId = setTimeout(() => {
|
||||
dbUnavailable = false;
|
||||
warnedUnavailable = false;
|
||||
retryTimeoutId = null;
|
||||
console.info('[DB] Retrying Supabase connection...');
|
||||
}, 60000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[DB] ${operation} error:`, message);
|
||||
}
|
||||
|
||||
function shouldSkipDb() {
|
||||
return dbUnavailable;
|
||||
}
|
||||
|
||||
// History
|
||||
|
||||
/**
|
||||
* Simpan satu titik data history ke Supabase.
|
||||
*/
|
||||
export async function insertHistoryPoint(row: HistoryRow) {
|
||||
if (shouldSkipDb()) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.from('ups_history').insert([row]);
|
||||
if (error) logDbIssue('insertHistoryPoint', error);
|
||||
} catch (error) {
|
||||
logDbIssue('insertHistoryPoint', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil N data history terakhir dari Supabase, diurutkan dari terlama ke terbaru.
|
||||
*/
|
||||
export async function fetchHistory(limit = 100): Promise<HistoryRow[]> {
|
||||
if (shouldSkipDb()) return [];
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('ups_history')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
logDbIssue('fetchHistory', error);
|
||||
return [];
|
||||
}
|
||||
return (data as HistoryRow[]).reverse();
|
||||
} catch (error) {
|
||||
logDbIssue('fetchHistory', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil data history dari Supabase dalam rentang tanggal tertentu.
|
||||
* @param from ISO string (misal "2024-05-01T00:00:00.000Z")
|
||||
* @param to ISO string (misal "2024-05-07T23:59:59.999Z")
|
||||
* @param limit maksimum jumlah baris yang diambil
|
||||
*/
|
||||
export async function fetchHistoryByRange(from: string, to: string, limit = 2000): Promise<HistoryRow[]> {
|
||||
if (shouldSkipDb()) return [];
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('ups_history')
|
||||
.select('*')
|
||||
.gte('created_at', from)
|
||||
.lte('created_at', to)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
logDbIssue('fetchHistoryByRange', error);
|
||||
return [];
|
||||
}
|
||||
return data as HistoryRow[];
|
||||
} catch (error) {
|
||||
logDbIssue('fetchHistoryByRange', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Events
|
||||
|
||||
/**
|
||||
* Simpan satu event log ke Supabase.
|
||||
*/
|
||||
export async function insertEvent(row: EventRow) {
|
||||
if (shouldSkipDb()) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.from('ups_events').insert([row]);
|
||||
if (error) logDbIssue('insertEvent', error);
|
||||
} catch (error) {
|
||||
logDbIssue('insertEvent', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil N event log terakhir dari Supabase, diurutkan dari terbaru ke terlama.
|
||||
*/
|
||||
export async function fetchEvents(limit = 200): Promise<EventRow[]> {
|
||||
if (shouldSkipDb()) return [];
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('ups_events')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
logDbIssue('fetchEvents', error);
|
||||
return [];
|
||||
}
|
||||
return data as EventRow[];
|
||||
} catch (error) {
|
||||
logDbIssue('fetchEvents', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus semua event log dari Supabase.
|
||||
*/
|
||||
export async function clearAllEvents() {
|
||||
if (shouldSkipDb()) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('ups_events')
|
||||
.delete()
|
||||
.gte('id', 0); // delete all rows
|
||||
if (error) logDbIssue('clearAllEvents', error);
|
||||
} catch (error) {
|
||||
logDbIssue('clearAllEvents', error);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn(
|
||||
'[Supabase] Missing environment variables: NEXT_PUBLIC_SUPABASE_URL and/or NEXT_PUBLIC_SUPABASE_ANON_KEY. ' +
|
||||
'Database features will be unavailable.'
|
||||
);
|
||||
}
|
||||
|
||||
export const supabase = createClient(
|
||||
supabaseUrl || 'https://placeholder.supabase.co',
|
||||
supabaseAnonKey || 'placeholder-key'
|
||||
);
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { decrypt } from '@/lib/auth';
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
|
||||
// Protect root path and api control
|
||||
const isProtectedRoute = path === '/' || path.startsWith('/api/control');
|
||||
|
||||
if (isProtectedRoute) {
|
||||
const cookie = request.cookies.get('auth_token')?.value;
|
||||
const session = await decrypt(cookie);
|
||||
|
||||
// If accessing /api/control without cookie, check if they have the bearer token instead
|
||||
if (path.startsWith('/api/control')) {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const apiSecret = process.env.CONTROL_API_SECRET;
|
||||
|
||||
// If there's an API secret configured, and they provided the right bearer token, let them through
|
||||
if (apiSecret && authHeader === `Bearer ${apiSecret}`) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Otherwise, if they don't have a valid web session cookie, block them
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// For the web dashboard (root), redirect to login if not authenticated
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect authenticated users away from the login page
|
||||
if (path === '/login') {
|
||||
const cookie = request.cookies.get('auth_token')?.value;
|
||||
const session = await decrypt(cookie);
|
||||
if (session) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/', '/login', '/api/control/:path*'],
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||