Контроллер доступа на 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 <OneWire.h>
#include <EEPROM.h>

#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
Метки:
  • +16
  • 10,5k
  • 8
Поделиться публикацией
Комментарии 8
  • 0
    А что мешало использовать (пусть вшитые) ключи для авторизации административных операций? Или банальные логин-пароль?
    • 0
      По большому счет, ни чего не мешало. Но т.к. контроллер управляется в составе автоматики через Control4 контроллер, то необходимо было написать драйвер для этого контроллера для взаимодействия со СКУД. Изначально инсталяторы, которые монтировали автоматику, реализовали драйвер без возможности даже Basic авторизации. На данный момент драйвер переписан уже моими силами и в дальнейшем будет реализован доступ к HTTP API через Token.
    • +1
      Вопрос, который напрашивается сам собой: зачем необходима лишняя сущность в виде Arduino? Raspberry Pi не случайно оборудован GPIO.
      • 0
        Конечно, GPIO на RPi присутствует, но при подключение считывателя возникли проблемы (то считыватель отваливался, то ключи рваные приходили). На Arduino все заработало, как говорится, из коробки. С реле также возникли сложности с питанием. Его было недостаточно от ног GPIO. Питание Arduino от USB порта RPi хватило вполне. Уверен, что можно было бы решить все эти сложности, но решено было использовать костыль в виде микроконтроллера.
        • +1

          Кто бы написал статью про создание драйвера для RPi для работы с GPIO.

          • 0
            Не встречал проблем с управление GPIO. Есть же стандартные библиотеки управления ими.
            Хоть из питона, хоть из C++.
            А использовать Arduino вместо простого согласования входов/выходов (да хоть простыми биполярными транзисторами) это очень оригинальное решение.

            Кстати, как показал мой опыт, малина ооочень нестабильная штука и для контроллеров, где требуется 100% надежность я бы ее не использовал (управление замком...!).

            Делал видеонаблюдение для дачи полностью на плате малины (с механизмом поворота камеры в двух осях управляемых через GPIO). Увы… за неделю минимум один раз малина зависает. И питание ей дела с буфером через свинцовый аккумулятор и радиатор вешал на проц…

        • +1

          Именно сомнения в надёжности RPi, при навешиванием на нее доп. оборудования через GPIO, склонили к использованию отдельного микроконтроллера, оставив на малине только программную часть.


          Насколько я знаю, подобная связка используется в большинстве общественных железок (венденговые и кофейные аппараты и т.п.). Там всегда есть ПК и микроконтроллер и связаны они, в большинстве случаев через RS-232 или RS-485 интерфейсы. Конечно, там используют не ардуино, а более промышленные варианты типа STM32.


          Еще момент в не стабильности малины был замечен при использовании второй и третьей версии платы. Я реализовал решение на первой. Как писал в статье полгода, полет нормальный.


          А вообще, относительно надёжности, на малине даже спутник реализовали и вроде как удачно.
          https://www.raspberrypi.org/blog/european-astro-pi-mission-complete/


          Так что может я просто не умею её нормально готовить)))

          • +1
            Аналогично на днях реализовал подобный проект, но без Ардуино, используя только Raspberry PI. То что у вас были проблемы с данными со считывало, все решается двумя резисторами, для согласования уровней. А для срабатывания релюшек, обязательно через транзисторный ключ, напрямую от GPIO можно и ножки попалить и конечно будет не хватать напряжения.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.