В силу исторических причин, у нас в конторе, используется старенькая АТС Panasonic TDA200. И, как известно, журнал звонков она выводит в последовательный порт, для чтения данных из которого, на сервере использовалась одна программулька. У этого ПО есть ряд ограничений, делающий его использование неудобным (размер лог-файла, размер БД) и дабы побороть эти недостатки и в силу природной лени (чтобы избежать постоянной очистки лога и БД вручную) было решено набыдлокодить что-то своё. А так как, уже давно, на глаза попадается слово «python» да и пытливый ум периодически просыпается, то решено было данную задачу реализовать на этом языке и попутно на, хорошо мне знакомом, tcl. Ну а результатами решил поделиться с обществом. Да, сразу замечу, что задача решена и сервис доведён до «промышленной» эксплуатации. Для хранения данных используется СУБД MariaDB (оно уже было), в качестве хост-системы CentOS 7.
И ещё одно замечание — питонист из меня ещё тот, поэтому за качество кода пинать можно но не сильно. Примеры кода будут приводиться в таком порядке: сперва питон потом тикль, описание процедур и команд будет идти в порядке необходимом для понимания работы скриптов, а не так как они записаны в коде.
Python
Tcl
Тут в общем всё понятно — импортируем нужные и не очень модули, и инициализируем переменные. Но в примере на tcl строки закоментированы (эти строки нужно вынести в другой файл см. под спойлер), ниже будет ясно почему.
Скрипт может обрабатывать данные из текстового файла и напрямую из последовательного порта, для этого добавлены ключи для запуска, соответсвенно -port — чтение из порта, -file — чтение из файла:
В tcl-скрипте добавлена еще одна опция -conf, тестирование кода проводилось на рабочем сервере, и корячить туда помимо питона ещё и тикль, было уже черезчур. И по сему, пришлось собирать «экзешник» по принципу «всё включено» а чтобы обеспечить гибкость настроек и была добавлена эта опция (да и так правильнее).
Идём дальше. Функции работы с последовательным портом:
В tcl операции работы с файлами или портами используются одни и те же. Т.е. сперва создаётся так называемый pipe (труба или канал) командой open потом уже из этой «трубы» читаются данные или записываются туда, будь то файл или последовательный порт.
Команда
позволяет навешивать на канал событие, точнее реакцию на событие, в данном случае мы указали, что при появлении каких-то данных в канале выполнять процедуру Read.
Вот и подошли к ключевому моменту — разбору строки. АТС «выбрасывает» данные ввиде строки где поля разделены пробелами, точнее, для каждого поля задан размер в символах и отсутствующие данные добиваются пробелами:
Код функции на питоне:
В строковых функциях между питом и тиклем есть некоторые различия, к примеру line[9:14] вернёт содержимое строки начиная с 9 и кончая 13 символом включительно, т.е. в качестве правой границы указывается следующий за значимым символ. В tcl, для этой цели, используется команда [string range $line 9 13].
В тикле есть такая замечательная штука, как массив переменных, в данном случае это arrVariables(), в который сохраняются все данные в соответсвующих переменных, определяемых ключом, к примеру arrVariables(call_time) — это время звонка. Можно было это всё сохранить ввиде списка списков «ключ — значение», на примере предыдущей переменной, это выглядело-бы следющим образом:
А теперь добавим строку в БД, структура которой описана ниже:
Запрос к БД строится динамически на основе параметров переданных в функцию. Из кода, в принципе, всё понятно — форматируем строки в соответствии с требованиями к SQL-запросу, в нужных местах вставляем запятые или скобки и т.д. И так как запрос строится динамически, то в некоторых местах добавляется лишняя запятая и пробел, которые приходится удалять командой rstrip(', ') (можно, конечно, считать количество полей и добавлять нужное число запятых, но накладных расходов это не уменьшит и потому сделано так). Так как данные сыпятся ни часто, то на каждую строку данных (один запрос) выполняется одна транзакция, т.е. подключились, выполнили запрос, отключились.
И собсвенно код функции:
А теперь то же самое на тикле:
Вот на этом можно и завершить повествование. На мой взгляд никаких приемуществ, в данном конкретном случае, ни у того ни у другого языка нет (лукавлю слегка, по мне тикль красивше, но это в силу привычки). Сложностей с питоном также не возникает. Исходники тестировались в Centos и Fedore последних версий и Windows 10. Проектик (в части сбора данных) доведён до логического завершения и запущен в работу, есть еще простенькая вэб мордочка со справочником телефонов и отчетами по собранным данным, но это тема другой статьи.
Исходники доступны тут: Git репозитарий
И ещё одно замечание — питонист из меня ещё тот, поэтому за качество кода пинать можно но не сильно. Примеры кода будут приводиться в таком порядке: сперва питон потом тикль, описание процедур и команд будет идти в порядке необходимом для понимания работы скриптов, а не так как они записаны в коде.
Python
import pymysql
import sys, os
import re
import datetime
# параметры соединения с СУБД
db_host = 'host'
db_user = 'dbuser'
db_pass = 'dbpass'
out_dir = '/var/log/ats'
Tcl
package require mysqltcl
# параметры соединения с СУБД
#set db(host) "host"
#set db(user) "user"
#set db(pass) "password"
#set db(dbname) "ats_test"
#set out_dir "~/tmp/ats"
Тут в общем всё понятно — импортируем нужные и не очень модули, и инициализируем переменные. Но в примере на tcl строки закоментированы (эти строки нужно вынести в другой файл см. под спойлер), ниже будет ясно почему.
config.tcl
# параметры соединения с СУБД
set db(host) "host"
set db(user) "user"
set db(pass) "password"
set db(dbname) "ats_test"
set out_dir "~/tmp/ats"
Скрипт может обрабатывать данные из текстового файла и напрямую из последовательного порта, для этого добавлены ключи для запуска, соответсвенно -port — чтение из порта, -file — чтение из файла:
if __name__ == "__main__":
if len(sys.argv) > 2:
if sys.argv[1] == '-port':
#action = 'read_port'
port_name = sys.argv[2]
port_data_read(port_name)
if sys.argv[1] == '-file':
#action = 'read_file'
log_file_name = sys.argv[2]
log = open(log_file_name)
for line in log:
parce_string(line)
log.close()
else:
print ("\nФормат вызова:\n- для обработки файла\
\n # python data_reader.py -file TDA20013082015_12052016.lg\
\n- для чтения данных напрямую с com-порта АТС\
\n # python data_reader.py -port /dev/ttyUSB0\n")
sys.exit(1)
В tcl-скрипте добавлена еще одна опция -conf, тестирование кода проводилось на рабочем сервере, и корячить туда помимо питона ещё и тикль, было уже черезчур. И по сему, пришлось собирать «экзешник» по принципу «всё включено» а чтобы обеспечить гибкость настроек и была добавлена эта опция (да и так правильнее).
# Обработка ключей командной сроки
if {[llength $argv] >= 2} {
if {[lindex $argv 0] == "-conf"} {
source [lindex $argv 1]
} else {
puts "Не указан конфигурационный файл"
}
if {[lindex $argv 2] == "-port"} {
set port_name [lindex $argv 3]
PortDataRead $port_name
}
if {[lindex $argv 2] == "-file"} {
set log_file_name [lindex $argv 3]
set log [open $log_file_name "r"]
# проверям наличие каталога и если его нет то создаём
if {[file isdirectory $out_dir] == 0} {
file mkdir $out_dir
}
# читаем файл построчно
while {[gets $log line] >= 0} {
ParceString $line
}
close $log
}
} else {
puts "\nФормат вызова:\n- для обработки файла\
\n # -conf config.tcl
\n # tclsh logger.tcl -conf config.tcl -file TDA20013082015_12052016.lg\
\n- для чтения данных напрямую с com-порта АТС\
\n # tclsh logger.tcl -conf config.tcl -port /dev/ttyUSB0\n"
exit
}
Идём дальше. Функции работы с последовательным портом:
def port_data_read(port_name):
global out_dir
"""Чтение данных из последовательного порта"""
import serial
ser = serial.Serial(port_name)
ser.baudrate = 9600
while True:
# читаем строку из порта
line = ser.readline()
# декодируем строку в текстовый формат
line = line.decode()
# обрезаем лишние пробельные символы
line = line.rstrip()
# отдаём процедуре обработки строки
parce_string(line)
В tcl операции работы с файлами или портами используются одни и те же. Т.е. сперва создаётся так называемый pipe (труба или канал) командой open потом уже из этой «трубы» читаются данные или записываются туда, будь то файл или последовательный порт.
# читаем данные из порта
proc PortDataRead {portName} {
global fh
# открываем порт в режииме "только чтение"
set fh [open $portName RDONLY]
# настраиваем канал в неблокирующем режиме и соответсвующими параметрами порта
fconfigure $fh -blocking 0 -buffering line -mode 9600,n,8,1 -translation crlf -eofchar {}
# "навешиваем" событие
fileevent $fh readable Read
vwait forever
}
# читаем строку из порта и отправляем на обработку
proc Read {} {
global fh
if {[gets $fh line] >= 0} {
ParceString $line
}
}
Команда
fileevent $fh readable Read
позволяет навешивать на канал событие, точнее реакцию на событие, в данном случае мы указали, что при появлении каких-то данных в канале выполнять процедуру Read.
Вот и подошли к ключевому моменту — разбору строки. АТС «выбрасывает» данные ввиде строки где поля разделены пробелами, точнее, для каждого поля задан размер в символах и отсутствующие данные добиваются пробелами:
30/09/16 10:44 501 01 <I> 0'00 00:00'13 D0
Код функции на питоне:
def parce_string(line):
"""Получает на вход строку и раскидывает её в нужном виде и пишет в файл"""
# тут проверяется строка на ненужный хлам
if line[:3] == "---" or line == "" or line[3:7] == "Date":
print(line)
return
print(line)
# Создаём текстовые файлы на всякий случай, для дублирования информации
now = datetime.datetime.now()
out_log_name = os.path.join(out_dir, '{}_{}'.format(now.month, now.year))
out_log = open(out_log_name,"a+")
out_log.write(line + '\n')
out_log.close()
# Разбор строки
# Преобразуем дату к виду "ДД/ММ/ГГГГ" (с годом решил не мудрствовать а решить в лоб)
call_date = "20{}/{}/{}".format(line[6:8],line[3:5],line[:2])
# выдёргиваем данные и обрезаем лишние пробелы
call_time = line[9:14].strip()
int_number = line[19:22].strip()
ext_co_line = line[23:25].strip()
dial_number = line[26:51].strip()
ring = line[52:56].strip()
call_duration = re.sub("'", ":", line[57:65].strip())
acc_code = line[66:77].strip()
call_code = line[77:81].strip()
# Проверяем признак входящщего звонка
if dial_number == "<I>":
call_direct = "Входящий"
dial_number = ""
elif dial_number[:3] == "EXT":
call_direct = "Внутренний"
dial_number = dial_number[3:]
else: call_direct = "Исходящий"
# отправлем в процедуру добавление в БД
insert(call_date=call_date,
call_time=call_time,
int_number=int_number,
ext_co_line=ext_co_line,
dial_number=dial_number,
ring=ring,
call_duration=call_duration,
acc_code=acc_code,
call_code=call_code,
call_direct=call_direct)
В строковых функциях между питом и тиклем есть некоторые различия, к примеру line[9:14] вернёт содержимое строки начиная с 9 и кончая 13 символом включительно, т.е. в качестве правой границы указывается следующий за значимым символ. В tcl, для этой цели, используется команда [string range $line 9 13].
proc ParceString {line} {
global out_dir arrVariables
# Получает на вход строку и раскидывает её в нужном виде и пишет в файл
if {[string range $line 0 2] == "---" || $line == "" || [string range $line 3 6] == "Date"} {
#puts $line
return
}
# Создаём текстовые файлы на всякий случай, для дублирования информации
# получим имя файла ввиде ММ_ГГГГ используя цепь вложенных команд clock
set fName [clock format [clock scan "now" -base [clock seconds]] -format %m_%Y]
set out_log_name [file join $out_dir $fName]
set out_log [open $out_log_name "a+"]
puts $out_log "$line"
close $out_log
# Разбор строки
# все данные сохраняются в именованом массиве переменных
# Преобразуем дату к виду "ДД/ММ/ГГГГ"
set arrVariables(call_date) "20[string range $line 6 7]\/[string range $line 3 4]\/[string range $line 0 1]"
set arrVariables(call_time) [string range $line 9 13]
set arrVariables(int_number) [string range $line 19 21]
set arrVariables(ext_co_line) [string range $line 23 24]
set arrVariables(dial_number) [string range $line 26 50]
set arrVariables(ring) [string range $line 52 55]
set arrVariables(call_duration) [string range $line 57 66]
set arrVariables(acc_code) [string range $line 66 76]
set arrVariables(call_code) [string range $line 77 81]
# Проверяем признак входящщего звонка
if {$arrVariables(dial_number) == "<I>"} {
set arrVariables(call_direct) "In"
set arrVariables(dial_number) ""
} elseif {[string range $arrVariables(dial_number) 0 3] == "EXT"} {
set arrVariables(call_direct) "Ext"
set arrVariables(dial_number) [string range $arrVariables(dial_number) 3 end]
} else {
set arrVariables(call_direct) "Out"
}
InsertData
В тикле есть такая замечательная штука, как массив переменных, в данном случае это arrVariables(), в который сохраняются все данные в соответсвующих переменных, определяемых ключом, к примеру arrVariables(call_time) — это время звонка. Можно было это всё сохранить ввиде списка списков «ключ — значение», на примере предыдущей переменной, это выглядело-бы следющим образом:
lappend lstVar [list call_time [string range $line 9 13]]
т.е. в список lstVar (точнее переменную содержащую список) добавляем список из двух значений call_time и содержимое строки $line между 9 и 13 символами включительно.А теперь добавим строку в БД, структура которой описана ниже:
Структура таблицы
CREATE TABLE `cdr` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`call_date` date DEFAULT NULL,
`call_time` time DEFAULT NULL,
`int_number` varchar(11) DEFAULT NULL,
`ext_co_line` char(2) DEFAULT NULL,
`dial_number` varchar(30) DEFAULT NULL,
`ring` varchar(5) DEFAULT NULL,
`call_duration` time DEFAULT NULL,
`acc_code` varchar(20) DEFAULT NULL,
`call_code` char(2) DEFAULT NULL,
`call_direct` varchar(45) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2775655 DEFAULT CHARSET=utf8 COMMENT='Call Data Records';
Запрос к БД строится динамически на основе параметров переданных в функцию. Из кода, в принципе, всё понятно — форматируем строки в соответствии с требованиями к SQL-запросу, в нужных местах вставляем запятые или скобки и т.д. И так как запрос строится динамически, то в некоторых местах добавляется лишняя запятая и пробел, которые приходится удалять командой rstrip(', ') (можно, конечно, считать количество полей и добавлять нужное число запятых, но накладных расходов это не уменьшит и потому сделано так). Так как данные сыпятся ни часто, то на каждую строку данных (один запрос) выполняется одна транзакция, т.е. подключились, выполнили запрос, отключились.
И собсвенно код функции:
def insert(**kwargs):
"""Вставка данных в БД. В качестве параметров список полей и значений"""
qwery = 'INSERT INTO `cdr` ('
for key in kwargs.keys():
qwery = "{} `{}`, ".format(qwery,key)
qwery = qwery.rstrip(', ') + ') VALUES('
for key in kwargs.keys():
#qwery = qwery + '"' + kwargs.get(key) +'", '
qwery = "{} \"{}\",".format(qwery,kwargs.get(key))
А теперь то же самое на тикле:
proc InsertData {} {
global arrVariables db
set qwery "INSERT INTO `cdr` ("
# читаем тот самый массив с параметрами и генерим запрос
foreach key [array names arrVariables] {
set qwery "$qwery `$key`, "
}
set qwery "[string trimright $qwery ", "]\) VALUES\("
foreach key [array names arrVariables] {
set qwery "$qwery \"[string trim $arrVariables($key)]\","
}
set qwery "[string trimright $qwery ", "]\);"
puts $qwery
# подключаемся к БД выполняем запрос и отключаемся
set conn [mysql::connect -host $db(host) -db $db(dbname) -user $db(user) -password $db(pass) -encoding utf-8]
mysql::exec $conn $qwery
mysql::commit $conn
mysql::close $conn
}
Вот на этом можно и завершить повествование. На мой взгляд никаких приемуществ, в данном конкретном случае, ни у того ни у другого языка нет (лукавлю слегка, по мне тикль красивше, но это в силу привычки). Сложностей с питоном также не возникает. Исходники тестировались в Centos и Fedore последних версий и Windows 10. Проектик (в части сбора данных) доведён до логического завершения и запущен в работу, есть еще простенькая вэб мордочка со справочником телефонов и отчетами по собранным данным, но это тема другой статьи.
Исходники доступны тут: Git репозитарий