ODNFC-LAN RFID для разработчиков (LUA)
Краткое руководство по программированию
RFID считывателя-контроллера
Ключевые правила разработки
Работа с Embedded устройством вносит свои особенности в разработку
  • 1
    Lua 5.3
    В устройстве работает полноценная Lua 5.3. Для удобства мы выкладываем отдельно справочник по Lua, справочник по ОС и справочник с исходными кодами по библиотекам, которые мы разработали.
  • 2
    Библиотеки
    Мы разработали несколько библиотек для упрощения работы с устройствами. Часть из них сделаны в виде "классов" из предпололжения, что у вас возникнет желание "наследоваться" от них. И реализацию всех файлов можно посмотреть прямо в устройстве с помощью консоли, а актуальную версию (она может отличаться) - в репозитории. Также доступно описание библиотек.
    Также доступен пакетный менеджер для установки дополнительных библиотек.
  • 3
    Операционная система
    Наша система построена на базе проекта от whitecatboard и представляет собой связку Lua-интерпретатора, FreeRTOS, ESP-IDF и большого количества вспомогательного кода. Для удобства мы перевели документацию, исправили ошибки и добавили описание своей реализации по ссылке.
  • 4
    Пользовательский код
    Подавляющее большинство задач можно выполнить в пределах файла usercode.lua, доступного через web-интерфейс. Но, если у вас очень большой или специфический проект, можно "вооружиться" консолью и переназначить все - от системных выполняющихся файлов, до используемых библиотек: только спросите нас - как.
  • 5
    Кооперативная многозадачность
    В системе используется кооперативная многозадачность. Это значит, что в каждом процессе должно быть место, где он передает управление другим задачам, например, через thread.sleep или io-блокировку.
  • 6
    Время исполнения и web-интерфейс
    Из предыдущего пункта следует, что если вы слишком сильно загрузите основную задачу, то процессу ему не хватит времени, чтобы выдать вашему браузеру web-интерфейс и браузер разорвёт соединение до загрузки страницы. Поэтому, всегда оставляйте достаточное время для исполнения других потоков когда. Для этого у вас в основном цикле должна быть строка типа thread.sleepms(100)
  • 7
    Ограниченные ресурсы
    По-сравнению с ПК, у устройства очень мало памяти и вычислительной мощности. Это необходимо помнить при разработке: старайтесь не использовать большие структуры в коде и не проводить долгих вычислений, не открывать много потоков.
  • 8
    Garbage collector
    Т.к. в качестве инструмента программирования используется скриптовый язык, то в системе работает и gc. Если его сильно нагрузить, например, постоянно создавать много больших локальных файлов, то его работа может оказаться заметной (т.е. вызывать небольшие "фризы").
    Поэтому, во-первых, старайтесь не нагружать gc - аккуратно подходите к распределению ресурсов.
    Во-вторых, вы можете вызывать gc самостоятельно в тех местах, где это некритично.
    Продумывайте, где лучше использовать глобальные переменные (потратить память, но ускорить gc), а где - локальные.
  • 9
    Порядок разработки
    Разрабатывать сложные алгоритмы под Embedded устройство бывает не всегда удобно. Поэтому используем следующую схему:
    1. Предварительно код, не зависящий от железа, разрабаывается и проверяется на "обычном" Lua для пк.
    2. Аппаратное взамодействие проверяется в консоли устройства (лучше через донгл, хуже - через ssh). Чтобы устройство не перегрузилось по watchdog'у, его надо предварительно выключить в настройках.
    3. Итоговый вариант можно загружать в устройство и смотреть в первой вкладке ошибки.
    4. Для сложных проектов, по-крайней мере на этапе разработки, возможно лучше взять версию PRO (если она доступна для данного исполнения), потому что там гораздо больше ресурсов.
  • 10
    Применение нового кода
    Чтобы новый код устройства вступил в силу, нужно нажать на кнопку "сохранить" и перезагрузить устройство и убедитесь, что устройство перезагрузилось.
    Браузер не всегда "подцепляет" перезагрузившуюся страницу (зависит от того, попала ли перезагрузка на запрос), поэтому обновите страницу вручную.
  • 11
    Сторожевой таймер
    Сторожевой таймер нужен для перезагрузки устройства, если оно повиснет, или код где-то "застрянет". Если он включен в настройках, то сторожевой таймер запустится и у программы будет 10 секунд, чтобы его сбросить. Поставьте его сбор в основной (или самый ответственный) цикл программы.
    Отключайте его на период разработки и не забудьте включить перед установкой.
Как из кода получить доступ к настройкам
web-интерфейса
Для удобства работы с устройством самые используемые настройки выведены в Web-интерфейс.
Часть настроек считыватель использует сам, а часть предназначена для использования в коде и выведена только для того, чтобы при настройке пользователь лишний раз не лез в код и случайно не делал в нем ошибок. Так что, при желании, экраном настроек можно не пользоваться и писать код с константами.

Для работы с настройками определены две функции:
settings_get(key) - прочитать настройку,
settings_set(key, value) - записать настройку.

Настройки условно делятся на следующие группы:

Системные:
sys_wdt = "on" - включать сторожевой таймер при запуске "on"/"off". Нужно сбрасывать чаще, чем раз в 5 сек.
sys_lang = "auto" - язык интерфейса. строка: "auto" / "ru" / "en"
sys_telnet = "off" - включать telnet-сервер при запуске "on"/"off"
sys_web = "on" - включать web-интерфейс при запуске "on"/"off"
sys_ntp = "" - адрес ntp-сервера
sys_ntp_off = "0" - смещение времени по часовому поясу
sys_pwd = "" - пароль для web-интерфейса и telnet
sys_ssh = "off" - включать ssh при запуске "on"/"off"

Сеть:
net_type = "DHCP" - тип сети и способ получения адреса:
  • "DHCP" - ethernet-клиент с получением адреса от сервера,
  • "STATIC" - статические настройки сети,
  • "STA" - wifi-клиент с получением адреса от сервера,
  • "AP" - wifi-точка доступа. Создает wifi-сеть,
  • "WPS" - wifi-клиент с настройкой через WPS,
  • "TOUCH" - wifi-клиент с настройкой через приложение "ESP-TOUCH",
net_ssid = "odnfc-" - название wifi сети
net_password = "" - пароль wifi сети
net_ip = "192.168.1.2" - ip для режима net_type = "STATIC"
net_mask = "255.255.255.0" - маска сети для режима net_type = "STATIC"
net_gw = "192.168.1.1" - шлюз для режима net_type = "STATIC"
net_dns = "8.8.8.8" - dns для режима net_type = "STATIC"
net_mdns = "on" - включать MDNS при запуске "on"/"off". Создает http-адрес типа odnc-[4-последние-цифры-серийного-номера].local. Например http://odnfc-1234.local
net_dest = "192.168.1.1" - ip-получателя. Используется для сценариев взаимодействия с сервером.

RFID:
rfid_format="HU*" - строка форматирования RFID,
rfid_keytype="A" - тип ключа шифрования метки RFID; строка 'A'/'B'/'UL',
rfid_key="FFFFFFFFFFFF" - значение ключа метки; строка в hex.
Пример обращения к настройкам
wdt_on = Settings.get("sys_wdt") == "on"
server = Settings.get("net_dest")
Примеры программ
Ниже представлены примеры программ из устройств (в точно таком виде, как они поставляются с устройствами или чуть упрощенном виде)
Устройство передает UID поднесенной карты заданному в настройках серверу по HTTP, и в зависимости от ответа, открывает или нет.
-- импорт библиотек
local rfid = require("rfid")
local indication = require("indication")
-- иницализация
reader = rfid.Reader({})
leds = indication.Leds.new({RGB=true})
snd = indication.Sound.new()
locker = rfid.OUT()
-- читаем настройки
wdt_on = Settings.get("sys_wdt") == "on"
server = Settings.get("net_dest")
host, path, ssl, port = net.parseUrl(Settings.get("net_dest"))
-- стартовая анимация
leds:start()
snd:start()
-- настраиваем индикацию и время открытия замка
function locker_open_sound()
    for i=1, 8 do
        if wdt_on then
            cpu.watchdog.reset()
        end
	snd:play("G6", 4)
	thread.sleepms(500)
    end
end
-- запускаем функцию-обработчик
reader.process({
    timeout_ms = 200, -- таймаут в цикле. всегда должен быть больше 0
    mode = rfid.MODE_LOOP, -- вечный цикл, однократный или в режиме сопрограммы
    wdt = wdt_on, -- сторожевой таймер
    checkfunc = function(uid) -- функция для проверки карты
        -- отправляем http get-запрос и анализируем код ответа
        local status, res = pcall(function () return net.http.get(host, path.."?uid="..uid, "text/html", "", ssl, port) end)
        return res == 200
    end,
    okfunc = function() -- функция для обработки успешного поведения.
        locker.open() -- открытие замка
        leds:ok() -- индикация
        snd:ok() -- звук
        thread.sleepms(100)
        locker_open_sound() -- озвученная пауза на время открытия двери
        locker.close() -- закрытие замка
    end,
    errfunc = function() -- индикация ошибки
        leds:err()
        snd:err()
    end
})
Код тестового web-сервера на python bottlepy
from bottle import route, run, request

@route('/<path:path>')
def show_params(path):
    params = request.query.decode()
    print(f"Path: {path}")
    print("Parameters:")
    for key, value in params.items():
        print(f"  {key}: {value}")
    return "Parameters received"

run(host='0.0.0.0', port=5000, debug=True)
Данный код открывает дверь при найденном в списке UID, который загружается из файла
-- импорт библиотек
rfid = require("rfid")
indication = require("indication")
-- иницализация
leds = indication.Leds.new({RGB=true})
snd = indication.Sound.new()
reader = rfid.Reader({})
locker = rfid.OUT()
database = rfid.UID_cache({file="userlist.txt", size=1000, strlen=0})
-- читаем настройки
wdt_on = Settings.get("sys_wdt") == "on"
format = Settings.get("rfid_format")
-- стартовая анимация
leds:start()
snd:start()
-- настраиваем индикацию и время открытия замка
function locker_open_sound()
    for i=1, 8 do
        if wdt_on then
            cpu.watchdog.reset()
        end
    snd:play("G6", 4)
    thread.sleepms(500)
    end
end
-- запускаем функцию-обработчик
reader.process({
    timeout_ms = 200, -- таймаут в цикле. всегда должен быть больше 0
    mode = rfid.MODE_LOOP, -- вечный цикл, однократный или в режиме сопрограммы
    wdt = wdt_on, -- сторожевой таймер
    format = format, -- формат чтения с метки
    uidtype = rfid.UID_INT,
    checkfunc = function(uid) -- функция для проверки карты
        return database.find(uid) 
    end,
    okfunc = function() -- функция для обработки успешного поведения.
        locker.open() -- открытие замка
        leds:ok() -- индикация
        snd:ok() -- звук
        thread.sleepms(100)
        locker_open_sound() -- озвученная пауза для выхода
        locker.close() -- закрытие замка
    end,
    errfunc = function() -- индикация ошибки
        leds:err()
        snd:err()
    end
})
Устройство передает UID поднесенной карты заданному в настройках серверу по UDP. Адрес задается в виде ip:port, например, 192.168.1.233:5555.
-- импорт библиотек
rfid = require("rfid")
indication = require("indication")
-- читаем настройки
wdt_on = Settings.get("sys_wdt") == "on"
format = Settings.get("rfid_format")
host, path, ssl, port = net.parseUrl(Settings.get("net_dest"))
leds = indication.Leds.new({RGB=true})
snd = indication.Sound.new()
reader = rfid.Reader({})
-- стартовая индикация
leds:start()
snd:start()
-- запускаем функцию-обработчик
reader.process({
    timeout_ms = 200, -- таймаут в цикле. всегда должен быть больше 0
    mode = rfid.MODE_LOOP, -- вечный цикл, однократный или в режиме сопрограммы
    wdt = wdt_on, -- сторожевой таймер
    format = format, -- формат чтения с метки
    checkfunc = function(uid)
        return net.udp.sendto(host, port, uid)
    end,
    okfunc = function() -- функция для обработки успешного поведения.
        leds:ok() -- индикация
        snd:ok() -- звук
    end
})
Устройство передает UID поднесенной карты заданному в настройках серверу по MQTT и получает команды для управления
-- импорт библиотек
rfid = require("rfid")
indication = require("indication")
-- читаем настройки
wdt_on = Settings.get("sys_wdt") == "on"
format = Settings.get("rfid_format")
dst = Settings.get("net_dest")
leds = indication.Leds.new({RGB=true})
snd = indication.Sound.new()
reader = rfid.Reader({})
locker = rfid.OUT()
client = mqtt.client("100", "test.mosquitto.org", 1883, false)
client:connect("","")
client:subscribe("/test112233/cmd", mqtt.QOS0, function(len, message, topic_len, topic_name)
  if message == "ok" then
    locker.open() -- открытие замка
    leds:ok() -- индикация
    snd:ok() -- звук
    thread.sleepms(3000) -- пауза 3 сек
    locker.close() -- закрытие замка
   else
    leds:err() -- индикация
    snd:err() -- звук
   end
  print("message: "..message)
end)
-- стартовая индикация
leds:start()
snd:start()
-- запускаем функцию-обработчик
reader.process({
    mode = rfid.MODE_LOOP, -- вечный цикл
    wdt = wdt_on, -- сторожевой таймер включен
    checkfunc = function(uid)
        client:publish("/test112233/uid", uid, mqtt.QOS0)
    end
})
Подписываемся на топик, чтобы убедиться, что MQTT работает и отдельно тестируем управление.
Подписаться на топик
mosquitto_sub -h test.mosquitto.org -t "/test112233/uid" -v

Отправить команду "ok" на открытие
mosquitto_pub -h test.mosquitto.org -t "/test112233/cmd" -m ok

Отправить (любую другую) команду - "ошибка"
mosquitto_pub -h test.mosquitto.org -t "/test112233/cmd" -m err
Реализация классической схемы считывателя с мастер-ключом на добавление и удаление меток
-- импорт библиотек
rfid = require("rfid")
indication = require("indication")
Masterkey = require("masterkey")
-- инициализация
leds = indication.Leds.new({RGB=true})
snd = indication.Sound.new()
reader = rfid.Reader({})
locker = rfid.OUT()
time_filter = rfid.time_filter(1)
-- читаем настройки
wdt_on = Settings.get("sys_wdt") == "on"
format = Settings.get("rfid_format")
-- стартовая анимация
leds:start()
snd:start()
-- настраиваем индикацию и время открытия замка
function locker_open_sound()
    for i=1, 8 do
        if wdt_on then
            cpu.watchdog.reset()
        end
    snd:play("G6", 4)
    thread.sleepms(500)
    end
end

-- большой класс для реализации функционала "Мастер-ключ"
master = MasterKey:new({
writeKey="348FBF61785966", -- мастер-ключ "запись"
deleteKey="042F0932AD4184", -- мастер-ключ "удаление" 
filePath="userlist.txt", -- список ключей. его можно загрузить через web
writefunc=function() leds:set(200, 200, 0); snd:start() end, -- поведение при входе в запись
delfunc=function() leds:set(0, 0, 255); snd:start() end, -- поведение при входе в чтение
idlefunc=function() leds:clear(); snd:ok() end, -- поведение при входе в обычный режим 
okfunc=function() leds:ok(); snd:ok(); end, -- индикация ок
errfunc=function() leds:err(); snd:err(); end}) -- индикация ошибки

-- запускаем функцию-обработчик
reader.process({
    mode = rfid.MODE_LOOP, -- вечный цикл
    wdt = wdt_on, -- сторожевой таймер
    format = format, -- формат чтения с метки
    checkfunc = function(uid) -- функция для проверки карты
        if time_filter.find(uid) == nil then  -- фильтр от повторного поднесения
            time_filter.append(uid)
            if master:process(uid) then -- обработчик "мастер-ключа"
                return rfid.UID_finder("userlist.txt")
            end 
        end 
    end,
    okfunc = function() -- функция для обработки успешного поведения.
        locker.open() -- открытие замка
        leds:ok() -- индикация
        snd:ok() -- звук
        thread.sleepms(100)
        locker_open_sound() -- озвученная пауза для выхода
        locker.close() -- закрытие замка
    end,
    errfunc = function() -- индикация ошибки
        leds:err()
        snd:err()
    end
})
Опрос UID, инициированный сервером по http с адреса http://<ip>/backend?action=get
-- импорт библиотек
rfid = require("rfid")
indication = require("indication")
-- иницализация
leds = indication.Leds.new({RG=true})
snd = indication.Sound.new(pio.GPIO2)
reader = rfid.Reader.new({})

-- читаем настройки
wdt_on = Settings.get("sys_wdt") == "on"
format = Settings.get("rfid_format")
-- стартовая анимация
leds:start()
snd:start()

--функция для хранения uid
function uid_storage()
    local mtx = thread.createmutex()
    local value = ""

    return function(newValue)  -- set
                mtx:lock()
                value = newValue
                mtx:unlock()
            end, 
            function()  -- get
                mtx:lock()
                local currentValue = value
                value = ""
                mtx:unlock()
                return currentValue
           end
end

setUID, getUID = uid_storage()

-- функция, которую можно вызвать http-запросом
-- http://<ip>/backend?action=get
function http_get(params)
    return getUID()
end

-- запускаем функцию-обработчик
reader:process({
    timeout_ms = 200, -- таймаут в цикле. всегда должен быть больше 0
    loop = true, -- вечный опрос
    wdt = wdt_on, -- сторожевой таймер включен
    format = format, -- формат чтения с метки
    okfunc = function() -- функция для обработки успешного поведения.
        setUID(uid)
        leds:ok() -- индикация
        snd:ok() -- звук
    end
})
Передача данных по TCP
-- импорт библиотек
rfid = require("rfid")
indication = require("indication")
-- читаем настройки и делаем инициализацию
wdt_on = Settings.get("sys_wdt") == "on"
format = Settings.get("rfid_format")
leds = indication.Leds.new({RGB=true})
snd = indication.Sound.new()
out = rfid.OUT()
reader = rfid.Reader({})
host, path, ssl, port = net.parseUrl(Settings.get("net_dest"))
if not port then port = 5555 end
-- стартовая индикация
leds:start()
snd:start()
out.set(0)
-- запускаем функцию-обработчик
reader.process({
    timeout_ms = 200, -- таймаут в цикле. всегда должен быть больше 0
    mode = rfid.MODE_LOOP, -- вечный цикл
    wdt = wdt_on, -- сторожевой таймер
    format = format, -- формат чтения с метки
    checkfunc = function(uid)
        return net.tcp.sendto(host, port, uid)
    end,
    okfunc = function() -- функция для обработки успешного поведения.
        leds:ok() -- индикация
        snd:ok() -- звук
    end
})
Готовые рецепты

Небольшие куски кода для решения типовых задач

Отправка почты. Рассчитано для использовании в Pro версии. В остальных может не заработать: зависит от нагрузки на устройство, используемых сертификатов почтового сервера и т.д.
-- задаем имя и порт почтового сервера
net.curl.mailserver("smtp.адрес.сервера", 465)

-- остальные настройки
options = {
user = "логин",
pass = "пароль",
to = {"получатель1", "получатель2"},
subj = "Тема",
msg = "Тестовая посылка",
secure = true,
attach = {"picture.jpg"}
}

ret, msg = net.curl.sendmail(options)
Добавление новых UID с сервера через HTTP GET
-- ip, path, content-type, ssl_en
code, ret = net.http.get("192.168.0.160", "/userlist.txt", "text/html", false)
if code == 200 then
       file = io.open("userlist.txt", "a")
       file:write(ret[1])
       file:close()
end
Чат-бот Telegram
createBot = require("telegram")
token = "токен_вашего_бота"

bot = createBot(token,
    function(chat_id, text, sendMessage, sendDocument)
        -- input text
        sendMessage(chat_id, "Вы сказали: " .. text)
    end,
    function(chat_id, document, sendMessage, saveDocument)
        -- input file
        local res, msg = saveDocument(document.file_id, "/downloads/"..document.file_name)
        sendMessage(chat_id, "Загрузка документа: " .. (res and "успешно" or msg))
    end
)

while true do
    bot.getUpdates()
    thread.sleepms(200)
end
Примеры отправки UDP и TCP
--- udp ---
net.udp.sendto("192.168.68.110", 5555, "hello")

--- tcp ---
net.tcp.sendto("192.168.68.110", 5555, "hello")
Упрощенный код для пополнения UID по UDP.
Аналогично можно сделать пополнение и другими интерфейсами: mqtt, http, telegam.
s = net.udp.bind(5555)

while true do
    from, data = net.udp.recvfrom(s, 100)
    if from then
        print(from, data)
        file = io.open("userlist.txt", "a")
        file:write(data)
        file:close()
    end
    print("timeout")
    thread.sleepms(100)
end
Дополнительные материалы
0
Строка форматирования
Как пользоваться, если выхотите читать не uid, а данные с карты.
1
Консоль
Маленькая шпаргалка "как работать с консолью".
2
Библиотеки и RTOS
Большая документация на нашем отдельном сайте.
3
Справочник по Lua
Русскоязычная он-лайн версия с поиском и перекрестными ссылками.
4
Справочник по LuaSocket
На оригинальном сайте.