Локальная база RFID-пользователей в ODNFC
Как выбрать и реализовать локальное хранилище RFID-пользователей в ODNFC на Lua: таблица, cutils, M*LIB, FlashDB или SQLite.
Все варианты ниже позволяют формировать или обновлять список во время работы.
Таблицу Lua и кэш cutils можно заполнить из программы, файла, ответа сервера,
TCP- или UDP-пакета. M*LIB, FlashDB и SQLite поддерживают собственные операции
изменения. Отдельный вопрос — откуда восстановить данные после перезапуска:
снова получить их из файла или сети либо прочитать из локальной базы.
Выбирайте хранилище по доступным операциям, структуре записи, допустимому расходу памяти и способу синхронизации, а не только по числу карт.
В примерах ниже UID служит ключом, а значение описывает пользователя или право доступа. Все фрагменты рассчитаны на ODNFC с Lua-прошивкой и дополняют основной цикл чтения карты. Чтение показано для встроенного MFRC522. В исполнении с другим считывателем передавайте в те же функции строку UID из соответствующего модуля.
Как выбрать хранилище
| Вариант | Изменения во время работы | Восстановление после перезапуска | Когда использовать |
|---|---|---|---|
| Таблица Lua | Добавление, замена и удаление UID с произвольными полями | Заново выполнить код, прочитать файл или получить данные по сети | База помещается в RAM, а формат и синхронизацию определяет программа |
cutils.newi | Добавление и проверка 4-байтных UID как 32-битных ключей | Снова заполнить из программы, файла или сети | Нужен компактный и быстрый белый список без полей пользователя |
cutils.news | Добавление и проверка UID-строк; для удаления нужно пересобрать кэш | Снова заполнить из программы, файла или сети | Нужен список допуска для UID разной длины |
mlib.dict | Добавление, замена и удаление пар UID–значение | Загрузить снимок mlib.load() или получить данные извне | Нужен контейнер M*LIB и периодическое сохранение снимка |
| FlashDB KVDB | Добавление, замена и удаление отдельных пар | Открыть локальную базу; успешные операции уже записаны | Нужна локальная база с независимым обновлением пользователей |
| SQLite | Добавление, изменение и удаление строк, запросы и транзакции | Открыть локальный файл базы | Нужны поля, индексы, массовые изменения, сложные запросы или обмен готовой базой с ПК |
Набор модулей зависит от исполнения ODNFC и версии прошивки. Перед проектированием проверьте нужный модуль на устройстве:
for _, name in ipairs({"cutils", "mlib", "flashdb", "lsqlite3"}) do
local ok, module_or_error = pcall(require, name)
print(name, ok and "доступен" or ("нет: " .. tostring(module_or_error)))
end
Файл, HTTP, TCP и UDP — источники и каналы синхронизации. Полученные записи можно поместить в любую структуру с подходящими операциями. При полной замене сначала соберите и проверьте новый набор, затем одной операцией замените ссылку на него: так обработчик карты не увидит частично загруженный список.
Выберите одно представление UID
Для таблицы Lua, cutils.news, M*LIB, FlashDB и SQLite удобно использовать
mfrc522.scan(0, callback): он передаёт UID как шестнадцатеричную строку в
верхнем регистре. Записывайте ключи в том же виде, без пробелов, двоеточий и
префикса 0x.
local uid = "04A1B2C3D4E5F6"
Кэш cutils.newi предназначен для 4-байтных UID, упакованных в 32-битное число. mfrc522.scan(2, callback)
сразу возвращает UID в подходящем для addi() и findi() виде, поэтому
преобразование из hex-строки не требуется. UID длиной 7 и 10 байт храните
строкой.
1. Таблица Lua — изменяемый словарь в оперативной памяти
Таблица даёт простой поиск по UID и позволяет хранить произвольные поля пользователя. Начальные записи можно задать в программе:
local users = {
["04A1B2C3"] = {id = "employee-17", zone = "office"},
["11223344"] = {id = "service-02", zone = "service"},
}
local function find_user(uid)
return users[uid]
end
local function put_user(uid, user)
users[uid] = user
end
local function delete_user(uid)
users[uid] = nil
end
put_user() и delete_user() можно вызывать из обработчика файла, HTTP,
TCP, UDP или другого протокола. Таблица не требует, чтобы список был задан в
usercode.lua и оставался неизменным.
Сама таблица находится в оперативной памяти. После перезапуска программа должна заново построить её из выбранного источника: исходного кода, локального файла или сервера. Если изменения должны храниться на самом устройстве, сериализуйте таблицу в файл либо используйте одно из постоянных хранилищ ниже.
2. cutils — компактные белые списки в памяти
Модуль cutils реализует специальные userdata-кэши; имя cacheutils оставлено для обратной
совместимости.
newi — экономичный и быстрый поиск 4-байтных UID
cutils.newi() хранит только 32-битные ключи без строк и полей пользователя.
Он предназначен для белого списка, в котором на каждый UID нужен один ответ:
«разрешён» или «не разрешён».
local cutils = require("cutils")
local allowed = cutils.newi(2000)
local function add_uid(uid32)
return cutils.addi(allowed, uid32)
end
local function find_user(uid32)
return cutils.findi(allowed, uid32) or nil
end
local callback_ok, err = mfrc522.scan(2, function(uid32)
if find_user(uid32) then
iprint("Access granted")
else
wprint("Access denied")
end
end)
if callback_ok == false then
error(err, 0)
end
result_type = 2 разрешён только для 4-байтного UID и упаковывает его в
big-endian uint32. В 32-битной сборке Lua значения больше 0x7FFFFFFF
отображаются как отрицательные целые. Это не мешает кэшу: addi() и findi()
преобразуют аргумент обратно в uint32_t и сравнивают все 32 бита.
В реализации для сборок с MLIB используется хеш-множество со средним временем поиска O(1). В сборках без MLIB применяется компактный массив и линейный поиск O(n). В обоих случаях ёмкость задаётся заранее и составляет от 1 до 5000 UID.
Белый список можно наполнить во время запуска или позднее — из файла, TCP,
UDP, HTTP и другого источника. Для проверки карты преобразование не нужно:
передавайте значение из mfrc522.scan(2, callback) прямо в findi(). Формат
файла или сетевого протокола выбирайте так, чтобы при загрузке сохранялись все
32 бита UID.
news — строки для UID разной длины
Если встречаются 7- или 10-байтные UID, используйте строковый кэш:
local cutils = require("cutils")
local allowed = cutils.news(500, 20)
assert(cutils.adds(allowed, "04A1B2C3"))
assert(cutils.adds(allowed, "04A1B2C3D4E5F6"))
local function add_uid(uid)
return cutils.adds(allowed, uid)
end
local function find_user(uid)
return cutils.finds(allowed, uid) or nil
end
news принимает от 1 до 5000 строк длиной не более 20 байт. Этого хватает для
UID длиной до 10 байт в hex-представлении. Размер кэша фиксируется при создании,
а adds() возвращает false, если новая строка не помещается.
Новые UID можно добавлять в кэш во время работы, в том числе после чтения файла или получения данных по сети. У кэша нет удаления, перебора, полей пользователя и встроенной сериализации. Чтобы удалить UID или заменить весь список, создайте новый кэш, заполните его актуальными значениями и замените ссылку:
local function replace_allowed(uids)
local next_allowed = cutils.news(500, 20)
for _, uid in ipairs(uids) do
assert(cutils.adds(next_allowed, uid), "UID не помещается в кэш")
end
allowed = next_allowed
end
После перезапуска кэш нужно снова наполнить из выбранного источника. В текущей
реализации строки ищутся линейным перебором. Оба варианта cutils полезны,
когда нужен только ответ «UID есть в списке» и важно заранее ограничить его
размер. Полный API описан в справочнике
Utils.
3. M*LIB — словарь со снимком в файле
mlib.dict — хеш-таблица со строковыми ключами и поиском за O(1) в среднем.
Контейнер живёт в оперативной памяти, но его можно явно сохранить в файл и
загрузить при следующем запуске:
local mlib = require("mlib")
local filename = "/rfid-users.bin"
local users = mlib.load(filename)
if not users then
users = mlib.dict()
end
local function find_user(uid)
return users:get(uid)
end
local function put_user(uid, user_id)
users:set(uid, user_id)
end
local function delete_user(uid)
return users:del(uid)
end
local function save_users()
return mlib.save(users, filename)
end
put_user() и delete_user() изменяют словарь сразу. Их можно вызывать из
обработчиков файловых и сетевых данных. save_users() записывает текущий снимок во флеш-память; для применения
изменений во время работы он не нужен.
При сериализации значение должно быть nil, boolean, числом или строкой.
Таблица с полями в снимок не записывается: упакуйте запись в одну строку либо храните
поля под отдельными ключами. Снимок ограничен 4096 элементами и 524 288 байтами.
mlib.save() перезаписывает файл целиком и не создаёт транзакцию. Не сохраняйте
базу после каждого чтения карты. При обновлении сохраняйте резервную копию и
учитывайте, что отключение питания во время записи может повредить снимок.
Формат снимка внутренний и не содержит версии, поэтому храните исходный список
отдельно и проверяйте загрузку после смены прошивки.
Подходит ли дерево
Для Lua в M*LIB доступно красно-чёрное дерево mlib.rbtree():
оно ищет, вставляет и удаляет ключи за O(log n), а обходит их в порядке
сортировки.
local users = mlib.rbtree()
users:set("04A1B2C3", "employee-17")
users:set("11223344", "service-02")
users:iter(function(uid, user_id)
print(uid, user_id) -- UID идут по возрастанию
end)
Для обычной проверки одного UID дерево не даёт преимущества перед
mlib.dict: хеш-таблица в среднем ищет быстрее. Выбирайте rbtree, только если
нужен отсортированный экспорт или последовательный просмотр ключей. Ограничения
сериализации у него те же. Методы обоих контейнеров перечислены в
справочнике M*LIB.
4. FlashDB — постоянная база ключ-значение
FlashDB KVDB обновляет отдельные пары без перезаписи всей базы. UID помещается в ключ, а идентификатор или сериализованная карточка пользователя — в значение:
local flashdb = require("flashdb")
local users, message, code = flashdb.open_kv(
"rfid", -- имя базы, до 8 символов
"/rfid-users", -- каталог в LittleFS
64 * 1024 -- размер базы
)
if not users then
error(string.format("FlashDB %s: %s", tostring(code), message))
end
assert(users:set("04A1B2C3", "employee-17"))
assert(users:set("11223344", "service-02"))
local function find_user(uid)
return users:get(uid)
end
local function put_user(uid, user_id)
return users:set(uid, user_id)
end
local function delete_user(uid)
return users:delete(uid)
end
put_user() и delete_user() можно вызывать после разбора файла или сетевого
сообщения. В отличие от RAM-контейнеров, каждая успешная операция сразу меняет
локальную базу.
Ключ FlashDB занимает до 63 байт, значение — до 3968 байт. Поэтому UID можно хранить непосредственно, а запись пользователя при необходимости кодировать в JSON, CBOR или MessagePack, если соответствующий модуль есть в прошивке.
Успешная операция set() сохраняет одну запись с синхронизацией на носитель.
Несколько ключей не образуют общую транзакцию: после сбоя часть пакетного
обновления может быть новой, а часть — старой. Для массовой замены сначала
запишите версию набора под новыми ключами и только затем переключите отдельный
ключ активной версии либо используйте SQLite.
Удалённые и заменённые значения освобождаются сборкой мусора. Оставляйте в базе несколько свободных секторов и закрывайте её перед копированием файлов или обновлением прошивки. Подробности — в справочнике FlashDB.
5. SQLite — пользователи, права и транзакции
SQLite подходит, когда у пользователя несколько полей, нужны выборки по зоне
или статусу, а обновления должны применяться одной транзакцией. Храните UID в
TEXT PRIMARY KEY, а не в INTEGER:
local sqlite3 = require("lsqlite3")
local db, code, message = sqlite3.open("/rfid-users.db")
if not db then
error(string.format("SQLite %s: %s", tostring(code), message))
end
assert(db:exec([[
CREATE TABLE IF NOT EXISTS rfid_user(
uid TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
zone TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1))
) WITHOUT ROWID
]]) == sqlite3.OK)
local put = assert(db:prepare([[
INSERT INTO rfid_user(uid, user_id, zone, enabled)
VALUES(?1, ?2, ?3, ?4)
ON CONFLICT(uid) DO UPDATE SET
user_id=excluded.user_id,
zone=excluded.zone,
enabled=excluded.enabled
]]))
local function put_user(uid, user_id, zone, enabled)
assert(put:bind_values(uid, user_id, zone, enabled and 1 or 0)
== sqlite3.OK)
assert(put:step() == sqlite3.DONE)
assert(put:reset() == sqlite3.OK)
end
put_user("04A1B2C3", "employee-17", "office", true)
local lookup = assert(db:prepare([[
SELECT user_id, zone FROM rfid_user
WHERE uid=?1 AND enabled=1
]]))
local function find_user(uid)
assert(lookup:bind_values(uid) == sqlite3.OK)
local result = lookup:step()
local user
if result == sqlite3.ROW then
user = {
id = lookup:get_value(0),
zone = lookup:get_value(1),
}
else
assert(result == sqlite3.DONE, "SQLite code: " .. tostring(result))
end
assert(lookup:reset() == sqlite3.OK)
return user
end
Файл SQLite можно подготовить на ПК: создать схему, импортировать пользователей,
проверить запросы и затем загрузить готовую .db на считыватель. Обратный
сценарий тоже работает: базу, которую считыватель создал или обновил во время
работы, можно выгрузить на ПК для просмотра, отчётов, резервного копирования или
подготовки следующей версии.
Такой обмен переносит снимок базы целиком. Перед копированием завершите
транзакции, закройте все statement и соединение SQLite. Между ПК и
считывателем передавайте основной файл .db, без -wal и -shm.
put_user() можно вызывать из файлового или сетевого обработчика. Для массового
импорта оборачивайте изменения в BEGIN IMMEDIATE и COMMIT, а при ошибке
выполняйте ROLLBACK. После окончания работы вызовите put:finalize() и
lookup:finalize(), затем db:close(). Не полагайтесь на сборщик мусора для
закрытия этих объектов.
В совместимых исполнениях ODNFC SQLite использует обычный незашифрованный файл
LittleFS, rollback journal и synchronous=FULL; WAL не поддерживается.
Оставляйте место под базу, журнал и временные файлы — для большой транзакции
требуется больше двух размеров базы плюс запас файловой системы. Полный API и
порядок загрузки готового файла описаны в справочнике
SQLite.
Подключите поиск к чтению карты
Каждый строковый вариант выше определяет find_user(uid). В основном цикле
передайте UID в эту функцию и открывайте замок только при найденной активной
записи:
local callback_ok, err = mfrc522.scan(0, function(uid)
local user = find_user(uid)
if user then
iprint("Access granted:", uid)
-- Здесь включите индикацию и кратковременно откройте замок.
else
wprint("Access denied:", uid)
end
end)
if callback_ok == false then
eprint("RFID database callback: " .. tostring(err))
end
Для числового кэша cutils.newi() используйте отдельный пример с
mfrc522.scan(2, callback) из соответствующего раздела.
Не выводите полные UID и персональные данные в постоянный журнал. Проверка только по UID не защищает от копирования метки: для такой задачи используйте защищённую память карты, индивидуальные ключи или счётчики. Варианты разобраны в статье «Защита от клонов RFID-меток». Файлы M*LIB, FlashDB и SQLite не зашифрованы: храните в них по возможности обезличенные идентификаторы и ограничьте доступ к консоли, веб-интерфейсу и файловой системе устройства.
Если обновления приходят по сети, проверяйте формат и размер каждой записи и аутентифицируйте источник. UDP не гарантирует доставку и порядок пакетов: для синхронизации полного списка предусмотрите номера версий, контроль целостности и повторную передачу либо используйте надёжный протокол.