[Из песочницы] Контроллер доступа на Go + Raspberry Pi + Arduino Nano

Хочу поделится очередным решением тривиальной задачи реализации сетевого контроллера доступа (СКУД).

Предыстория возникновения данной задачи заключается, как это часто бывает, в желании заказчика получить особый функционал работы СКУД контроллера. Эта особая функциональность заключается в следующем:

  • Контроллер должен быть сетевой (открытие, закрытие замка и режим «жесткой блокировки»)
  • Открытие и закрытие по кнопке (режим триггер)
  • Открытие и закрытие по ключу (режим триггер)
  • Режим «Жесткой блокировки» входа\выхода
  • Добавление, чтение и удаление ключей
  • Блокировка ключей
  • Журналирование событий


В режиме жесткой блокировки замок нельзя открыть ни с помощью ключа, ни через кнопку, ни через HTTP. Отключение происходит либо отменой режима блокировки, либо сбросом контроллера по питанию.

Учитывая, что заказчик готов был платить, практически, любые деньги, то изначально разрабатывать свой контроллер не планировалось, а решено было найти готовый на рынке контроллер и реализовать поставленную задачу. Но на практике оказалось все не так радостно. В качестве уточнения необходимо сказать, что в офисе заказчика реализована автоматика (система «Умный дом») на базе KNX + Control4.

Имеющиеся сетевые контроллеры очень функциональны, но в большинстве случаев этот функционал зашит микропрограммой производителя и изменить его естественно нельзя. Например, ни один из рассмотренных нами контроллеров (не буду приводить список, чтобы это не выглядело как реклама или антиреклама, да и вообще для смысла статьи, это не важно) не имеет функции закрывания двери по кнопке (наверняка, какие-то контроллеры это могут, может мы их пропустили). Но сетевой контроллер теоретически может получать различные команды по HTTP, в том числе и открытия\закрытия, а контроллер Control4 может эти команды отправлять. Но имеющиеся к рассматриваемым контроллерам SDK реализованы на .NET библиотеках старых версий 1,2,3, что подразумевало использовать Windows ПК в роли шлюза (сейчас в меня должны полететь помидоры от негодования .NET разработчиков, типа того, что есть .NetCore, Mono и т.п.). Наверняка, можно было и .Net разработку запилить адаптировать под Linux, но уверенности в правильности и стабильности такого подхода на тот момент не было.

Другой проблемой стало закрывание замка по ключу. С этой задачей легко справился только один контроллер, бюджетный (позволю обозначить один из претендентов) Z-5R от Ironlogic. У него есть режим «Триггер», но кнопка только на открытие. Вменяемой информации по SDK от техподдержки так и не получил. В общем проанализировав и оценив всю информацию было принято решение разработать собственное решение.

В качестве аппаратной платформы решили использовать связку Raspberry Pi (rev.B) + Arduino Nano. Arduino отлично работает с низкоуровневыми и интерфейсами, а на «малинке» можно поднять полноценный сетевой стек и использовать высокоуровневые языки программирования. Коммуникация между платами осуществляется через USB (по Serial Port)

image
На данной схеме не обозначен элемент звуковой индикации (звуковой спикер), реализация которого отражена в коде для Arduino. Из кода будет видно, что он подключен к пину — 9.

Для реализации использовались следующие компоненты:

• Raspberry Pi (rev. B) — 1 шт.
• Arduino Nano — 1 шт.
• Звуковой спикер — 1 шт.
• Считыватель ключей Touch Memory (iButton) — 1 шт.
• Резистор 220 Ом — 1 тш.
• Реле 12В — 1 шт.
• Замок электромагнитный 12В — 1 шт.

Требования к среде разработки

Перед тем как осуществлять разработку на Go необходимо подготовить среду (т.к. я осуществлял разработку в Windows, то список зависимостей описан именно для этой ОС). Я не буду подробно останавливаться на каждом пункте т.к. про них и так уже много сказано и написано.

  1. Установка Go под Windows
  2. Установка средства разработки. Я использовал Visual Studio Code. Очень удобный и функциональный редактор кода. Рекомендую! Хотя для Go можно использовать IDE Goland от JetBrains
  3. Настройка Visual Studio Code для работы с Go. Инструкция на английском, но всё описано достаточно понятно.
  4. Установленная Arduino IDE — для заливки скетча.
  5. Средство работы с Git репозиторием (для загрузки пакетов Go с Github)


Скетч СКУД для Arduino

Код для Arduino очень простой и понятный. Единственный момент на который нужно обратить внимание, что библиотека OneWire не входит в стандартный набор и ее необходимо скачать.

Небольшой особенностью в коде является сохранение текущего состояния замка в EEPROM микроконтроллера, на тот случай, чтобы запомнить текущее состояние замка если вдруг будет короткий сбой и потеря питания.

Скетч СКУД для Arduino
#include 
#include 

#define RELAY1 6  // пин подключения реле
boolean isClose; // флаг текущего состояния замка
boolean hl=false; // флаг текущего состояния режима блокировки
byte i; 
OneWire ds(7);  //пин подключения считывателя 
byte addr[8];  //буфер приема ключей

String inCommand = "";  // входящая команда от Raspberry Pi
char character; //буфер приема входящих команд

void setup() {
        Serial.begin(9600);
        pinMode(RELAY1, OUTPUT);
        stateRead(); 
}

void loop(){
        if (ds.search(addr)) {
                ds.reset_search();
                if ( OneWire::crc8( addr, 7) != addr[7]) {
                }
                else
                {
                        if(!hl){
                                for( i = 0; i < 8; i++) {
                                        Serial.print(addr[i],HEX);
                                }
                                Serial.println();
                        }
                }
        }

        ds.reset();
        delay(500);
        
        while(Serial.available()) {
                character = Serial.read();
                inCommand.concat(character);
        }
        if (inCommand=="hlock1"){
                hl=true;  
                r_close();
                Serial.println("HardLock Enable");
        }
        if (inCommand=="hlock0"){
                hl=false; 
                Serial.println("HardLock Disable");
        }
        if (inCommand != "" && !hl) {
                if ((inCommand=="open") && (isClose) ){
                        r_open();
                }
                if ((inCommand=="close") &&(!isClose)){
                        r_close();
                }
        }
        inCommand="";
}

void r_open(){
        digitalWrite(RELAY1,LOW);
        isClose=false;
        stateSave(isClose);
        SoundTone(0);
        delay(100);
        Serial.println("Relay Open ");
}

void r_close(){
        digitalWrite(RELAY1,HIGH);
        isClose=true;
        stateSave(isClose);
        SoundTone(1);
        delay(100);
        Serial.println("Realy Close");
}


void stateSave(boolean st) // Запись в текущего состояния в EEPROM
{
        if (st)
        {
                int val=1;
                EEPROM.write(0,val);
        }
        else 
        {
                int val=0;
                EEPROM.write(0,val);
        }
}

void stateRead()
{
        int val;
        val= (EEPROM.read(0));
        if (val==1)
                r_close();
        else 
                r_open();
}

void SoundTone(boolean cmd){
        if(!cmd){
                for (int i=0;i<10;i++){
                        tone(9, 815, 100);
                        delay(250);
                }
        }
        else {
                for (int i=0;i<4;i++){
                        tone(9, 395, 500);
                        delay(350);
                }
        }
        noTone(9);
}  



Основной код контроллера как уже говорилось написан на Go. Практически все библиотеки взяты из стандартных исходников Go, за исключением двух.

Первая, это основная базы данных BoltDB, типа key\value. Работа с ней не требует «танцев с бубном», она очень простая и быстрая. Вторая реализует работу с COM портом.

Основной алгоритм работы контроллера следующий:

  1. При старте читается конфигурация из файла config.json;
  2. Запускается небольшой HTTP REST-сервис;
  3. Открывается COM порт для обмена данными с Arduino;
  4. Создается канал типа bool который отправляется вместе с указателем на COM порт в go-рутину, где происходить чтение ID ключей от Arduino;
  5. Далее стартует цикл, в котором идет ожидание данных из, отправленного ранее в go-рутину, канала. Данные поступят в канал только в случае если прочитанный ключ существует в базе и активен, после чего на Arduino будет отправлена команда переключения реле.


Добавление, удаление, чтение ключей и управление замком осуществляется через HTTP запросы. Многие сразу же скажут, что это глупо, т.к. любой может выполнить запрос к контроллеру. Да, соглашусь, что безопасность нужно еще дорабатывать, но в качестве превентивной меры в файле конфигурации реализована возможность изменять наименования endpoint-ов для различных команд. Это немного затруднить захват управления контроллером посторонними.

Код контроллера
package main

import (
        "bufio"

        "encoding/json"
        "io/ioutil"

        "fmt"
        "log"
        "net/http"
        "os"
        "regexp"
        "time"

        "github.com/boltdb/bolt"
        "github.com/tarm/serial"
)

const dbname = "access.db" //имя файла основной БД

var isOpen, isHLock bool = false, false
var serialPort *serial.Port

func main() {
        
        // Чтение конфигурации
        config, err := readConfig()

        if err != nil {
                fmt.Printf("Error read config file %s", err.Error())
                return
        }
        // Настройка логирования
        f, err := os.OpenFile(config.LogFilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
        if err != nil {
                log.Fatalf("error opening file: %v", err)
        }
        defer f.Close()
        log.SetOutput(f)

        // Настройка и запуск HTTP-сервиса
        http.HandleFunc("/"+config.NormalModeEndpoint, webNormalMode)
        http.HandleFunc("/"+config.HardLockModeEndpoint, webHLockMode)
        http.HandleFunc("/"+config.CloseEndpoint, webCloseRelay)
        http.HandleFunc("/"+config.OpenEndpoint, webOpenRelay)
        http.HandleFunc("/"+config.AddKeyEndpoint, addKey)
        http.HandleFunc("/"+config.ReadKeysEndpoint, readKeys)
        http.HandleFunc("/"+config.DeleteKeyEndpoint, deleteKey)

        go http.ListenAndServe(":"+config.HTTPPort, nil)

        log.Printf("Listening on port %s...", config.HTTPPort)

        // Проверка доступности БД
        db, err := bolt.Open(dbname, 0600, nil)
        if err != nil {
                log.Fatal(err)
        }
        db.Close()

        // Доступ к Serial порту
        c := &serial.Config{Name: config.SerialPort, Baud: 9600}
        s, err := serial.OpenPort(c)

        if err != nil {
                fmt.Printf("Error open serial port %s ", err.Error())
                log.Fatal(err)

        }
        serialPort = s
        
        // Создание канала и запус процесса, go-рутины
        ch := make(chan bool) // wait chanel until key is valid
        go getData(ch, s)

        for {
                time.Sleep(time.Second)
                tmp := <-ch
                if tmp {
                        if isOpen {
                                closeRelay()
                        } else {
                                openRelay()
                        }
                }
        }

}

func getData(ch chan bool, s *serial.Port) {

        for {
                reader := bufio.NewReader(s)
                reply, err := reader.ReadBytes('\n')
                if err != nil {
                        log.Fatal(err)
                }
                k := string(reply)

                if chk := checkKey(k); chk {

                        ch <- chk
                        time.Sleep(2 * time.Second)
                }

        }

}
func invertBool() { //Смена флага состояния замка
        isOpen = !isOpen
}

func checkErr(err error) {
        if err != nil {
                panic(err)
        }
}

func boltStore(value Key) {
        db, err := bolt.Open(dbname, 0600, nil)
        if err != nil {
                log.Fatal(err)
        }

        defer db.Close()

        db.Update(func(tx *bolt.Tx) error {
                b, err := tx.CreateBucketIfNotExists([]byte("keys"))
                if err != nil {
                        return err
                }
                return b.Put([]byte(value.Key), []byte(value.isEnable))
        })
}

func boltRead(key string) bool {
        var strKey string
        db, err := bolt.Open(dbname, 0600, nil)

        if err != nil {
                log.Fatal(err)
                return false
        }

        defer db.Close()

        db.View(func(tx *bolt.Tx) error {

                re := regexp.MustCompile(`\r\n`)
                key := re.ReplaceAllString(key, "")
                re = regexp.MustCompile(`\n`)
                key = re.ReplaceAllString(key, "")
                re = regexp.MustCompile(`\r`)
                key = re.ReplaceAllString(key, "")
                log.Printf("Readed key: %s\n", key)

                b := tx.Bucket([]byte("keys"))
                v := b.Get([]byte(key))

                strKey = string(v)

                return nil
        })
        if strKey == "1" {
                log.Printf("Key %s valid\n", key)
                return true
        }
        return false

}

func addKey(w http.ResponseWriter, r *http.Request) {
        params := r.URL.Query()
        var key Key
        key.Key = params.Get("key")
        key.isEnable = params.Get("enable")
        boltStore(key)
        log.Printf("You add the key %s", key.Key)
        fmt.Fprintln(w, "You add the key", key.Key)

}
func readKeys(w http.ResponseWriter, r *http.Request) {
        keys := make(map[string]string)
        db, err := bolt.Open(dbname, 0600, nil)
        if err != nil {
                log.Fatal(err)
        }
        defer db.Close()

        db.View(func(tx *bolt.Tx) error {
                b := tx.Bucket([]byte("keys"))

                b.ForEach(func(k, v []byte) error {
                        keys[string(k)] = string(v)
                        fmt.Printf("map: %s\n", keys[string(k)])
                        return nil
                })
                return nil
        })
        data, _ := json.Marshal(keys)
        fmt.Fprintln(w, string(data))
}

func deleteKey(w http.ResponseWriter, r *http.Request) {
        params := r.URL.Query()
        deleteKey := params.Get("key")
        db, err := bolt.Open(dbname, 0600, nil)
        if err != nil {
                log.Fatal(err)
        }
        defer db.Close()
        db.Update(func(tx *bolt.Tx) error {
                // Retrieve the users bucket.
                // This should be created when the DB is first opened.
                b := tx.Bucket([]byte("keys"))
                err := b.Delete([]byte(deleteKey))
                if err != nil {
                        fmt.Printf("Key: \"%s\" delete failed: %s\n", deleteKey, err.Error())
                        return err
                }
                fmt.Fprintf(w, "Key: \"%s\" deleted succesfully\n", deleteKey)

                // Persist bytes to users bucket.
                return nil
        })

}

func webNormalMode(w http.ResponseWriter, r *http.Request) {
        isHLock = false
        _, err := serialPort.Write([]byte("hlock0"))
        if err != nil {
                log.Fatal(err)
        }
        fmt.Fprintln(w, "Normal Mode")
}
func webHLockMode(w http.ResponseWriter, r *http.Request) {
        _, err := serialPort.Write([]byte("hlock1"))
        if err != nil {
                log.Fatal(err)
        }
        isHLock = true
        fmt.Fprintln(w, "HardLock Mode")
}
func webCloseRelay(w http.ResponseWriter, r *http.Request) {
        switchRelay()
        fmt.Fprintln(w, "switch relay")
}
func webOpenRelay(w http.ResponseWriter, r *http.Request) {
        openRelay()
        fmt.Fprintln(w, "open lock")
}

func closeRelay() {

        _, err := serialPort.Write([]byte("close"))
        if err != nil {
                log.Fatal(err)
        }
        invertBool()
        log.Println("Close")

}

func openRelay() {

        _, err := serialPort.Write([]byte("open"))
        if err != nil {
                log.Fatal(err)
        }
        invertBool()
        log.Println("Open")

}
func switchRelay() {
        if isOpen {
                closeRelay()
        } else {
                openRelay()
        }
}
func checkKey(key string) bool {
        if boltRead(key) {

                return true
        }
        return false
}

func readConfig() (*Config, error) {
        plan, _ := ioutil.ReadFile("config.json")
        config := Config{}
        err := json.Unmarshal([]byte(plan), &config)
        return &config, err
}




Сборку бинарного файла я осуществлял на самом Raspberry Pi (естественно пришлось установить все зависимости для Go на «малину»).

GOOS=linux GOARCH=arm go build -o /home/pi/skud-go/skud-go


Также главное не забыть положить вместе с бинарным файлом следующие зависимые файлы:


config.json
access.db


config.json

{
«serialPort»:»/dev/ttyUSB0»,
«httpPort»:»80»,
«normalModeEndpoint»: «normal»,
«hardLockModeEndpoint»: «block»,
«closeEndpoint»: «close»,
«openEndpoint»: «open»,
«addKeyEndpoint»: «addkey»,
«deleteKeyEndpoint»: «deletekey»,
«readKeysEndpoint»: «readkeys»,
«logFilePath»:»/var/log/skud-go.log»
}


Типы данных контроллера. skud_type.go
package main

//Key тип данных ключа доступа
type Key struct {
        Key      string
        isEnable string
}

//Config основные параметры контроллера
type Config struct {
        SerialPort           string `json:"serialPort"`
        HTTPPort             string `json:"httpPort"`
        NormalModeEndpoint   string `json:"normalModeEndpoint"`
        HardLockModeEndpoint string `json:"hardLockModeEndpoint"`
        CloseEndpoint        string `json:"closeEndpoint"`
        OpenEndpoint         string `json:"openEndpoint"`
        AddKeyEndpoint       string `json:"addKeyEndpoint"`
        DeleteKeyEndpoint    string `json:"deleteKeyEndpoint"`
        ReadKeysEndpoint     string `json:"readKeysEndpoint"`
        LogFilePath          string `json:"logFilePath"`
}


Чтобы запустить контроллер как сервис, нужно создать дополнительный unit-файл. Такой файл сообщает системе инициализации systemd, как управлять тем или иным ресурсом. Сервисы — наиболее распространённый тип unit-файлов, определяющий зависимости и параметры запуска и остановки программы.

Создайте такой файл для skud-go. Файл будет называться skud-go.service и храниться в /etc/systemd/system.


sudo nano /etc/systemd/system/skud-go.service


Содержимое файла:

[Unit]
Description=Access Control System Controller by Go
After=network.target
[Service]
User=pi
ExecStart=/home/pi/skud-go/skud-go 
[Install]
WantedBy=multi-user.target


Чтобы запустить новый сервис, введите:

sudo systemctl start skud-go


Теперь нужно включить автозапуск данного сервиса:

sudo systemctl enable skud-go


В итоге получился достаточно простой и функциональный контроллер uptime которого, уже более 6 месяцев (крохотный конечно, но все впереди). Надеюсь данная статья будет для кого-нибудь полезной.

→ Исходники доступны на Github

© Geektimes