«Умный дом» собственными руками. Часть 4. Организуем веб-интерфейс

    В прошлой статье мы смогли научить нашу систему «умный дом» распознавать сказанное нами и синтезировать голосовые ответы при помощи Google.
    Сегодня я хочу рассказать, как организовать доступ к нашей системе через веб-интерфейс.


    Технологии


    Как вы помните, ПО для управления нашим «умным домом» мы пишем на языке perl. Современная информационная система практически немыслима без БД. Мы тоже не останемся в стороне и для хранения наших данных будем использовать СУБД MySQL. Для реализации веб-сервера я решил воспользоваться не сторонним софтом, а модулем для perl — HTTP::Server::Simple, в частности — HTTP::Server::Simple::CGI. Для чего я это сделал? В большой части, ради интереса ;) Но в теории, можно получить доступ к низкоуровневой обработке HTTP-запросов/ответов без нагромождения комплекса Apache/mod_perl. В целом, ничего не мешает перевести проект на рельсы Apache, если у вас будет желание и достаточно времени.

    База данных


    Первым делом установим СУБД MySQL и создадим базу с таблицами из db.sql. Вот листинг:

    CREATE DATABASE ion;
    USE ion;
    
    #
    # Table structure for table 'calendar'
    #
    
    DROP TABLE IF EXISTS calendar;
    CREATE TABLE `calendar` (
      `id` int(15) NOT NULL AUTO_INCREMENT,
      `date` datetime NOT NULL,
      `message` text,
      `nexttimeplay` datetime NOT NULL,
      `expired` datetime NOT NULL,
      `type` int(1) DEFAULT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=MyISAM DEFAULT CHARSET=latin1;
    
    #
    # Table structure for table 'commandslog'
    #
    
    DROP TABLE IF EXISTS commandslog;
    CREATE TABLE `commandslog` (
      `id` int(15) NOT NULL AUTO_INCREMENT,
      `date` datetime NOT NULL,
      `cmd` varchar(255) NOT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;
    
    #
    # Table structure for table 'log'
    #
    
    DROP TABLE IF EXISTS log;
    CREATE TABLE `log` (
      `id` int(15) NOT NULL AUTO_INCREMENT,
      `date` datetime NOT NULL,
      `message` varchar(255) NOT NULL,
      `level` int(1) DEFAULT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;
    


    Выполним необходимые действия:

    nix@nix-boss:~$ sudo apt-get install mysql-server
    nix@nix-boss:~$ mysql -uroot -ppassword < db.sql

    Модифицируем код


    Теперь нам необходимо создать папки lib, html и config (рядом с папкой data). В папку lib мы положим модуль, отвечающий за реализацию веб-сервера и обработку наших HTTP-запросов.

    Нам нужно немного подправить скрипт srv.pl. Добавим к блоку инциализации:

    our %cfg = readCfg("common.cfg");
    our $dbh = dbConnect($cfg{'dbName'}, $cfg{'dbUser'}, $cfg{'dbPass'});
    

    Добавим строки, отвечающие за запуск HTTP-сервера ниже блока инициализации:

    ## Запуск HTTP-сервера
    ################################
    
    my $pid = lib::HTTP->new($cfg{'httpPort'})->background();
    print "HTTP PID: $pid\n";
    logSystem("Сервис HTTP - PID: $pid, порт: $cfg{'httpPort'}, хост: $cfg{'httpHost'}", 0);
    
    ################################
    

    А теперь добавим недостающие функции в конец файла:

    sub readCfg
    {
      my $file = shift;
      my %cfg;
    
      open(CFG, "<config/$file") || die $!;
        my @cfg = <CFG>;
    
        foreach my $line (@cfg)
        {
          next if $line =~ /^\#/;
    
          if ($line =~ /(.*?) \= \"(.*?)\"\;/)
          {
    	chomp $2;
    	$cfg{$1} = $2;
          }
        }
      close(CFG);
    
      return %cfg;
    }
    
    ########################################
    
    sub dbConnect
    {
     my ($db, $user, $pass) = @_;
     return $dbh = DBI->connect("DBI:mysql:$db", $user, $pass) || die "Could not connect to database: $DBI::errstr";
    }
    
    ########################################
    
    sub logSystem
    {
     my ($text, $level) = @_;
     my %cfg = readCfg("common.cfg");
    
     dbConnect($cfg{'dbName'}, $cfg{'dbUser'}, $cfg{'dbPass'});
     $dbh->do("INSERT INTO log (date, message, level) VALUES (NOW(), '$text', $level)");
    }
    


    Как можно понять по названиям функций, dbConnect() — отвечает за соединение с нашей СУБД, logSystem() — за логгирование, readCfg() — за загрузку конфигурации. Остановимся на ней подробнее. Конфигурация представляет собой простой текстовый файл в директории config. В нашем случае, он называется common.cfg. Выглядит примерно так:

    ## Настройки
    
    daemonMode = "undef";
    
    logSystem = "1";
    logUser = "1";
    
    dbName = "ion";
    dbUser = "root";
    dbPass = "password";
    
    camNumber = "4";
    camMotionDetect = "1"; 
    
    httpPort = "16100";
    httpHost = "localhost";
    
    telnetPort = "16000";
    telnetHost = "localhost";
    
    micThreads = "5";
    


    Некоторые строки в нем будут использованы позже. Нас же пока интересуют только строки, начинающиеся с префикса db. Как мы видим — это настройки для соединения с нашей БД.

    Теперь расскажу о том, как побороть многократное выполнение команды. Подредактируем функцию checkcmd():

    sub checkcmd
    {
    	my $text = shift;
    	chomp $text;
    	$text =~ s/ $//g;
    
    	print "+OK - Got command \"$text\" (Length: ".length($text).")\n";
    
    	if($text =~ /система/)
    	{
    
    	#################################################
    
     	my $sth = $dbh->prepare('SELECT cmd FROM commandslog WHERE DATE_SUB(NOW(),INTERVAL 4 SECOND) <= date LIMIT 0, 1');
    	$sth->execute();
    	my $result = $sth->fetchrow_hashref();
    
    	if($result->{cmd} ne "") { return; }
    
    	$dbh->do("INSERT INTO commandslog (date, cmd) VALUES (NOW(), '$text')");
    
    	#################################################
    
    	  if($text =~ /провер/) { my $up = `uptime`; $up =~ /up (.*?),/; sayText("Время работы сервера - $1. Номер главного процесса - $parent."); }
    
    	  if($text =~ /врем/) { my $up = `uptime`; $up =~ /(.*?) up/;  sayText("Сейчас $1"); }
    
    	  if($text =~ /законч/ || $text =~ /заверш/) { sayText("Завершаю работу. Всего доброго!"); system("killall motion"); system("rm ./data/*.flac && rm ./data/*.wav"); system("killall perl"); exit(0); }
    
    	  if($text =~ /погод/)
    	      {
    		  my ($addit, $mod);
    		  my %wh = lib::HTTP::checkWeather();
    		  $wh{'condition'} = Encode::decode_utf8( $wh{'condition'}, $Encode::FB_DEFAULT );
    		  $wh{'hum'} = Encode::decode_utf8( $wh{'hum'}, $Encode::FB_DEFAULT );
    		  $wh{'wind'} = Encode::decode_utf8( $wh{'wind'}, $Encode::FB_DEFAULT );
    
    		  if($wh{'temp'} < 0) { $mod = "ниже нуля"; }
    		  if($wh{'temp'} > 0) { $mod = "выше нуля"; }
    
    		  $wh{'wind'} =~ s/: В,/восточный/; $wh{'wind'} =~ s/: З,/западный/; $wh{'wind'} =~ s/: Ю,/южный/; $wh{'wind'} =~ s/: С,/северный/;
    		  $wh{'wind'} =~ s/: СВ,/северо-восточный/; $wh{'wind'} =~ s/: СЗ,/северо-западный/; $wh{'wind'} =~ s/: ЮВ,/юго-восточный/; $wh{'wind'} =~ s/: ЮЗ,/юго-западный/;
    
    		  sayText("Сейчас $wh{'condition'}, $wh{'temp'} градусов $mod. $wh{'hum'}. $wh{'wind'}");
    		  if ($wh{'temp'} <= 18) { $addit = sayText("Одевайтесь теплее, на улице холодно!"); }
    		  if ($wh{'temp'} >= 28) { $addit = sayText("Переносной кондиционер не помешает!"); }
    	      }
    	}
    
    	#sayText("Ваша команда - $text");
    
    	return;
    }
    

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

    Модуль HTTP.pm


    Вернемся к реализации встроенного HTTP-сервера. Создадим файл HTTP.pm в директории lib. Запишем туда следующий код:

    package lib::HTTP;
    
    use HTTP::Server::Simple::CGI;
    use LWP::UserAgent;
    use URI::Escape;
    use base qw(HTTP::Server::Simple::CGI);
    
    use Template;
    
    #########################################
    #########################################
    
    our %dispatch = (
         '/' => \&goIndex,
         '/index' => \&goIndex,
         '/camers' => \&goCamers,
    );
    
    our $tt = Template->new();
    
    
    #########################################
    #########################################
    
    sub handle_request {
         my $self = shift;
         my $cgi  = shift;
    
         my $path = $cgi->path_info();
         my $handler = $dispatch{$path};
    
         if ($path =~ qr{^/(.*\.(?:png|gif|jpg|css|xml|swf))})
    	{
    		my $url = $1;
    
    		print "HTTP/1.0 200 OK\n";
    		print "Content-Type: text/css\r\n\n" if $url =~ /css/;
    		print "Content-Type: image/jpeg\r\n\n" if $url =~ /jpg/;
    		print "Content-Type: image/png\r\n\n" if $url =~ /png/;
    		print "Content-Type: image/gif\r\n\n" if $url =~ /gif/;
    		print "Content-Type: text/xml\r\n\n" if $url =~ /xml/;
    		print "Content-Type: application/x-shockwave-flash\r\n\n" if $url =~ /swf/;
    
    		open(DTA, "<$url") || die "ERROR: $! - $url";
    		   binmode DTA if $url =~ /jpg|gif|png|swf/;
    		   my @dtast = <DTA>;
    		   foreach my $line (@dtast) { print $line; }
    		close(DTA);
    		return;
    	}
    
         if (ref($handler) eq "CODE") {
             print "HTTP/1.0 200 OK\r\n";
             $handler->($cgi);
    
         } else {
             print "HTTP/1.0 404 Not found\r\n";
             print $cgi->header,
                   $cgi->start_html('Not found'),
                   $cgi->h1('Not found'),
    	       $cgi->h2($cgi->path_info());
                   $cgi->end_html;
         }
    }
    
    ## Обработка запроса /
    ########################################
    
    sub goIndex {
    
         my $cgi  = shift;   # CGI.pm object
         return if !ref $cgi;
    
         my %w = checkWeather();
         my $cmd;
    
    	my $dbh = iON::dbConnect($iON::cfg{'dbName'}, $iON::cfg{'dbUser'}, $iON::cfg{'dbPass'});
     	my $sth = $dbh->prepare('SELECT cmd FROM commandslog WHERE id > 0 ORDER BY id DESC LIMIT 0, 1');
    	$sth->execute();
    	my $result = $sth->fetchrow_hashref();
    
    	if($result->{cmd} ne "") { $cmd = $result->{cmd}; } else { $cmd = "Нет комманд..."; }
    
         print "Content-Type: text/html; charset=UTF-8\n\n";
    
          my $uptime = `uptime`;
    	 $uptime =~ /up (.*?),/;
    	 $uptime = $1;
    
          my $videosys = `ps aux | grep motion`;
    	 if ($videosys =~ /motion -c/) { $videosys = "<font color=green>работает</font>"; } else { $videosys = "<font color=red>не работает</font>"; }
    
          my $micsys = `ps aux | grep mic`;
    	 if ($micsys =~ /perl mic\.pl/) { $micsys = "<font color=green>работает</font>"; } else { $micsys = "<font color=red>не работает</font>"; }
    
    	my $vars = {
    	    whIcon => $w{'icon'},
    	    whCond => $w{'condition'},
    	    whTemp => $w{'temp'},
    	    whHum => $w{'hum'},
    	    whWind => $w{'wind'},
    	    cmd => $cmd,
    	    uptime => $uptime,
    	    video => $videosys,
    	    mic => $micsys,
    	    threads => $iON::cfg{'micThreads'},
    	};
    
         my $output;
         $tt->process('html/index', $vars, $output) || print $tt->error(), "\n";
    }
    
    ## Обработка запроса /camers
    ########################################
    
    sub goCamers {
    
         my $cgi  = shift;   # CGI.pm object
         return if !ref $cgi;
    
         my %w = checkWeather();
    
         my $cmd;
    
    	my $dbh = iON::dbConnect($iON::cfg{'dbName'}, $iON::cfg{'dbUser'}, $iON::cfg{'dbPass'});
     	my $sth = $dbh->prepare('SELECT cmd FROM commandslog WHERE id > 0 ORDER BY id DESC LIMIT 0, 1');
    	$sth->execute();
    	my $result = $sth->fetchrow_hashref();
    
    	if($result->{cmd} ne "") { $cmd = $result->{cmd}; } else { $cmd = "Нет комманд..."; }
    
         if($cgi->param("text") ne "")
          {
    	my $txt = $cgi->param('text');
    
    	require Encode;
    	$txt = Encode::decode_utf8( $txt, $Encode::FB_DEFAULT );
    	iON::sayText($txt);
          }
    
         print "Content-Type: text/html; charset=UTF-8\n\n";
    
    	my $vars = {
    	    camera1 => 'video-0/camera.jpg',
                camera2 => 'video-1/camera.jpg',
                camera3 => 'video-2/camera.jpg',
                camera4 => 'video-3/camera.jpg',
    	    whIcon => $w{'icon'},
    	    whCond => $w{'condition'},
    	    whTemp => $w{'temp'},
    	    whHum => $w{'hum'},
    	    whWind => $w{'wind'},
    	    cmd => $cmd,
    	};
    
         my $output;
         $tt->process('html/camers', $vars, $output) || print $tt->error(), "\n";
    }
    
    ## Погода
    ########################################
    
    sub checkWeather {
    
      my %wh;
    
      my $ua = LWP::UserAgent->new(
    		agent => "Mozilla/5.0 (Windows NT 5.1; ru-RU) AppleWebKit/535.2 (KHTML, like Gecko) Chrome/15.0.872.0 Safari/535.2");
    
      my $content = $ua->get("http://www.google.com/ig/api?hl=ru&weather=".uri_escape("Санкт-Петербург"));
         $content->content =~ /<current_conditions>(.*?)<\/current_conditions>/g;
      my $cond = $1;
    
      $cond =~ /<condition data="(.*?)"/g;
      $wh{'condition'} = $1;
    
      $cond =~ /temp_c data="(.*?)"/g;
      $wh{'temp'} = $1;
    
      $cond =~ /humidity data="(.*?)"/g;
      $wh{'hum'} = $1;
    
      $cond =~ /icon data="(.*?)"/g;
      $wh{'icon'} = $1;
    
      $cond =~ /wind_condition data="(.*?)"/g;
      $wh{'wind'} = $1;
    
     return %wh;
    }
    
    #########################################
    #########################################
    
    1;
    


    Разберем содержимое подробнее. В хэше %dispatch мы определяем соответствие URL-адреса и вызываемой функции. Все прочие URL, не описанные в этом хэше, будут выдавать страницу 404.
    Шаблонизатором у нас будет выступать мощная и гибкая библиотека Template Toolkit. Её мы инициализируем строкой:

    our $tt = Template->new();
    

    Перегружая функцию handle_request() родительского класса, мы получаем управление обработкой запросов к HTTP-серверу. Для отдачи браузеру статического контента (png, gif, jpg, css, xml, swf) используется блок:

         if ($path =~ qr{^/(.*\.(?:png|gif|jpg|css|xml|swf))})
    	{
    		my $url = $1;
    
    		print "HTTP/1.0 200 OK\n";
    		print "Content-Type: text/css\r\n\n" if $url =~ /css/;
    		print "Content-Type: image/jpeg\r\n\n" if $url =~ /jpg/;
    		print "Content-Type: image/png\r\n\n" if $url =~ /png/;
    		print "Content-Type: image/gif\r\n\n" if $url =~ /gif/;
    		print "Content-Type: text/xml\r\n\n" if $url =~ /xml/;
    		print "Content-Type: application/x-shockwave-flash\r\n\n" if $url =~ /swf/;
    
    		open(DTA, "<$url") || die "ERROR: $! - $url";
    		   binmode DTA if $url =~ /jpg|gif|png|swf/;
    		   my @dtast = <DTA>;
    		   foreach my $line (@dtast) { print $line; }
    		close(DTA);
    		return;
    	}
    

    Так как MIME-типов у меня получилось немного, я записал их чуть по-индусски ;)
    Дальше начинаются функции, отвечающие за генерацию контента определенного URL. Пока их всего две — индекс и страница с камерами.
    На индексе мы сможем увидеть, работают ли такие подсистемы, как видео- и аудио-захват. Отдельной строкой идет:

    my %w = checkWeather();
    

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

    Следующая функция goCamers() выполняет те же функции, что и индекс, только вместо вывода информации по состоянию подсистем, показывает изображение с наших камер и имеется возможность написать какой-либо текст, который будет синтезирован и озвучен нашим «умным домом».

    Все странички строятся на основе шаблонов, лежащих в папке html. Выкладывать листинг здесь будет не удобно, поэтому дам ссылку на архив — html.zip.

    Итого


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

    В следующий статье я расскажу о том, как работать с устройствами X10 и интегрировать их в нашу систему «умного дома».

    upd: Продолжение
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 13
    • +1
      У меня только один вопрос: а кирпичи для «умного дома» Вы тоже сами лепите и обжигаете? Или пользуетесь «готовыми решениями»?
      • +1
        Пост напомнил студенчество: была лаба — написать «http сервер» на tcl.

        P.S. за серию постов — спасибо!
        • +1
          Спасибо, вы меня радуете интересными постами.
          • 0
            Arei, Ваш ник на скриншот попал.
          • +3
            Можно увидеть скриншот веб-интерфейса?
            • +7

              • +1
                Спасибо ))
                • +4
                  В шапке лучше разместить что-нибудь более полезное. Имхо
              • +1
                А я то думаю, куда прикрутить старенькую чб аналоговую камеру и объемные датчики движений от старой ахтубы.
                Кстати, вы Festival не прикручивали к Вашему дому?
                Очень удобно утром. У меня будильник по расписанию говорит дату, время, погоду и праздники на сегодня. Вот еще дни рождения добавлю и будет норм =)
                Рабочие дни только нужно указать, я из 1С выгрузил…
                • 0
                  камеру можно прикрутить через карту захвата за 5 баксов с ebay. снимать видео через ZoneMinder например. или еще как с /dev/video0

                  А вот по объемникам если кто расскажет, буду рад. Хотя есть вневедомственная охрана дома, хочтеся продублировать.
                • +1
                  И все же, лучше «оторвать» веб-сервер от сервера умного дома. Особенно «самописный».
                  Вот узнаю Ваш адрес и заDDOSю егоет сосед Ваш адрес и начнет над командной строкой экспериментировать…
                  • 0
                    В таком случае нужен мост: управляющие процедуры отдельно, информационные данные — отдельно.
                    Поскольку мониторить дом действительно нужно, полезно знать где какие розетки потребляют какую мощность и…
                    вовремя получить смс о том что не выключил утюг.
                  • 0
                    А планируется управление, с привязкой к веб-интерфейсу, по 1-wire и/или rs485?
                    Также неплохо б написать софт под Android… Такое есть в планах?

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