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(net.http.get, host, path.."?uid="..uid, "text/html", "", ssl, port);
        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

-- импорт библиотек
rfid = require("rfid")
indication = require("indication")
local socket = require("socket")
local udp = assert(socket.udp())
-- читаем настройки
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({})
-- стартовая индикация
leds:start()
snd:start()
-- запускаем функцию-обработчик
reader.process({
    timeout_ms = 200, -- таймаут в цикле. всегда должен быть больше 0
    mode = rfid.MODE_LOOP, -- вечный цикл, однократный или в режиме сопрограммы
    wdt = wdt_on, -- сторожевой таймер
    format = format, -- формат чтения с метки
    checkfunc = function(uid)
        udp:sendto(uid, dst, 5555)
        return true
    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")
local socket = require("socket")
local tcp = socket.tcp()
-- читаем настройки и делаем инициализацию
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)
        out.set(1)
        snd:ok()
        leds:set(255, 0, 255)
        local s, err = tcp:connect(host, port)
        if s then
            tcp:send(uid)
            tcp:close()
            thread.sleep(1)
        end
        leds:clear()
        out.set(0) 
        
    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
Получение данных по UDP

local socket = require("socket")

local udp = socket.udp()
udp:setsockname("*", 53474)
udp:settimeout(0.1)

local data = udp:receive()
if data then
    print("Received: ", data)
end


Примеры отправки UDP и TCP

local socket = require("socket")

--- udp ---
udp = socket.udp()
udp:sendto("hi","192.168.68.115", 5555)

--- tcp ---
foo = socket.protect(function()
-- connect somewhere
local c = socket.try(socket.connect("192.168.68.115", 12345))
-- create a try function that closes 'c' on error
local try = socket.newtry(function() c:close() end)
-- do everything reassured c will be closed
try(c:send("hello there?\r\n"))
---local answer = try(c:receive())
---...
---try(c:send("good bye\r\n"))
c:close()
end)

Упрощенный код для пополнения UID по UDP.
Аналогично можно сделать пополнение и другими интерфейсами: mqtt, http, telegam.

local socket = require("socket")

local udp = socket.udp()
udp:setsockname("*", 53474)
udp:settimeout(0.1)

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