Arduino

Итоговый мини-проект: умная панель доступа и журналирования событий (RFID + RTC + LCD + зуммер)

1 просмотров

Введение

В заключительной статье цикла и всего расширенного плана статей собираем мини-проект, объединяющий несколько модулей стартового комплекта в единую, законченную систему — панель контроля доступа: RFID-считыватель проверяет карту, RTC-модуль фиксирует точное время каждой попытки доступа, LCD отображает текущий статус, зуммер подаёт звуковой сигнал результата, а Qt-приложение ведёт журнал всех событий и позволяет администрировать список разрешённых карт.

Концепция

Архитектура проекта применяет паттерны, разобранные на протяжении всего цикла: неблокирующий loop() с раздельной обработкой каждого модуля (RFID, RTC, команды), паттерн OutputIndicator для согласованного отображения статуса сразу на LCD и через зуммер, и хранение списка разрешённых карт на стороне Qt-приложения (а не зашитого в прошивку) — Arduino пересылает считанный UID карты в Qt, которое самостоятельно принимает решение о доступе и сообщает результат обратно для отображения.

Пример кода

// firmware/src/main.cpp — итоговая прошивка панели доступа
#include <SPI.h>
#include <MFRC522.h>
#include <ThreeWire.h>
#include <RtcDS1302.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>

#define RFID_SS_PIN 10
#define RFID_RST_PIN 9
#define BUZZER_PIN 5

MFRC522 rfid(RFID_SS_PIN, RFID_RST_PIN);
ThreeWire rtcWire(6, 7, 8);
RtcDS1302<ThreeWire> rtc(rtcWire);
LiquidCrystal_I2C lcd(0x27, 16, 2);

void setup()
{
    Serial.begin(9600);
    SPI.begin();
    rfid.PCD_Init();
    rtc.Begin();
    lcd.init();
    lcd.backlight();

    if (!rtc.IsDateTimeValid()) {
        rtc.SetDateTime(RtcDateTime(__DATE__, __TIME__));
    }

    displayStatus("Готов к проверке", "");
    delay(100);
    Serial.println("READY");
}

void loop()
{
    handleRfidScan();
    handleIncomingCommands();
}

void handleRfidScan()
{
    if (!rfid.PICC_IsNewCardPresent() || !rfid.PICC_ReadCardSerial()) return;

    String uidString = "";
    for (byte i = 0; i < rfid.uid.size; i++) {
        if (rfid.uid.uidByte[i] < 0x10) uidString += "0";
        uidString += String(rfid.uid.uidByte[i], HEX);
    }
    uidString.toUpperCase();

    const RtcDateTime now = rtc.GetDateTime();
    char timeBuffer[20];
    snprintf(timeBuffer, sizeof(timeBuffer), "%04u-%02u-%02u %02u:%02u:%02u",
             now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second());

    // Отправляем UID и время на проверку Qt-стороне — само решение о доступе
    // принимается там, на основе хранимого в базе данных списка разрешённых карт
    Serial.print("ACCESS_REQUEST:");
    Serial.print(uidString);
    Serial.print(":");
    Serial.println(timeBuffer);

    displayStatus("Проверка карты...", uidString);
    rfid.PICC_HaltA();
}

void handleIncomingCommands()
{
    static String inputBuffer = "";
    while (Serial.available() > 0) {
        char c = Serial.read();
        if (c == 'n') {
            processCommand(inputBuffer);
            inputBuffer = "";
        } else if (inputBuffer.length() < 64) {
            inputBuffer += c;
        }
    }
}

void processCommand(String command)
{
    command.trim();
    if (command == "ACCESS:GRANTED") {
        displayStatus("Доступ разрешён", "");
        tone(BUZZER_PIN, 1500, 200);
    } else if (command == "ACCESS:DENIED") {
        displayStatus("Доступ запрещён", "");
        for (int i = 0; i < 2; i++) { tone(BUZZER_PIN, 2000, 100); delay(150); }
    } else if (command == "PING") {
        Serial.println("PONG");
    }
}

void displayStatus(String line1, String line2)
{
    lcd.setCursor(0, 0);
    lcd.print("                ");
    lcd.setCursor(0, 0);
    lcd.print(line1.substring(0, 16));

    lcd.setCursor(0, 1);
    lcd.print("                ");
    lcd.setCursor(0, 1);
    lcd.print(line2.substring(0, 16));
}
// accesscontrolservice.h — Qt-сторона: проверка доступа по базе и журналирование
#pragma once
#include <QObject>
#include <QSqlQuery>
#include <QSqlDatabase>
#include "arduinoconnection.h"

class AccessControlService : public QObject
{
    Q_OBJECT
public:
    AccessControlService(ArduinoConnection *connection, QSqlDatabase db, QObject *parent = nullptr)
        : QObject(parent), m_connection(connection), m_db(db)
    {
        connect(connection, &ArduinoConnection::lineReceived, this, &AccessControlService::onLineReceived);
    }

private slots:
    void onLineReceived(const QString &line)
    {
        if (!line.startsWith("ACCESS_REQUEST:")) return;

        const QStringList parts = line.mid(15).split(':', Qt::KeepEmptyParts);
        if (parts.size() < 2) return;

        const QString uid = parts[0];
        const QString timestamp = parts.mid(1).join(':'); // время может содержать ':' внутри себя

        const bool granted = isCardAuthorized(uid);
        logAccessAttempt(uid, timestamp, granted);

        m_connection->sendCommand(granted ? "ACCESS:GRANTED" : "ACCESS:DENIED");
        emit accessAttemptProcessed(uid, timestamp, granted);
    }

signals:
    void accessAttemptProcessed(const QString &uid, const QString &timestamp, bool granted);

private:
    bool isCardAuthorized(const QString &uid)
    {
        QSqlQuery query(m_db);
        query.prepare("SELECT 1 FROM authorized_cards WHERE uid = :uid AND active = 1");
        query.bindValue(":uid", uid);
        query.exec();
        return query.next();
    }

    void logAccessAttempt(const QString &uid, const QString &timestamp, bool granted)
    {
        QSqlQuery query(m_db);
        query.prepare("INSERT INTO access_log (uid, attempt_time, granted) VALUES (:uid, :time, :granted)");
        query.bindValue(":uid", uid);
        query.bindValue(":time", timestamp);
        query.bindValue(":granted", granted);
        query.exec();
    }

    ArduinoConnection *m_connection;
    QSqlDatabase m_db;
};

Пояснения к коду

Прошивка делегирует само решение о доступе Qt-стороне — handleRfidScan() только считывает UID карты, дополняет его текущей временной меткой из RTC-модуля, и отправляет как единый запрос ACCESS_REQUEST:UID:timestamp, после чего отображает промежуточный статус «Проверка карты…» на LCD, ожидая ответа от Qt-приложения в виде команды ACCESS:GRANTED/ACCESS:DENIED, которая и определяет финальное отображение и звуковой сигнал — это прямое применение архитектурного решения, устраняющего необходимость перепрошивки устройства при изменении списка разрешённых карт.

AccessControlService на Qt-стороне объединяет паттерны из цикла про базы данных — isCardAuthorized() — простой repository-подобный запрос проверки наличия активной карты в таблице authorized_cards, а logAccessAttempt() записывает каждую попытку доступа в таблицу access_log, что даёт полную историю всех событий доступа с точными временными метками, синхронизированными именно с RTC-модулем устройства, а не с часами компьютера (что важно, если устройство и компьютер физически разнесены и их часы могут расходиться).

Подводные камни

  • Задержка между считыванием карты на устройстве и получением ответа от Qt-приложения (время, требуемое на передачу запроса через последовательный порт, выполнение SQL-запроса, и передачу ответа обратно) создаёт заметную пользователю паузу между приближением карты и финальным результатом — для большинства практических случаев (доли секунды при локальной SQLite-базе) это совершенно приемлемо, но при использовании более медленной, удалённой базы данных эта задержка может стать заметной, и для UX стоит явно показывать промежуточный статус «Проверка…» (как в примере), а не оставлять пользователя в недоумении перед молчащим устройством.
  • Полная зависимость работоспособности системы доступа от того, что Qt-приложение в данный момент запущено и подключено — если компьютер с Qt-приложением выключен или приложение не запущено, устройство получит запрос на доступ, отправит его через последовательный порт, но никогда не получит ответа (поскольку никто не слушает), и пользователь останется перед застывшим экраном «Проверка карты…» без какого-либо разрешения ситуации; для критичных систем доступа нужен явный тайм-аут ожидания ответа на устройстве с понятным сообщением об ошибке связи, а не бесконечное ожидание.
  • Отсутствие защиты от повторной отправки одного и того же запроса проверки доступа, если пользователь случайно подержал карту в зоне действия считывателя дольше, чем требуется для одного считывания (несмотря на rfid.PICC_HaltA(), разобранный как частичная защита) — для производственной системы стоит дополнительно защититься от множественных, очень близких по времени запросов от одной и той же карты, фильтруя их либо на стороне прошивки, либо на стороне Qt-сервиса перед записью в журнал и отправкой ответа.
  • Хранение временной метки попытки доступа как простой текстовой строки в базе данных (а не как специализированного типа datetime конкретной СУБД) затрудняет последующие SQL-запросы, требующие сравнения или сортировки по времени (например, «показать все попытки доступа за последнюю неделю») — для production-уровня системы стоило бы преобразовывать строку времени, полученную от Arduino, в нативный тип QDateTime перед сохранением, используя соответствующий тип столбца СУБД, что обеспечило бы корректную сортировку и фильтрацию по времени средствами самой базы данных, а не текстовым сравнением строк.