geektimes

Термокоса под управлением Arduino и LabVIEW

  • четверг, 6 ноября 2014 г. в 02:11:16
http://habrahabr.ru/post/242377/

Привет, Хабр!

Я учусь в МФТИ и занимаюсь научной работой в Институте общей физики РАН. Профиль нашей лаборатории — лазерное дистанционное зондирование, конкретно — лидары. Если вы не знаете, что это за звери, можно прочесть, к примеру, в википедии. В двух словах, лидар — это радар, в котором вместо радиоволны используется лазерное излучение. Принципиальное отличие и преимущество лидара в том, что с его помощью можно судить не только о расстоянии до объекта зондирования по задержке обратного сигнала, но и (по спектру этого сигнала) о составе и свойствах объекта. К примеру, существуют методы лидарного определения температурного профиля воды в водоёмах в зависимости от глубины.

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

Железо


Бесконтактный метод с применением лидара позволяет промерять температуру воды на глубину до нескольких метров (это зависит от прозрачности, ясно, что в грязной воде лазерный пучок быстро рассеивается и далеко не проходит), поэтому термокоса небольшая, состоит из пяти термодатчиков, размещённых на кабеле с интервалом 1 м, плюс ещё 4 м кабеля, считая от «верхнего» датчика.

В качестве чувствительных элементов я выбрал цифровые термометры DS18B20 (даташит, 320 кб) в герметичном исполнении, вот такие:



Почему именно такие? Потому что герметичные (улыбка), поставляются уже с кабелем длиной 1 м, дают высокую точность и работают по протоколу 1-Wire, что существенно упрощает коммуникацию с ними.

Вдумчивое изучение даташита дало следующую информацию. Датчики можно подключать двумя способами: обычным, по трём проводам (земля, «плюс» питания и сигнальная шина) и в «паразитном» режиме, когда питание датчик получает с линии данных. «Паразитный» режим ещё более упрощает подключение (всего два провода), но может иногда искажать показания датчика. Любое ухудшение точности нам вредно, да и с платы Arduino, управляющей датчиками, легко доступны 5 вольт, поэтому я решил запитывать датчики обычным способом.


Схема термокосы

Даташит рекомендует использовать подтягивающий резистор номиналом 4.7 кОм, у меня в хозяйстве нашлись только два по 2.2, но на работоспособности прибора это не сказалось.

За управление датчиками и за их связь с внешним миром, т.е., с ПК, отвечает Arduino Nano с контроллером ATMega328P.

Вот так выглядит схема, собранная на макетной плате:



Вот так — конечный вариант после пайки и изолирования:



А это — вся термокоса в сборе (управляющая электроника не изолирована):



Я выбрал Arduino в качестве «мозгов» устройства, во-первых, потому, что эта платформа проста в освоении, а во-вторых, потому что ею можно управлять с ПК из-под LabVIEW (далее для краткости LabVIEW = LV), что немаловажно, так как софт большинства проектов нашей лаборатории написан именно в этой среде, и возможность встраивания простой автоматизированной системы контроля температуры в другие схемы дорого стоит.

Софт


Главной фишкой данной задачи является работа с прибором из среды LV, поэтому программирование было решено начать с изучения взаимодействия Arduino и LV. На хабре практически нет информации об этом взаимодействии, поэтому, с вашего позволения, буду описывать всё достаточно подробно.

Начало


Итак, что нам нужно (информация отсюда):
  1. LV 2009 или новее.
  2. NI VISA (модуль LV для общения виртуальных приборов с реальными).
  3. Arduino IDE и драйвера.
  4. Библиотека OneWire для Arduino — положите содержимое ZIPа в /[директория установки Arduino IDE]/libraries/.
  5. Разработчик LV предлагает расширение для работы с платами Arduino — LabVIEW Interface for Arduino, или просто LIFA. С недавнего времени развитие LIFA официально прекращено, вместо него NI предлагают пользоваться тулкитом LINX от LabVIEW Hacker. Он поддерживает бóльшее число устройств и содержит больше инструментов, однако, я пользовался LIFA, потому что в LINX прошивки контроллеров имеют вид HEX-файлов, возиться с разборкой и редактированием которых у меня не было ни желания, ни времени. А в LIFA исходники — привычные для Arduino скетчи.
    LIFA можно установить непосредственно из LV через интерфейс VI Package Manager (Tools -> VI Package Manager). После установки на палитре функций появится подпалитра «Arduino»:



Чтобы начать работать с Arduino в LV, нужно прошить ваш контроллер скетчем LIFA_Base.ino, взятым из папки C:/Program Files/National Instruments/LabVIEW [версия]/vi.lib/LabVIEW Interface for Arduino/Firmware/LIFA_Base/. В указанной папке лежит куча файлов — С-шные библиотеки, исходники и два скетча, LabVIEWInterface.ino и LIFA_Base.ino. Первый содержит описания всех функций для работы с Arduino, второй же коротенький и собирает всё воедино для заливки в контроллер.

Вуаля, теперь мы с компьютера посредством LV имеем доступ к большинству возможностей Arduino. Как вы догадываетесь, первое, что я сделал, разобравшись со всем описанным выше, — это поморгал светодиодиком на пине №13 (улыбка).

Поигрались, теперь за дело.
Протокол 1-Wire и термодатчики DS18B20 существуют уже достаточно давно и широко распространены, поэтому я решил поискать информацию о совместном использовании DS18B20 и Arduino. И почти сразу же наткнулся на подходящий источник, и не где-нибудь, а на официальном форуме LabVIEW (ссылка). Топикстартер имел сходную с моей задачу — считывать показания термодатчика DS18B20 с Arduino из среды LabVIEW. Он занялся поисками и в ролике на YouTube увидел диаграмму LV с присутствующим на ней ВП OneWire Read и спросил у гуру, что это за ВП и где его заиметь. На его просьбу откликнулся автор видео и предоставил исходники и подробную инструкцию, как и что делать.

Датчики DS18B20 управляются следующим образом: «мастер» (контроллер, микропроцессор) посылает по линии данных двузначную шестнадцатеричную команду, в зависимости от которой датчик производит измерение температуры, принимает от «мастера» байты на запись в свою память либо отправляет на линию данных текущее содержимое памяти. Автор видео модифицировал скетчи, заливаемые в Arduino для работы с LIFA:
  1. В файле LIFA_Base.ino подключил библиотеку OneWire.h,
  2. В файле LabVIEWInterface.ino в структуре case, отвечающей за обработку команд, поступающих из LV по последовательной шине, он добавил вариант 0x1E, вызывающий функцию считывания температуры, написанную им же:
    Код
    case 0x1E:  // OneWire Read
    OneWire_Read()
    break;

    Функция эта отправляет на линию данных команду измерения температуры 0x44 («конвертирование»), дожидается окончания конвертирования, отправляет команду считывания памяти 0xBE, читает, из полученной информации достаёт показание температуры и отправляет его на последовательную шину:
    Код
    void OneWire_Read()
    {
    	OneWire ds(2);          // Create a OneWire Object "ds" on pin 2.  Hard coding for now, because I can't declare this in a case.
    	byte OneWireData[9];    // Defining stuff for the added OneWire function because I'm getting irritated with trying to make this fit into a case or function.
    	int Fract, Whole, Tc_100, SignBit, TReading;
    
    // Start the Conversion
    
    	ds.reset();      // Reset the OneWire bus in preparation for communication
    	ds.skip();       // Skip addressing, since there is only one sensor
    	ds.write(0x44);  // Send 44, the conversion command
    
    // Wait for the Conversion
    	delay(1000);     // Wait for the conversion to complete
    
    // Read back the data
    	ds.reset();                      // Reset the OneWire bus in preparation for communication
    	ds.skip();                       // Skip addressing, since there is only one sensor
    	ds.write(0xBE);                  // Send the "Read Scratchpad" command
    	for ( byte i = 0; i < 9; i++) { 
    			OneWireData[i] = ds.read();    // Read the 9 bytes into data[]
    		}
    
    // Scale the data
    	TReading = (OneWireData[1] << 8) + OneWireData[0];
    	SignBit = TReading & 0x8000;               // Mask out all but the MSB
    	if (SignBit)                                   // If the MSB is negative, take the Two's Compliment to make the reading negative
    		{
    			TReading = (TReading ^ 0xffff) + 1;          // 2's comp
    		}
    	Tc_100 = (6 * TReading) + TReading / 4;    // Scale by the sensitivity (0.0625°C per bit) and 100
    
    	Whole = Tc_100 / 100;                      // Split out the whole number portion of the reading
    	Fract = Tc_100 % 100;                      // Split out the fractional portion of the reading
    
     // Return the data serially
    	if (SignBit) {              // If the reading is negative, print a negative sign
    			Serial.print("-");
    		}
    	Serial.print(Whole);        // Print the whole number portion and a decimal
    	Serial.print(".");
    	if (Fract < 10) {            // if the fraction portion is less than .1, append a 0 decimal
    			Serial.print("0");
    		}
    	Serial.print(Fract);        // Otherwise print the fractional portion
    }

Предложенный же ВП, в сущности, всего лишь отправляет в указанный ему порт последовательного интерфейса шестнадцатеричное число 1E, дожидается ответа и считывает его:



Всё довольно просто.

Читаем один датчик вручную


Первым делом я отредактировал LIFA_BASE.ino и LabVIEWInterface.ino в соответствии с инструкциями и сделал ВП. Проверил, всё работает, отлично. Потом я сделал кое-что, о чём впоследствии пожалел. В вышеуказанной теме на форуме LV парой сообщений ниже один из участников предложил свою версию ВП, считывающего показания термодатчика, состоящую, по сути, всего из одного подприбора — Send Receive.vi из подпалитры Arduino:



Соблазнившись простотой и не вникнув в подробности, в своих дальнейших экспериментах я ничтоже сумняшеся пользовался этой простенькой версией. Нет-нет, всё хорошо и прекрасно, она корректно работает, однако, тут есть некая тонкость, связанная с различиями между моим сценарием работы цепочки датчик-Arduino-LabVIEW и тем сценарием, для которого сделан ВП с форума. Эта тонкость доставила мне впоследствии некоторое количество головной боли, но об этом чуть позже.

Одной из особенностей датчиков DS18B20 является то, что каждый отдельный экземпляр имеет свой уникальный 8-байтовый адрес (ROM-код), зашитый в него в процессе производства. Это теоретически позволяет вешать на одну 1-Wire линию неограниченно много датчиков. Для реализации такой возможности предусмотрена команда адресации к конкретному датчику.

Чтобы адресоваться, нужно знать адрес (улыбка). ROM-коды своих датчиков я узнал, воспользовавшись примером DS18x20_Temperature из библиотеки OneWire, и записал их в пять переменных, объявленных в начале программы:
// DS18B20 temperature sensors' addresses:

byte sensor_1[8] = {0x28,0xFF,0xBE,0xCE,0x14,0x14,0x00,0x8A};
byte sensor_2[8] = {0x28,0xFF,0x42,0x43,0x15,0x14,0x00,0xE2};
byte sensor_3[8] = {0x28,0xFF,0xED,0x55,0x15,0x14,0x00,0x8F};
byte sensor_4[8] = {0x28,0xFF,0x3D,0x6E,0x15,0x14,0x00,0x0D};
byte sensor_5[8] = {0x28,0xFF,0x5E,0x66,0x15,0x14,0x00,0x4E};

В предложенном варианте OneWire_Read не получает никаких значений. Добавляем в неё параметр — адрес датчика (байтовый массив из 8 элементов):

void OneWire_Read(byte addr[8])

Перед каждой отправкой какой-либо команды адресуемся к датчику:

// Start the Conversion
	ds.reset();                  // Reset the OneWire bus in preparation for communication
	ds.select(addr);       // Addressing
	ds.write(0x44);              // Send 44, the conversion command

// Read back the data
	ds.reset();                      // Reset the OneWire bus in preparation for communication
	ds.select(addr);           // Addressing
	ds.write(0xBE);                  // Send the "Read Scratchpad" command

и добавляем по варианту на каждый датчик в структуру выбора:
	/*********************************************************************************
	** OneWire temperature sensors reading
	*********************************************************************************/
		case 0x2E:  // sensor 1 read
			OneWire_Read(sensor_1);
		break;
		case 0x2F:  // sensor 2 read
			OneWire_Read(sensor_2);
		break;
		case 0x30:  // sensor 3 read
			OneWire_Read(sensor_3);
		break;
		case 0x31:  // sensor 4 read
			OneWire_Read(sensor_4);
		break;
		case 0x32:  // sensor 5 read
			OneWire_Read(sensor_5);
		break;

Для испытаний того, что получилось, я сделал свой маленький ВП для единичного опроса одного датчика:



Как видно, выбор датчика для опроса я реализовал через case-структуру на блок-диаграмме.

Для удобства дальнейшего применения я сваял маленький ВПП, как показано на скриншоте ниже, запарился и нарисовал для него няшную иконку и обозвал DS18B20 Read.



Не считая кластеров ресурса Arduino и ошибок, ВПП получает на вход номер датчика для опроса и на выход подаёт показание температуры в виде строки.

Ура! Испытания прошли успешно.

Читаем один датчик в автоматическом режиме


Хорошо, мы теперь умеем опрашивать вручную один датчик. Следующий шаг — циклический опрос одного датчика в автоматическом режиме. Для этого я сделал следующую блок-диаграмму:



Для начала интервал фиксирован, программа раз в секунду опрашивает датчик и после остановки цикла пользователем пишет собранные данные в массив. Для удобства я к каждому показанию температуры добавил временну́ю метку с помощью функции Get Date/Time String.
Включаем, ждём секунд 20, останавливаем… И тут начинается веселье.
Просмотр массива показывает, что температура считывается только первые 5 раз после запуска программы, дальше лишь временны́е метки без показаний температуры:



Я долго не мог понять, в чём же дело — на стороне LV вроде бы ошибки быть не может, блок-диаграмма до безобразия проста, код скетча Arduino тоже корректен, т.к. в режиме единичного ручного опроса работает безотказно. Что ещё может быть? Сама плата Arduino? Понаблюдав за ней, обнаружил следующее. Запускаем программу, дважды мигает светодиод L на пине 13, потом мигает светодиод RX (контроллер принял команду для термодатчика, отправленную ПК), проходит одна секунда (датчик проводит «конвертирование» температуры в байты в своей памяти, ПК ждёт от него ответа), мигает светодиод TX (контроллер получил от датчика байты и отправил их ПК), снова мигает диод RX, снова проходит секунда, снова мигает TX, и так далее по кругу, пока мы не остановим выполнение программы. Так вот, в моей схеме этот калейдоскоп огоньков продолжался первые ~5 секунд, а потом контроллер переставал отвечать, беспрерывно мигал диод RX, и программу получалось остановить только кнопкой останова выполнения в интерфейсе LabVIEW.
Вся эта катавасия натолкнула меня на мысль, что где-то что-то не в порядке с таймингом, и я начал копать в этом направлении, изменял время ожидания в ВП, в скетче, анализировал код скетча буквально по строчке, блок-диаграмму ВП по элементику, но ничего не помогало. В конце концов от отчаяния распотрошил Send Receive.vi, потому что ну неоткуда больше было взяться проблеме. Взгляните на его блок-диаграмму:



Send Receive, как ему и полагается, берёт данные, отправляет их по указанному направлению и принимается ждать. Если в течение 100 миллисекунд ответа не поступает, ждёт ещё 5 миллисекунд, очищает буфер вывода и повторно отправляет данные, всего до 10 таких попыток. Где-то между Send Receive, микроконтроллером и главным ВП в процессе работы возникает и накапливается рассинхрон, и из-за этого к шестой итерации опроса датчика происходит какая-то нестыковка отправляемых и принимаемых команд, которая вешает контроллер.

Как показывает опыт, простое на вид решение — не всегда самое лучшее, поэтому я переделал свой DS18B20 Read.vi:



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

Читаем все датчики в автоматическом режиме


Умея читать в авторежиме один датчик, запилить чтение сразу всех пяти — дело техники. Для этого я вписал в LabVIEWInterface.ino ещё одну функцию — OneWire_Read_All():
Код
void OneWire_Read_All()
{
	OneWire ds(2);
	byte Data[9];
	int Fract, Whole, Tc_100, SignBit, TReading;

	ds.reset();
	ds.skip();       // Addressing to all sensors on the line
	ds.write(0x44);

	delay(1000);

// reading sensor 1
	ds.reset();
	ds.select(sensor_1);           // Addressing to sensor 1
	ds.write(0xBE);
	for ( byte i = 0; i < 9; i++)
	{ 
		Data[i] = ds.read();
    }

	TReading = (Data[1] << 8) + Data[0];
	SignBit = TReading & 0x8000;
	if (SignBit)
	{
		TReading = (TReading ^ 0xffff) + 1;
	}
	Tc_100 = (6 * TReading) + TReading / 4;

	Whole = Tc_100 / 100;
	Fract = Tc_100 % 100;

	if (SignBit)
	{
		Serial.print("-");
	}
	Serial.print(Whole);
	Serial.print(",");
	if (Fract < 10)
	{
		Serial.print("0");
	}
	Serial.print(Fract);
	Serial.print(" ");

// reading sensor 2    
	ds.reset();
	ds.select(sensor_2);           // Addressing to sensor 2
	ds.write(0xBE);
	for ( byte i = 0; i < 9; i++)
	{ 
		Data[i] = ds.read();
	}

	TReading = (Data[1] << 8) + Data[0];
	SignBit = TReading & 0x8000;
	if (SignBit)
	{
		TReading = (TReading ^ 0xffff) + 1;
	}
	Tc_100 = (6 * TReading) + TReading / 4;

	Whole = Tc_100 / 100;
	Fract = Tc_100 % 100;

	if (SignBit)
	{
		Serial.print("-");
	}
	Serial.print(Whole);
	Serial.print(",");
	if (Fract < 10)
	{
		Serial.print("0");
	}
	Serial.print(Fract);
	Serial.print(" ");

// reading sensor 3    
	ds.reset();
	ds.select(sensor_3);           // Addressing to sensor 3
	ds.write(0xBE);
	for ( byte i = 0; i < 9; i++)
	{ 
		Data[i] = ds.read();
	}

	TReading = (Data[1] << 8) + Data[0];
	SignBit = TReading & 0x8000;
	if (SignBit)
	{
		TReading = (TReading ^ 0xffff) + 1;
	}
	Tc_100 = (6 * TReading) + TReading / 4;

	Whole = Tc_100 / 100;
	Fract = Tc_100 % 100;

	if (SignBit)
	{
		Serial.print("-");
	}
	Serial.print(Whole);
	Serial.print(",");
	if (Fract < 10)
	{
		Serial.print("0");
	}
	Serial.print(Fract);
	Serial.print(" ");

// reading sensor 4    
	ds.reset();
	ds.select(sensor_4);           // Addressing to sensor 4
	ds.write(0xBE);
	for ( byte i = 0; i < 9; i++)
	{ 
		Data[i] = ds.read();
	}

	TReading = (Data[1] << 8) + Data[0];
	SignBit = TReading & 0x8000;
	if (SignBit)
	{
		TReading = (TReading ^ 0xffff) + 1;
	}
	Tc_100 = (6 * TReading) + TReading / 4;

	Whole = Tc_100 / 100;
	Fract = Tc_100 % 100;

	if (SignBit)
	{
		Serial.print("-");
	}
	Serial.print(Whole);
	Serial.print(",");
	if (Fract < 10)
	{
		Serial.print("0");
	}
	Serial.print(Fract);
	Serial.print(" ");

// reading sensor 5    
	ds.reset();
	ds.select(sensor_5);           // Addressing to sensor 5
	ds.write(0xBE);
	for ( byte i = 0; i < 9; i++)
	{ 
		Data[i] = ds.read();
	}

	TReading = (Data[1] << 8) + Data[0];
	SignBit = TReading & 0x8000;
	if (SignBit)
	{
		TReading = (TReading ^ 0xffff) + 1;
	}
	Tc_100 = (6 * TReading) + TReading / 4;

	Whole = Tc_100 / 100;
	Fract = Tc_100 % 100;

	if (SignBit)
	{
		Serial.print("-");
	}
	Serial.print(Whole);
	Serial.print(",");
	if (Fract < 10)
	{
		Serial.print("0");
	}
	Serial.print(Fract);
}

Как видите, она, за небольшими изменениями, является повторённой 5 раз функцией чтения одного датчика.

Также пришлось немного изменить DS18B20 Read.vi — сделал его универсальным, как для опроса отдельных датчиков (получает на вход номер от 1 до 5), так и для всех сразу (6 на входе). Ещё я изменил число байтов, читаемых из буфера, т.к. при опросе всех датчиков сразу на выходе ВП строка почти в 6 раз длиннее, и увеличил интервал опроса буфера:



Ура, товарищи! Всё работает именно так, как я хотел.

Калибровка


Казалось бы, всё готово, тут можно и успокоиться, но при тестах все пять датчиков, будучи помещёнными в одинаковые условия (стакан с водой), давали несколько разные показания. Поэтому их нужно было прокалибровать.
Для этого понадобились: ртутный термометр с ценой деления 0,01 градус Цельсия, лабораторная стойка с лапкой (возможно, знакомая вам по школьным лабораторным работам по физике (улыбка)), стакан, немного льда из морозилки, электрочайник и вода. Импровизированная установка выглядела так:



Прошу прощения за качество фотографий и за беспорядок в лаборатории.

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

В качестве примера — калибровочная кривая для датчика №1.



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

Последние штрихи


Интерфейс LIFA для работы с Arduino предоставляет кучу возможностей — работа с LCD-дисплеями, серводвигателями, управление по ИК-каналу и т.д., это всё полезно, но в моём случае совершенно не нужно, и поэтому я довольно радикально урезал содержимое LabVIEWInterface.ino, LIFA_BASE.ino, LabVIEWInterface.h и папки LIFA_Base, убрав оттуда всё лишнее. Листинги тут приводить не буду, если кому-нибудь захочется поглядеть, обращайтесь, все исходники предоставлю с удовольствием.

Для управляющей программы я сделал вот такую фронт-панель:



Платка Arduino для защиты от окружающей среды была упакована в термоусадочную трубку, загерметизированную с торцов:





Прибор готов:



Итоги


Стоимость компонентов и материалов:
  1. Arduino Nano — 1900 руб;
  2. 5 термодатчиков DS18B20 — 1950 руб;
  3. 10 м кабеля — 150 руб;
  4. Мелочи (термоусадка, кабельные стяжки, ...) — 200 руб;

В сумме — 4200 руб.

А теперь давайте подумаем. В продаже есть фабричные термокосы, легко гуглится, к примеру, «термокоса ТК-10/10» средней стоимостью 13000 рублей. Вы можете спросить: «А нафига было париться, если существуют аналоги промышленного изготовления сравнимой стоимости, дающие такую же или пренебрежимо худшую точность, заведомо лучше отлаженные, более надёжные и качественно исполненные?» Отвечу, тому несколько причин:

  1. /*Говоря не о серьёзной научной аппаратуре, а об устройствах, подобных описываемому выше.*/ Покупая готовое решение, ты вынужден верить цифрам характеристик, которые указал производитель. Это нормально при применении прибора на производстве или в быту, но не для научных целей. Я не говорю, что производитель намеренно даёт ложные сведения, но, как правило, ты ничего не знаешь о тонкостях внутреннего устройства, о методиках оценки параметров прибора, использованных при его изготовлении, а они могут оказаться неточными или содержать неуместные допущения. В общем, вы поняли, главный принцип научного мировоззрения — «Ничего не принимай на веру». Другое дело, если собираешь прибор сам буквально по детальке, сам задаёшь логику его работы и оцениваешь его точность по выбранным тобой методам.
  2. С образовательной точки зрения изготовление термокосы принесло ценный опыт работы паяльником, программирования Arduino и понимания его связи с компьютером посредством LabVIEW, особенно в свете того, что я продолжаю изучение связки Arduino-LV-ПК в проекте, на который переключился по окончании этого.
  3. В меньшей степени, но вопрос стоимости тоже имел значение.


Благодарю всех за внимание! Если возникнут вопросы/предложения/критика, всегда рад выслушать, исходники скетчей и VI-шки предоставлю с удовольствием, как уже писал выше, обращайтесь.

P.S. Мои навыки в программировании недалеко ушли от «Hello world!», поэтому не судите строго, если какие-то термины я употребил неточно или не совсем по назначению.