Initial commit: Rancang Bangun UPS DC Berbasis IoT (Web Dashboard & Firmware ESP-12F)

This commit is contained in:
rioahmadfadilahxiv 2026-08-04 16:36:14 +07:00
commit 92c212c997
61 changed files with 16066 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -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

30
README.md Normal file
View File

@ -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`

View File

@ -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() {
}

View File

@ -0,0 +1,5 @@
{
"wifi_ssid": "YourNetwork",
"wifi_pass": "YourPassword",
"mode": "demo"
}

View File

@ -0,0 +1,2 @@
Wemos D1 Mini LittleFS Demo
Uploaded from Arduino IDE using LittleFS Data Upload plugin.

View File

@ -0,0 +1,2 @@
# LittleFS Demo
This is a sample file stored in internal flash using LittleFS.

View File

@ -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;
}

View File

@ -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
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -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>

View File

@ -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();
}

212
firmware_iot_esp12f/mqtt.h Normal file
View File

@ -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

View File

@ -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!");
}
}

1338
firmware_iot_esp12f/upsIO.h Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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();
}

View File

@ -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();
}
}

832
firmware_iot_esp12f/webUI.h Normal file
View File

@ -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 CircleBlack CircleWhite 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";

106
firmware_iot_esp12f/wifi.h Normal file
View File

@ -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();
}
}

41
web_ups/.gitignore vendored Normal file
View File

@ -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

37
web_ups/README.md Normal file
View File

@ -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

18
web_ups/eslint.config.mjs Normal file
View File

@ -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;

7
web_ups/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6926
web_ups/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
web_ups/package.json Normal file
View File

@ -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"
}
}

1
web_ups/public/file.svg Normal file
View File

@ -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

1
web_ups/public/globe.svg Normal file
View File

@ -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

1
web_ups/public/next.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }
);
}
}

View File

@ -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 });
}

View File

@ -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 });
}
}

BIN
web_ups/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -0,0 +1,5 @@
import Login from '@/components/Login';
export default function LoginPage() {
return <Login />;
}

View File

@ -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);
}
}

314
web_ups/src/app/page.tsx Normal file
View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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; }
}

View File

@ -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>
</>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

23
web_ups/src/lib/auth.ts Normal file
View File

@ -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;
}
}

189
web_ups/src/lib/db.ts Normal file
View File

@ -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);
}
}

View File

@ -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'
);

52
web_ups/src/proxy.ts Normal file
View File

@ -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*'],
};

42
web_ups/tsconfig.json Normal file
View File

@ -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"
]
}