Контроль дверей: делаем «умный датчик»

Как ни странно, но до сих пор у меня не было ни одного датчика из тех, которые принято относить к «охранным сигнализациям».
Но вот понадобилось поставить сигнализацию на некоторую Железную Дверь, которая должна быть всегда закрыта, если без присмотра. Весь процесс — далее.

Задача простая: сообщать, когда дверь открывается, когда закрывается, и если осталась открытой — напоминать об этом.
Но дверь железная — буквально, в том смысле что она из железных профтруб, в железной раме, а еще — она на улице, т.е. всякие варианты с микровыключателями тут не подходят, всё это будет съедено коррозией.

Поэтому за основу взял индуктивный датчик: такие обычно используются во всяких промышленных системах. Работают просто: подносишь железку — включаются, убираешь — выключаются. Продаются на маркетплейсах, довольно распространенная штука.

69a6ee87eafaa658fda0321b2cc636c0.png

Вот именно такой датчик и буду использовать, установив его так, чтобы закрытая дверь приводила к срабатыванию. Сам он герметичный, а блок управления — рядом, в серой пластиковой распаечной коробке — как показала практика такие коробки прекрасно переносят условия улицы, дождь, снег, солнце.
Дрель, метчик на 3, два болтика из комплекта и кусок серой трубы для кабеля.

5cddb99a9f3baa18ab8bd35f2701e791.png

Питание 12 вольт уже есть, датчик работает от 10 до 30. Остается подключить к этому всему ESP, завязать в PainlessMesh, ловить события «дверь открыта».
Также, для контроля работоспособности, отправлять периодически состояние статус: если очередное сообщение не приходит больше трех минут — значит что-то вышло из строя.

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

1f37868ec93bbfe32becb8de6c23df41.png

Также, потребуется понижение напряжения питания с 12 до 3.3 вольт, для этого использую готовый модуль DC-DC.
Ну, и чтобы все это было как-то аккуратнее — соберу на печатной плате.

Размеры печатной платы под коробку 60×35 мм. Разводку платы делаю как обычно во Fritzing. Знаю, что «профессионалы» ее терпеть не могут за «ардуинистость», но она как раз подходит для подобных случаев.

Проблема в том, что для создания печатной платы нужны «отпечатки» деталей, и если с оптронами, резисторами, и даже ESP12 проблемы нет — то найти готовый «отпечаток» под безымянный модуль с Али — задача непростая.
А во Fritzing его можно просто нарисовать!

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

Тот самый DC-DC

Тот самый DC-DC

Фотографию модуля нужно развернуть так, чтобы он был по возможности горизонтально и без искажений — с этим прекрасно справится Gimp.

Теперь нужен SVG-редактор. Как ни странно, наиболее известный Inkscape здесь не подходит, создаваемые им SVG не подходят без ручного ковыряния текста SVG, а это как бы неудобно.
Поэтому я использую тут boxy-svg.

Принцип следующий: импортируем картинку модуля, под ее размеры подгоняем viewport, и указываем реальные размеры в миллиметрах.
Теперь всё что будет нарисовано «по фотографии» — уже будет соответствовать реальным размерам.

d5fd1eaa019b776715b069f6e4f4658f.pngd61d7ac354d3eef40d5b968aff6c03c9.png

Самая важная часть — нарисовать контактные площадки.
В данном случае это должны быть кольца, с отверстием посередине — для этого подходит инструмент «эллипс», точнее круг, у которого отключено заполнение, но задана увеличенная толщина линии периметра. Потом, на реальной плате, это будет выглядеть как кольцо.
У этого модуля контактные площадки сдвоенные -, но лучше нарисовать одинарные, одна площадка — один контакт.

b7f358d06c2795cc00357c5c04e8de25.png

Контактные площадки группируются в группу, называемую copper1 — это значит они будут отображаться на нижней стороне платы. (copper0 — лицевая, верхняя сторона).Нюанс в том, что у группы есть название, и есть ID — вот тут важно чтобы ID был copper1.

e241913cb23bc01c87983388d4b1c4cc.pngfa4982b65e5bdb81c68c155ff614c41d.png

Также рисую примерно расположение деталей на модуле и группирую их в группу layout — это не обязательно, просто потом так легче узнавать, какой именно модуль используется.
Фотографию из файла можно удалить, она больше не нужна.

3da0ed089c4eaa48c9e2e29627d52030.png

Дальше во Fritzing находим что-нибудь похожее, чтобы не рисовать еще и схему — тоже 4 контакта, вход-выход, и редактируем, сохраняя как новую деталь.
Отмечаем контакты как штырьки — это заставит программу размещать деталь на лицевой стороне.
В разделе PCB загружаем новый SVG, и отмечаем какой контакт к какой площадке относится.

3a56dccd62ec3dcbfa8168b918b2bd20.png

В разделе Schematic уже есть схема «блока питания», и контакты к выводам привязаны — можно не менять.
В разделе Breadboard снова загружаем SVG — должна появится условная картинка с деталями — тут тоже нужно привязать контакты к площадкам.
В разделе Icon можно просто повторно использовать Breadboard, это только для картинки в списке деталей.
Всё это сохраняется как новый элемент — теперь можно добавлять его в схему:

8185fd33c4436d5db7e51337c84808a1.png

И конечно, развести плату:

5714d3c0733149fa0cb5c8f7e2696146.png

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

Сохраняем проект как etched pdf, из которого будут нужны два файла: дорожки и маска.

f6d414ca48545846cdb87d307270afa1.png

PDF затягиваем в Gimp с разрешением 1200 dpi, инвертируем цвета.
Так же рядом затягиваем маску с тем же разрешением, ничего не инвертируем.

4839d6befe58e04a1be0897ab9a8cedd.png

Печатается это все на листе тонкой бумаги, около 80 г/м2, или на кальке

326c6041f14d6c992903da9e0d08978b.png

На кусок стектотекстолита накатывается ламинатором пленка фоторезиста (Китай, маркетплейс)

0ca7cb779c50a1dbfc4f1c3b89c999ab.png

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

edc52943e1d58d75e887add49b12dc8b.png

4 секунды под светодиодной УФ-лампой, сдираем верхнюю защитную пленочку — готово к проявке

7896ee4972fda64f915aec6fde03705e.png

3 минуты в растворе строительного «жидкого стекла» с водой, примерно 1:2 — незасвеченное сползает лохмотьями

651bfc282254761fafd22f54408876f7.png

Травление: аптечная 3% перекись + чайная ложка соли + чайная ложка лимонной кислоты — примерно 10 минут

cae930772154679c9955525acb0bee96.png

Смывка фоторезиста — «Крот» + вода, соотношение примерно 1:3, ок. 3 минут — остатки фоторезиста обесцвечиваются и сползают.

2bbf0f2fafa28126feb784554aab3fe0.png

Ну, дальше нанесение маски, засветка, смывка — это всегда плохо получалось, поэтому углубляться не стоит. Сверление отверстий, обрезка краев шлифмашинкой.
В итоге получается плата, готовая к распайке:

1428137360708d09072f22cba47dc1df.png

Теперь программная часть.
Уже практически типовой скетч:

/*
 * check PIN state, set event
 *
 */

#include "painlessMesh.h"
#include 
#include 

#define VERSION "Guard v1.0"
#define OTA_NAME  "guard"

#define   MESH_PREFIX     "ХХХХХ"
#define   MESH_PASSWORD   "ХХХХХ"
#define   MESH_PORT       5678

painlessMesh  mesh;

size_t gw_id = 0;
size_t me_id = 0;

int state;

#define PIN_CTL 13
#define IND     2

// =========================================================
// Prototypes
void receivedCallback( uint32_t from, String &msg );
void newConnectionCallback(uint32_t nodeId);

// =========================================================
void MeshSetup(){

  gw_id = 0;

  mesh.setDebugMsgTypes( ERROR | STARTUP | DEBUG | CONNECTION );

  mesh.init( MESH_PREFIX, MESH_PASSWORD, MESH_PORT );
  mesh.onReceive(&receivedCallback);
  mesh.onNewConnection(&newConnectionCallback);
  mesh.initOTAReceive(OTA_NAME);

  mesh.setContainsRoot(true);
}

// =========================================================

#define   TTL       180000
unsigned long ttl_timer;
int ttl_check;

void TtlSetup(){

  ttl_timer = millis();
  ttl_check = 0;

}

void TtlLoop(){

  if(abs((long)(millis() - ttl_timer)) > TTL){
    ttl_timer = millis();

    if (ttl_check == 0){
      ESP.reset();
    }else{
      ttl_check = 0;
      mesh.sendBroadcast("ping");
    }
  }

}

// time ===================================================
#include 
#include 

void TimeSetup(){

  sntp_stop();

}

// =========================================================
unsigned long state_timer;
#define STATE_PERIOD  60000
#define STATE_RND     5000

void StateSetup(){
  state_timer = 0;
  gw_id       = 0;
}

void StateLoop(){
  DynamicJsonDocument doc(300);     // messages

  if(abs((long)(millis() - state_timer)) > STATE_PERIOD){
    state_timer = millis();
    state_timer += random(STATE_RND);

    doc["ident"]  = OTA_NAME;

    time_t now = time(nullptr);
    doc["dtm"]   = now;

    doc["block"] = "state";

    long rssi = WiFi.RSSI();
    doc["rssi"] = rssi;

    doc["state"] = state;

    if(rssi < -60){
      digitalWrite(IND,HIGH);
    }else{
      digitalWrite(IND,LOW);
    }

    String message;
    serializeJson(doc, message);


    if(gw_id > 0){
      mesh.sendSingle(gw_id, message);
    }
    else{
      mesh.sendBroadcast(message);
    }

  }
}
// =========================================================

void setup() {

  LittleFS.begin();

  pinMode(IND,OUTPUT);
  digitalWrite(IND,HIGH);

  MeshSetup();

  TimeSetup();

  pinMode(PIN_CTL, INPUT_PULLUP);
  state = digitalRead(PIN_CTL);

  TtlSetup();

  StateSetup();

}

void loop() {
  mesh.update();

  TtlLoop();

  int x = digitalRead(PIN_CTL);
  if(state != x){
    String str = "{\"alarm\":1,\"state\":";
    str += x;
    str += "}";
    state = x;

    if(gw_id > 0){
      mesh.sendSingle(gw_id, str);
    }
    else{
      mesh.sendBroadcast(str);
    }

  }

  StateLoop();

  delay(100);
}

void newConnectionCallback(uint32_t nodeId) {
  char buf[80];

  time_t now = time(nullptr);
  sprintf(buf,"settime %u",now);
  mesh.sendSingle(nodeId, buf);
}

void receivedCallback( uint32_t from, String &msg ) {

  ttl_check = 1;

  // ===============================
  if (msg == "state") {
    gw_id = from;
    state_timer = 0;
  } else
  // ===============================
  if (msg == "version") {
    mesh.sendSingle(from, VERSION);
  } else
  // ===============================
  if (msg == "restart") {
    mesh.sendSingle(from, "OK");
    ESP.restart();
  } else
  // ===============================
  if (msg.startsWith("settime ")) {

    String num = msg.substring(8);
    time_t tim = num.toInt();
    time_t now = time(nullptr);
    if(tim != 0 && tim > now){
      timeval tv = { tim, 0 };
      settimeofday(&tv, nullptr);
    }

  }
}

Суть программы простая: подключаемся в mesh-сеть, ловим сообщения, отправляем свои.
При подключении к какому-то из модулей он отправляет команду settime — это подерживает единое время во всей сети.
Периодически отправка пингов, периодически отправка сообщений о своем состоянии. Чтобы не накладывались сообщения — рандомизация.
Если нет новых сообщений более чем 180 сек — перезагрузка модуля.
Специфическое для данного модуля — за каждую итерацию проверка пина на подтягивание к «земле», с изменением статуса и отправкой сообщения.

Итого, пока дверь закрыта — от датчика идет ток на оптрон, который опускает пин к «земле». Как только дверь открылась — ток выключается, оптрон закрывается, на входе 1, отправляется сообщение. Потом наоборот.
Периодически передается текущее состояние.

Сообщения, отправляемые датчиком, выглядят примерно так:

mesh/from/2874622439 {"ident":"guard","dtm":1738189126,"block":"state","rssi":-91,"state":0}

Теперь их нужно применить в общую систему. Для этого делается скрипт-драйвер:

#!/usr/bin/perl -w

$|=1;

use Net::MQTT::Simple;
use JSON;
use Data::Dumper;

$SIG{CHLD} = "IGNORE";

########################################

my $mqtt = Net::MQTT::Simple->new("127.0.0.1");

#------------------------------------------


# send MQTT message
sub pub {
 my ($topic, $message, $retain) = @_;
 my $pid = fork();
 if(defined $pid && $pid == 0){
   if($retain){
     $mqtt->retain($topic => $message);
   }else{
     $mqtt->publish($topic => $message);
   }
   sleep(2);
   exit(0);
 }
}
#------------------------------------------
my $door1 = '2874622439';


# processing
$mqtt->run(
  # receive Info requests
  "mesh/from/$door1" => sub {
    my ($topic, $message) = @_;

    print ".";

    my $data = undef;

    if($message =~ /^{.*}$/){
      $data = from_json($message);
    }


    if(defined $data && defined $data->{ state }){

      my $t = $data->{state};

      if($t == 1){
        pub("alarm/text","Дверь открыта!");
      }
      elsif($data->{ alarm }){
        pub("alarm/text","Дверь закрыта");
      }
    }
  },
);

exit;

Вот теперь всё: дверь открывается — на телефон приходит сообщение, дверь закрывается — снова приходит сообщение.

Избыточно сложно? Может быть, но в пару строк в драйвере можно добавить отправку команды на включение чего-нибудь (красной лампочки, прожектора, сирены), вести лог когда что открывалось.
А еще получился по сути универсальный модуль, который можно подключить к любому «шлейфу», будь это герконные датчики на окнах, PIR-сенсоры, или какие-нибудь лазерные барьеры — просто сообщения будут приходить от разных ID.
Но такой задачи нет. Пока нет.

© Habrahabr.ru