Введение
В заключительной статье цикла и всего расширенного плана статей собираем мини-проект, объединяющий несколько модулей стартового комплекта в единую, законченную систему — панель контроля доступа: 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 ×tamp, 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 ×tamp, 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перед сохранением, используя соответствующий тип столбца СУБД, что обеспечило бы корректную сортировку и фильтрацию по времени средствами самой базы данных, а не текстовым сравнением строк.