Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Всем привет! В процессе работы над гексаподом появилась потребность в каком-нибудь интерфейсе для общения с ним. В результате тесной работы с Linux я подумал, а почему бы не использовать терминал для гексапода? Я был удивлен, что по запросу "STM32 terminal" не было готовых решений. Ну раз нет готовых, то напишем свой терминальный сервер для использования в микроконтроллерах. Сделаем это без использования динамической памяти и прочих опасных радостей, быстро, просто и понятно.
Github: https://github.com/NeoProg2013/terminal-server
Синхронизируем понятия
Терминал, консоль — клиентская часть (KiTTY, PuTTY и прочие). Выполняется на стороне клиента (PC). Возьмем готовую реализацию в виде KiTTY.
Терминальный сервер — удаленная часть, выполняется на микроконтроллере. Именно эту часть я и буду делать.
Аппаратные тонкости
В качестве тестового микроконтроллера я буду использовать STM32F030. Общаться с MCU мы будем через USB-USART преобразователь CH340E.
Почему именно она? Для корректной работы терминального сервера нужно уметь отслеживать текущее состояние подключения, т.е. нам нужен аналог TCP протокола в железном варианте и эта микросхема отлично для этого подходит — тут есть DTR. Просто берем этот вывод и заводим его на ногу микроконтроллера, тем самым мы сможем определить подключение.
DTR=Low — пользователь открыл терминал;
DTR=High — пользователь закрыл терминал;
Зачем нам вообще следить за подключением? Дело в том, что терминальному серверу необходимо знать когда "забыть" текущую сессию и обнулить все буферы. Можно закрыть терминал в середине набора команды и при следующем подключением можно обнаружить неадекватное поведение. Так же нам нужно знать когда вывести приглашение в консоль пользователя, всё должно быть по канону :)
В итоге, для работы требуется всего 4 провода: RX, TX, DTR, GND. Мне кажется это хорошим началом — минимум проводов. На этом аппаратные детали закончились.
Управляющие последовательности (УП) и символы (УС)
Что это и зачем они нужны? Вся вот эта красивая разноцветная консолька это "клиент-сервер", неважно SSH там или провод между ними. Нужно каким-то образом сообщать серверу или клиенту о каких-либо действиях — для этого используются УП и УС.
Начнем с управляющих последовательностей (УП)
Для простоты можно рассматривать УП как некие события, которые посылает клиент в сторону сервера и наоборот. Если нужно на стороне клиента переместить курсор влево (поправить опечатку, например), то мы должны как-то сообщить серверу об этом. Для этого мы отправляем ему событие в виде УП.
УП начинаются со специального кода ESC (0х1В), после которого идут несколько байт, определяющие тип события. Например, событие перемещения курсора влево будет иметь следующий вид: [0x1B 0x5B 0x44].
Управляющие символы (УС)
В отличие от УП они имеют размерность 1 байт. В качестве примера можно взять всем известные \r (0x0D) и \n (0x0A). Они находятся в самом начале ANSII таблицы [0x00 - 0x31, 0x7F] и в целом с ними нет никаких проблем.
Когда вы хотите прервать команду, то нажимаете Ctrl+C. В итоге это превращается в управляющий символ 0x03 (End of text). Для закрытия сессии Ctrl+D, это превращается в 0x04 (End of transmission) и т.д.
Программные тонкости
Ниже я приведу несколько УП и УС, которые хочу реализовать на этом этапе. Более обширный их список можно найти тут
"\x0D" — Carriage Return (CR, возврат каретки), он же \r;
"\x0A" — Line Feed (LF, перевод каретки), он же \n;
"\x0D" — TAB button (ну куда же без авто завершения команд);
"\x7F" и "\x08" — BACKSPACE button;
"\x1B[3~" — DEL button;
"\x1B[A" и "\x1B[B" — arrow up \ down;
"\x1B[D" и "\x1B[C" — arrow left \ right;
"\x1B[4~" — END button;
"\x1B[1~" — HOME button;
Этого списка достаточно для простого терминального сервера. В дальнейшем новые УП и УС будут добавляться по мере необходимости.
Есть еще две хитрых УП, которые я буду посылать клиенту. При посылке любого символа клиенту на его стороне не просто отображается символ, но и перемещается курсор. Использование этих двух УП позволяет убрать слежение за положением курсора в некоторых местах. Это сильно упростит манипуляции с клиентской частью и сократит трафик по USART.
"\x1B[s" — Запомнить положение курсора;
"\x1B[u" — Восстановить положение курсора;
Закинем их в отдельные функции:
static inline void save_cursor_pos() { send_data("\x1B[s", 3); }
static inline void load_cursor_pos() { send_data("\x1B[u", 3); }
Важно: не все терминалы поддерживают эти команды, нужно иметь это ввиду.
Ну давайте код попишем
Начнем с простого — драйвер USART. Вжух и драйвер готов:
int main() {
system_init(); // Инициализация источников тактирования, FLASH latency и т.п.
// Запуск тактирования USART1
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
while ((RCC->APB2ENR & RCC_APB2ENR_USART1EN) == 0);
// Настройка TX вывода
gpio_set_mode (GPIOA, USART_TX_PIN, GPIO_MODE_AF); // Передаем управление пином переферии
gpio_set_output_speed(GPIOA, USART_TX_PIN, GPIO_SPEED_HIGH); // Ну конечно же максимальная скорость пина
gpio_set_pull (GPIOA, USART_TX_PIN, GPIO_PULL_NO); // Без подтягивающих регисторов обойдемся
gpio_set_af (GPIOA, USART_TX_PIN, 1); // Какой периферии мы передаем пин, в данном случае USART1
// Настройка RX вывода
gpio_set_mode (GPIOA, USART_RX_PIN, GPIO_MODE_AF);
gpio_set_output_speed(GPIOA, USART_RX_PIN, GPIO_SPEED_HIGH);
gpio_set_pull (GPIOA, USART_RX_PIN, GPIO_PULL_UP);
gpio_set_af (GPIOA, USART_RX_PIN, 1);
// Конфигурация USART: 8N1, 115200
RCC->APB2RSTR |= RCC_APB2RSTR_USART1RST;
RCC->APB2RSTR &= ~RCC_APB2RSTR_USART1RST;
USART1->BRR = SYSTEM_CLOCK_FREQUENCY / 115200;
// Запускаем USART1
USART1->CR1 |= USART_CR1_UE;
USART1->CR1 |= USART_CR1_TE | USART_CR1_RE;
while (true) {
if (USART1->ISR & USART_ISR_RXNE) {
uint8_t data = USART1->RDR;
}
}
}
void send_data(const char* data, uint8_t size) {
while (size) {
while ((USART1->ISR & USART_ISR_TXE) != USART_ISR_TXE);
USART1->TDR = *data;
++data;
--size;
}
}
Я не буду детально останавливаться на каждой строчке, расскажу общую идею. Драйвер блокирующий (polling) без использования прерываний и DMA. Сделано это в угоду простоты и скорости реализации. В главном цикле мы читаем данные пока в никуда, так же есть отдельная функция для передачи данных. Я планирую её передавать терминальному серверу в виде указателя на функцию, чтобы тот мог передавать данные. Это позволит отвязать сервер от периферии (абстракция и все такое).
Первые шаги
"Нажать кнопку и увидеть символ в консоли" звучит просто, но не все так гладко. При нажатии клавиши клиент передает серверу её скан-код, а тот уже решает что с этим делать. Он может послать скан-код обратно клиенту для отображения в консоли или выполнить какое-либо действие, если это УП.
УП и УС я решил обрабатывать как обычные пользовательские команды, по сути то они ни чем не отличаются. Соответственно их обработчики будут внутри кода сервера и будут храниться в отдельной переменной.
Для выполнения команд нам нужно где-то буферизировать вводимые символы, при этом УП и УС должны попадать в отдельный буфер. Еще нам нужно где-то хранить список пользовательских команд и иметь возможность указывать их через интерфейс. И нельзя забывать про историю команд. Появляются следующие структуры данных:
typedef struct {
char cmd[MAX_COMMAND_LENGTH];
uint16_t length;
} cmd_info_t;
static term_srv_cmd_t esc_seq_list[ESCAPE_SEQUENCES_COUNT] = {
{ .cmd = "\x0D", .len = 1, .handler = esc_return_handler, },
{ .cmd = "\x0A", .len = 1, .handler = esc_return_handler, },
{ .cmd = "\x7F", .len = 1, .handler = esc_backspace_handler, },
{ .cmd = "\x08", .len = 1, .handler = esc_backspace_handler, },
{ .cmd = "\x1B[3~", .len = 4, .handler = esc_del_handler, },
{ .cmd = "\x1B[A", .len = 3, .handler = esc_up_handler, },
{ .cmd = "\x1B[B", .len = 3, .handler = esc_down_handler, },
{ .cmd = "\x1B[D", .len = 3, .handler = esc_left_handler, },
{ .cmd = "\x1B[C", .len = 3, .handler = esc_right_handler, },
{ .cmd = "\x1B[4~", .len = 4, .handler = esc_end_handler, },
{ .cmd = "\x1B[1~", .len = 4, .handler = esc_home_handler, }
};
static void(*send_data)(const char* data, uint16_t) = NULL; // Указатель на функцию для передачи данных клиенту
static term_srv_cmd_t* ext_cmd_list = NULL; // Указатель на список пользовательских команд
static uint8_t ext_cmd_count = 0; // Количество пользовательских команд
static uint16_t cursor_pos = 0; // Текущая позиция курсора
static cmd_info_t current_cmd = {0}; // Информация о вводимой в данный момент команде
static char esc_seq_buffer[10] = {0}; // Буфер для управляющих последовательностей
static uint16_t esc_seq_length = 0; // Длина управляющей последовательности на данный момент
static cmd_info_t history_elements[MAX_COMMAND_HISTORY_LENGTH] = {0}; // Элементы истории. Вместо динамического выделения памяти
static cmd_info_t* history[MAX_COMMAND_HISTORY_LENGTH] = {0}; // Указатели на элементы. Всегда отсортированы по времени
static int16_t history_len = 0; // Длина истории (количество команд в ней)
static int16_t history_pos = -1; // Текущая позиция в истории
esc_seq_list это те самые УП и УС, которые я буду обрабатывать. В этой структуре есть сигнатура, её длина и функция-обработчик. При совпадении сигнатуры будет вызываться функция-обработчик и выполняться какое-либо действие (перемещение курсора, удаление символа и т.п.)
Теперь нам нужен внешний интерфейс для работы с сервером (заголовочный файл):
#ifndef _TERM_SRV_H_
#define _TERM_SRV_H_
#include <stdint.h>
// Settings
#define MAX_COMMAND_HISTORY_LENGTH (3) // Command count in history
#define MAX_COMMAND_LENGTH (64) // Command length in bytes
#define GREETING_STRING ("\x1B[36mroot@hexapod-AIWM: \x1B[0m")
#define UNKNOWN_COMMAND_STRING ("\x1B[31m - command not found\x1B[0m")
typedef struct {
char* cmd;
uint16_t len;
void(*handler)(const char* cmd);
} term_srv_cmd_t;
extern void term_srv_init(void(*_send_data)(const char*, uint16_t), term_srv_cmd_t* _ext_cmd_list, uint8_t _ext_cmd_count);
extern void term_srv_attach(void);
extern void term_srv_detach(void);
extern void term_srv_process(char symbol);
#endif // _TERM_SRV_H_
term_srv_init инициализирует терминальный сервер. В нее передается указатель на функцию для передачи данных клиенту и список пользовательских команд в виде массива структур. В самой структуре содержится команда, её длина и функция-обработчик, которая будет вызываться сервером при получении команды.
term_srv_process вызывается для обработки принятого символа. В нее просто передается принятый символ. Её мы добавляем в драйвер USART после приема символа.
term_srv_attach вызывается при подключении пользователя к серверу. В это время выводится приглашение.
term_srv_detach вызывается для сброса сервера при изменении уровня на DTR пине или еще при каком-нибудь событии.
Просто не правда ли? Теперь реализуем все это дело:
void term_srv_init(void(*_send_data)(const char*, uint16_t), term_srv_cmd_t* _ext_cmd_list, uint8_t _ext_cmd_count) {
send_data = _send_data;
ext_cmd_list = _ext_cmd_list;
ext_cmd_count = _ext_cmd_count;
term_srv_detach();
}
void term_srv_attach(void) {
send_data("\r\n", 2);
send_data(GREETING_STRING, strlen(GREETING_STRING));
}
void term_srv_detach(void) {
cursor_pos = 0;
memset(¤t_cmd, 0, sizeof(current_cmd));
esc_seq_length = 0;
memset(esc_seq_buffer, 0, sizeof(esc_seq_buffer));
memset(history_elements, 0, sizeof(history_elements));
for (int16_t i = 0; i < MAX_COMMAND_HISTORY_LENGTH; ++i) {
history[i] = &history_elements[i];
}
history_len = 0;
history_pos = 0;
}
Тут всё предельно просто: в случае init сохраняем переданные параметры и сбрасываем состояние сервера. В случае detach обнуляем вообще всё. Как-будто нечего и не было. В attach выводим приглашение в консоль клиента.
Теперь самое интересное — обработка символов
void term_srv_process(char symbol) {
if (current_cmd.length + 1 >= MAX_COMMAND_LENGTH) {
return; // Incoming buffer is overflow
}
if (esc_seq_length == 0) {
// Check escape signature
if (symbol <= 0x1F || symbol == 0x7F) {
esc_seq_buffer[esc_seq_length++] = symbol;
}
// Print symbol if escape sequence signature is not found
if (esc_seq_length == 0) {
if (cursor_pos < current_cmd.length) {
memmove(¤t_cmd.cmd[cursor_pos + 1], ¤t_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos); // Offset symbols after cursor
save_cursor_pos();
send_data(¤t_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos + 1);
load_cursor_pos();
}
current_cmd.cmd[cursor_pos++] = symbol;
++current_cmd.length;
send_data(&symbol, 1);
}
} else {
esc_seq_buffer[esc_seq_length++] = symbol;
}
// Process escape sequence
if (esc_seq_length != 0) {
int8_t possible_esc_seq_count = 0;
int8_t possible_esc_idx = 0;
for (int8_t i = 0; i < ESCAPE_SEQUENCES_COUNT; ++i) {
if (esc_seq_length <= esc_seq_list[i].len && memcmp(esc_seq_buffer, esc_seq_list[i].cmd, esc_seq_length) == 0) {
++possible_esc_seq_count;
possible_esc_idx = i;
}
}
switch (possible_esc_seq_count) {
case 0: // No sequence - display all symbols
for (int8_t i = 0; i < esc_seq_length && current_cmd.len + 1 < MAX_COMMAND_LENGTH; ++i) {
if (esc_seq_buffer[i] <= 0x1F || esc_seq_buffer[i] == 0x7F) {
esc_seq_buffer[i] = '?';
}
current_cmd.cmd[cursor_pos + i] = esc_seq_buffer[i];
++current_cmd.len;
}
send_data(¤t_cmd.cmd[cursor_pos], esc_seq_length);
cursor_pos += esc_seq_length;
esc_seq_length = 0;
break;
case 1: // We found one possible sequence - check size and call handler
if (esc_seq_list[possible_esc_idx].len == esc_seq_length) {
esc_seq_length = 0;
esc_seq_list[possible_esc_idx].handler();
}
break;
default: // We found few possible sequences
break;
}
}
}
Погнали по строчкам.
8-10: тут мы проверяем соответствие символа на сигнатуру УП и УС, если до этого мы её еще не обнаружили. Если мы все же нашли соответствие, то мы сохраняем символ в другой буфер и переходим в режим их обработки (esc_seq_length != 0);
12-21: если мы не нашли причин считать, что мы принимаем УП или УС, то просто отправляем принятый символ для отображения в консоли клиента и записываем его в буфер;
13-18: этот кейс работает, если курсор находится не в конце команды (переместили его ранее). Данные в буфере смещаются вправо и отображаются в консоли клиента. Тут используются save_cursor_pos и load_cursor_pos, чтобы руками не вычислять каждый раз позицию курсора - пусть за нас это сделает клиент;
29-36: сюда мы попадаем только в режиме обработки УП и УС. Тут мы бегаем по нашему списку и ищем количество частичных совпадений.
- possible_esc_seq_count показывает столько мы таких нашли,
- possible_esc_idx хранит индекс в списке для последнего найденного совпадения;40-50: сюда мы попадаем, если входная УП оказалась не УП, либо мы её не поддерживаем. В этом случае вываливаем всё обратно в консоль клиента, заменяя невидимые символы на '?';
47-52: сюда мы попадаем, если мы однозначно определили УП или УС, но могли её не полностью принять (проверяем её длину). Как только мы получили её полностью — дергаем обработчик и выходим из режима обработки последовательностей. При этом саму последовательность мы не отправляем обратно.
Обработчики УП и УС
Начнем с базовых вещей: \r и \n, стрелок влево\вправо, home, end.
static void esc_return_handler(const char* cmd) {
send_data("\r\n", 2);
// Add command into history
if (current_cmd.len) {
if (history_len >= MAX_COMMAND_HISTORY_LENGTH) {
// If history is overflow -- offset all items to begin by 1 position, first item move to end
void* temp_addr = history[0];
for (int16_t i = 0; i < MAX_COMMAND_HISTORY_LENGTH - 1; ++i) {
history[i] = history[i + 1];
}
history[MAX_COMMAND_HISTORY_LENGTH - 1] = temp_addr;
--history_len;
}
memcpy(history[history_len++], ¤t_cmd, sizeof(current_cmd));
history_pos = history_len;
}
// Calc command length without args
void* addr = memchr(current_cmd.cmd, ' ', current_cmd.len);
int16_t cmd_len = current_cmd.len;
if (addr != NULL) {
cmd_len = (uint32_t)addr - (uint32_t)current_cmd.cmd;
}
// Search command
bool is_find = false;
for (int16_t i = 0; i < ext_cmd_count; ++i) {
if (cmd_len == ext_cmd_list[i].len && memcmp(ext_cmd_list[i].cmd, current_cmd.cmd, cmd_len) == 0) {
ext_cmd_list[i].handler(current_cmd.cmd);
send_data("\r\n", 2);
is_find = true;
break;
}
}
if (!is_find && cmd_len) {
send_data(current_cmd.cmd, cmd_len);
send_data(UNKNOWN_COMMAND_STRING, strlen(UNKNOWN_COMMAND_STRING));
send_data("\r\n", 2);
}
send_data(GREETING_STRING, strlen(GREETING_STRING));
// Clear buffer to new command
memset(¤t_cmd, 0, sizeof(current_cmd));
cursor_pos = 0;
}
static void esc_left_handler(const char* cmd) {
if (cursor_pos > 0) {
send_data("\x1B[D", 3);
--cursor_pos;
}
}
static void esc_right_handler(const char* cmd) {
if (cursor_pos < current_cmd.length) {
send_data("\x1B[C", 3);
++cursor_pos;
}
}
static void esc_home_handler(const char* cmd) {
while (cursor_pos > 0) {
send_data("\x1B[D", 3);
--cursor_pos;
}
}
static void esc_end_handler(const char* cmd) {
while (cursor_pos < current_cmd.length) {
send_data("\x1B[C", 3);
++cursor_pos;
}
}
Просто и лаконично.
Аргумент cmd для внутренних обработчиков всегда NULL, я использовал структуру term_srv_cmd_t, чтобы не дублировать её еще раз под другим именем из-за различий в прототипе.
esc_return_handler: мы сразу же посылаем клиенту "\r\n", что приведет к переводу каретки в начало новой строки. Сохраняем команду в историю и выполняем её автоматическую сортировку по времени. Дальше мы пробегаемся по массиву пользовательских команд и ищем совпадения, если нашли дергаем функцию-обработчик. После этого мы обнуляем буфер команды и посылаем приглашение (в данном случае это "root@hexapod-AIWM: ")
esc_right_handler и esc_left_handler: тут мы просто смещаем внутреннюю позицию курсора и пересылаем УП "\x1B[C" обратно, чтобы переместить курсор уже в консоли клиента. Аналогично и для esc_left_handler, только там в другую сторону.
esc_home_handler и esc_end_handler: по сути это те же перемещения курсора влево\вправо только не на одну позицию, а в начало и конец — цикл с посылкой нужной УП ("\x1B[D" влево, "\x1B[C" вправо).
Мы научились печатать символы, перемещать курсор и нажимать Enter. Теперь возьмем что-нибудь сложнее — УС backspace и УС delete подойдут, символы то нужно уметь стирать.
static void esc_backspace_handler(const char* cmd) {
if (cursor_pos > 0) {
memmove(¤t_cmd.cmd[cursor_pos - 1], ¤t_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos); // Remove symbol from buffer
current_cmd.cmd[current_cmd.length - 1] = 0; // Clear last symbol
--current_cmd.length;
--cursor_pos;
send_data("\x7F", 1); // Remove symbol
save_cursor_pos();
send_data(¤t_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos); // Replace old symbols
send_data(" ", 1); // Hide last symbol
load_cursor_pos();
}
}
static void esc_del_handler(const char* cmd) {
if (cursor_pos < current_cmd.length) {
memmove(¤t_cmd.cmd[cursor_pos], ¤t_cmd.cmd[cursor_pos + 1], current_cmd.length - cursor_pos); // Remove symbol from buffer
current_cmd.cmd[current_cmd.length - 1] = 0; // Clear last symbol
--current_cmd.length;
save_cursor_pos();
send_data(¤t_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos);
send_data(" ", 1); // Hide last symbol
load_cursor_pos();
}
}
В самом начале идут манипуляции с буфером команды, от туда мы удаляем символ под курсором и сдвигаем всё что после него влево. Этот код превращается в диалог типа "Удали текущий символ со сдвигом курсора влево, сохрани позицию курсора, отобрази все символы после него, замени последний пробелом и восстанови положение курсора".
С delete все тоже самое, только символ стирается после курсора.
История и авто завершение
Перемещение по истории производится стрелками вверх и вниз. История представляет себя отсортированный по времени массив указателей на структуру команды (сама команда + длина + обработчик). Тут не так сложно как может показаться. Мы просто перемещаемся по истории и отображаем команду одну за одной из нее.
Единственная сложность это учесть размер команд. Например, текущая команда длиннее следующей и если просто её отобразить, то мы увидим в консоли конец предыдущей команды. Для решения этой проблемы вычисляется разница между текущей и следующей командами, и затираем хвосты в зависимости от её значения.
static void esc_up_handler(const char* cmd) {
if (history_pos - 1 < 0) {
return;
}
--history_pos;
// Calculate diff between current command and command from history
int16_t remainder = current_cmd.len - history[history_pos]->len;
// Move cursor to begin of command
while (cursor_pos) {
send_data("\x1B[D", 3);
--cursor_pos;
}
// Print new command
memcpy(¤t_cmd, history[history_pos], sizeof(current_cmd));
send_data(current_cmd.cmd, current_cmd.len);
cursor_pos += current_cmd.len;
// Clear others symbols
save_cursor_pos();
for (int16_t i = 0; i < remainder; ++i) {
send_data(" ", 1);
}
load_cursor_pos();
}
static void esc_down_handler(const char* cmd) {
if (history_pos + 1 > history_len) {
return;
}
++history_pos;
int16_t remainder = 0;
if (history_pos < history_len) {
// Calculate diff between current command and command from history
remainder = current_cmd.len - history[history_pos]->len;
// Move cursor to begin of command
while (cursor_pos) {
send_data("\x1B[D", 3);
--cursor_pos;
}
// Print new command
memcpy(¤t_cmd, history[history_pos], sizeof(current_cmd));
send_data(current_cmd.cmd, current_cmd.len);
cursor_pos += current_cmd.len;
}
else {
remainder = current_cmd.len;
// Move cursor to begin of command
while (cursor_pos) {
send_data("\x1B[D", 3);
--cursor_pos;
}
memset(¤t_cmd, 0, sizeof(current_cmd));
}
// Clear others symbols
save_cursor_pos();
for (int16_t i = 0; i < remainder; ++i) {
send_data(" ", 1);
}
load_cursor_pos();
}
Вот, кстати, пример одновременного перемещения курсора на стороне сервера и на стороне клиента. В данном случае курсор перемещается в начало команды.
while (cursor_pos) {
send_data("\x1B[D", 3);
--cursor_pos;
}
Авто завершение. Я не стал запариваться с умным авто завершением и сделал по однозначному совпадению. Т.е. если у нас есть 2 похожих команды (command1, command2), то при вводе "comm" и нажатии TAB авто завершение не сработает. А вот если команда command1 единственная в своем роде, то при вводе "co" команда автоматически допишется.
Результат
Надеюсь он кому-нибудь будет полезен. Всем спасибо за внимание!