HARDWARE | FPC1020A 지문센서 이용해 보기 - 2

이 글은 아래 글의 후편 입니다.

* HARDWARE | FPC1020A 지문센서 이용해 보기 - 1




1. Challenge

지문 센서를 가지고 여러 활용할 수 있는 방법을 5년째 손을 못 대고 있었습니다. 가장 해결하기 힘든 부분은, 제가 구입한 센서가 '일반적인' 센서가 아닌 것으로 부터 시작합니다. Library 가 잘 지원되며, 많이들 사용하고, 메이저 업체에서 출시한 센서를 사용하는 것이 이런 스트레스로부터 해방될 수 있습니다.

저는 그렇지 못했으므로, 쉽지 않은 길을 가고 있는 셈이지요. 다만, 요즘은 AI 가 프로그래밍에 대부분을 커버해 주므로, 5년이 지난 시점에 다시 도전해 보게 됩니다.

전체적인 구성은, Arduino 보다 성능이 좋고, Wifi 기능이 있는 ESP32 로 컨트롤 하며, 상태 확인을 할 수 있는 메뉴와 이를 보여주는 OLED 디스플레이, 다이얼 조작과 선택 기능이 있는 Rotary Encoder, 그리고 소리와 불빛을 이용하여 사용자가 상황을 인지할 수 있게, buzzer 와 LED 를 추가하는 구성 입니다. 모두 예전에 사용해 봤던 모듈들이라, 저에게는 친숙한 부품들 입니다.

* Hardware | ESP32 간단 사용기

* Hardware | Rotary Encoder 를 사용해 보자

* Hardware | SSD1309 128x64 1.54" yellow OLED

* Hardware | Arduino 로 buzzer 소리내기

* Hardware | LED 구매하기

구성 부품 정보는 다음과 같습니다.

+----------------------+----------------------------------+
| Component            | Model/Spec                       |
+----------------------+----------------------------------+
| MCU                  | ESP32 DevKit                     |
| Fingerprint Sensor   | FPC1020A Module (Biovo Protocol) |
| Display              | 128x64 OLED SSD1306 (SPI)        |
| Input                | Rotary Encoder with Push Button  |
| Output               | LED, Buzzer Module (3-pin)       |
+----------------------+----------------------------------+



2. Layout

전체적인 구성은 다음과 같습니다. 오랜만에 Fritzing 을 이용하여 그려 봤습니다. 오랜만에 사이트에 들어갔더니 유료화가 되어 있더군요. 저는 예전에 후원을 한번 해 놔서, 후원자 자격으로 다운로드 했습니다. 드.디.어. 버전이 1.0.6 이 되었네요. 몇년 전까지만 해도 0.9 버전대 였습니다.

* Fritzing


실제로는 기능만 확인할 수 있도록, 스파케티같이 연결되어 있습니다.


연결 정보는 다음과 같습니다.

+------------------+----------+------------------+
| Component        | Pin      | ESP32 GPIO       |
+------------------+----------+------------------+
| FPC1020A         | GND      | GND              |
| (6-pin module)   | UART_RX  | GPIO17 (TX2)     |
|                  | UART_TX  | GPIO16 (RX2)     |
|                  | VCC      | 3.3V             |
|                  | TOUCH    | NC (not used)    |
|                  | V-TOUCH  | 3.3V             |
+------------------+----------+------------------+
| OLED SSD1306     | CLK      | GPIO18           |
| (SPI)            | MOSI     | GPIO23           |
|                  | DC       | GPIO19           |
|                  | CS       | GPIO15           |
|                  | RESET    | GPIO5            |
|                  | VCC      | 3.3V             |
|                  | GND      | GND              |
+------------------+----------+------------------+
| Rotary Encoder   | CLK      | GPIO25           |
|                  | DT       | GPIO26           |
|                  | SW       | GPIO27           |
|                  | VCC      | 3.3V             |
|                  | GND      | GND              |
+------------------+----------+------------------+
| LED              | Anode    | GPIO4            |
|                  | Cathode  | GND (via 220Ω)   |
+------------------+----------+------------------+
| Buzzer Module    | VCC      | 3.3V             |
| (3-pin, active)  | GND      | GND              |
|                  | SIG      | GPIO2            |
+------------------+----------+------------------+

참고로 LED 에 붙이는 저항값이 220 ohm 인 이유는, LED 에는 6mA 정도가 적당한 전류량인데, 3.3V 와 5V 입력시에 적절한 저항값은 다음과 같습니다. 저항은 일반적으로 구할 수 있는 저항을 기준으로 했습니다.

+---------+------------+---------+------------------+
| Voltage | Best Value | Current | Reason           |
+---------+------------+---------+------------------+
| 3.3V    | 220Ω       | ~6mA    | Common, safe     |
| 5.0V    | 470Ω       | ~6mA    | Common, safe     |
+---------+------------+---------+------------------+



3. FPC1020A Protocol Specification

Protocol 의 packet 구조와 이를 활용한 command 를 정의 했습니다. Command 들은 기존 스펙에 나와 있는 것들도 있지만, 0x2B / GET_ALL_USERS 와 같은 추가된 command 도 만들었습니다.

또한, 기존 library 와 sample code 가 SoftwareSerial 을 이용한 한계가 있어, ESP32 를 이용하기 위한 HardwareSerial 을 적용하면서 upgrade 된 library, 그리고 완벽히 구동하는 code 를 아래에 올렸습니다.

* FPC1020A for ESP32
  - GitHub
  - FPC1020A_ESP32 (Library and Sample Code)

# Packet Structure

+------+------+------+------+------+------+------+------+------+
| Byte |  0   |  1   |  2   |  3   |  4   |  5   |  6   |  7   |
+------+------+------+------+------+------+------+------+------+
| Name |START | CMD  | P1   | P2   | P3   | P4   | CHK  | END  |
+------+------+------+------+------+------+------+------+------+
| Value| 0xF5 | xx   | xx   | xx   | xx   | xx   | xx   | 0xF5 |
+------+------+------+------+------+------+------+------+------+

CHK = CMD ^ P1 ^ P2 ^ P3 ^ P4 (XOR of bytes 1-5)


# Command List

+------+------------------+------------------------------------------+
| CMD  | Name             | Description                              |
+------+------------------+------------------------------------------+
| 0x01 | ENROLL_STEP1     | Enroll fingerprint - 1st scan            |
| 0x02 | ENROLL_STEP2     | Enroll fingerprint - 2nd/3rd scan        |
| 0x03 | ENROLL_STEP3     | Enroll fingerprint - final scan          |
| 0x04 | DELETE           | Delete user by ID                        |
| 0x05 | CLEAR            | Delete all users                         |
| 0x09 | USER_COUNT       | Get number of enrolled users             |
| 0x0A | GET_PERMISSION   | Get user permission (check if exists)    |
| 0x0B | VERIFY           | 1:1 matching (verify specific ID)        |
| 0x0C | SEARCH           | 1:N matching (find any match)            |
| 0x2B | GET_ALL_USERS    | Get all user IDs (extended format)       |
| 0x2F | DETECT           | Detect finger presence                   |
+------+------------------+------------------------------------------+

# Response Codes (Q3/Byte 4)

+------+------------------+------------------------------------------+
| Code | Name             | Description                              |
+------+------------------+------------------------------------------+
| 0x00 | ACK_SUCCESS      | Operation successful                     |
| 0x01 | ACK_FAIL         | Operation failed                         |
| 0x04 | ACK_FULL         | Database full                            |
| 0x05 | ACK_NOUSER       | User does not exist                      |
| 0x06 | ACK_USER_OCCUPIED| User ID already exists                   |
| 0x07 | ACK_USER_EXIST   | Fingerprint already enrolled             |
| 0x08 | ACK_TIMEOUT      | Acquisition timeout                      |
+------+------------------+------------------------------------------+

특히, SEARCH 를 하면, 1-99 까지 모든 기록을 하나하나 확인하여 매우 효율이 떨어집니다. 그래서 확장된 command 를 만들었으며, 0x2B 로 정의 하였습니다.

등록된 99개의 LIST 를 보여줘야 하므로, page 를 나누어서 보여질 수 있도록 하였습니다. 아래 화면은 총 6개가 등록되어 있고, 등록된 ID 는 어떤 것들이 있으며, 총 2페이지의 첫번째 페이지를 보여주고 있다는 표현 입니다. 원래 SEARCH 명령어 만으로는 이 페이지를 표시하는데 한참 걸리나, 새롭게 만든 0x2B 명령어로 인하여, 바로 보여지게끔 되었습니다.


다음 페이지를 Rotary Encoder 를 돌려 표시한 화면 입니다.




3. Command Details

Command 의 예제를 3개 가지고 왔습니다. 이 외의 command 들에 대한 자세한 설명과 예시는, 저의 GitHub 에 올려져 있으니 참고하시면 되겠습니다.

* FPC1020A-ESP32

# 1. Finger Detection (0x2F)

TX: F5 2F 00 00 00 00 2F F5
       |  |  |  |  |  |
       |  +--+--+--+  +-- CHK = 0x2F
       |     |
       |     +-- P1-P4: unused (all 0x00)
       +-- CMD: 0x2F (DETECT)

RX: F5 2F 00 XX 00 00 CHK F5
             |
             +-- Touch value:
                 0xFF = No finger present
                 < 0xFA = Finger detected (lower = stronger contact)


# 2. Enroll (0x02)

TX: F5 02 P1 P2 P3 00 CHK F5
       |  |  |  |
       |  |  |  +-- P3: Permission level (must match Step 1)
       |  +--+-- P1,P2: User ID (must match Step 1)
       +-- CMD: 0x02 (ENROLL_STEP2)

RX: F5 02 00 00 Q3 00 CHK F5
                |
                +-- Q3: Result code
                    0x00 = ACK_SUCCESS
                    0x01 = ACK_FAIL
                    0x08 = ACK_TIMEOUT


# 3. Enroll (0x03)

TX: F5 03 P1 P2 P3 00 CHK F5
       |  |  |  |
       |  |  |  +-- P3: Permission level (must match previous steps)
       |  +--+-- P1,P2: User ID (must match previous steps)
       +-- CMD: 0x03 (ENROLL_STEP3)

RX: F5 03 00 00 Q3 00 CHK F5
                |
                +-- Q3: Result code
                    0x00 = ACK_SUCCESS (enrollment complete)
                    0x01 = ACK_FAIL (merge failed)
                    0x08 = ACK_TIMEOUT



4. Flows

전체적인 흐름은 아래와 같습니다.

# Scan Flow

                      +--------+
                      |  SCAN  |
                      +---+----+
                          |
                    Place finger
                          |
                   Send 0x0C Search
                          |
            +-------------+-------------+
            |             |             |
       [Q3=1,2,3]    [Q3=0x05]     [Q3=0x08]
       Match found    No match      Timeout
            |             |             |
       "Access OK"    "Denied"     "Timeout"
         ID: X        No match

# Enroll Flow

                      +--------+
                      | ENROLL |
                      +---+----+
                          |
                     Select ID
                          |
                          v
                   +------+------+
                   |   Step 1    |
                   |   (0x01)    |
                   +------+------+
                          |
              +-----------+-----------+
              |                       |
         [Q3=0x00]              [Q3=error]
          Success                 Fail
              |                       |
              v                  "ID in use"
       +------+------+           "Finger exists"
       |   Step 2    |           "Timeout"
       |   (0x02)    |
       +------+------+
              |
              +---> (repeat Step 2 once more)
              |
              v
       +------+------+
       |   Step 4    |
       |   (0x03)    |
       +------+------+
              |
    +---------+---------+
    |                   |
[Q3=0x00]          [Q3=0x01]
 Success              Fail
    |                   |
"Enrolled!"        "Step Fail"
  ID: X            (retry needed)

# Delete Flow

                      +--------+
                      | DELETE |
                      +---+----+
                          |
                     Select ID
                          |
                   Send 0x04 Delete
                          |
              +-----------+-----------+
              |                       |
         [Q3=0x00]              [Q3=0x05]
          Success               Not found
              |                       |
         "Deleted"              "Not found"
           ID: X



5. Timing Requirements & LED/Buzzer Feedback

센서가 받아들이고 처리하는 타이밍들이 전부 다릅니다. 적절한 값을 찾는 것이 쉽지 않았었는데, 여러번의 시도 끝에 다음과 가팅 정리 할 수 있었습니다. 그리고, 시각과 청각으로 상태를 감지할 수 있도록 LED / Buzzer 에 대한 정의도 다음과 같습니다.

# Timing Requirements

+----------------------+-------------+
| Operation            | Timeout     |
+----------------------+-------------+
| Finger detect        | 300 ms      |
| Search (1:N)         | 10000 ms    |
| Enroll step          | 10000 ms    |
| Delete               | 2000 ms     |
| Clear all            | 3000 ms     |
| Check user           | 500 ms      |
| Get all users        | 2000 ms     |
| Get all users data   | 3000 ms     |
| Between commands     | 50 ms       |
+----------------------+-------------+


# LED / Buzzer Feedback

+------------------+------------------+------------------+
| Event            | LED              | Buzzer           |
+------------------+------------------+------------------+
| Startup          | Off              | 4-tone melody    |
| Scanning         | On               | Short beep       |
| Success          | On (2 sec)       | Rising melody    |
| Denied/Fail      | Blink            | Falling melody   |
| Menu navigation  | Off              | Short beep       |
+------------------+------------------+------------------+

아주 자세한 내용은 위에 공유된 GitHub 를 참고해 주시면 되겠습니다.



6. Main Codes

FPC1020A_ESP.ino 파일을 아래와 같이 공유합니다. GitHub 가 없어질 수 있잖아요.

#include <Adafruit_SSD1306.h>
#include <SPI.h>
#include "FPC1020_ESP32.h"

#define OLED_CLK   18
#define OLED_MOSI  23
#define OLED_RESET 5
#define OLED_DC    19
#define OLED_CS    15

#define LED_PIN    4
#define BUZZER_PIN 2
#define CLK_PIN    25
#define DT_PIN     26
#define SW_PIN     27

HardwareSerial fpSerial(2);
FPC1020_ESP32 finger(&fpSerial);
Adafruit_SSD1306 oled(128, 64, OLED_MOSI, OLED_CLK, OLED_DC, OLED_RESET, OLED_CS);

int lastClk = HIGH;
const char* menuNames[] = {"SCAN", "ENROLL", "DELETE", "LIST", "CLEAR"};
int menuIndex = 0;  // Start with SCAN
bool inSubmenu = false;
int selectedId = 1;

void beep(int f, int d) { tone(BUZZER_PIN, f, d); delay(d + 10); noTone(BUZZER_PIN); }
void beepShort() { beep(800, 30); }
void melodyOK() { beep(523, 80); beep(659, 80); beep(784, 150); }
void melodyNG() { beep(200, 100); beep(150, 200); }

void showStatus(const char* l1, const char* l2, const char* l3 = "", const char* l4 = "") {
  oled.clearDisplay();
  oled.setTextSize(2); oled.setTextColor(WHITE);
  oled.setCursor(0, 0); oled.println(l1);
  oled.setTextSize(1);
  oled.setCursor(0, 30); oled.println(l2);
  if (strlen(l3)) { oled.setCursor(0, 42); oled.println(l3); }
  if (strlen(l4)) { oled.setCursor(0, 54); oled.println(l4); }
  oled.display();
}

void showMenu() {
  char buf[24];
  if (inSubmenu && (menuIndex == 1 || menuIndex == 2)) {
    sprintf(buf, "ID: %d", selectedId);
    showStatus(menuIndex == 1 ? "ENROLL" : "DELETE", buf, "Rotate: ID", "Press: OK");
  } else {
    showStatus(menuNames[menuIndex], "Rotate: MENU", "Press: OK");
  }
}

void doScan() {
  Serial.println("\n=== SCAN ===");
  showStatus("SCAN", "Place finger now!", "Waiting...");
  beepShort();
  digitalWrite(LED_PIN, HIGH);
  
  uint8_t r = finger.Search();
  digitalWrite(LED_PIN, LOW);
  
  char buf[20];
  if (r == ACK_SUCCESS) {
    sprintf(buf, "ID: %d", g_matchedId);
    Serial.printf("MATCH: ID=%d, Perm=%d\n", g_matchedId, g_matchedPerm);
    showStatus("Access OK", buf, "");
    melodyOK();
    digitalWrite(LED_PIN, HIGH);
    delay(2000);
    digitalWrite(LED_PIN, LOW);
  } else {
    const char* msg = "Error";
    if (r == ACK_NOUSER) msg = "No match";
    else if (r == ACK_TIMEOUT) msg = "Timeout";
    Serial.printf("DENIED: %s (0x%02X)\n", msg, r);
    showStatus("Denied", msg, "");
    melodyNG();
    delay(1500);
  }
}

const char* errMsg(uint8_t r) {
  switch(r) {
    case ACK_USER_OCCUPIED: return "ID in use";
    case ACK_USER_EXIST: return "Finger exists";
    case ACK_TIMEOUT: return "Timeout";
    case ACK_FULL: return "DB full";
    default: return "Error";
  }
}

void doEnroll(int id) {
  Serial.printf("\n=== ENROLL ID %d ===\n", id);
  char buf[20];
  uint8_t r;
  
  for (int step = 1; step <= 4; step++) {
    sprintf(buf, "Step %d/4", step);
    showStatus(buf, "Place finger...", "");
    beepShort();
    digitalWrite(LED_PIN, HIGH);
    
    Serial.printf("Step %d: ", step);
    if (step == 1) r = finger.Enroll1(id, 1);
    else if (step < 4) r = finger.Enroll2(id, 1);
    else r = finger.Enroll3(id, 1);
    
    digitalWrite(LED_PIN, LOW);
    
    if (r != ACK_SUCCESS) {
      sprintf(buf, "%d/4 Fail", step);
      Serial.printf("FAILED (0x%02X)\n", r);
      showStatus(buf, errMsg(r), "");
      melodyNG();
      delay(2000);
      return;
    }
    Serial.println("OK");
    
    if (step < 4) {
      sprintf(buf, "%d/4 OK", step);
      showStatus(buf, "Lift finger!", "");
      beepShort();
      delay(1500);
    }
  }
  
  sprintf(buf, "ID: %d", id);
  showStatus("Enrolled!", buf, "");
  Serial.printf("=== ENROLL SUCCESS ID=%d ===\n", id);
  melodyOK();
  delay(2000);
}

void doDelete(int id) {
  Serial.printf("\n=== DELETE ID %d ===\n", id);
  char buf[20];
  sprintf(buf, "ID: %d", id);
  showStatus("Deleting", buf, "");
  
  uint8_t r = finger.Delete(id);
  Serial.printf("Result: %s (0x%02X)\n", r == ACK_SUCCESS ? "OK" : "FAIL", r);
  showStatus(r == ACK_SUCCESS ? "Deleted" : "Failed", r == ACK_NOUSER ? "Not found" : buf, "");
  r == ACK_SUCCESS ? melodyOK() : melodyNG();
  delay(1500);
}

void doClear() {
  Serial.println("\n=== CLEAR ===");
  showStatus("CLEAR ALL", "Press OK to confirm", "Rotate to cancel");
  
  unsigned long start = millis();
  while (millis() - start < 5000) {
    if (readEncoder()) { Serial.println("CLEAR cancelled"); showMenu(); return; }
    if (digitalRead(SW_PIN) == LOW) {
      delay(30);
      while (digitalRead(SW_PIN) == LOW) delay(10);
      
      showStatus("Clearing", "Please wait...", "");
      uint8_t r = finger.Clear();
      Serial.printf("Clear result: %s (0x%02X)\n", r == ACK_SUCCESS ? "OK" : "FAIL", r);
      showStatus(r == ACK_SUCCESS ? "Cleared!" : "Failed", r == ACK_SUCCESS ? "All deleted" : "Error", "");
      r == ACK_SUCCESS ? melodyOK() : melodyNG();
      delay(2000);
      return;
    }
    delay(50);
    yield();
  }
  Serial.println("CLEAR timeout");
}

void doList() {
  Serial.println("\n=== LIST ===");
  showStatus("LIST", "Reading...", "");
  
  uint16_t ids[50];
  uint8_t perms[50];
  uint16_t count = 0;
  
  uint8_t r = finger.GetAllUsers(ids, perms, &count, 50);
  
  if (r != ACK_SUCCESS) {
    Serial.printf("GetAllUsers FAILED: 0x%02X\n", r);
    showStatus("LIST", "Read failed", "");
    melodyNG();
    delay(1500);
    return;
  }
  
  Serial.printf("Total users: %d\n", count);
  
  if (count == 0) {
    showStatus("LIST", "No users", "Total: 0");
    delay(2000);
    return;
  }
  
  Serial.print("IDs: ");
  for (int i = 0; i < count; i++) Serial.printf("%d ", ids[i]);
  Serial.println();
  
  // Sort IDs in ascending order
  for (int i = 0; i < count - 1; i++) {
    for (int j = i + 1; j < count; j++) {
      if (ids[j] < ids[i]) {
        uint16_t tmp = ids[i]; ids[i] = ids[j]; ids[j] = tmp;
      }
    }
  }
  
  Serial.print("Sorted: ");
  for (int i = 0; i < count; i++) Serial.printf("%d ", ids[i]);
  Serial.println();
  
  // Display pages (5 IDs per page)
  int page = 0;
  int totalPages = (count + 4) / 5;
  if (totalPages == 0) totalPages = 1;
  
  while (true) {
    char line1[20], line2[32], line3[32];
    sprintf(line1, "Total: %d", count);
    
    // Build ID list for this page
    line2[0] = 0;
    line3[0] = 0;
    int startIdx = page * 5;
    for (int i = 0; i < 5 && (startIdx + i) < count; i++) {
      char tmp[8];
      sprintf(tmp, "%d ", ids[startIdx + i]);
      if (i < 3) strcat(line2, tmp);
      else strcat(line3, tmp);
    }
    
    char pageInfo[16];
    sprintf(pageInfo, "Page %d/%d", page + 1, totalPages);
    
    oled.clearDisplay();
    oled.setTextSize(2); oled.setTextColor(WHITE);
    oled.setCursor(0, 0); oled.println("LIST");
    oled.setTextSize(1);
    oled.setCursor(0, 20); oled.println(line1);
    oled.setCursor(0, 32); oled.print("IDs: "); oled.println(line2);
    if (strlen(line3)) { oled.setCursor(29, 42); oled.println(line3); }
    oled.setCursor(0, 54); oled.println(pageInfo);
    oled.display();
    
    // Wait for input
    unsigned long waitStart = millis();
    while (millis() - waitStart < 10000) {
      int dir = readEncoder();
      if (dir) {
        beepShort();
        page = (page + dir + totalPages) % totalPages;
        break;
      }
      if (digitalRead(SW_PIN) == LOW) {
        delay(30);
        while (digitalRead(SW_PIN) == LOW) delay(10);
        Serial.println("LIST exit");
        return;
      }
      delay(20);
      yield();
    }
    if (millis() - waitStart >= 10000) {
      Serial.println("LIST timeout");
      return;
    }
  }
}

int readEncoder() {
  int clk = digitalRead(CLK_PIN);
  if (clk != lastClk && clk == LOW) {
    lastClk = clk;
    return (digitalRead(DT_PIN) != clk) ? 1 : -1;
  }
  lastClk = clk;
  return 0;
}

void setup() {
  Serial.begin(115200);
  Serial.println("\n\n=== FPC1020A Door Lock ===");
  
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);
  pinMode(CLK_PIN, INPUT_PULLUP);
  pinMode(DT_PIN, INPUT_PULLUP);
  pinMode(SW_PIN, INPUT_PULLUP);
  
  oled.begin(SSD1306_SWITCHCAPVCC);
  showStatus("FPC1020A", "Initializing...", "");
  
  finger.begin(16, 17);
  Serial.println("Sensor initialized (19200 baud)");
  
  beep(262, 80); beep(330, 80); beep(392, 80); beep(523, 150);
  delay(3000);  // Show init screen for 3 seconds
  
  // Reset menu state
  menuIndex = 0;  // Force SCAN
  inSubmenu = false;
  lastClk = digitalRead(CLK_PIN);  // Sync encoder state
  
  Serial.println("Ready! Menu: SCAN");
  showMenu();
}

void loop() {
  int dir = readEncoder();
  if (dir) {
    beepShort();
    if (!inSubmenu) menuIndex = (menuIndex + dir + 5) % 5;
    else selectedId = constrain(selectedId + dir, 1, 100);
    showMenu();
  }
  
  if (digitalRead(SW_PIN) == LOW) {
    delay(30);
    if (digitalRead(SW_PIN) == LOW) {
      while (digitalRead(SW_PIN) == LOW) { delay(10); yield(); }
      beepShort();
      
      if (!inSubmenu) {
        if (menuIndex == 0) { doScan(); showMenu(); }
        else if (menuIndex == 3) { doList(); showMenu(); }
        else if (menuIndex == 4) { doClear(); showMenu(); }
        else { inSubmenu = true; showMenu(); }
      } else {
        if (menuIndex == 1) doEnroll(selectedId);
        else doDelete(selectedId);
        inSubmenu = false;
        showMenu();
      }
    }
  }
  
  delay(20);
  yield();
}



7. Library Files

Library 파일인 FPC1020A_ESP32.hFPC1020A_ESP32.cpp 파일도 공유 합니다. 저는 강박관념이 조금 있습니다. 우선 FPC1020A_ESP32.h 파일 입니다.

#ifndef FPC1020_ESP32_H
#define FPC1020_ESP32_H

#include <Arduino.h>

#define ACK_SUCCESS 0x00
#define ACK_FAIL 0x01
#define ACK_FULL 0x04
#define ACK_NOUSER 0x05
#define ACK_USER_OCCUPIED 0x06
#define ACK_USER_EXIST 0x07
#define ACK_TIMEOUT 0x08

extern uint16_t g_matchedId;
extern uint8_t g_matchedPerm;

class FPC1020_ESP32 {
public:
  FPC1020_ESP32(HardwareSerial *ser);
  void begin(int rx, int tx);
  bool fingerPresent();
  uint8_t Search();
  uint8_t Enroll1(uint16_t userId, uint8_t perm = 1);
  uint8_t Enroll2(uint16_t userId, uint8_t perm = 1);
  uint8_t Enroll3(uint16_t userId, uint8_t perm = 1);
  uint8_t Delete(uint16_t userId);
  uint8_t Clear();
  uint8_t GetUserCount(uint16_t *count);
  uint8_t CheckUser(uint16_t userId);
  uint8_t GetAllUsers(uint16_t *ids, uint8_t *perms, uint16_t *count, uint8_t maxUsers);

private:
  HardwareSerial *_serial;
  uint8_t _buf[12];
  void _sendCmd(uint8_t cmd, uint8_t p1, uint8_t p2, uint8_t p3, uint8_t p4);
  int _readResp(uint8_t expectedCmd, unsigned long timeout);
  void _flush();
};

#endif

아래는 FPC1020A_ESP32.cpp 입니다.

#include "FPC1020_ESP32.h"

uint16_t g_matchedId = 0;
uint8_t g_matchedPerm = 0;

FPC1020_ESP32::FPC1020_ESP32(HardwareSerial *ser) : _serial(ser) {}

void FPC1020_ESP32::begin(int rx, int tx) {
  _serial->begin(19200, SERIAL_8N1, rx, tx);
  delay(200);
  _flush();
}

void FPC1020_ESP32::_flush() {
  _serial->flush();
  while (_serial->available()) _serial->read();
}

void FPC1020_ESP32::_sendCmd(uint8_t cmd, uint8_t p1, uint8_t p2, uint8_t p3, uint8_t p4) {
  _flush();
  delay(50);
  
  uint8_t chk = cmd ^ p1 ^ p2 ^ p3 ^ p4;
  uint8_t pkt[8] = {0xF5, cmd, p1, p2, p3, p4, chk, 0xF5};
  
  Serial.print("TX: ");
  for (int i = 0; i < 8; i++) Serial.printf("%02X ", pkt[i]);
  Serial.println();
  
  _serial->write(pkt, 8);
  _serial->flush();
}

int FPC1020_ESP32::_readResp(uint8_t expectedCmd, unsigned long timeout) {
  unsigned long start = millis();
  int idx = 0;
  memset(_buf, 0, sizeof(_buf));
  
  while (millis() - start < timeout) {
    while (_serial->available() && idx < 8) {
      uint8_t c = _serial->read();
      if (idx == 0) {
        if (c == 0xF5) _buf[idx++] = c;
      } else {
        _buf[idx++] = c;
      }
    }
    
    if (idx == 8) {
      if (_buf[1] == expectedCmd && _buf[7] == 0xF5) break;
      idx = 0;
      memset(_buf, 0, sizeof(_buf));
    }
    delay(10);
    yield();
  }
  
  Serial.print("RX: ");
  for (int i = 0; i < idx; i++) Serial.printf("%02X ", _buf[i]);
  Serial.printf("(len=%d)\n", idx);
  
  return idx;
}

bool FPC1020_ESP32::fingerPresent() {
  _sendCmd(0x2F, 0, 0, 0, 0);
  int len = _readResp(0x2F, 300);
  return (len == 8 && _buf[3] < 0xFA);
}

uint8_t FPC1020_ESP32::Search() {
  // Search waits internally for finger, needs longer timeout
  _sendCmd(0x0C, 0, 0, 0, 0);
  int len = _readResp(0x0C, 10000);  // 10 second timeout
  if (len == 8) {
    uint8_t q3 = _buf[4];
    Serial.printf("Search Q3=0x%02X\n", q3);
    if (q3 >= 1 && q3 <= 3) {
      g_matchedId = ((uint16_t)_buf[2] << 8) | _buf[3];
      g_matchedPerm = q3;
      return ACK_SUCCESS;
    }
    return q3;
  }
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::Enroll1(uint16_t userId, uint8_t perm) {
  _sendCmd(0x01, userId >> 8, userId & 0xFF, perm, 0);
  int len = _readResp(0x01, 10000);
  if (len == 8) return _buf[4];
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::Enroll2(uint16_t userId, uint8_t perm) {
  _sendCmd(0x02, userId >> 8, userId & 0xFF, perm, 0);
  int len = _readResp(0x02, 10000);
  if (len == 8) return _buf[4];
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::Enroll3(uint16_t userId, uint8_t perm) {
  _sendCmd(0x03, userId >> 8, userId & 0xFF, perm, 0);
  int len = _readResp(0x03, 10000);
  if (len == 8) return _buf[4];
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::Delete(uint16_t userId) {
  _sendCmd(0x04, userId >> 8, userId & 0xFF, 0, 0);
  int len = _readResp(0x04, 2000);
  if (len == 8) return _buf[4];
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::Clear() {
  _sendCmd(0x05, 0, 0, 0, 0);
  int len = _readResp(0x05, 3000);
  if (len == 8) return _buf[4];
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::GetUserCount(uint16_t *count) {
  _sendCmd(0x09, 0, 0, 0, 0);
  int len = _readResp(0x09, 2000);
  if (len == 8 && _buf[4] == ACK_SUCCESS) {
    *count = ((uint16_t)_buf[2] << 8) | _buf[3];
    return ACK_SUCCESS;
  }
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::CheckUser(uint16_t userId) {
  _sendCmd(0x0A, userId >> 8, userId & 0xFF, 0, 0);
  int len = _readResp(0x0A, 500);
  if (len == 8) {
    return _buf[4];
  }
  return ACK_TIMEOUT;
}

uint8_t FPC1020_ESP32::GetAllUsers(uint16_t *ids, uint8_t *perms, uint16_t *count, uint8_t maxUsers) {
  // Send 0x2B command
  _sendCmd(0x2B, 0, 0, 0, 0);
  
  // Read header (8 bytes)
  int len = _readResp(0x2B, 2000);
  if (len != 8 || _buf[4] != ACK_SUCCESS) {
    Serial.printf("GetAllUsers header failed: len=%d, Q3=0x%02X\n", len, _buf[4]);
    *count = 0;
    return (_buf[4] != 0) ? _buf[4] : ACK_FAIL;
  }
  
  // Get data length from header
  uint16_t dataLen = ((uint16_t)_buf[2] << 8) | _buf[3];
  Serial.printf("GetAllUsers dataLen=%d\n", dataLen);
  
  if (dataLen < 2) {
    *count = 0;
    return ACK_SUCCESS;
  }
  
  // Read data part: F5 + data + CHK + F5
  delay(50);
  unsigned long start = millis();
  uint8_t dataBuf[200];
  int idx = 0;
  
  while (millis() - start < 3000 && idx < dataLen + 3) {
    if (_serial->available()) {
      uint8_t c = _serial->read();
      if (idx == 0 && c != 0xF5) continue;
      dataBuf[idx++] = c;
    }
    yield();
  }
  
  Serial.print("Data RX: ");
  for (int i = 0; i < idx && i < 30; i++) Serial.printf("%02X ", dataBuf[i]);
  if (idx > 30) Serial.print("...");
  Serial.printf(" (len=%d)\n", idx);
  
  if (idx < dataLen + 3) {
    Serial.println("GetAllUsers data incomplete");
    *count = 0;
    return ACK_TIMEOUT;
  }
  
  // Parse: dataBuf[0]=0xF5, dataBuf[1..2]=count, dataBuf[3..]=user data
  uint16_t userCount = ((uint16_t)dataBuf[1] << 8) | dataBuf[2];
  *count = userCount;
  Serial.printf("User count: %d\n", userCount);
  
  // Extract IDs and permissions
  int numExtracted = 0;
  for (int i = 0; i < userCount && numExtracted < maxUsers; i++) {
    int offset = 3 + i * 3;  // 3 bytes per user
    if (offset + 2 < idx) {
      ids[numExtracted] = ((uint16_t)dataBuf[offset] << 8) | dataBuf[offset + 1];
      perms[numExtracted] = dataBuf[offset + 2];
      numExtracted++;
    }
  }
  
  return ACK_SUCCESS;
}



8. Play

시작을 하면, Initializing... 이라는 대문과 함께 3초 정도 보여 줍니다.


첫 메뉴는 SCAN 이며, Rotary Encoder 를 돌리면서 메뉴를 바꿔 갈 수 있습니다. 순서는 SCAN > ENROLL > DELETE > LIST > CLEAR 순 입니다.


ENROLL 및 SCAN 등을 하나로 해보는 동영상 입니다.


등록된 LIST 를 보여주는 방식이, 0-99 까지 full scan 하는 방식이었습니다. 0x2B 라는 명령어를 새로 정의하여, 시간을 단축하였으나, 그냥 SEARCH 만을 활용 했을 시의 비효율을 공유합니다.




9. Conclusion

어떤 모듈을 사용하기 위해,  제조사에서 제공되는 신호 특성과 Protocol, 그리고 명령어 체계를 습득한 후, 그를 활용하는 방법은 시간과 노력이 많이 듭니다. 특히 전공자가 아닌 사람이 실사용 적용까지 많은 시간이 듭니다.

그러나, 시대가 바뀌었습니다. 저희에게는 AI 라는 강력한 도구가 생겼으며, 시간적인 제약을 없애주고 단숨에 준 전문가의 영역으로 발을 내디딜 수 있게 되었습니다. 이번 FPC1020A 모듈의 활용은 5년의 고민을 5시간으로 줄여 주었습니다. 뿐만 아니라, Github 용인 README.md 파일 작성을 자동으로 생성해 주었으며, 미심적거나 모르는 내용에 대한 자연어 질문을 통해 지금까지 궁금하거나 grey 한 부분을 명확하게 할 수 있었습니다. 뭐든지 물어볼 수 있으며, 즉각적인 대답과 적용 그리고 갱신을 이룰 수 있는 AI 툴은 너무 강력함을 다시금 느낄 수 있는 계기였습니다.

조금 무서움도 느끼는 과정이었지만, 한 개인의 한계를 없애준다는 매력은, 한동한 취하게 될 듯 합니다. 지금까지 쌓아 놓고만 있던 미지의 모듈들도 하나씩 정리해 가 보겠습니다.

FIN

댓글