Перейти к содержанию

Локальная база 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 не гарантирует доставку и порядок пакетов: для синхронизации полного списка предусмотрите номера версий, контроль целостности и повторную передачу либо используйте надёжный протокол.