Close Menu
  • Articles
    • Learn Electronics
    • Product Review
    • Tech Articles
  • Electronics Circuits
    • 555 Timer Projects
    • Op-Amp Circuits
    • Power Electronics
  • Microcontrollers
    • Arduino Projects
    • STM32 Projects
    • AMB82-Mini IoT AI Camera
    • BLE Projects
  • IoT Projects
    • ESP8266 Projects
    • ESP32 Projects
    • ESP32 MicroPython
    • ESP32-CAM Projects
    • LoRa/LoRaWAN Projects
  • Raspberry Pi
    • Raspberry Pi Projects
    • Raspberry Pi Pico Projects
    • Raspberry Pi Pico W Projects
  • Electronics Calculator
Facebook X (Twitter) Instagram
  • About Us
  • Disclaimer
  • Privacy Policy
  • Contact Us
  • Advertise With Us
Facebook X (Twitter) Instagram Pinterest YouTube LinkedIn
How To Electronics
  • Articles
    • Learn Electronics
    • Product Review
    • Tech Articles
  • Electronics Circuits
    • 555 Timer Projects
    • Op-Amp Circuits
    • Power Electronics
  • Microcontrollers
    • Arduino Projects
    • STM32 Projects
    • AMB82-Mini IoT AI Camera
    • BLE Projects
  • IoT Projects
    • ESP8266 Projects
    • ESP32 Projects
    • ESP32 MicroPython
    • ESP32-CAM Projects
    • LoRa/LoRaWAN Projects
  • Raspberry Pi
    • Raspberry Pi Projects
    • Raspberry Pi Pico Projects
    • Raspberry Pi Pico W Projects
  • Electronics Calculator
How To Electronics
Home » ESP32 Fingerprint Attendance System with Live Web Dashboard
ESP32 Projects IoT Projects

ESP32 Fingerprint Attendance System with Live Web Dashboard

Mamtaz AlamBy Mamtaz AlamUpdated:June 14, 202610 Mins Read
Share Facebook Twitter LinkedIn Telegram Reddit WhatsApp
Share
Facebook Twitter LinkedIn Pinterest Email Reddit Telegram WhatsApp

Overview

In this IoT project, we will learn how to build a Fingerprint Biometric Attendance System using ESP32, R503 Fingerprint Sensor, 0.96” OLED Display, and a professional web dashboard. The ESP32 Wi-Fi module is used to connect the system to a local network and host a real-time dashboard directly from the device. The R503 fingerprint sensor is used to enroll and verify users, while the OLED display shows system status, scan instructions, user name, entry status, exit status, and registration messages.

This ESP32-based biometric attendance system allows users to register their fingerprint along with personal details such as name, college ID, role, and department. Once a registered fingerprint is scanned, the system automatically marks the user’s attendance and updates the dashboard with entry and exit records. The dashboard displays live attendance activity, registered user records, fingerprint IDs, login time, logout time, and attendance history. The attendance data can also be exported as a CSV file for use in Excel or record-keeping.

The professional dashboard makes this project more advanced by providing a user-friendly interface for fingerprint registration, attendance monitoring, record management, and fingerprint deletion. This project is a practical and low-cost solution for building a smart biometric attendance system with real-time monitoring and secure user verification.


Overview

Following are the components required to making ESP32 Fingerprint Attendance System. All these components can be purchased from the given links.

S.N.Components NameQuantityPurchase Link
1ESP32 Board1Amazon | AliExpress
2R503 Fingerprint Sensor1Amazon | AliExpress
3OLED Display 0.96"1Amazon | AliExpress
410uF Electrolytic Capacitor2Amazon | AliExpress
50.1uF Ceramic Capacitor4Amazon | AliExpress
6Jumper Wires4Amazon | AliExpress




Block Diagram of ESP32 Fingerprint Biometric Attendance System

Let’s take a look at the block diagram of the ESP32-based Fingerprint Biometric Attendance System.

Block Diagram ESP32 Fingerprint Attendance System
Fig: Block Diagram ESP32 Fingerprint Attendance System

In this system, the R503 fingerprint sensor works as the input device and sends fingerprint data to the ESP32 for processing and verification. After the fingerprint is verified, the ESP32 displays the result on the OLED screen and updates the web dashboard for real-time attendance monitoring.

The dashboard can show registered users, entry and exit records, attendance history, and other important details in a simple and organized way.


Circuit Diagram

Let’s take a look at the schematic of the ESP32-based Fingerprint Biometric Attendance System.

Fig: Schematic ESP32-based Fingerprint Biometric Attendance System

In this circuit, the ESP32 works as the main controller and is connected with the R503 fingerprint sensor, OLED display, and push buttons. The R503 fingerprint sensor communicates with the ESP32 through UART pins, while the OLED display uses the I2C pins for showing scan status, user name, entry, exit, and system messages.

The push buttons are connected as input controls and can be used for functions like enrollment, cancel, or other menu actions. But in this project, we are not using the push buttons. Pull-up resistors are used with the buttons, so the ESP32 can read a stable HIGH or LOW signal when a button is pressed. Capacitors are also added near the modules to make the power supply more stable and reduce noise in the circuit.




PCB Designing & Gerber Files

The schematic of the ESP32 Fingerprint Attendance System was created in EasyEDA, and from there it was converted into a compact PCB layout.

The board is designed with mostly through-hole components to make easier assembly. You can view the 2D view of the PCB.

All of the critical components, including the ESP32 module, are placed on the front side of the PCB, which makes assembly easier and more reliable.

Here are the link of files that you can download for PCB manufacturing and PCB assembly services.

  1. Download: Schematic PDF
  2. Download: Gerber File

PCB Ordering Online & Assembly

The Gerber file for this ESP32, PMS7003, BME680 Air Quality Monitor is provided above. You can download the Gerber file and place an order with a PCB manufacturer like AIVON for as low as $1 for a PCB Prototype.

To order the PCB, visit the AIVON Official Website and upload the Gerber file using the Quote Now option. You can then choose your required parameters, such as Material Type, Dimensions, Quantity, Thickness, and Solder Mask Color.

AIVON is making PCB prototyping and assembly more affordable for new users by offering $1 PCB Prototype and $35 PCB Assembly with Shipping fee $30 OFF on your first PCB order. With this promotion, you can enjoy free shipping on your first order and affordable assembly service for your project.

Here is the promotion link: AIVON PCB/PCBA Promotion Offer

Once all the details are filled in, select your country and shipping method. After confirming everything, you can place the order and wait for your boards to arrive.




Assembly of the ESP32 Fingerprint Attendance System

After I ordered the custom PCB, I received it within just 3 days. The PCB quality was very good, and it looked clean and professional. It is a double-sided PCB, which makes the circuit compact and neat.

Then I started soldering and assembling all the components on the board. For the ESP32 board, OLED display, and Fingerprint sensor, I used female header pins. This makes it easy to remove or replace the modules later if needed.

I also soldered the required capacitors on different parts of the PCB. These capacitors help to keep the power supply stable and reduce noise in the circuit. For the R503 Fingerprint sensor, I used 6 jumper wires to connect it to the PCB board terminal.

ESP32 Fingerprint Attendance System

After soldering all the parts, I connected the all the modules and completed the final assembly of the IoT ESP32 Fingerprint Attendance System device.


Source Code/program

Let us take a look at the programming part of this project.

The code starts by including the required libraries for Wi-Fi, web server, fingerprint sensor, OLED display, and ESP32 memory storage. The WiFi and WebServer libraries are used to connect the ESP32 to the local network and host the attendance dashboard. The Adafruit Fingerprint library is used to communicate with the R503 fingerprint sensor, while the Adafruit GFX library and Adafruit SSD1306 library are used to display messages on the OLED screen.

In the Wi-Fi section, the SSID and password are added so the ESP32 can connect to the network. After connecting, the ESP32 shows its IP address on the OLED display and Serial Monitor. This IP address can be opened in a browser to access the professional web dashboard.

The main loop checks for fingerprint scans, enrollment requests, and button inputs. When a registered finger is scanned, the ESP32 verifies the fingerprint, marks entry or exit, shows the user’s first name on the OLED, and updates the attendance records on the dashboard. New users can also be registered from the dashboard by entering their name, college ID, role, and department.

The code is divided into two main parts: the main .ino file for ESP32 control and the dashboard.h file for the web dashboard interface.



Main .ino code

Copy the following code and paste it to your Arduino IDE editor window.

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_Fingerprint.h>
#include "dashboard.h"
 
// =====================================================
// WIFI SETTINGS
// =====================================================
const char* WIFI_SSID = "****************";
const char* WIFI_PASSWORD = "***************";
 
// Ontario / Toronto timezone
const char* TIME_ZONE = "EST5EDT,M3.2.0/2,M11.1.0/2";
 
WebServer server(80);
Preferences prefs;
 
// =====================================================
// OLED SETTINGS
// =====================================================
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C
 
#define OLED_SDA 21
#define OLED_SCL 22
 
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
 
// =====================================================
// R503 FINGERPRINT SENSOR UART
// =====================================================
#define FINGER_RX 16
#define FINGER_TX 17
 
HardwareSerial fingerSerial(2);
Adafruit_Fingerprint finger = Adafruit_Fingerprint(&fingerSerial);
 
// =====================================================
// BUTTONS
// =====================================================
#define BTN_ENROLL 18
#define BTN_CANCEL 5
#define DEBOUNCE_MS 250
 
unsigned long lastEnrollBtnTime = 0;
unsigned long lastCancelBtnTime = 0;
 
// =====================================================
// MATCH SETTINGS
// =====================================================
#define MATCH_SECURITY_LEVEL 2
#define MAX_FINGERPRINTS 200
 
// =====================================================
// PROFILE STORAGE
// =====================================================
struct PersonProfile {
  String name;
  String collegeId;
  String role;
  String department;
  String note;
};
 
// =====================================================
// LOGGING
// =====================================================
#define MAX_LOGS 80
 
struct AttendanceLog {
  String timeText;
  String event;
  int id;
  String name;
  String collegeId;
  String role;
  String department;
  String message;
};
 
AttendanceLog logs[MAX_LOGS];
int logCount = 0;
int logWriteIndex = 0;
 
// =====================================================
// APP STATE
// =====================================================
uint16_t nextEnrollID = 1;
unsigned long lastIdleRefresh = 0;
 
bool isEnrolling = false;
bool cancelEnroll = false;
bool duplicateFound = false;
bool dashboardEnrollRequest = false;
 
uint16_t cachedTemplateCount = 0;
String cachedRegisteredJson = "{\"fingerprints\":[]}";
 
int entryCount = 0;
int exitCount = 0;
int lastEntryId = -1;
 
bool insideState[MAX_FINGERPRINTS + 1];
 
// Dashboard form values
String pendingName = "";
String pendingCollegeId = "";
String pendingRole = "";
String pendingDepartment = "";
String pendingNote = "";
 
// =====================================================
// FUNCTION DECLARATIONS
// =====================================================
void connectWiFi();
void setupTime();
void setupDashboardRoutes();
 
void smartDelay(unsigned long ms);
 
String uptimeText();
String currentDateTimeText();
String jsonEscape(String s);
String firstNameOnly(String fullName);
 
void addLog(String event, int id, String message);
AttendanceLog getLogByDisplayIndex(int i);
 
String profileKey(uint16_t id, const char* field);
void saveProfile(uint16_t id, PersonProfile p);
PersonProfile loadProfile(uint16_t id);
void deleteProfile(uint16_t id);
 
void oledScreen(String title, String line1 = "", String line2 = "", String line3 = "");
void oledBig(String title, String line1 = "", String line2 = "");
void oledScanScreen();
 
void oledScanAnimation(String title, String line1, uint16_t durationMs);
void oledProgressBar(String title, String line1, uint8_t percent);
void oledSuccessAnimation(String line1, String line2 = "");
void oledErrorAnimation(String line1, String line2 = "");
 
String clipText(String text, uint8_t maxChars);
void drawCenteredText(String text, int y, uint8_t textSize);
void drawTitleBar(String title);
void drawMiniFingerprint(int x, int y);
void drawLoadingDots(int x, int y, uint8_t frame);
void drawMovingBar(int y, uint8_t frame);
void drawProgress(uint8_t percent);
void drawWiFiIcon(bool connected);
 
bool buttonPressed(uint8_t pin, unsigned long &lastTime);
bool enrollButtonPressed();
bool cancelButtonPressed();
bool checkCancelDuringEnroll();
 
void scanFingerprint();
uint8_t enrollFingerprint(uint16_t id);
 
void waitForFingerRemove();
uint16_t findNextFreeID(uint16_t startID);
void printSensorParameters();
void refreshTemplateCountSerialOnly();
void refreshTemplateCountCache();
void rebuildRegisteredCache();
void setMatchingSecuritySerialOnly();
 
// =====================================================
// SETUP
// =====================================================
void setup() {
  Serial.begin(115200);
  delay(1000);
 
  pinMode(BTN_ENROLL, INPUT);
  pinMode(BTN_CANCEL, INPUT);
 
  for (int i = 0; i <= MAX_FINGERPRINTS; i++) {
    insideState[i] = false;
  }
 
  prefs.begin("fps_db", false);
 
  Wire.begin(OLED_SDA, OLED_SCL);
 
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println("OLED not found");
    while (1) {
      delay(1000);
    }
  }
 
  display.clearDisplay();
  display.setTextWrap(false);
  display.display();
 
  oledBig("STARTING", "Fingerprint", "System");
  smartDelay(900);
 
  connectWiFi();
  setupTime();
  setupDashboardRoutes();
 
  fingerSerial.begin(57600, SERIAL_8N1, FINGER_RX, FINGER_TX);
  finger.begin(57600);
 
  smartDelay(500);
 
  oledScanAnimation("CHECKING", "Sensor", 900);
 
  if (finger.verifyPassword()) {
    Serial.println("Fingerprint sensor found!");
    addLog("INFO", -1, "Fingerprint sensor ready");
    oledSuccessAnimation("Sensor ready");
    smartDelay(900);
  } else {
    Serial.println("Fingerprint sensor not found!");
    addLog("ERROR", -1, "Fingerprint sensor not found");
    oledErrorAnimation("Sensor not found", "Check wiring");
 
    while (1) {
      server.handleClient();
      delay(10);
    }
  }
 
  printSensorParameters();
  setMatchingSecuritySerialOnly();
 
  refreshTemplateCountSerialOnly();
  rebuildRegisteredCache();
 
  nextEnrollID = findNextFreeID(1);
 
  Serial.print("Next enroll ID: ");
  Serial.println(nextEnrollID);
 
  addLog("INFO", -1, "System ready");
 
  smartDelay(500);
  oledScanScreen();
}
 
// =====================================================
// LOOP
// =====================================================
void loop() {
  server.handleClient();
 
  if ((enrollButtonPressed() || dashboardEnrollRequest) && !isEnrolling) {
    dashboardEnrollRequest = false;
 
    if (pendingName.length() > 0) {
      addLog("ENROLL", -1, "Enrollment started for " + pendingName);
    } else {
      addLog("ENROLL", -1, "Enrollment started");
    }
 
    isEnrolling = true;
    cancelEnroll = false;
    duplicateFound = false;
 
    uint8_t result = enrollFingerprint(nextEnrollID);
 
    if (cancelEnroll) {
      addLog("CANCEL", -1, "Enrollment cancelled");
      oledBig("CANCELLED", "Back to scan");
      smartDelay(1200);
    }
    else if (duplicateFound) {
      addLog("DUPLICATE", finger.fingerID, "Fingerprint already exists");
      smartDelay(1200);
    }
    else if (result == FINGERPRINT_OK) {
      PersonProfile p;
      p.name = pendingName;
      p.collegeId = pendingCollegeId;
      p.role = pendingRole;
      p.department = pendingDepartment;
      p.note = pendingNote;
 
      saveProfile(nextEnrollID, p);
 
      String savedName = firstNameOnly(p.name);
 
      if (savedName.length() == 0) {
        savedName = "ID " + String(nextEnrollID);
      }
 
      String enrollMsg = "Fingerprint enrolled successfully";
 
      if (p.name.length() > 0) {
        enrollMsg = p.name + " enrolled successfully";
      }
 
      addLog("ENROLLED", nextEnrollID, enrollMsg);
 
      oledSuccessAnimation("Saved", savedName);
 
      Serial.print("Enrollment successful. ID: ");
      Serial.println(nextEnrollID);
 
      smartDelay(1200);
 
      refreshTemplateCountSerialOnly();
      rebuildRegisteredCache();
      nextEnrollID = findNextFreeID(nextEnrollID + 1);
    }
    else {
      addLog("ERROR", -1, "Enrollment failed");
      oledErrorAnimation("Not saved", "Try again");
      smartDelay(1600);
    }
 
    pendingName = "";
    pendingCollegeId = "";
    pendingRole = "";
    pendingDepartment = "";
    pendingNote = "";
 
    isEnrolling = false;
    cancelEnroll = false;
    duplicateFound = false;
 
    oledScanScreen();
  }
 
  if (cancelButtonPressed() && !isEnrolling) {
    oledScanScreen();
    smartDelay(300);
  }
 
  if (!isEnrolling) {
    scanFingerprint();
  }
 
  delay(30);
}
 
// =====================================================
// SMART DELAY
// =====================================================
void smartDelay(unsigned long ms) {
  unsigned long start = millis();
 
  while (millis() - start < ms) {
    server.handleClient();
    delay(5);
  }
}
 
// =====================================================
// WIFI + TIME + DASHBOARD ROUTES
// =====================================================
void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
 
  unsigned long start = millis();
  uint8_t frame = 0;
 
  while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
    display.clearDisplay();
    drawTitleBar("WIFI");
    drawCenteredText("Connecting", 24, 1);
    drawLoadingDots(28, 42, frame);
    display.display();
 
    frame++;
    delay(300);
  }
 
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("WiFi connected. IP: ");
    Serial.println(WiFi.localIP());
 
    oledBig("WIFI OK", "IP Address", WiFi.localIP().toString());
    smartDelay(5000);
  } else {
    Serial.println("WiFi not connected.");
    oledBig("WIFI OFF", "Dashboard offline");
    smartDelay(1800);
  }
}
 
void setupTime() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi not connected. NTP skipped.");
    return;
  }
 
  configTzTime(TIME_ZONE, "pool.ntp.org", "time.nist.gov", "time.google.com");
 
  Serial.print("Syncing time");
 
  struct tm timeinfo;
  int retry = 0;
 
  while (!getLocalTime(&timeinfo, 1000) && retry < 12) {
    Serial.print(".");
    retry++;
    server.handleClient();
  }
 
  Serial.println();
 
  if (retry < 12) {
    Serial.print("Time synced: ");
    Serial.println(currentDateTimeText());
  } else {
    Serial.println("Time sync failed. Using uptime fallback.");
  }
}
 
void setupDashboardRoutes() {
  server.on("/", HTTP_GET, []() {
    server.send_P(200, "text/html", DASHBOARD_HTML);
  });
 
  server.on("/api/status", HTTP_GET, []() {
    String lastJson = "null";
 
    if (logCount > 0) {
      AttendanceLog l = getLogByDisplayIndex(logCount - 1);
 
      lastJson = "{";
      lastJson += "\"time\":\"" + jsonEscape(l.timeText) + "\",";
      lastJson += "\"event\":\"" + jsonEscape(l.event) + "\",";
      lastJson += "\"id\":" + String(l.id) + ",";
      lastJson += "\"name\":\"" + jsonEscape(l.name) + "\",";
      lastJson += "\"collegeId\":\"" + jsonEscape(l.collegeId) + "\",";
      lastJson += "\"role\":\"" + jsonEscape(l.role) + "\",";
      lastJson += "\"department\":\"" + jsonEscape(l.department) + "\",";
      lastJson += "\"message\":\"" + jsonEscape(l.message) + "\"";
      lastJson += "}";
    }
 
    String lastEntryJson = "null";
 
    if (lastEntryId >= 1) {
      PersonProfile p = loadProfile(lastEntryId);
 
      lastEntryJson = "{";
      lastEntryJson += "\"id\":" + String(lastEntryId) + ",";
      lastEntryJson += "\"name\":\"" + jsonEscape(p.name) + "\",";
      lastEntryJson += "\"collegeId\":\"" + jsonEscape(p.collegeId) + "\",";
      lastEntryJson += "\"department\":\"" + jsonEscape(p.department) + "\",";
      lastEntryJson += "\"message\":\"Last entry\"";
      lastEntryJson += "}";
    }
 
    String json = "{";
    json += "\"wifi\":" + String(WiFi.status() == WL_CONNECTED ? "true" : "false") + ",";
    json += "\"ip\":\"" + String(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : "") + "\",";
    json += "\"templates\":" + String(cachedTemplateCount) + ",";
    json += "\"nextId\":" + String(nextEnrollID) + ",";
    json += "\"logCount\":" + String(logCount) + ",";
    json += "\"entryCount\":" + String(entryCount) + ",";
    json += "\"exitCount\":" + String(exitCount) + ",";
    json += "\"isEnrolling\":" + String(isEnrolling ? "true" : "false") + ",";
    json += "\"sensor\":\"Ready\",";
    json += "\"last\":" + lastJson + ",";
    json += "\"lastEntry\":" + lastEntryJson;
    json += "}";
 
    server.send(200, "application/json", json);
  });
 
  server.on("/api/logs", HTTP_GET, []() {
    String json = "{\"logs\":[";
 
    for (int i = 0; i < logCount; i++) {
      AttendanceLog l = getLogByDisplayIndex(i);
 
      if (i > 0) {
        json += ",";
      }
 
      json += "{";
      json += "\"time\":\"" + jsonEscape(l.timeText) + "\",";
      json += "\"event\":\"" + jsonEscape(l.event) + "\",";
      json += "\"id\":" + String(l.id) + ",";
      json += "\"name\":\"" + jsonEscape(l.name) + "\",";
      json += "\"collegeId\":\"" + jsonEscape(l.collegeId) + "\",";
      json += "\"role\":\"" + jsonEscape(l.role) + "\",";
      json += "\"department\":\"" + jsonEscape(l.department) + "\",";
      json += "\"message\":\"" + jsonEscape(l.message) + "\"";
      json += "}";
    }
 
    json += "]}";
 
    server.send(200, "application/json", json);
  });
 
  server.on("/api/registered", HTTP_GET, []() {
    server.send(200, "application/json", cachedRegisteredJson);
  });
 
  server.on("/api/enroll", HTTP_POST, []() {
    if (isEnrolling || dashboardEnrollRequest) {
      server.send(409, "application/json", "{\"ok\":false,\"message\":\"Enrollment already running\"}");
      return;
    }
 
    pendingName = server.arg("name");
    pendingCollegeId = server.arg("collegeId");
    pendingRole = server.arg("role");
    pendingDepartment = server.arg("department");
    pendingNote = server.arg("note");
 
    dashboardEnrollRequest = true;
 
    String msg = "Enroll requested from dashboard";
 
    if (pendingName.length() > 0) {
      msg += " for " + pendingName;
    }
 
    addLog("ENROLL REQUEST", -1, msg);
 
    server.send(200, "application/json", "{\"ok\":true,\"message\":\"Enrollment requested\"}");
  });
 
  server.on("/api/cancel", HTTP_POST, []() {
    cancelEnroll = true;
    dashboardEnrollRequest = false;
 
    addLog("CANCEL", -1, "Cancel requested from dashboard");
 
    server.send(200, "application/json", "{\"ok\":true,\"message\":\"Cancel requested\"}");
  });
 
  server.on("/api/fingerprint", HTTP_DELETE, []() {
    if (!server.hasArg("id")) {
      server.send(400, "application/json", "{\"ok\":false,\"message\":\"Missing ID\"}");
      return;
    }
 
    int id = server.arg("id").toInt();
 
    if (id < 1 || id > MAX_FINGERPRINTS) {
      server.send(400, "application/json", "{\"ok\":false,\"message\":\"Invalid ID\"}");
      return;
    }
 
    if (isEnrolling) {
      server.send(409, "application/json", "{\"ok\":false,\"message\":\"Enrollment running. Try again later.\"}");
      return;
    }
 
    uint8_t p = finger.deleteModel(id);
 
    smartDelay(150);
 
    uint8_t verify = finger.loadModel(id);
 
    if (p == FINGERPRINT_OK && verify != FINGERPRINT_OK) {
      deleteProfile(id);
 
      if (id >= 0 && id <= MAX_FINGERPRINTS) {
        insideState[id] = false;
      }
 
      addLog("DELETE", id, "Fingerprint deleted from sensor memory");
 
      refreshTemplateCountSerialOnly();
      rebuildRegisteredCache();
      nextEnrollID = findNextFreeID(1);
 
      server.send(200, "application/json", "{\"ok\":true,\"message\":\"Fingerprint deleted from sensor memory\"}");
    }
    else if (p == FINGERPRINT_OK && verify == FINGERPRINT_OK) {
      addLog("ERROR", id, "Delete command sent but fingerprint still exists");
      server.send(500, "application/json", "{\"ok\":false,\"message\":\"Delete command sent but fingerprint still exists in sensor\"}");
    }
    else {
      addLog("ERROR", id, "Delete failed");
      server.send(500, "application/json", "{\"ok\":false,\"message\":\"Delete failed. Fingerprint may not exist or sensor rejected command\"}");
    }
  });
 
  server.on("/api/export", HTTP_GET, []() {
    String csv = "time,event,fingerprint_id,name,college_id,role,department,message\n";
 
    for (int i = 0; i < logCount; i++) {
      AttendanceLog l = getLogByDisplayIndex(i);
 
      csv += "\"" + l.timeText + "\",";
      csv += "\"" + l.event + "\",";
      csv += String(l.id) + ",";
      csv += "\"" + l.name + "\",";
      csv += "\"" + l.collegeId + "\",";
      csv += "\"" + l.role + "\",";
      csv += "\"" + l.department + "\",";
      csv += "\"" + l.message + "\"\n";
    }
 
    server.sendHeader("Content-Disposition", "attachment; filename=attendance_logs.csv");
    server.send(200, "text/csv", csv);
  });
 
  server.begin();
  Serial.println("Dashboard server started.");
}
 
// =====================================================
// PROFILE STORAGE FUNCTIONS
// =====================================================
String profileKey(uint16_t id, const char* field) {
  return String(id) + "_" + String(field);
}
 
void saveProfile(uint16_t id, PersonProfile p) {
  prefs.putString(profileKey(id, "name").c_str(), p.name);
  prefs.putString(profileKey(id, "cid").c_str(), p.collegeId);
  prefs.putString(profileKey(id, "role").c_str(), p.role);
  prefs.putString(profileKey(id, "dept").c_str(), p.department);
  prefs.putString(profileKey(id, "note").c_str(), p.note);
}
 
PersonProfile loadProfile(uint16_t id) {
  PersonProfile p;
 
  p.name = prefs.getString(profileKey(id, "name").c_str(), "");
  p.collegeId = prefs.getString(profileKey(id, "cid").c_str(), "");
  p.role = prefs.getString(profileKey(id, "role").c_str(), "");
  p.department = prefs.getString(profileKey(id, "dept").c_str(), "");
  p.note = prefs.getString(profileKey(id, "note").c_str(), "");
 
  return p;
}
 
void deleteProfile(uint16_t id) {
  prefs.remove(profileKey(id, "name").c_str());
  prefs.remove(profileKey(id, "cid").c_str());
  prefs.remove(profileKey(id, "role").c_str());
  prefs.remove(profileKey(id, "dept").c_str());
  prefs.remove(profileKey(id, "note").c_str());
}
 
// =====================================================
// LOG + TIME FUNCTIONS
// =====================================================
String uptimeText() {
  return currentDateTimeText();
}
 
String currentDateTimeText() {
  struct tm timeinfo;
 
  if (getLocalTime(&timeinfo, 1000)) {
    char buf[24];
    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo);
    return String(buf);
  }
 
  unsigned long total = millis() / 1000;
  unsigned long h = total / 3600;
  unsigned long m = (total % 3600) / 60;
  unsigned long s = total % 60;
 
  char buf[24];
  snprintf(buf, sizeof(buf), "UPTIME %02lu:%02lu:%02lu", h, m, s);
 
  return String(buf);
}
 
String jsonEscape(String s) {
  s.replace("\\", "\\\\");
  s.replace("\"", "\\\"");
  s.replace("\n", "\\n");
  s.replace("\r", "");
 
  return s;
}
 
String firstNameOnly(String fullName) {
  fullName.trim();
 
  if (fullName.length() == 0) {
    return "";
  }
 
  int spaceIndex = fullName.indexOf(' ');
 
  if (spaceIndex > 0) {
    return fullName.substring(0, spaceIndex);
  }
 
  return fullName;
}
 
void addLog(String event, int id, String message) {
  logs[logWriteIndex].timeText = uptimeText();
  logs[logWriteIndex].event = event;
  logs[logWriteIndex].id = id;
  logs[logWriteIndex].message = message;
 
  logs[logWriteIndex].name = "";
  logs[logWriteIndex].collegeId = "";
  logs[logWriteIndex].role = "";
  logs[logWriteIndex].department = "";
 
  if (id >= 1 && id <= MAX_FINGERPRINTS) {
    PersonProfile p = loadProfile(id);
 
    logs[logWriteIndex].name = p.name;
    logs[logWriteIndex].collegeId = p.collegeId;
    logs[logWriteIndex].role = p.role;
    logs[logWriteIndex].department = p.department;
  }
 
  logWriteIndex = (logWriteIndex + 1) % MAX_LOGS;
 
  if (logCount < MAX_LOGS) {
    logCount++;
  }
 
  Serial.print("[LOG] ");
  Serial.print(event);
  Serial.print(" ID=");
  Serial.print(id);
  Serial.print(" ");
  Serial.println(message);
}
 
AttendanceLog getLogByDisplayIndex(int i) {
  int start = (logWriteIndex - logCount + MAX_LOGS) % MAX_LOGS;
  int idx = (start + i) % MAX_LOGS;
 
  return logs[idx];
}
 
// =====================================================
// OLED DISPLAY FUNCTIONS
// =====================================================
String clipText(String text, uint8_t maxChars) {
  text.trim();
 
  if (text.length() <= maxChars) {
    return text;
  }
 
  if (maxChars <= 1) {
    return text.substring(0, maxChars);
  }
 
  return text.substring(0, maxChars - 1) + ".";
}
 
void drawCenteredText(String text, int y, uint8_t textSize) {
  text.trim();
 
  uint8_t maxChars = (textSize == 2) ? 10 : 21;
 
  text = clipText(text, maxChars);
 
  int charWidth = 6 * textSize;
  int textWidth = text.length() * charWidth;
  int x = (SCREEN_WIDTH - textWidth) / 2;
 
  if (x < 0) {
    x = 0;
  }
 
  display.setTextSize(textSize);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(x, y);
  display.print(text);
}
 
void drawWiFiIcon(bool connected) {
  int x = 113;
  int y = 2;
 
  if (connected) {
    display.drawCircle(x + 7, y + 11, 1, SSD1306_WHITE);
    display.drawLine(x + 2, y + 8, x + 7, y + 4, SSD1306_WHITE);
    display.drawLine(x + 7, y + 4, x + 12, y + 8, SSD1306_WHITE);
    display.drawLine(x + 4, y + 10, x + 7, y + 8, SSD1306_WHITE);
    display.drawLine(x + 7, y + 8, x + 10, y + 10, SSD1306_WHITE);
  } else {
    display.drawRect(x + 1, y + 3, 12, 9, SSD1306_WHITE);
    display.drawLine(x, y + 2, x + 14, y + 13, SSD1306_WHITE);
    display.drawLine(x + 14, y + 2, x, y + 13, SSD1306_WHITE);
  }
}
 
void drawTitleBar(String title) {
  display.fillRect(0, 0, 128, 16, SSD1306_BLACK);
 
  String shortTitle = clipText(title, 16);
  int textWidth = shortTitle.length() * 6;
  int x = (112 - textWidth) / 2;
 
  if (x < 0) {
    x = 0;
  }
 
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(x, 2);
  display.print(shortTitle);
 
  drawWiFiIcon(WiFi.status() == WL_CONNECTED);
 
  display.drawLine(0, 15, 127, 15, SSD1306_WHITE);
}
 
void oledScreen(String title, String line1, String line2, String line3) {
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar(title);
 
  if (line1.length() > 0) {
    drawCenteredText(line1, 22, 1);
  }
 
  if (line2.length() > 0) {
    drawCenteredText(line2, 36, 1);
  }
 
  if (line3.length() > 0) {
    drawCenteredText(line3, 50, 1);
  }
 
  display.display();
}
 
void oledBig(String title, String line1, String line2) {
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar(title);
 
  if (line1.length() > 0) {
    drawCenteredText(line1, 24, 1);
  }
 
  if (line2.length() > 0) {
    drawCenteredText(line2, 42, 1);
  }
 
  display.display();
}
 
void drawMiniFingerprint(int x, int y) {
  display.drawCircle(x + 18, y + 18, 17, SSD1306_WHITE);
  display.drawCircle(x + 18, y + 18, 12, SSD1306_WHITE);
  display.drawCircle(x + 18, y + 18, 7, SSD1306_WHITE);
 
  display.fillRect(x - 2, y + 28, 42, 14, SSD1306_BLACK);
  display.fillRect(x - 2, y - 2, 7, 44, SSD1306_BLACK);
  display.fillRect(x + 32, y - 2, 8, 44, SSD1306_BLACK);
 
  display.drawLine(x + 18, y + 18, x + 18, y + 36, SSD1306_WHITE);
  display.drawLine(x + 12, y + 21, x + 10, y + 35, SSD1306_WHITE);
  display.drawLine(x + 24, y + 21, x + 28, y + 34, SSD1306_WHITE);
}
 
void drawLoadingDots(int x, int y, uint8_t frame) {
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(x, y);
  display.print("Please wait");
 
  for (uint8_t i = 0; i < frame % 4; i++) {
    display.print(".");
  }
}
 
void drawMovingBar(int y, uint8_t frame) {
  int boxX = 12;
  int boxW = 104;
  int fillW = 22;
  int pos = (frame * 8) % (boxW - fillW);
 
  display.drawRect(boxX, y, boxW, 8, SSD1306_WHITE);
  display.fillRect(boxX + 2 + pos, y + 2, fillW, 4, SSD1306_WHITE);
}
 
void drawProgress(uint8_t percent) {
  if (percent > 100) {
    percent = 100;
  }
 
  int barX = 10;
  int barY = 52;
  int barW = 108;
  int barH = 9;
 
  display.drawRect(barX, barY, barW, barH, SSD1306_WHITE);
 
  int fillW = map(percent, 0, 100, 0, barW - 4);
 
  display.fillRect(barX + 2, barY + 2, fillW, barH - 4, SSD1306_WHITE);
 
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(52, 41);
  display.print(percent);
  display.print("%");
}
 
void oledScanScreen() {
  static uint8_t idleFrame = 0;
  idleFrame++;
 
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar("SCAN MODE");
  drawMiniFingerprint(8, 22);
 
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
 
  display.setCursor(56, 24);
  display.print("Place finger");
 
  display.setCursor(56, 38);
  display.print("Waiting");
 
  for (uint8_t i = 0; i < idleFrame % 4; i++) {
    display.print(".");
  }
 
  int r = 3 + (idleFrame % 4);
 
  display.drawCircle(103, 50, r, SSD1306_WHITE);
 
  display.display();
}
 
void oledScanAnimation(String title, String line1, uint16_t durationMs) {
  unsigned long startTime = millis();
  uint8_t frame = 0;
 
  while (millis() - startTime < durationMs) {
    server.handleClient();
 
    display.clearDisplay();
    display.setTextWrap(false);
 
    drawTitleBar(title);
    drawMiniFingerprint(8, 21);
 
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
 
    display.setCursor(56, 24);
    display.print(clipText(line1, 11));
 
    drawLoadingDots(56, 38, frame);
 
    int scanY = 22 + ((frame * 4) % 32);
 
    display.drawLine(8, scanY, 45, scanY, SSD1306_WHITE);
    display.drawLine(8, scanY + 1, 45, scanY + 1, SSD1306_WHITE);
 
    drawMovingBar(54, frame);
 
    display.display();
 
    frame++;
    delay(100);
  }
}
 
void oledProgressBar(String title, String line1, uint8_t percent) {
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar(title);
  drawCenteredText(line1, 24, 1);
  drawProgress(percent);
 
  display.display();
}
 
void oledSuccessAnimation(String line1, String line2) {
  for (int r = 3; r <= 14; r += 3) {
    server.handleClient();
 
    display.clearDisplay();
    display.setTextWrap(false);
 
    drawTitleBar("SUCCESS");
 
    display.drawCircle(64, 34, r, SSD1306_WHITE);
 
    if (r >= 9) {
      display.drawLine(56, 34, 62, 41, SSD1306_WHITE);
      display.drawLine(62, 41, 74, 27, SSD1306_WHITE);
      display.drawLine(56, 35, 62, 42, SSD1306_WHITE);
      display.drawLine(62, 42, 74, 28, SSD1306_WHITE);
    }
 
    display.display();
 
    smartDelay(120);
  }
 
  smartDelay(300);
 
  if (line2.length() > 0) {
    oledBig("SUCCESS", line1, line2);
  } else {
    oledBig("SUCCESS", line1);
  }
}
 
void oledErrorAnimation(String line1, String line2) {
  for (int i = 0; i < 3; i++) {
    server.handleClient();
 
    display.clearDisplay();
    display.setTextWrap(false);
 
    drawTitleBar("NOTICE");
 
    display.drawCircle(64, 34, 13, SSD1306_WHITE);
    display.drawLine(56, 26, 72, 42, SSD1306_WHITE);
    display.drawLine(72, 26, 56, 42, SSD1306_WHITE);
    display.drawLine(57, 26, 73, 42, SSD1306_WHITE);
    display.drawLine(73, 26, 57, 42, SSD1306_WHITE);
 
    display.display();
 
    smartDelay(180);
 
    display.clearDisplay();
    drawTitleBar("NOTICE");
    display.display();
 
    smartDelay(100);
  }
 
  if (line2.length() > 0) {
    oledBig("TRY AGAIN", line1, line2);
  } else {
    oledBig("TRY AGAIN", line1);
  }
}
 
// =====================================================
// BUTTON HANDLING
// =====================================================
bool buttonPressed(uint8_t pin, unsigned long &lastTime) {
  if (digitalRead(pin) == LOW) {
    if (millis() - lastTime > DEBOUNCE_MS) {
      lastTime = millis();
      return true;
    }
  }
 
  return false;
}
 
bool enrollButtonPressed() {
  if (buttonPressed(BTN_ENROLL, lastEnrollBtnTime)) {
    while (digitalRead(BTN_ENROLL) == LOW) {
      server.handleClient();
      delay(10);
    }
 
    return true;
  }
 
  return false;
}
 
bool cancelButtonPressed() {
  if (buttonPressed(BTN_CANCEL, lastCancelBtnTime)) {
    while (digitalRead(BTN_CANCEL) == LOW) {
      server.handleClient();
      delay(10);
    }
 
    return true;
  }
 
  return false;
}
 
bool checkCancelDuringEnroll() {
  server.handleClient();
 
  if (buttonPressed(BTN_CANCEL, lastCancelBtnTime) || cancelEnroll) {
    cancelEnroll = true;
 
    while (digitalRead(BTN_CANCEL) == LOW) {
      server.handleClient();
      delay(10);
    }
 
    oledBig("CANCELLED", "Back to scan");
    smartDelay(800);
 
    return true;
  }
 
  return false;
}
 
// =====================================================
// SCAN FINGERPRINT - STABLE VERSION
// =====================================================
void scanFingerprint() {
  uint8_t p = finger.getImage();
 
  if (p == FINGERPRINT_NOFINGER) {
    if (millis() - lastIdleRefresh > 700) {
      lastIdleRefresh = millis();
      oledScanScreen();
    }
 
    return;
  }
 
  if (p != FINGERPRINT_OK) {
    addLog("ERROR", -1, "Could not read fingerprint");
    oledErrorAnimation("Could not read", "Place properly");
 
    smartDelay(800);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  oledScreen("SCANNING", "Hold finger", "Reading...");
 
  p = finger.image2Tz(1);
 
  if (p != FINGERPRINT_OK) {
    addLog("ERROR", -1, "Fingerprint image not clear");
    oledErrorAnimation("Finger not clear", "Try again");
 
    smartDelay(800);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  refreshTemplateCountSerialOnly();
 
  if (finger.templateCount == 0) {
    addLog("NOT FOUND", -1, "No fingerprints stored");
    oledBig("NOT FOUND", "No saved print");
 
    smartDelay(1600);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  oledScreen("SEARCHING", "Please wait", "Checking...");
 
  // IMPORTANT:
  // fingerSearch(1) is more reliable for your R503.
  // If it fails, fastSearch is tried as fallback.
  p = finger.fingerSearch(1);
 
  if (p != FINGERPRINT_OK) {
    p = finger.fingerFastSearch();
  }
 
  if (p == FINGERPRINT_OK) {
    int id = finger.fingerID;
 
    PersonProfile profile = loadProfile(id);
 
    String fullName = profile.name;
    String displayName = firstNameOnly(fullName);
 
    if (displayName.length() == 0) {
      displayName = "ID " + String(id);
    }
 
    String logName = fullName;
 
    if (logName.length() == 0) {
      logName = "ID " + String(id);
    }
 
    if (id >= 1 && id <= MAX_FINGERPRINTS) {
      if (insideState[id] == false) {
        insideState[id] = true;
        entryCount++;
        lastEntryId = id;
 
        addLog("ENTRY", id, logName + " entered");
 
        oledSuccessAnimation("Entry", displayName);
      } else {
        insideState[id] = false;
        exitCount++;
 
        addLog("EXIT", id, logName + " exited");
 
        oledSuccessAnimation("Exit", displayName);
      }
    } else {
      addLog("MATCH", id, logName + " matched");
      oledSuccessAnimation("Matched", displayName);
    }
 
    smartDelay(1500);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  if (p == FINGERPRINT_NOTFOUND) {
    addLog("NOT FOUND", -1, "No matching fingerprint");
    oledErrorAnimation("Not found", "No match");
 
    smartDelay(1200);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  addLog("ERROR", -1, "Fingerprint search failed");
  oledErrorAnimation("Search failed", "Try again");
 
  smartDelay(1200);
 
  waitForFingerRemove();
  oledScanScreen();
}
 
// =====================================================
// ENROLL FINGERPRINT
// =====================================================
uint8_t enrollFingerprint(uint16_t id) {
  int p = -1;
 
  oledProgressBar("ENROLLING", "Place finger", 10);
 
  while (p != FINGERPRINT_OK) {
    server.handleClient();
 
    if (checkCancelDuringEnroll()) {
      return 255;
    }
 
    p = finger.getImage();
 
    if (p == FINGERPRINT_OK) {
      oledProgressBar("ENROLLING", "Checking", 20);
      smartDelay(500);
      break;
    }
    else if (p == FINGERPRINT_NOFINGER) {
      // waiting
    }
    else {
      oledErrorAnimation("Could not read", "Try again");
      return p;
    }
 
    delay(100);
  }
 
  if (checkCancelDuringEnroll()) {
    return 255;
  }
 
  p = finger.image2Tz(1);
 
  if (p != FINGERPRINT_OK) {
    oledErrorAnimation("Finger not clear", "Try again");
    return p;
  }
 
  refreshTemplateCountSerialOnly();
 
  if (finger.templateCount > 0) {
    oledProgressBar("CHECKING", "Existing", 30);
 
    uint8_t searchResult = finger.fingerSearch(1);
 
    if (searchResult != FINGERPRINT_OK) {
      searchResult = finger.fingerFastSearch();
    }
 
    if (searchResult == FINGERPRINT_OK) {
      duplicateFound = true;
 
      PersonProfile existingProfile = loadProfile(finger.fingerID);
      String existingName = firstNameOnly(existingProfile.name);
 
      if (existingName.length() == 0) {
        existingName = "ID " + String(finger.fingerID);
      }
 
      addLog("DUPLICATE", finger.fingerID, "Already enrolled");
 
      oledBig("ALREADY", existingName);
 
      smartDelay(2500);
 
      waitForFingerRemove();
 
      return 255;
    }
  }
 
  oledProgressBar("ENROLLING", "First scan OK", 40);
  smartDelay(600);
 
  oledProgressBar("ENROLLING", "Remove finger", 50);
  smartDelay(1200);
 
  while (finger.getImage() != FINGERPRINT_NOFINGER) {
    server.handleClient();
 
    if (checkCancelDuringEnroll()) {
      return 255;
    }
 
    delay(100);
  }
 
  smartDelay(500);
 
  p = -1;
 
  oledProgressBar("ENROLLING", "Same finger", 65);
 
  while (p != FINGERPRINT_OK) {
    server.handleClient();
 
    if (checkCancelDuringEnroll()) {
      return 255;
    }
 
    p = finger.getImage();
 
    if (p == FINGERPRINT_OK) {
      oledProgressBar("ENROLLING", "Second OK", 75);
      smartDelay(500);
      break;
    }
    else if (p == FINGERPRINT_NOFINGER) {
      // waiting
    }
    else {
      oledErrorAnimation("Could not read", "Try again");
      return p;
    }
 
    delay(100);
  }
 
  if (checkCancelDuringEnroll()) {
    return 255;
  }
 
  p = finger.image2Tz(2);
 
  if (p != FINGERPRINT_OK) {
    oledErrorAnimation("Finger not clear", "Try again");
    return p;
  }
 
  oledProgressBar("PROCESSING", "Creating", 85);
 
  p = finger.createModel();
 
  if (p == FINGERPRINT_OK) {
    // model created
  }
  else if (p == FINGERPRINT_ENROLLMISMATCH) {
    oledErrorAnimation("Mismatch", "Use same finger");
    return p;
  }
  else {
    oledErrorAnimation("Could not save", "Try again");
    return p;
  }
 
  oledProgressBar("SAVING", "Please wait", 95);
 
  p = finger.storeModel(id);
 
  if (p == FINGERPRINT_OK) {
    oledProgressBar("SAVING", "Complete", 100);
    smartDelay(500);
 
    return FINGERPRINT_OK;
  }
  else {
    oledErrorAnimation("Memory problem", "Not saved");
    return p;
  }
}
 
// =====================================================
// HELPERS
// =====================================================
void waitForFingerRemove() {
  uint8_t p = finger.getImage();
 
  while (p != FINGERPRINT_NOFINGER) {
    server.handleClient();
    delay(100);
    p = finger.getImage();
  }
}
 
uint16_t findNextFreeID(uint16_t startID) {
  if (startID < 1) {
    startID = 1;
  }
 
  for (uint16_t id = startID; id <= MAX_FINGERPRINTS; id++) {
    server.handleClient();
 
    uint8_t p = finger.loadModel(id);
 
    if (p != FINGERPRINT_OK) {
      return id;
    }
  }
 
  return MAX_FINGERPRINTS;
}
 
void refreshTemplateCountSerialOnly() {
  uint8_t p = finger.getTemplateCount();
 
  if (p == FINGERPRINT_OK) {
    cachedTemplateCount = finger.templateCount;
 
    Serial.print("Stored fingerprints: ");
    Serial.println(cachedTemplateCount);
  } else {
    Serial.println("Could not read template count.");
  }
 
  delay(20);
}
 
void refreshTemplateCountCache() {
  uint8_t p = finger.getTemplateCount();
 
  if (p == FINGERPRINT_OK) {
    cachedTemplateCount = finger.templateCount;
  }
 
  delay(20);
}
 
void rebuildRegisteredCache() {
  String json = "{\"fingerprints\":[";
  bool first = true;
 
  for (uint16_t id = 1; id <= MAX_FINGERPRINTS; id++) {
    server.handleClient();
 
    uint8_t p = finger.loadModel(id);
 
    if (p == FINGERPRINT_OK) {
      PersonProfile profile = loadProfile(id);
 
      if (!first) {
        json += ",";
      }
 
      json += "{";
      json += "\"id\":" + String(id) + ",";
      json += "\"name\":\"" + jsonEscape(profile.name) + "\",";
      json += "\"collegeId\":\"" + jsonEscape(profile.collegeId) + "\",";
      json += "\"role\":\"" + jsonEscape(profile.role) + "\",";
      json += "\"department\":\"" + jsonEscape(profile.department) + "\"";
      json += "}";
 
      first = false;
    }
  }
 
  json += "]}";
 
  cachedRegisteredJson = json;
}
 
void setMatchingSecuritySerialOnly() {
  Serial.print("Setting security level to ");
  Serial.println(MATCH_SECURITY_LEVEL);
 
  uint8_t p = finger.setSecurityLevel(MATCH_SECURITY_LEVEL);
 
  if (p == FINGERPRINT_OK) {
    Serial.println("Security level updated.");
  } else {
    Serial.println("Could not update security level.");
  }
 
  delay(100);
 
  finger.getParameters();
 
  Serial.print("Current security level: ");
  Serial.println(finger.security_level);
}
 
void printSensorParameters() {
  finger.getParameters();
 
  Serial.println();
  Serial.println("Sensor parameters:");
 
  Serial.print("Status: 0x");
  Serial.println(finger.status_reg, HEX);
 
  Serial.print("System ID: 0x");
  Serial.println(finger.system_id, HEX);
 
  Serial.print("Capacity: ");
  Serial.println(finger.capacity);
 
  Serial.print("Security level: ");
  Serial.println(finger.security_level);
 
  Serial.print("Device address: ");
  Serial.println(finger.device_addr, HEX);
 
  Serial.print("Packet length: ");
  Serial.println(finger.packet_len);
 
  Serial.print("Baud rate: ");
  Serial.println(finger.baud_rate);
 
  Serial.println();
}



dashboard.h code

Here is the header file for the project. You need to create another tab on Arduino IDE editor window and paste the following code that consist of webpage files.

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
#ifndef DASHBOARD_H
#define DASHBOARD_H
 
#include <Arduino.h>
 
const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Fingerprint Attendance Dashboard</title>
<style>
:root{--bg:#eef3fb;--card:#fff;--text:#0f172a;--muted:#64748b;--line:#dbe3ef;--blue:#2563eb;--green:#16a34a;--red:#dc2626;--amber:#f59e0b;--dark:#0f172a;--shadow:0 20px 60px rgba(15,23,42,.12)}
*{box-sizing:border-box}
body{margin:0;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;background:radial-gradient(circle at 0% 0%,rgba(37,99,235,.13),transparent 30rem),radial-gradient(circle at 100% 0%,rgba(22,163,74,.10),transparent 28rem),var(--bg);color:var(--text)}
.app{display:grid;grid-template-columns:270px 1fr;min-height:100vh}
.sidebar{background:#0f172a;color:#fff;padding:24px 18px}
.brand{display:flex;align-items:center;gap:12px;margin-bottom:28px}
.logo{width:48px;height:48px;border-radius:16px;background:linear-gradient(135deg,#38bdf8,#2563eb);display:grid;place-items:center;font-size:26px;box-shadow:0 16px 34px rgba(37,99,235,.35)}
.brand h1{font-size:19px;margin:0;line-height:1.1}.brand p{margin:4px 0 0;color:#94a3b8;font-size:12px}
.nav button{width:100%;display:flex;gap:10px;align-items:center;background:transparent;color:#cbd5e1;border:0;border-radius:14px;padding:13px 14px;font-weight:800;cursor:pointer;margin:6px 0;text-align:left}
.nav button:hover,.nav button.active{background:rgba(255,255,255,.12);color:#fff}
.device{margin-top:28px;padding:16px;border-radius:18px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.10);color:#cbd5e1;font-size:13px;line-height:1.7}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--red);box-shadow:0 0 0 5px rgba(220,38,38,.12)}.dot.ok{background:var(--green);box-shadow:0 0 0 5px rgba(22,163,74,.14)}
.main{padding:28px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:20px}.topbar h2{margin:0;font-size:34px;letter-spacing:-.04em}.topbar p{margin:6px 0 0;color:var(--muted)}
.actions{display:flex;gap:10px;flex-wrap:wrap}
button,.btn{border:0;border-radius:14px;padding:12px 16px;font-weight:900;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:8px}
.btn-primary{background:var(--blue);color:white}.btn-green{background:var(--green);color:white}.btn-red{background:var(--red);color:white}.btn-mini-red{background:var(--red);color:white;border-radius:10px;padding:8px 10px;font-size:12px}.btn-mini-red:hover{background:#b91c1c}
.btn-dark{background:var(--dark);color:white}.btn-soft{background:white;color:var(--text);border:1px solid var(--line)}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:18px}.card{background:var(--card);border:1px solid rgba(255,255,255,.65);border-radius:24px;box-shadow:var(--shadow);overflow:hidden}.pad{padding:20px}
.metric{grid-column:span 3}.metric small{display:block;color:var(--muted);font-weight:900;text-transform:uppercase;letter-spacing:.1em;margin-bottom:10px}.metric strong{display:block;font-size:38px;letter-spacing:-.05em}.metric span{color:var(--muted);font-size:13px}
.hero{grid-column:span 12;min-height:260px;background:linear-gradient(135deg,#0f172a,#1e3a8a 55%,#2563eb);color:#fff}.control{grid-column:span 4}
.hero-inner{display:flex;align-items:center;gap:28px;height:100%}.finger{width:150px;height:150px;border-radius:42px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.18);display:grid;place-items:center;position:relative;overflow:hidden;flex:0 0 auto}
.finger:before{content:"";position:absolute;left:20px;right:20px;height:4px;border-radius:999px;background:#22c55e;box-shadow:0 0 24px rgba(34,197,94,.8);animation:scan 1.3s linear infinite}@keyframes scan{0%{top:22px}50%{top:124px}100%{top:22px}}
.finger .icon{font-size:74px;z-index:1}.hero h3{font-size:32px;margin:0 0 8px;letter-spacing:-.04em}.hero p{margin:0;color:#cbd5e1;line-height:1.6}
.pills{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}.pill{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.12);border-radius:999px;padding:8px 11px;font-size:13px;color:#e2e8f0;font-weight:800}
.form-group{margin-bottom:13px}.form-group label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);font-weight:900;margin-bottom:6px}
input,select,textarea{width:100%;border:1px solid var(--line);border-radius:14px;padding:12px 13px;font-size:14px;outline:none;background:#fff}textarea{min-height:72px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--blue);box-shadow:0 0 0 4px rgba(37,99,235,.12)}
.table-card{grid-column:span 12}.table-header{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 20px;border-bottom:1px solid var(--line);flex-wrap:wrap}.table-header h3{margin:0}.table-header p{margin:4px 0 0;color:var(--muted);font-size:13px}
.table-wrap{overflow:auto;max-height:460px}table{width:100%;border-collapse:collapse;min-width:850px}th,td{padding:14px 18px;border-bottom:1px solid var(--line);text-align:left;font-size:14px}th{background:#f8fafc;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}tbody tr:hover{background:#f8fafc}
.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;font-weight:900}.b-entry{background:#dcfce7;color:#166534}.b-exit{background:#dbeafe;color:#1e40af}.b-enrolled{background:#ede9fe;color:#5b21b6}.b-error{background:#fee2e2;color:#991b1b}.b-info{background:#fef3c7;color:#92400e}
.status-strip{margin-top:16px;padding:12px 14px;border-radius:16px;background:#f8fafc;border:1px solid var(--line);color:var(--muted);font-size:13px}.page{display:none}.page.active{display:block}
.live-modal{position:fixed;inset:0;z-index:1000;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(15,23,42,.40);backdrop-filter:blur(10px)}.live-modal.show{display:flex;animation:fadeIn .18s ease}
.live-card{width:min(560px,96vw);background:white;border-radius:32px;box-shadow:0 40px 110px rgba(15,23,42,.35);padding:34px;text-align:center;position:relative;overflow:hidden}.live-card:before{content:"";position:absolute;width:210px;height:210px;border-radius:50%;right:-80px;top:-90px;background:rgba(22,163,74,.12)}.live-card.error:before{background:rgba(220,38,38,.12)}.live-card.info:before{background:rgba(37,99,235,.12)}
.big-icon{width:118px;height:118px;border-radius:50%;margin:0 auto 18px;display:grid;place-items:center;font-size:56px;font-weight:950;color:white;background:var(--green);position:relative;animation:pop .34s ease}.live-card.error .big-icon{background:var(--red)}.live-card.info .big-icon{background:var(--blue)}
.big-icon:after{content:"";position:absolute;inset:-10px;border-radius:50%;border:3px solid rgba(22,163,74,.20);animation:pulse 1.25s ease infinite}.live-card.error .big-icon:after{border-color:rgba(220,38,38,.20)}.live-card.info .big-icon:after{border-color:rgba(37,99,235,.20)}
.live-card h2{margin:0 0 8px;font-size:32px;letter-spacing:-.05em}.live-card p{margin:0;color:var(--muted);line-height:1.6}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:20px;text-align:left}.detail{border:1px solid var(--line);background:#f8fafc;border-radius:16px;padding:12px 14px}.detail span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em;font-weight:900;margin-bottom:4px}.detail strong{color:var(--text)}
.confetti{position:absolute;width:8px;height:14px;border-radius:3px;animation:fall 1.35s ease forwards}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes pop{from{transform:scale(.72);opacity:0}to{transform:scale(1);opacity:1}}@keyframes pulse{from{transform:scale(.9);opacity:1}to{transform:scale(1.25);opacity:0}}@keyframes fall{from{transform:translateY(-100px) rotate(0deg);opacity:1}to{transform:translateY(260px) rotate(260deg);opacity:0}}
.banner{position:fixed;right:22px;top:22px;z-index:1001;width:min(430px,calc(100vw - 44px));display:none;align-items:center;gap:14px;background:white;border-radius:22px;border:1px solid var(--line);border-left:6px solid var(--blue);box-shadow:0 24px 70px rgba(15,23,42,.20);padding:16px 18px}.banner.show{display:flex;animation:slide .2s ease}.banner.success{border-left-color:var(--green)}.banner.error{border-left-color:var(--red)}.banner-icon{width:48px;height:48px;border-radius:16px;display:grid;place-items:center;background:#eff6ff;color:var(--blue);font-size:24px;font-weight:950}.banner.success .banner-icon{background:#dcfce7;color:var(--green)}.banner.error .banner-icon{background:#fee2e2;color:var(--red)}.banner strong{display:block;margin-bottom:3px}.banner span{display:block;color:var(--muted);font-size:13px;line-height:1.35}@keyframes slide{from{transform:translateY(-12px);opacity:0}to{transform:translateY(0);opacity:1}}
 
/* ===== Improved Register Fingerprint UI ===== */
.register-layout{
  display:grid;
  grid-template-columns:1fr;
  gap:10px;
  grid-column:span 12;
}
.register-hero-card{
  background:
    radial-gradient(circle at 12% 0%,rgba(37,99,235,.16),transparent 36%),
    linear-gradient(135deg,#ffffff,#f8fbff);
  border:1px solid rgba(255,255,255,.78);
  border-radius:28px;
  box-shadow:var(--shadow);
  overflow:hidden;
}
.register-head{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:16px;
  padding:16px 20px;
  border-bottom:1px solid var(--line);
}
.register-title-wrap{
  display:flex;
  align-items:center;
  gap:14px;
}
.register-icon{
  width:56px;
  height:56px;
  border-radius:18px;
  display:grid;
  place-items:center;
  background:linear-gradient(135deg,#2563eb,#38bdf8);
  color:white;
  font-size:28px;
  box-shadow:0 16px 36px rgba(37,99,235,.22);
}
.register-head h2{
  margin:0;
  font-size:26px;
  letter-spacing:-.04em;
}
.register-head p{
  margin:5px 0 0;
  color:var(--muted);
  line-height:1.45;
}
.enroll-status-pill{
  display:inline-flex;
  align-items:center;
  gap:8px;
  border-radius:999px;
  background:#eff6ff;
  color:#1d4ed8;
  border:1px solid #bfdbfe;
  padding:9px 12px;
  font-size:13px;
  font-weight:900;
  white-space:nowrap;
}
.enroll-status-dot{
  width:9px;
  height:9px;
  border-radius:50%;
  background:#2563eb;
  box-shadow:0 0 0 5px rgba(37,99,235,.12);
}
.register-steps{
  display:grid;
  grid-template-columns:repeat(3,1fr);
  gap:10px;
  padding:12px 20px 0;
}
.reg-step{
  display:flex;
  align-items:center;
  gap:10px;
  padding:12px;
  border-radius:16px;
  border:1px solid var(--line);
  background:#f8fafc;
  color:var(--muted);
  font-size:13px;
  font-weight:900;
}
.reg-step-num{
  width:28px;
  height:28px;
  border-radius:10px;
  display:grid;
  place-items:center;
  background:#e2e8f0;
  color:#475569;
}
.reg-step.active{
  background:#eff6ff;
  color:#1d4ed8;
  border-color:#bfdbfe;
}
.reg-step.active .reg-step-num{
  background:#2563eb;
  color:white;
}
.reg-step.done{
  background:#f0fdf4;
  color:#166534;
  border-color:#bbf7d0;
}
.reg-step.done .reg-step-num{
  background:#16a34a;
  color:white;
}
.register-form{
  padding:14px 20px 18px;
}
.form-grid-register{
  display:grid;
  grid-template-columns:repeat(4,minmax(0,1fr));
  gap:10px;
}
.form-group.full{
  grid-column:1/-1;
}
.input-with-icon{
  position:relative;
}
.input-with-icon .field-icon{
  position:absolute;
  left:13px;
  top:50%;
  transform:translateY(-50%);
  color:#94a3b8;
  font-size:16px;
  pointer-events:none;
}
.input-with-icon input,
.input-with-icon select{
  padding-left:40px;
}
.register-form textarea{
  min-height:64px;
}
.form-help{
  display:flex;
  align-items:flex-start;
  gap:10px;
  margin-top:10px;
  padding:13px 14px;
  border-radius:18px;
  background:#f8fafc;
  border:1px solid var(--line);
  color:var(--muted);
  font-size:13px;
  line-height:1.45;
}
.form-help b{color:var(--text)}
.register-actions{
  display:flex;
  gap:10px;
  flex-wrap:wrap;
  margin-top:12px;
}
.register-actions button{
  min-width:170px;
}
.preview-card{
  background:linear-gradient(180deg,#ffffff,#f8fafc);
  border:1px solid rgba(255,255,255,.78);
  border-radius:28px;
  box-shadow:var(--shadow);
  overflow:hidden;
}
.preview-top{
  padding:22px;
  background:linear-gradient(135deg,#0f172a,#1e3a8a);
  color:white;
}
.preview-top h3{
  margin:0;
  font-size:22px;
  letter-spacing:-.03em;
}
.preview-top p{
  margin:7px 0 0;
  color:#cbd5e1;
  line-height:1.5;
}
.student-card-preview{
  margin:22px;
  padding:20px;
  border:1px solid var(--line);
  border-radius:22px;
  background:white;
}
.avatar-large{
  width:74px;
  height:74px;
  border-radius:24px;
  display:grid;
  place-items:center;
  background:linear-gradient(135deg,#dbeafe,#e0e7ff);
  color:#1d4ed8;
  font-size:25px;
  font-weight:950;
  margin-bottom:14px;
}
.preview-name{
  font-size:22px;
  font-weight:950;
  letter-spacing:-.035em;
  margin-bottom:4px;
}
.preview-sub{
  color:var(--muted);
  font-size:14px;
  margin-bottom:14px;
}
.preview-row{
  display:flex;
  justify-content:space-between;
  gap:12px;
  padding:10px 0;
  border-top:1px solid var(--line);
  color:var(--muted);
  font-size:13px;
}
.preview-row strong{
  color:var(--text);
  text-align:right;
}
.sensor-guide{
  margin:0 22px 22px;
  padding:18px;
  border-radius:22px;
  background:#ecfdf5;
  border:1px solid #bbf7d0;
}
.sensor-guide-title{
  display:flex;
  align-items:center;
  gap:10px;
  font-weight:950;
  color:#166534;
  margin-bottom:8px;
}
.sensor-guide p{
  margin:0;
  color:#166534;
  line-height:1.55;
  font-size:13px;
}
.scan-mini{
  width:44px;
  height:44px;
  border-radius:14px;
  display:grid;
  place-items:center;
  background:#dcfce7;
  position:relative;
  overflow:hidden;
  flex:0 0 auto;
}
.scan-mini:before{
  content:"";
  position:absolute;
  left:7px;
  right:7px;
  height:3px;
  border-radius:99px;
  background:#16a34a;
  animation:miniScan 1.25s linear infinite;
}
@keyframes miniScan{
  0%{top:9px}
  50%{top:33px}
  100%{top:9px}
}
.register-warning{
  display:none;
  margin-top:12px;
  padding:12px 14px;
  border-radius:16px;
  background:#fff7ed;
  border:1px solid #fed7aa;
  color:#9a3412;
  font-size:13px;
  line-height:1.45;
}
.register-warning.show{display:block}
@media(max-width:1050px){
  .register-layout{grid-template-columns:1fr}
}
@media(max-width:1100px){.form-grid-register{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){
  .register-head{align-items:flex-start;flex-direction:column}
  .register-steps{grid-template-columns:1fr}
  .form-grid-register{grid-template-columns:1fr}
  .register-actions button{width:100%}
}
 
 
.sensor-sync-card{
  margin:14px 20px 0;
  padding:14px 16px;
  border-radius:18px;
  background:#eff6ff;
  border:1px solid #bfdbfe;
  color:#1e40af;
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:12px;
  flex-wrap:wrap;
  font-size:13px;
}
.sensor-sync-card strong{color:#1e3a8a}
 
@media(max-width:950px){.app{grid-template-columns:1fr}.metric,.hero,.control{grid-column:span 12}.hero-inner{flex-direction:column;text-align:center}}@media(max-width:560px){.main{padding:18px}.topbar{flex-direction:column;align-items:stretch}.actions button,.actions .btn{width:100%}.detail-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
  <aside class="sidebar">
    <div class="brand"><div class="logo">⌁</div><div><h1>BioTrack Pro</h1><p>Fingerprint attendance system</p></div></div>
    <nav class="nav">
      <button class="active" onclick="showPage('dashboard',this)">📊 Dashboard</button>
      <button onclick="showPage('register',this)">➕ Register</button>
      <button onclick="showPage('history',this)">🕒 Attendance History</button>
      <button onclick="showPage('records',this)">👥 Records</button>
    </nav>
    <div class="device">
      <b>ESP32 Dashboard</b><br>
      WiFi: <span id="wifiText">Checking...</span> <span id="wifiDot" class="dot"></span><br>
      IP: <span id="ipText">--</span><br>
      Last API update: <span id="apiTime">--</span>
    </div>
  </aside>
  <main class="main">
    <header class="topbar">
      <div><h2 id="pageTitle">Attendance Dashboard</h2><p id="pageSubtitle">Live fingerprint registration, entry, exit, and records.</p></div>
      <div class="actions">
        <button id="soundToggleBtn" class="btn-soft" onclick="toggleSound()">🔇 Enable Sound</button>
        <button class="btn-green" onclick="exportAttendanceCsv()">Export Attendance CSV</button>
      </div>
    </header>
    <section id="page-dashboard" class="page active">
      <div class="grid">
        <div class="card pad hero">
          <div class="hero-inner">
            <div class="finger"><div class="icon">⌁</div></div>
            <div>
              <h3 id="heroTitle">Waiting for fingerprint scan</h3>
              <p id="heroMessage">When someone scans or when registration completes, this dashboard will show a popup, update this display, and play a sound.</p>
              <div class="pills"><span class="pill" id="lastEventPill">No event yet</span><span class="pill" id="lastPersonPill">No user</span><span class="pill" id="lastTimePill">--:--:--</span></div>
            </div>
          </div>
        </div>
        <div class="card table-card">
          <div class="table-header"><div><h3>Live Attendance Feed</h3><p>Newest scans and registration events.</p></div><button class="btn-soft" onclick="refreshNow(true)">Refresh Now</button></div>
          <div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Finger ID</th><th>Name</th><th>College ID</th><th>Message</th></tr></thead><tbody id="liveBody"></tbody></table></div>
          <div class="status-strip" id="debugStrip">Waiting for dashboard data...</div>
        </div>
      </div>
    </section>
    <section id="page-register" class="page">
      <div class="grid">
        <div class="card pad metric"><small>Total Enrolled</small><strong id="totalEnrolled">--</strong><span>Fingerprints stored</span></div>
        <div class="card pad metric"><small>Entries</small><strong id="entryCount">0</strong><span>People entered</span></div>
        <div class="card pad metric"><small>Exits</small><strong id="exitCount">0</strong><span>People exited</span></div>
        <div class="card pad metric"><small>Next ID</small><strong id="nextId">--</strong><span>Next enrollment slot</span></div>
 
        <div class="register-layout">
          <div class="register-hero-card">
            <div class="register-head">
              <div class="register-title-wrap">
                <div class="register-icon">⌁</div>
                <div>
                  <h2>Register Fingerprint</h2>
                  <p>Add student/staff details, then enroll their finger on the R503 sensor.</p>
                </div>
              </div>
              <div class="enroll-status-pill"><span class="enroll-status-dot"></span><span id="registerStatusText">Ready</span></div>
            </div>
 
            <div class="register-steps">
              <div class="reg-step active" id="regStepInfo"><span class="reg-step-num">1</span>Person Details</div>
              <div class="reg-step" id="regStepScan"><span class="reg-step-num">2</span>Finger Scan</div>
              <div class="reg-step" id="regStepSave"><span class="reg-step-num">3</span>Save Record</div>
            </div>
 
            <div class="register-form">
              <div class="form-grid-register">
                <div class="form-group">
                  <label>Name</label>
                  <div class="input-with-icon"><span class="field-icon">👤</span><input id="enrollName" placeholder="Full name" oninput="updateRegisterPreview()"></div>
                </div>
 
                <div class="form-group">
                  <label>College ID</label>
                  <div class="input-with-icon"><span class="field-icon">🪪</span><input id="enrollCollegeId" placeholder="Student / staff ID" oninput="updateRegisterPreview()"></div>
                </div>
 
                <div class="form-group">
                  <label>Role</label>
                  <div class="input-with-icon">
                    <span class="field-icon">🎓</span>
                    <select id="enrollRole" onchange="updateRegisterPreview()">
                      <option>Student</option>
                      <option>Staff</option>
                      <option>Faculty</option>
                      <option>Visitor</option>
                    </select>
                  </div>
                </div>
 
                <div class="form-group">
                  <label>Department</label>
                  <div class="input-with-icon"><span class="field-icon">🏫</span><input id="enrollDept" placeholder="Department / Program" oninput="updateRegisterPreview()"></div>
                </div>
 
                <div class="form-group full">
                  <label>Notes</label>
                  <textarea id="enrollNote" placeholder="Optional notes, class, lab group, or remarks" oninput="updateRegisterPreview()"></textarea>
                </div>
              </div>
 
              <div id="registerWarning" class="register-warning">Please enter at least a name or college ID before starting registration.</div>
 
              <div class="register-actions">
                <button class="btn-green" onclick="startEnroll('full')">Start Registration</button>
                <button class="btn-soft" onclick="clearRegisterForm()">Clear Form</button>
                <button class="btn-red" onclick="cancelEnroll()">Cancel Enrollment</button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
    <section id="page-history" class="page">
      <div class="card table-card">
        <div class="table-header">
          <div>
            <h3>Attendance History</h3>
            <p>Student/staff in and out records generated from fingerprint scans.</p>
          </div>
        </div>
        <div class="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Name</th>
                <th>College ID</th>
                <th>Role</th>
                <th>Department</th>
                <th>Finger ID</th>
                <th>Login Time</th>
                <th>Logout Time</th>
                <th>Status</th>
              </tr>
            </thead>
            <tbody id="historyBody"></tbody>
          </table>
        </div>
      </div>
    </section>
 
    <section id="page-records" class="page">
      <div class="card table-card">
        <div class="table-header">
          <div>
            <h3>Student / Staff Records</h3>
            <p>Registered people and their assigned fingerprint IDs.</p>
          </div>
          <button class="btn-soft" onclick="refreshNow(true)">Refresh</button>
        </div>
        <div class="sensor-sync-card">
          <div><strong>Sensor Sync:</strong> Records are loaded from the R503 sensor memory through ESP32. IDs without saved names are shown as unassigned fingerprints.</div>
          <div>Registered IDs found: <strong id="registeredCount">--</strong></div>
        </div>
        <div class="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Finger ID</th>
                <th>Name</th>
                <th>College ID</th>
                <th>Role</th>
                <th>Department</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody id="recordsBody"></tbody>
          </table>
        </div>
      </div>
    </section>
  </main>
</div>
<div id="banner" class="banner"><div id="bannerIcon" class="banner-icon">i</div><div><strong id="bannerTitle">Message</strong><span id="bannerText">Details</span></div></div>
<div id="liveModal" class="live-modal"><div id="liveCard" class="live-card"><div id="modalIcon" class="big-icon">✓</div><h2 id="modalTitle">Success</h2><p id="modalMessage">Action completed successfully.</p><div class="detail-grid"><div class="detail"><span>Name</span><strong id="modalName">-</strong></div><div class="detail"><span>Finger ID</span><strong id="modalId">-</strong></div><div class="detail"><span>College ID</span><strong id="modalCollege">-</strong></div><div class="detail"><span>Department</span><strong id="modalDept">-</strong></div></div><div style="margin-top:22px"><button class="btn-primary" onclick="closeModal()">Done</button></div></div></div>
<script>
let state={},logs=[],registeredFingerprints=[],lastSeenKey="",firstLoad=true,soundEnabled=false;
function showPage(name,btn){
  document.querySelectorAll(".page").forEach(p=>p.classList.remove("active"));
  document.getElementById("page-"+name).classList.add("active");
  document.querySelectorAll(".nav button").forEach(b=>b.classList.remove("active"));
  if(btn)btn.classList.add("active");
 
  if(name==="dashboard"){
    pageTitle.textContent="Attendance Dashboard";
    pageSubtitle.textContent="Live fingerprint registration, entry, exit, and records.";
  }else if(name==="register"){
    pageTitle.textContent="Register Fingerprint";
    pageSubtitle.textContent="Register a person and enroll their fingerprint.";
  }else if(name==="history"){
    pageTitle.textContent="Attendance History";
    pageSubtitle.textContent="View student/staff in and out attendance records.";
  }else{
    pageTitle.textContent="Student / Staff Records";
    pageSubtitle.textContent="Registered people and their assigned fingerprint IDs.";
  }
}
async function api(path,options={}){const res=await fetch(path,options);if(!res.ok){const txt=await res.text();throw new Error(txt||("HTTP "+res.status))}return res.json()}
function toggleSound(){
  soundEnabled = !soundEnabled;
 
  const btn = document.getElementById("soundToggleBtn");
 
  if(soundEnabled){
    if(btn) btn.textContent = "🔊 Disable Sound";
    beep("info");
    showBanner("info", "Sound Enabled", "Dashboard sounds are now active.");
  }else{
    if(btn) btn.textContent = "🔇 Enable Sound";
    showBanner("info", "Sound Disabled", "Dashboard sounds are now off.");
  }
}
function beep(type="info"){if(!soundEnabled)return;try{const C=window.AudioContext||window.webkitAudioContext;const ctx=new C();const osc=ctx.createOscillator();const gain=ctx.createGain();osc.connect(gain);gain.connect(ctx.destination);osc.frequency.value=type==="success"?880:type==="error"?180:520;gain.gain.setValueAtTime(.0001,ctx.currentTime);gain.gain.exponentialRampToValueAtTime(.15,ctx.currentTime+.02);gain.gain.exponentialRampToValueAtTime(.0001,ctx.currentTime+.18);osc.start();osc.stop(ctx.currentTime+.2)}catch(e){}}
function showBanner(type,title,text){bannerTitle.textContent=title;bannerText.textContent=text||"";bannerIcon.textContent=type==="success"?"✓":type==="error"?"!":"i";banner.className="banner show "+(type==="success"?"success":type==="error"?"error":"");setTimeout(()=>banner.className="banner",4500)}
function showModal(type,title,message,data={}){liveCard.className="live-card "+(type==="error"?"error":type==="info"?"info":"");modalIcon.textContent=type==="error"?"!":type==="info"?"i":"✓";modalTitle.textContent=title;modalMessage.textContent=message||"";modalName.textContent=data.name||"-";modalId.textContent=data.id>=0?data.id:"-";modalCollege.textContent=data.collegeId||"-";modalDept.textContent=data.department||"-";createConfetti(type);liveModal.classList.add("show");setTimeout(()=>closeModal(),6000)}
function closeModal(){liveModal.classList.remove("show")}
function createConfetti(type){liveCard.querySelectorAll(".confetti").forEach(e=>e.remove());if(type==="error")return;const colors=["#2563eb","#16a34a","#f59e0b","#7c3aed","#ef4444","#06b6d4"];for(let i=0;i<24;i++){const c=document.createElement("div");c.className="confetti";c.style.left=(15+Math.random()*80)+"%";c.style.top=(-10-Math.random()*40)+"px";c.style.background=colors[i%colors.length];c.style.animationDelay=(Math.random()*0.35)+"s";liveCard.appendChild(c)}}
function eventType(event){event=(event||"").toUpperCase();if(event.includes("ENTRY")||event.includes("MATCH"))return"entry";if(event.includes("EXIT"))return"exit";if(event.includes("ENROLLED")||event.includes("SAVED"))return"enrolled";if(event.includes("ERROR")||event.includes("NOT")||event.includes("DUPLICATE"))return"error";return"info"}
function eventBadge(event){const t=eventType(event);if(t==="entry")return"b-entry";if(t==="exit")return"b-exit";if(t==="enrolled")return"b-enrolled";if(t==="error")return"b-error";return"b-info"}
function eventKey(l){if(!l)return"";return[l.time,l.event,l.id,l.message].join("|")}
function personName(l){return l.name||(l.id>=0?("ID "+l.id):"System")}
function handleNewEvent(l){if(!l)return;const type=eventType(l.event);const name=personName(l);const msg=l.message||"";lastEventPill.textContent=l.event||"INFO";lastPersonPill.textContent=name;lastTimePill.textContent=l.time||"--:--:--";if(type==="enrolled"){setRegisterStep(3);if(typeof registerStatusText!=="undefined")registerStatusText.textContent="Saved successfully";heroTitle.textContent="Registration Successful";heroMessage.textContent=name+" has been enrolled successfully.";showBanner("success","Registration Successful",name+" has been enrolled successfully.");showModal("success","Registration Successful",name+" has been enrolled successfully.",l);beep("success")}else if(type==="entry"){heroTitle.textContent="Welcome, "+name;heroMessage.textContent=msg||"Attendance entry logged successfully.";showBanner("success","Welcome, "+name,msg||"Attendance entry logged.");showModal("success","Welcome, "+name,msg||"Attendance entry logged successfully.",l);beep("success")}else if(type==="exit"){heroTitle.textContent="Goodbye, "+name;heroMessage.textContent=msg||"Exit logged successfully.";showBanner("info","Exit Logged",name+" exited.");showModal("info","Exit Logged",name+" exited.",l);beep("info")}else if(type==="error"){if(typeof registerStatusText!=="undefined")registerStatusText.textContent="Needs attention";heroTitle.textContent="Attention Required";heroMessage.textContent=msg||l.event||"Something went wrong.";showBanner("error",l.event||"Error",msg||"Please try again.");showModal("error",l.event||"Error",msg||"Please try again.",l);beep("error")}else if((l.event||"").toUpperCase().includes("ENROLL")){setRegisterStep(2);if(typeof registerStatusText!=="undefined")registerStatusText.textContent="Scanning finger";heroTitle.textContent="Enrollment In Progress";heroMessage.textContent="Place finger on the sensor and follow the OLED instructions.";showBanner("info","Enrollment Started","Place finger on the R503 sensor.");beep("info")}else{heroTitle.textContent=l.event||"System Update";heroMessage.textContent=msg||"Dashboard received a new event.";showBanner("info",l.event||"System Update",msg||"New dashboard event.")}}
function escapeHtml(v){
  return String(v ?? "").replace(/[&<>"']/g, ch => ({
    "&":"&amp;",
    "<":"&lt;",
    ">":"&gt;",
    "\"":"&quot;",
    "'":"&#39;"
  }[ch]));
}
 
function buildPeopleRecords(){
  const people = {};
 
  // IMPORTANT:
  // Records page must be based ONLY on fingerprints currently stored in the R503 sensor.
  // Old logs are used only to enrich name/college/role/department for those existing IDs.
  // This prevents a deleted fingerprint from reappearing just because old logs still exist.
  registeredFingerprints.forEach(fp => {
    const id = Number(fp.id);
    if(id < 0) return;
 
    people[id] = {
      id:id,
      name:fp.name || "",
      collegeId:fp.collegeId || "",
      role:fp.role || "",
      department:fp.department || "",
      lastSeen:"",
      status:fp.name || fp.collegeId ? "Registered" : "Unassigned"
    };
  });
 
  logs.forEach(l => {
    const id = Number(l.id);
    if(id < 0) return;
 
    // Do not recreate deleted fingerprints from old logs.
    if(!people[id]) return;
 
    if(l.name) people[id].name = l.name;
    if(l.collegeId) people[id].collegeId = l.collegeId;
    if(l.role) people[id].role = l.role;
    if(l.department) people[id].department = l.department;
 
    const ev = (l.event || "").toUpperCase();
 
    if(ev.includes("ENROLLED") || ev.includes("SAVED")){
      people[id].status = "Registered";
      people[id].lastSeen = l.time || people[id].lastSeen;
    }
 
    if(ev.includes("ENTRY") || ev.includes("MATCH")){
      people[id].status = "Inside";
      people[id].lastSeen = l.time || people[id].lastSeen;
    }
 
    if(ev.includes("EXIT")){
      people[id].status = "Exited";
      people[id].lastSeen = l.time || people[id].lastSeen;
    }
  });
 
  return Object.values(people).sort((a,b)=>a.id-b.id);
}
 
function buildAttendanceHistory(){
  const openEntries = {};
  const rows = [];
 
  logs.forEach(l => {
    const id = Number(l.id);
    if(id < 0) return;
 
    const ev = (l.event || "").toUpperCase();
 
    const base = {
      id:id,
      name:l.name || ("ID " + id),
      collegeId:l.collegeId || "-",
      role:l.role || "-",
      department:l.department || "-",
      inTime:"",
      outTime:"",
      status:""
    };
 
    // Every new ENTRY/MATCH is treated as a new attendance line.
    // If there was already an open entry without an exit, keep that old line
    // instead of overwriting it.
    if(ev.includes("ENTRY") || ev.includes("MATCH")){
      if(openEntries[id]){
        rows.push(openEntries[id]);
      }
      openEntries[id] = {...base, inTime:l.time || "", outTime:"", status:"Inside"};
    }else if(ev.includes("EXIT")){
      if(openEntries[id]){
        openEntries[id].outTime = l.time || "";
        openEntries[id].status = "Completed";
        rows.push(openEntries[id]);
        delete openEntries[id];
      }else{
        rows.push({...base, inTime:"-", outTime:l.time || "", status:"Exit only"});
      }
    }
  });
 
  Object.keys(openEntries).forEach(id => rows.push(openEntries[id]));
 
  // Newest attendance line first.
  return rows.reverse();
}
 
function renderTables(){
  const liveRows = logs.slice().reverse().map(l => `
    <tr>
      <td>${escapeHtml(l.time || "")}</td>
      <td><span class="badge ${eventBadge(l.event)}">${escapeHtml(l.event || "INFO")}</span></td>
      <td>${l.id >= 0 ? l.id : "-"}</td>
      <td>${escapeHtml(l.name || "-")}</td>
      <td>${escapeHtml(l.collegeId || "-")}</td>
      <td>${escapeHtml(l.message || "")}</td>
    </tr>
  `).join("");
 
  liveBody.innerHTML = liveRows || `<tr><td colspan="6">No logs received yet.</td></tr>`;
 
  const historyRows = buildAttendanceHistory().map(r => `
    <tr>
      <td>${escapeHtml(r.name)}</td>
      <td>${escapeHtml(r.collegeId)}</td>
      <td>${escapeHtml(r.role)}</td>
      <td>${escapeHtml(r.department)}</td>
      <td>${r.id}</td>
      <td>${escapeHtml(r.inTime || "-")}</td>
      <td>${escapeHtml(r.outTime || "-")}</td>
      <td><span class="badge ${r.status==="Inside" ? "b-entry" : r.status==="Completed" ? "b-exit" : "b-info"}">${escapeHtml(r.status)}</span></td>
    </tr>
  `).join("");
 
  if(typeof historyBody !== "undefined"){
    historyBody.innerHTML = historyRows || `<tr><td colspan="8">No login/logout attendance history yet.</td></tr>`;
  }
 
  const peopleRows = buildPeopleRecords().map(p => `
    <tr>
      <td>${p.id}</td>
      <td>${escapeHtml(p.name || "-")}</td>
      <td>${escapeHtml(p.collegeId || "-")}</td>
      <td>${escapeHtml(p.role || "-")}</td>
      <td>${escapeHtml(p.department || "-")}</td>
      <td><button class="btn-mini-red" onclick="deleteFingerprint(${p.id})">Delete</button></td>
    </tr>
  `).join("");
 
  recordsBody.innerHTML = peopleRows || `<tr><td colspan="6">No registered fingerprints found in the sensor yet.</td></tr>`;
 
  if(typeof registeredCount !== "undefined") registeredCount.textContent = registeredFingerprints.length;
}
 
function csvEscape(value){
  value = String(value ?? "");
  value = value.replace(/"/g, '""');
  return '"' + value + '"';
}
 
function exportAttendanceCsv(){
  const rows = buildAttendanceHistory();
 
  if(!rows.length){
    showBanner("error", "No Attendance Data", "There is no login/logout attendance history to export yet.");
    return;
  }
 
  let csv = "name,college_id,role,department,fingerprint_id,login_time,logout_time,status\n";
 
  rows.slice().reverse().forEach(r => {
    csv += [
      csvEscape(r.name),
      csvEscape(r.collegeId),
      csvEscape(r.role),
      csvEscape(r.department),
      csvEscape(r.id),
      csvEscape(r.inTime || "-"),
      csvEscape(r.outTime || "-"),
      csvEscape(r.status)
    ].join(",") + "\n";
  });
 
  const blob = new Blob([csv], {type:"text/csv;charset=utf-8;"});
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "attendance_history.csv";
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
 
  showBanner("success", "Export Complete", "Attendance login/logout history was exported.");
}
 
function updateStatusUi(){const wifi=!!state.wifi;wifiText.textContent=wifi?"Connected":"Offline";wifiDot.className="dot "+(wifi?"ok":"");ipText.textContent=state.ip||"--";apiTime.textContent=new Date().toLocaleTimeString();totalEnrolled.textContent=state.templates??"--";nextId.textContent=state.nextId??"--";entryCount.textContent=state.entryCount??logs.filter(l=>eventType(l.event)==="entry").length;exitCount.textContent=state.exitCount??logs.filter(l=>eventType(l.event)==="exit").length;debugStrip.innerHTML="API connected. Logs: <b>"+logs.length+"</b> | Last event: <b>"+((state.last&&state.last.event)?state.last.event:"None")+"</b> | Enrolling: <b>"+(state.isEnrolling?"YES":"NO")+"</b>";
  updateRegisterPreview();
  if(state.isEnrolling){
    setRegisterStep(2);
    if(typeof registerStatusText!=="undefined")registerStatusText.textContent="Scanning finger";
  }
}
 
function getInitials(name){
  const parts = String(name || "New User").trim().split(/\s+/).filter(Boolean);
  return parts.slice(0,2).map(p => p[0].toUpperCase()).join("") || "NU";
}
 
function setRegisterStep(step){
  const info = document.getElementById("regStepInfo");
  const scan = document.getElementById("regStepScan");
  const save = document.getElementById("regStepSave");
  if(!info || !scan || !save) return;
 
  [info, scan, save].forEach(el => {
    el.classList.remove("active");
    el.classList.remove("done");
  });
 
  if(step === 1){
    info.classList.add("active");
  }else if(step === 2){
    info.classList.add("done");
    scan.classList.add("active");
  }else if(step === 3){
    info.classList.add("done");
    scan.classList.add("done");
    save.classList.add("active");
  }
}
 
function updateRegisterPreview(){
  // Live Profile Preview was removed. This function is kept so refresh/status code remains safe.
}
 
 
function clearRegisterForm(){
  enrollName.value = "";
  enrollCollegeId.value = "";
  enrollRole.value = "Student";
  enrollDept.value = "";
  enrollNote.value = "";
  const warn = document.getElementById("registerWarning");
  if(warn) warn.classList.remove("show");
  setRegisterStep(1);
  if(typeof registerStatusText !== "undefined") registerStatusText.textContent = "Ready";
  updateRegisterPreview();
}
 
function validateRegisterForm(){
  const hasName = (enrollName?.value || "").trim().length > 0;
  const hasId = (enrollCollegeId?.value || "").trim().length > 0;
  const warn = document.getElementById("registerWarning");
 
  if(!hasName && !hasId){
    if(warn) warn.classList.add("show");
    showBanner("error", "Missing Details", "Enter at least a name or college ID before registration.");
    return false;
  }
 
  if(warn) warn.classList.remove("show");
  return true;
}
 
async function refreshNow(forcePopup=false){try{const newState=await api("/api/status");const logData=await api("/api/logs");let regData={fingerprints:[]};try{regData=await api("/api/registered")}catch(e){regData={fingerprints:[]}}state=newState||{};logs=logData.logs||[];registeredFingerprints=regData.fingerprints||[];updateStatusUi();renderTables();const latest=state.last||logs[logs.length-1]||null;const key=eventKey(latest);if(firstLoad){firstLoad=false;lastSeenKey=key;if(latest){lastEventPill.textContent=latest.event||"INFO";lastPersonPill.textContent=personName(latest);lastTimePill.textContent=latest.time||"--:--:--";if(forcePopup)handleNewEvent(latest)}return}if((forcePopup&&latest)||(key&&key!==lastSeenKey)){lastSeenKey=key;handleNewEvent(latest)}if(state.isEnrolling){heroTitle.textContent="Enrollment In Progress";heroMessage.textContent="Place finger on the sensor and follow the OLED instructions.";debugStrip.innerHTML="Enrollment running. Waiting for fingerprint sensor..."}}catch(e){debugStrip.innerHTML="Dashboard API error: <b>"+e.message+"</b>";showBanner("error","Dashboard API Error",e.message)}}
async function startEnroll(mode){if(!validateRegisterForm())return;let name=enrollName.value||"",collegeId=enrollCollegeId.value||"",role=enrollRole.value||"",department=enrollDept.value||"",note=enrollNote.value||"";const form=new URLSearchParams();form.append("name",name);form.append("collegeId",collegeId);form.append("role",role);form.append("department",department);form.append("note",note);try{setRegisterStep(2);if(typeof registerStatusText!=="undefined")registerStatusText.textContent="Scanning finger";heroTitle.textContent="Enrollment In Progress";heroMessage.textContent="Place finger on the R503 sensor now.";showBanner("info","Enrollment Started","Place finger on the sensor.");showModal("info","Enrollment Started","Place finger on the R503 sensor now.",{name:name||"New User",id:-1,collegeId:collegeId,department:department});beep("info");await api("/api/enroll",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:form});setTimeout(()=>refreshNow(),500)}catch(e){setRegisterStep(1);if(typeof registerStatusText!=="undefined")registerStatusText.textContent="Failed";showBanner("error","Enrollment Failed",e.message);showModal("error","Enrollment Failed",e.message,{name:name,id:-1,collegeId:collegeId,department:department});beep("error")}}
async function cancelEnroll(){try{await api("/api/cancel",{method:"POST"});showBanner("info","Enrollment Cancelled","System returned to scan mode.");beep("info");refreshNow()}catch(e){showBanner("error","Cancel Failed",e.message)}}
async function deleteFingerprint(id){
  if(!id){
    showBanner("error","Missing ID","No fingerprint ID was selected.");
    return;
  }
 
  if(!confirm("Delete fingerprint ID "+id+" and remove this student/staff record?")){
    return;
  }
 
  try{
    await api("/api/fingerprint?id="+encodeURIComponent(id),{method:"DELETE"});
    showBanner("success","Record Deleted","Fingerprint ID "+id+" and assigned details were removed.");
    beep("success");
    refreshNow();
  }catch(e){
    showBanner("error","Delete Failed",e.message);
    beep("error");
  }
}
updateRegisterPreview();setRegisterStep(1);refreshNow();setInterval(refreshNow,1000);
</script>
</body>
</html>
)rawliteral";
 
#endif




Testing & Demo of ESP32 Fingerprint Attendance System

After completing the hardware connection and uploading the code, the ESP32 fingerprint attendance system is ready for testing.

When the device is powered on, the OLED display first shows the Wi-Fi connection status.

Once the ESP32 connects to the local Wi-Fi network, it displays the IP address on the OLED screen. This IP address can be opened in a web browser to access the attendance dashboard.

After Wi-Fi connection, the system checks the R503 fingerprint sensor. During this step, the OLED shows a checking message while the ESP32 communicates with the fingerprint module.

If the sensor is detected successfully, the OLED displays “Sensor Ready”, which means the fingerprint module is properly connected and ready for scanning.

Finally, the system enters scan mode. In this mode, the OLED displays “Place finger” and waits for a registered fingerprint.

IoT ESP32 Fingerprint Attendance System



Accessing the Dashboard

After the ESP32 connects to the Wi-Fi network, it displays its local IP address on the OLED screen. In this demo, the IP address shown is 192.168.2.112. By entering this IP address in a web browser on a phone, laptop, or computer connected to the same Wi-Fi network, we can access the BioTrack Pro attendance dashboard.

The dashboard page shows the live fingerprint system status, Wi-Fi connection, latest API update time, and live attendance feed. From the left-side menu, we can open different sections such as Dashboard, Register, Attendance History, and Records.

The Register page is used to add student or staff details and start fingerprint enrollment.

The Attendance History page shows login and logout records, while the Records page displays registered users and their assigned fingerprint IDs.

The dashboard also includes options to enable sound alerts and export attendance data as a CSV file.

Registering the Fingerprints

To register a new fingerprint, open the ESP32 web dashboard and go to the Register section. Here, enter the user details such as name, college ID, role, and department, then click the Start Registration button. In this example, the user name is entered as Joe Root with college ID CS-456 and department Computer.

After starting the registration, the dashboard shows an “Enrollment Started” popup and the OLED display asks the user to place a finger on the R503 fingerprint sensor.

The sensor first captures the fingerprint image, then asks the user to remove the finger and place the same finger again for the second scan.

The OLED display shows the enrollment progress step by step, such as placing the finger, removing the finger, scanning again, and saving the fingerprint.

Once both scans are matched successfully, the fingerprint template is saved in the R503 sensor memory and the user details are stored in the ESP32.

The OLED display shows a success message, and the dashboard also displays a “Registration Successful” popup with the user name, fingerprint ID, college ID, and department.

If the same registered finger is scanned again during the registration process, the system detects it as a duplicate fingerprint.

After this, the registered fingerprint can be used for marking attendance.



Recording the Attendance

After the fingerprint is registered, the system can be used to record attendance.

When a user places their finger on the R503 fingerprint sensor, the OLED display shows that the system is searching and checking the fingerprint.

If the fingerprint matches with a saved record, the ESP32 marks the user’s attendance and shows the entry or exit status.

For the first scan, the dashboard displays a welcome popup with the user’s name, fingerprint ID, college ID, and department. This means the user has entered, and the entry time is saved in the attendance log.

When the same user scans again, the system records it as an exit and displays a goodbye or exit logged message on the dashboard.

If an unknown or unregistered finger is scanned, the OLED display shows a try again or search failed message.

This means the fingerprint is not found in the saved database. The dashboard also keeps updating the live attendance feed, attendance history, and student/staff records.

In the Attendance History section, each entry and exit record is shown with name, college ID, role, department, finger ID, login time, logout time, and status.

The Records section shows all registered users along with their assigned fingerprint IDs and delete option.

Apart from registration and attendance recording, the dashboard also includes some useful management features. In the Student / Staff Records section, all registered users are listed with their fingerprint ID, name, college ID, role, and department. If any fingerprint needs to be removed, the user can click the Delete button, and the ESP32 deletes that fingerprint ID from the R503 sensor memory as well as removes the saved user details from the system.

The dashboard also provides an Export Attendance CSV option. This allows the attendance history to be downloaded as a CSV file, which can be opened in Excel or Google Sheets for record keeping.

Share. Facebook Twitter Pinterest LinkedIn Tumblr Email Reddit Telegram WhatsApp
Previous ArticleIoT Based PM & Air Quality Monitoring System using ESP32

Related Posts

IoT Based PM & Air Quality Monitoring System using ESP32

IoT Based PM & Air Quality Monitoring System using ESP32

Updated:June 14, 2026
DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

Updated:May 10, 20261K
IoT Activity Tracker with ESP32 & Accelerometer Gyroscope

IoT Activity Tracker with ESP32 & Accelerometer/Gyroscope

Updated:May 2, 2026

ESP32 IoT Vehicle Motion Analyzer with MPU6050 & LIS3MDL

Updated:April 27, 20261K
High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

Updated:April 27, 20262K
DIY Colorimeter using AS7265x Spectroscopy Sensor & ESP32

DIY Colorimeter using AS7265x Spectroscopy Sensor & ESP32

Updated:February 1, 20261K
Add A Comment

CommentsCancel reply

Latest Posts

ESP32 Fingerprint Attendance System with Live Web Dashboard

June 14, 2026
IoT Based PM & Air Quality Monitoring System using ESP32

IoT Based PM & Air Quality Monitoring System using ESP32

June 14, 2026
DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

May 10, 2026
IoT Activity Tracker with ESP32 & Accelerometer Gyroscope

IoT Activity Tracker with ESP32 & Accelerometer/Gyroscope

May 2, 2026
A Guide to Sourcing Obsolete ICs for Vintage Projects

Beyond AliExpress: A Guide to Sourcing Obsolete ICs for Vintage Projects

April 21, 2026

ESP32 IoT Vehicle Motion Analyzer with MPU6050 & LIS3MDL

April 27, 2026
Building a Smart Sensor Node with a BLE Microcontroller

Building a Smart Sensor Node with a BLE Microcontroller

February 26, 2026
High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

April 27, 2026
Top Posts & Pages
  • IoT Based PM & Air Quality Monitoring System using ESP32
    IoT Based PM & Air Quality Monitoring System using ESP32
  • 12V DC to 220V AC Inverter Circuit & PCB
    12V DC to 220V AC Inverter Circuit & PCB
  • Buck Converter: Basics, Working, Design & Application
    Buck Converter: Basics, Working, Design & Application
  • How to use INA219 DC Current Sensor Module with Arduino
    How to use INA219 DC Current Sensor Module with Arduino
  • How to use ADS1220 24-Bit ADC Module with Arduino
    How to use ADS1220 24-Bit ADC Module with Arduino
  • IoT AC Energy Meter with PZEM-004T & ESP32 WebServer
    IoT AC Energy Meter with PZEM-004T & ESP32 WebServer
  • L293D Dual H-Bridge Motor Driver IC Pins, Circuit, Working
    L293D Dual H-Bridge Motor Driver IC Pins, Circuit, Working
  • LD2410 Sensor with ESP32 - Human Presence Detection
    LD2410 Sensor with ESP32 - Human Presence Detection
Categories
  • Arduino Projects (197)
  • Articles (60)
    • Learn Electronics (19)
    • Product Review (15)
    • Tech Articles (28)
  • Electronics Circuits (46)
    • 555 Timer Projects (21)
    • Op-Amp Circuits (7)
    • Power Electronics (13)
  • IoT Projects (205)
    • ESP32 MicroPython (7)
    • ESP32 Projects (82)
    • ESP32-CAM Projects (15)
    • ESP8266 Projects (76)
    • LoRa/LoRaWAN Projects (22)
  • Microcontrollers (38)
    • AMB82-Mini IoT AI Camera (4)
    • BLE Projects (18)
    • STM32 Projects (19)
  • Raspberry Pi (93)
    • Raspberry Pi Pico Projects (57)
    • Raspberry Pi Pico W Projects (12)
    • Raspberry Pi Projects (24)
Follow Us
  • Facebook
  • Twitter
  • Pinterest
  • Instagram
  • YouTube
About Us

“‘How to Electronics’ is a vibrant community for electronics enthusiasts and professionals. We deliver latest insights in areas such as Embedded Systems, Power Electronics, AI, IoT, and Robotics. Our goal is to stimulate innovation and provide practical solutions for students, organizations, and industries. Join us to transform learning into a joyful journey of discovery and innovation.

Copyright © How To Electronics. All rights reserved.
  • About Us
  • Disclaimer
  • Privacy Policy
  • Contact Us
  • Advertise With Us

Type above and press Enter to search. Press Esc to cancel.

Ad Blocker Enabled!
Ad Blocker Enabled!
Looks like you're using an ad blocker. Please allow ads on our site. We rely on advertising to help fund our site.