В предыдущей части статьи были рассмотрены принципы и механизмы работы PHY-уровня радиолюбительского OFDM-протокола, а также алгоритмы кодирования и декодирования данных. В этой статье рассматриваются принципы построения уровней управления доступом к среде (MAC) и управления логическим каналом (LLC), реализующих полудуплексное сетевое соединение между двумя трансиверами, а также анализируются возникшие при реализации и методы их решения.
Введение
Рассмотренный в рамках прошлой статьи OFDM-модем обеспечил возможность передачи произвольных данных, выполняя функции PHY-уровень. Для организации устойчивого обмена информацией между узлами сети требуется внедрение дополнительных уровней абстракции, обеспечивающих установление сессии, создание прямого канала передачи данных, мониторинг состояния соединения и контроль целостности доставки пакетов.
В соответствии с моделью OSI канальный уровень подразделяется на MAC и LLC подуровни. Подуровень MAC (Medium Access Control) отвечает за формирование пакетов и контроль коллизий в среде передачи. Функциями LLC (Logical Link Control) являются управление информационным потоком, контроль целостности принятых данных, их квитирование (подтверждение) и инициация повторной передачи искаженных пакетов.
Реализация указанных подуровней преобразует разрабатываемый OFDM-протокол из средства однонаправленной передачи данных (наподобие телеграфа) в полудуплексную систему связи.
TL;DR
Большая часть статьи посвящена описанию алгоритмов конечных автоматов (FSM) и сопутствующих структурах данных. Радиолюбительская часть начинается в разделе "Тестирование.
Под спойлером краткое изложение статьи в формате вопрос-ответ:
Hidden text
В: что изменилось на PHY-уровне?
О: добавлены 16-QAM, 32-QAM модуляции, снижен PAPR, исправлен эквалайзер, увеличен размер пакета, исправлен детектор преамбулы.В: что было с эквалайзером?
О: эффект деградации (размытия) созвездия при использовании фильтра Винера.В: какой теперь размер пакета?
О: длина ограничена 10 битным полем len (до 1023 бит на блок данных).В: что было с детектором?
О: техдолг, избыточный перебор гипотез ZC-преамбулы нивелировал грубую оценку преамбулы Ньюмана.В: что за MAC и LLC?
О: реализованы алгоритмы установления полудуплексного сетевого соединения между двумя узлами и поддержание сессии обмена данными.В: как они устроены?
О: автоматная (FSM) логика управляет работой OFDM-модема.В: а как они работают?
О: добавлены управляющие пакеты, схожие по назначению с пакетами в TCP/IP.В: как устанавливается соединение?
О: трехстороннее рукопожатие в полудуплексном режиме.В: как в полудуплексе решена проблема передачи "навстречу"?
О: механизм тайм-слотов, при соединении узлы договариваются кто в какое время выходит на передачу.В: какой пинг?
О: в лабораторных тестах пинг порядка 5 сек из-за работы звуковой карты, минимальный порог 200-300 мс.В: какая скорость передачи данных?
О: по оценке канала узлы определяют с какой скоростью готовы вести прием данных; в пике до 1500 бит/сек с 16-QAM модуляцией и повышенной скоростью кодера (прореживанием); с применением 32-QAM значения будут еще выше.В: как данные передаются?
О: режим Stop-and-Wait с подтверждением каждого блока и режим bulk, в котором скоп пакетов передается последовательно и подтверждается единожды.В: механизм ARQ?
О: реализован механизм запроса и повторной отправки данных.В: что можно передавать?
О: текст (открыто или со сжатием), двоичные данные.
Изменения на PHY-уровне
Параллельно с проектированием MAC и LLC-уровней, модернизации подвергся физический (PHY) уровень. Был интегрирован метод модуляции 16-QAM, обеспечивающий кодирование 4 бит на один поднесущий символ. Также было изменено количество поднесущих в одном символе, интервал частот составляет 46.875 Гц. Формируемый OFDM-символ использует сетку из 53 ортогональных поднесущих.
PSK-созвездия
На рисунках 1, 2, 3 приведены примеры сигнальных созвездий и соответствующее им кодирование бит.

Таблица кодирования BPSK:
_MAPPING_TABLE = { (0,): -1 + 0j, (1,): 1 + 0j, }

Таблица кодирования QPSK:
_NORM = np.sqrt(2.0) _MAPPING_TABLE = { (0, 0): (-1.0 - 1j) / _NORM, (0, 1): (-1.0 + 1j) / _NORM, (1, 1): (1.0 + 1j) / _NORM, (1, 0): (1.0 - 1j) / _NORM, }

Таблица кодирования 16-QAM:
_NORM = np.sqrt(10.0) _MAPPING_TABLE = { (0, 0, 0, 0): (-3.0 - 3j) / _NORM, (0, 0, 0, 1): (-3.0 - 1j) / _NORM, (0, 0, 1, 1): (-3.0 + 1j) / _NORM, (0, 0, 1, 0): (-3.0 + 3j) / _NORM, (0, 1, 0, 0): (-1.0 - 3j) / _NORM, (0, 1, 0, 1): (-1.0 - 1j) / _NORM, (0, 1, 1, 1): (-1.0 + 1j) / _NORM, (0, 1, 1, 0): (-1.0 + 3j) / _NORM, (1, 1, 0, 0): (1.0 - 3j) / _NORM, (1, 1, 0, 1): (1.0 - 1j) / _NORM, (1, 1, 1, 1): (1.0 + 1j) / _NORM, (1, 1, 1, 0): (1.0 + 3j) / _NORM, (1, 0, 0, 0): (3.0 - 3j) / _NORM, (1, 0, 0, 1): (3.0 - 1j) / _NORM, (1, 0, 1, 1): (3.0 + 1j) / _NORM, (1, 0, 1, 0): (3.0 + 3j) / _NORM, }
Метод квадратурной амплитудной модуляции 16-QAM характеризуется более плотным созвездием, позволяющий передавать по 4 бита в одном сигнальном символе. Для минимизации вероятности возникновения битовых ошибок в условиях замираний и шумов при маппинге применяется код Грея.
Также, в протокол внедрена модуляция 32-QAM, обеспечивающая перенос до 5 бит информации в сигнальном симовле.

Таблица кодирования 16-QAM:
_NORM = np.sqrt(20.0) _MAPPING_TABLE = { (0, 0, 0, 0, 0): (-3.0 - 5j) / _NORM, (0, 0, 0, 0, 1): (-3.0 - 3j) / _NORM, (0, 0, 0, 1, 1): (-3.0 - 1j) / _NORM, (0, 0, 0, 1, 0): (-3.0 + 1j) / _NORM, (0, 0, 1, 1, 0): (-3.0 + 3j) / _NORM, (0, 0, 1, 1, 1): (-3.0 + 5j) / _NORM, (0, 1, 0, 0, 0): (-1.0 - 5j) / _NORM, (0, 1, 0, 0, 1): (-1.0 - 3j) / _NORM, (0, 1, 0, 1, 1): (-1.0 - 1j) / _NORM, (0, 1, 0, 1, 0): (-1.0 + 1j) / _NORM, (0, 1, 1, 1, 0): (-1.0 + 3j) / _NORM, (0, 1, 1, 1, 1): (-1.0 + 5j) / _NORM, (1, 1, 0, 0, 0): (1.0 - 5j) / _NORM, (1, 1, 0, 0, 1): (1.0 - 3j) / _NORM, (1, 1, 0, 1, 1): (1.0 - 1j) / _NORM, (1, 1, 0, 1, 0): (1.0 + 1j) / _NORM, (1, 1, 1, 1, 0): (1.0 + 3j) / _NORM, (1, 1, 1, 1, 1): (1.0 + 5j) / _NORM, (1, 0, 0, 0, 0): (3.0 - 5j) / _NORM, (1, 0, 0, 0, 1): (3.0 - 3j) / _NORM, (1, 0, 0, 1, 1): (3.0 - 1j) / _NORM, (1, 0, 0, 1, 0): (3.0 + 1j) / _NORM, (1, 0, 1, 1, 0): (3.0 + 3j) / _NORM, (1, 0, 1, 1, 1): (3.0 + 5j) / _NORM, (0, 0, 1, 0, 0): (-5.0 - 3j) / _NORM, (0, 0, 1, 0, 1): (-5.0 - 1j) / _NORM, (0, 1, 1, 0, 1): (-5.0 + 1j) / _NORM, (0, 1, 1, 0, 0): (-5.0 + 3j) / _NORM, (1, 0, 1, 0, 0): (5.0 - 3j) / _NORM, (1, 0, 1, 0, 1): (5.0 - 1j) / _NORM, (1, 1, 1, 0, 1): (5.0 + 1j) / _NORM, (1, 1, 1, 0, 0): (5.0 + 3j) / _NORM, }
Примечание:
32-QAM модуляция была интегрирована к моменту окончания написания статьи. Так как она не была протестирована достаточным образом, на данный момент она имеет статус экспериментальный. Предшествующие испытания с данным типом модуляции не увенчались успехом из-за деградации созвездий (подробнее см. ниже). Тем не менее, первичные лабораторные тесты подтверждают принципиальную реализуемость данного метода.
По причине высокой чувствительности OFDM к временной синхронизации, критичной на уровне единичных семплов (смещение на 1 семпл индуцирует фазовый сдвиг, а погрешность в 3 семпла приводит к полной потере способности демодулятора декодировать сигнал), было принято решение оставить двухэтапную схему синхронизации, базирующейся на комбинации преамбул Ньюмана и Задова-Чу с последующим когерентным накоплением для обеспечения помехозащищенности.
В текущей версии перебор гипотез в широкой полосе частот осуществляется исключительно детектором преамбулы Ньюмана. Детектор ZC-преамбулы единоразово осуществляет точную оценку CFO в пределах своей локальной полосы частот и определяет точные временные границы начала OFDM-сигнала.
Примечание:
В прошлой версии этот технический долг приводил к тому, что процедура точной оценки CFO по ZC-преамбуле нивелировала результаты предварительного грубого расчета по преамбуле Ньюмана. Так, если оценка Ньюмана фиксировала сдвиг в 101.3 Гц, то алгоритм ZC ошибочно определял значение в -101.5 Гц, фактически компенсируя корректную поправку, что исключало возможность успешного декодирования пакета.
Данная проблема проявлялась в условиях, когда в звуковом тракте помимо частотного дрейфа присутствовали выраженные нелинейности и фазовые искажения сигнала.
coarse: 27.4511Hz fine: -0.0596Hz coarse: 27.4387Hz fine: -0.1148Hz coarse: 27.4294Hz fine: 0.0565Hz coarse: 27.4118Hz fine: -0.0813Hz
Снижение пик-фактора
В предыдущей модификации протокола с целью снижения PAPR применялся метод клиппирования с фильтрации (CAF), задача которого сводится к ограничению высоких значений амплитуд с последующей фильтрацией возникающих внеполосных спектральных составляющих. Многократное применение данного алгоритма обеспечило дополнительное снижение PAPR формируемого сигнала, а также привело к уменьшению вероятности появления ложных оценок CFO.
class OFDMWavModem(OFDMIQModem): DEFAULT_SAMPLE_RATE = 12000 CAF_COUNT = 3 def mod_payload_signal(self, header_payload: npt.NDArray[np.uint8], packet_payload: npt.NDArray[np.uint8], mode: ModType) -> npt.NDArray[np.float64]: assert self.CAF_COUNT >= 1 signal_full = super().mod_payload_signal(header_payload, packet_payload, mode) for _ in range(self.CAF_COUNT): signal_full = clip_and_filter( signal_full, papr_cutoff_db=6.0, cutoff_freq_hz=3000, fs_hz=self.DEFAULT_SAMPLE_RATE) gain = 0.9 signal = signal_full / np.max(np.abs(signal_full)) * gain return signal
Количество итераций CAF_COUNT=3 было установлено эмпирическим путем. При таком значении достигается оптимальное снижение PAPR при отсутствии критических нелинейных фазовых искажений сигнала.
class OFDMSoundModem(OFDMWavModem): def _get_mode_256(self) -> dict: return dict( mapper=BPSKMapper, sample_rate=self.DEFAULT_SAMPLE_RATE, fft_bins=256, freq_lo=300, freq_hi=2750, zc_preamble_root=31, zc_preamble_len=53, zc_preamble_count=4, zc_pilots_root=57, zc_pilots_count=11, cp_percent=0.25, sym_tile=4, max_cfo=8, detection_threshold=0.20, ) def _get_mode_128(self) -> dict: return dict( mapper=BPSKMapper, sample_rate=self.DEFAULT_SAMPLE_RATE, fft_bins=128, freq_lo=300, freq_hi=2400, zc_preamble_root=17, zc_preamble_len=23, zc_preamble_count=4, zc_pilots_root=3, zc_pilots_count=7, cp_percent=0.25, sym_tile=4, max_cfo=4, detection_threshold=0.20, ) _get_modem_params = _get_mode_256
Для формирования спектра из 53 поднесущих была задействована расширенная полоса частот, от 300 до 2750 Гц. Под передачу пилот-сигналы выделено 11 поднесущих. В связи с расширением спектра был пропорционально увеличен диапазон частотных гипотез при поиске преамбулы в сигнале (параметр max_cfo=8).
Заголовок пакета
Базовая структура заголовка не претерпела принципиальных изменений. Под поле len выделено 10 бит, что теоретически позволяет передавать пакеты длиной до 1023 бит.
Примечание:
Ввиду использования индексации с нулевого значения, максимальный размер блока не кратен степени двойки. Автор отдает себе отчет в том, что бессмысленно передавать пакеты нулевой длины, но арифметикой выравнивания и индексации все равно пренебрег.
Поле | Тип | Размер (бит) | Назначение |
ver | int | 2 | Номер версии протокола |
typ | PacketType | 4 | Тип пакета |
mod | ModType | 2 | Тип модуляции |
spd | CCSpeed | 2 | Скорость кодирования |
len | int | 10 | Размер пакета |
CRC | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости заголовка составляет 28 бит.
Реализация заголовка:
@bit_packet @dataclass class Header(PacketCRC8): ver: int = field(default=IntField(2, min_val=1)) typ: PacketType = field(default=EnumField(PacketType, 4)) mod: ModType = field(default=EnumField(ModType, 2)) spd: CCSpeed = field(default=EnumField(CCSpeed, 2)) len: int = field(default=IntField(10, min_val=1))
Структура ModType поля mod расширена для поддержки 16-QAM и 32-QAM.
class ModType(Enum): BPSK = 0 QPSK = 1 QAM16 = 2 QAM32 = 3
Поле скорости кодирования spd оставлено без изменений.
class CCSpeed(Enum): R13 = 0 # 1/3 slow R12 = 1 # 1/2 R23 = 2 # 2/3 R34 = 3 # 3/4 fast
Аналогично предыдущей версии, для достижения максимальной помехоустойчивости передача заголовка осуществляется с применением BPSK модуляции и скоростью сверточного кодирования ⅓.
Пример структуры сформированного заголовка: Header(ver=1, typ=<PacketType.BEACON: 0>, mod=<ModType.BPSK: 0>, spd=<CCSpeed.R13: 0>, len=90)
Битовое представление: 01_0000_00_00_0001011010_[01111001].
Эквалайзер и фильтр Винера
В условиях высокого отношения сигнал/шум (SNR) процесс эквализации с применением фильтра Винера вызывал деградацию сигнального созвездия (рисунок 5), что при использовании модуляций высших порядков существенно повышает вероятность битовых ошибок (BER).


Данный эффект был вызван некорректной регуляризацией в алгоритме расчета матрицы весов Винера. При высоких значениях SNR значение дисперсии шума ограничивалось снизу на фиксированном уровне. В результате перерегулирования фильтр вносил избыточное сглаживание в неискаженный сигнал. Для устранения этого эффекта в алгоритм эквалайзера была добавлена предварительная оценка дисперсии амплитудно-частотной характеристики (АЧХ), получаемой после линейной интерполяции методом Zero Forcing. На основе этой оценки адаптивный фильтр Винера применяется динамически — исключительно для поднесущих с высокой степенью деградации сигнала (рисунок 7).
core_channel = H_abs_init[4:-4] channel_ripple = np.max(core_channel) - np.min(core_channel) if channel_ripple > 0.6: H_est = self._channel_estimate_wiener(pilots_ref, pilots, self._pilot_carriers, curr_noise_var) equalized_all = (demod_channel * np.conj(H_est)) / (np.abs(H_est) ** 2 + curr_noise_var) equalized_data_symbols = equalized_all[self._data_local_indices] else: H_est = H_est_init equalized_data_symbols = data_init
Для вычисления оценки неравномерностей АЧХ анализируется центральный диапазон поднесущих. Это необходимо для компенсации спада АЧХ в переходных полосах звукового тракта, а также для исключения влияния эффекта утечки спектра на границах используемого частотного диапазона.
Пороговое значение 0.6 для параметра channel_ripple определяет критическую степень неравномерности АЧХ по поднесущим. При превышении этого порога затухание сигнала на отдельных поднесущих достигает уровня от -3 до -4 дБ. В этих условиях проявляется снижение эффективности эквалайзера Zero Forcing из-за эффекта усиления шума, что обуславливает необходимость применения адаптивного фильтра Винера.

Примечание:
Поскольку заголовок передается строго в BPSK модуляции, на диаграмме сигнального созвездия он отображается в виде двух аномальных точек, которые могут быть ошибочно интерпретированы как артефакты. Фактически, сигнальное созвездие всех частей пакета визуализируются как единый массив точек.
Декодирование пакетов
Входной сигнал непрерывно передается в кольцевой буфер, глубина памяти которого рассчитана на временной интервал в несколько секунд. Данные из кольцевого буфера анализируются декодером, функционирующего на основе детерминированного конечного автомата (рисунок 8).

Накопление и анализ принимаемого сигнала осуществляется в методе process, на вход которого подается комплексный дискретный сигнал, полученный со звуковой карты.
Примечание:
Необходимо напомнить, что комплексный сигнал формируется преобразованием Гильберта на основе вещественного дискретного сигнала, захватываемого из звуковой карты.
def process(self, signal: np.ndarray): if len(signal) > 0: self._append_buffer(signal) if self.decoder_state == DecoderState.DETECT: self._reset_curr_state() if offset := self.modem.find_preamble(self.decoder_buffer[self.decoder_buffer_ptr:]): est_time, est_cfo = offset self.curr_cfo = est_cfo self._preamble_found(self.curr_cfo, self.decoder_buffer_ptr + est_time) self.decoder_buffer_ptr += est_time self._set_decoding_mode(Header, ModType.BPSK, CCSpeed.R13, Header.PACKET_SIZE) self.decoder_state = DecoderState.COLLECT_HEADER else: return ...
В методе process кольцевой буфер (см. далее) производит последовательную буферизацию данных. В состоянии DETECT в накопленном массиве осуществляется поиск преамбулы. При достижении порога обнаружения, автомат генерирует событие preamblefound и переходит в состояние накопления заголовка пакета данных COLLECT_HEADER. Одновременно с этим декодер переводится в режим демодуляции BPSK со скоростью сверточного кодера ⅓, при этом указатель семпла кольцевого буфера перемещается к предполагаемой временной границе заголовка, следующему непосредственно после преамбулы.
... if self.decoder_state in (DecoderState.COLLECT_HEADER, DecoderState.COLLECT_BODY): if self.curr_samples_required is None: self.curr_samples_required = self.modem.get_packet_num_samples( self.curr_mod_type, self.curr_cc_speed, self.curr_packet_size ) if len(self.decoder_buffer) - self.decoder_buffer_ptr >= self.curr_samples_required: if self.decoder_state == DecoderState.COLLECT_HEADER: self.decoder_state = DecoderState.DECODE_HEAD else: self.decoder_state = DecoderState.DECODE_BODY else: return ...
В состоянии COLLECT (COLLECT_HEADER, COLLECT_BODY) отслеживается накопленный объем данных в кольцевом буфере.
Метод get_packet_num_samples вычисляет эквивалентное число семплов, необходимое для размещения OFDM-сигнала при заданных объемах информации, скорости кодирования и типе модуляции.
При достижении рассчитанного объема накопленных данных автомат переходит в состояние декодирования DECODE (DECODE_HEAD, DECODE_BODY).
... if self.decoder_state in (DecoderState.DECODE_HEAD, DecoderState.DECODE_BODY): if response := self.modem.demod( self.decoder_buffer[self.decoder_buffer_ptr:], self.curr_cfo, mod_type=self.curr_mod_type, cc_speed=self.curr_cc_speed, packet_size=self.curr_packet_size, decoder=self.curr_codec): self._demod_response(response) self.decoder_buffer_ptr += self.curr_samples_required packet = response.packet if self.decoder_state == DecoderState.DECODE_HEAD: self._set_decoding_mode(PACKET_CODECS[packet.typ], packet.mod, packet.spd, packet.len) self.decoder_state = DecoderState.COLLECT_BODY elif self.decoder_state == DecoderState.DECODE_BODY: self._set_decoding_mode(None, None, None, None) self.decoder_state = DecoderState.PROCESS_PACKET else: raise DecoderFSMInvalidState ...
В состоянии DECODE (DECODE_HEAD, DECODE_BODY) выполняется демодуляция накопленного блока данных. При успешном декодировании автомат сохраняет принятый блок пакета. Если был декодирован заголовок (DECODE_HEAD), автомат динамически изменяет параметры декодера в соответствии с актуальными значениями из packet, после чего инициализирует обработку следующего блока.
После декодирования всех частей пакета, автомат переходит в состояние его обработки PROCESS_PACKET.
... self.curr_EsN0_packet += response.EsN0_packet self.curr_EsN0_carriers += response.EsN0_carriers self.curr_sym_count += response.sym_count self.curr_bits_count += response.bits_count self.curr_bits_error += response.bits_error self.curr_samples_count += response.samples_count packet.parent = self.curr_packet # Link parent packet self.curr_packet = packet self.curr_samples_required = None ...
На данном этапе производится обновление внутренних счетчиков и регистрация метрик декодера. Поскольку для функционирования подуровня LLC требуется доступ к заголовку пакета, к телу добавляется соответствующий указатель в поле parent.
... elif self.decoder_state == DecoderState.DECODE_HEAD: self.decoder_state = DecoderState.DECODE_HEAD_FAILED elif self.decoder_state == DecoderState.DECODE_BODY: self.decoder_state = DecoderState.DECODE_BODY_FAILED else: raise DecoderFSMInvalidState ...
В случае некорректной демодуляции инициируется переход в состояние ошибки.
... if self.decoder_state == DecoderState.DECODE_HEAD_FAILED: self._decode_failed(True, False) self.decoder_state = DecoderState.DETECT if self.decoder_state == DecoderState.DECODE_BODY_FAILED: self._decode_failed(False, True) self.decoder_state = DecoderState.DETECT ...
Так как состояние ошибки не является терминальным, возвращается в исходное состояние детекции преамбулы. Метод _decode_failed генерирует событие об ошибке.
... if self.decoder_state == DecoderState.PROCESS_PACKET: BER = self.curr_bits_error / self.curr_bits_count EsN0_carriers_dB = dB_vectorized(self.curr_EsN0_carriers / self.curr_sym_count) EsN0_packet_dB = dB(self.curr_EsN0_packet / self.curr_sym_count) SNR_packet_dB = EsN0_packet_dB - self.modem._snr_off samples = self.curr_samples_count speed = self.curr_bits_count * self.modem.DEFAULT_SAMPLE_RATE / samples self._process_packet(self.curr_packet, EsN0_carriers_dB, EsN0_packet_dB, SNR_packet_dB, BER, speed) self.decoder_state = DecoderState.DETECT
В состоянии PROCESS_PACKET осуществляется обновление счетчиков, расчет отношения энергии OFDM-символов к спектральной мощности шума, вычисление интегрального SNR и оценка битовой скорости соединения. Далее формируется событие _process_packet и автомат возвращается в исходное состояние DETECT.
Алгоритм заполнения кольцевого буфера:
def _append_buffer(self, signal: np.ndarray) -> None: length = len(signal) self.decoder_buffer = np.roll(self.decoder_buffer, -length) self.decoder_buffer[-length:] = signal self.decoder_buffer_ptr = max(0, self.decoder_buffer_ptr - length)
Использование кольцевого буфера обусловлено необходимостью непрерывного размещения обрабатываемого фрагмента сигнала для корректной детекции преамбулы и демодуляции OFDM-данных. Данный подход упрощает реализацию и убирает необходимость вводить дополнительные состояния автоматов с прерываниями между уровнями абстракций.
MAC и LLC уровни
Процесс трансформации модема в полноценное средство связи осуществляется добавлением алгоритмов установления соединения и организации пакетной передачи данных с возможностью адаптации к динамическим параметрам канала связи.
Над PHY уровнем располагается логика подуровня MAC, функционирующая на основе модели конечного автомата, в задачи которого входит установка соединения между двумя узлами в канале, а также фильтрации входящего трафика.
Подуровень LLC реализует функции логической сессии, развертываемой в рамках установленного соединения. FSM сессии обеспечивает строгое соблюдение регламента протокола, координируя временное разделение приема и передачи, формируя входящие и исходящие очереди данных, а также инкапсулирует алгоритмы отправки, квитирования и ARQ-запросов.
На рисунке 9 представлена структурная схема иерархий модели протокола.

Помимо передачи полезной нагрузки, на уровне сессии осуществляется непрерывный мониторинг состояния радиоканала с последующим управлением модуляцией и скоростью кодирования, что определяет пропускную способность канала связи. На рисунке 9 он обозначен как блок "AMC" (Adaptive Modulation and Coding).
Таким образом, комбинация разработанных подуровней формирует высокоуровневый прикладной программный интерфейс передачи данных между удаленными узлами.
Типы сообщений
В предыдущей версии рассматривались только 2 типа сообщений: пакет-маяк и пакет произвольных данных. Поскольку реализация сетевого взаимодействия обуславливает необходимость разделения передаваемой информации на множество функциональных классов, перечень PacketType был значительно расширен.
class PacketType(Enum): BEACON = 0 CONN = 1 CONN_ACK = 2 PING = 3 PONG = 4 RTS = 5 CTS = 6 DATA = 7 ACK = 8 NACK = 9 SACK = 10 EOT = 11
Под идентификацию пакета пакета в заголовке выделено 4 бита информации, что позволяет определить до 16 разных типов пакетов.
В приведенной реализации управляющие и информационные пакеты классифицированы по функциональным группам.
Пакеты BEACON, CONN, CONN_ACK обеспечивают процедуру установки соединения. Пакеты PING и PONG предназначены для мониторинга параметров канали и поддержки активности соединения. Сигналы RTS (Request To Send), CTS (Clear To Send) информируют о готовности и начале передачи данных. Оставшиеся типы используются для непосредственной передачи полезных данных и подтверждения достоверности приема.
Структуры пакетов
В данном разделе представлены спецификации структур данных, используемых для сериализации и десериализации управляющих и информационных пакетов.
Кодирование числовых значений во всех пакетах осуществляется с применением big-endian порядка.
Beacon
Установка соединения начинается с отправки сообщения общего вызова. Пакет Beacon служит для идентификации вызывающей станции и определения первичных параметры подключения.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
callsign | str | 53 | Любительский позывной станции |
qth | str | 25 | 6 символьный QTH-локатор |
mode | BeaconMode | 4 | Режим работы маяка |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 90 бит (11.25 байт).
Режим функционирования маяка определяется значением из BeaconMode.
class BeaconMode(Enum): CQ = 0 CQ_DX = 1 CQ_AS = 2 CQ_NA = 3 CQ_SA = 4 CQ_EU = 5 CQ_AF = 6 CQ_OC = 7 BEACON = 15
Флаг CQ — циркулярный общий вызов всех корреспондентов; идентификаторы CQ_DX…CQ_OC задают селективный вызов с фильтрацией по географическим регионам. При работе в режиме BEACON установление связи не предусмотрено — станция работает исключительно в режиме периодического информирования о присутствии в эфире.
Реализация пакета:
@bit_packet @dataclass class Beacon(PacketCRC8): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.BEACON callsign: str = field(default=CallsignField(53)) qth: str = field(default=QTHField(25)) mode: BeaconMode = field(default=EnumField(BeaconMode, 4))
Позывной и QTH-локатор кодируются алгоритмом Base38, аналогично применяемому в протоколе FT-8.
ALPHABET = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/" ALPHABET_LEN = len(ALPHABET) MAX_CALL_LEN = 6 MAX_QTH_LEN = 6 def callsign_to_int(callsign: str) -> int: s = callsign.strip().upper()[:MAX_CALL_LEN].ljust(MAX_CALL_LEN, " ") val = 0 for char in s: val = val * ALPHABET_LEN + ALPHABET.index(char) return val def int_to_callsign(val: int) -> str: chars = [] for _ in range(MAX_CALL_LEN): chars.append(ALPHABET[val % ALPHABET_LEN]) val //= ALPHABET_LEN return "".join(reversed(chars)).strip() def qth6_to_int(qth: str) -> int: q = qth.upper() assert len(q) == MAX_QTH_LEN off = ord('A') lon_field = ord(q[0]) - off lat_field = ord(q[1]) - off lon_sq = int(q[2]) lat_sq = int(q[3]) lon_sub = ord(q[4]) - off lat_sub = ord(q[5]) - off val = lon_field val = val * 18 + lat_field val = val * 10 + lon_sq val = val * 10 + lat_sq val = val * 24 + lon_sub val = val * 24 + lat_sub return val def int_to_qth6(val: int) -> str: lat_sub = val % 24 val //= 24 lon_sub = val % 24 val //= 24 lat_sq = val % 10 val //= 10 lon_sq = val % 10 val //= 10 lat_field = val % 18 val //= 18 lon_field = val % 18 off = ord('A') return f"{chr(lon_field + off)}{chr(lat_field + off)}{lon_sq}{lat_sq}{chr(lon_sub + off)}{chr(lat_sub + off)}"
Пример структуры сформированного пакета: Beacon(callsign='R9FEU', qth='LO87cx', mode=<BeaconMode.CQ: 0>)
Битовое представление: 000000000000000000000100001011000100011010011110101100101_1101100011000000001110000_[00100100]
def mod_beacon_signal(self, callsign: str, qth: str, mode: BeaconMode = BeaconMode.BEACON) -> npt.NDArray: packet = Beacon(callsign, qth, mode) return self.mod_packet_signal(packet, PacketType.BEACON, mod_type=ModType.BPSK, cc_speed=CCSpeed.R13)
Connect
Участник, принявший сигнал Beacon может инициировать установку соединения с узлом, отправляя в эфир пакет Connect, в котором передается позывной вызываемой станции, а также позывной и локатор вызывающей стороны.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
called | str | 53 | Позывной вызываемого |
caller | str | 53 | Позывной вызывающего |
qth | str | 25 | QTH-локатор вызывающего |
caller_conn_id | int | 8 | Уникальный идентификатор подключения |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 147 бит (18.375 байт).
На данном этапе установки соединения обмен осуществляется с применением радиолюбительских позывных, по которым участники определяют, кому и от кого адресован пакет.
Реализация пакета:
@bit_packet @dataclass class Conn(PacketCRC8): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.CONN called: str = field(default=CallsignField(53)) caller: str = field(default=CallsignField(53)) qth: str = field(default=QTHField(25)) caller_conn_id: int = field(default=IntField(8)) def __post_init__(self): assert self.called != self.caller
Пример структуры сформированного пакета: Conn(called='R1ABC', caller='R2ABC', qth='LO88AA', caller_conn_id=1)
Битовое представление: 00000000000000000000010000100100001100000100000000110_00000000000000000000010000100101001011101100100010110_0101110110001101000000000_00000001_[10110111]
Параметры передачи: BPSK, CC ⅓.
def mod_connect_signal(self, called: str, caller: str, caller_qth: str, caller_conn_id: int) -> npt.NDArray: packet = Conn(called, caller, caller_qth, caller_conn_id) return self.mod_packet_signal(packet, PacketType.CONN, mod_type=ModType.BPSK, cc_speed=CCSpeed.R13)
Connect acknowledge
Подтверждение установки соединения осуществляется отправкой пакета ConnAck со стороны вызываемого. Позывные обеих станций передаются симметрично. Вызываемый подтверждает установление передачей исходного caller_conn_id+1.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
called | str | 53 | Позывной вызываемого |
caller | str | 53 | Позывной вызывающего |
caller_conn_id | int | 8 | Подтвержденный идентификатор подключения вызывающего |
called_conn_id | int | 8 | Уникальный идентификатор подключения вызываемого |
mod | int | 8 | Модуль времени |
master_window | int | 8 | Размер временного окна ведущего узла |
start_lag | int | 8 | Таймаут начала двустороннего обмена |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 154 бит (19.25 байт).
Реализация пакета:
@bit_packet @dataclass class ConnAck(PacketCRC8): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.CONN_ACK caller: str = field(default=CallsignField(53)) called: str = field(default=CallsignField(53)) caller_conn_id: int = field(default=IntField(8)) called_conn_id: int = field(default=IntField(8)) mod: int = field(default=IntField(8)) master_window: int = field(default=IntField(8)) start_lag: int = field(default=IntField(8)) def __post_init__(self): assert self.called != self.caller assert 0 < self.master_window < self.mod
Пример структуры сформированного пакета: ConnAck(caller='R2ABC', called='R1ABC', caller_conn_id=1, called_conn_id=1, mod=2, master_window=1, start_lag=30)
Битовое представление: 00000000000000000000010000100101001011101100100010110_00000000000000000000010000100100001100000100000000110_00000001_00000001_00000010_00000001_00011110_[00111111]
Параметры передачи: BPSK, CC ⅓.
def mod_connect_ack_signal(self, caller: str, called: str, caller_conn_id: int, called_conn_id: int, mod: int, master_window: int, start_lag: int) -> npt.NDArray: packet = ConnAck(caller, called, caller_conn_id, called_conn_id, mod, master_window, start_lag) return self.mod_packet_signal(packet, PacketType.CONN_ACK, mod_type=ModType.BPSK, cc_speed=CCSpeed.R13)
Ping
Короткий пакет-запрос для проверки связи и замера времени отклика между узлами сети. В качестве полезной нагрузки передается значение часов узла-отправителя.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
time | int | 20 | Отметка времени |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 44 (5.5 байт) бит.
Реализация пакета:
@dataclass class ConnPacketABC(PacketCRC): conn_id: int = field(default=IntField(8)) @dataclass class ConnTransPacketABC(PacketCRC8, ConnPacketABC): trans_id: int = field(default=IntField(8)) @bit_packet @dataclass class Ping(ConnTransPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.PING time: int = field(default=IntField(20))
Пример структуры сформированного пакета: Ping(conn_id=1, trans_id=2, time=1048570).
Битовое представление: 00000001_000000101_1111111111111111010_[01101111].
Параметры передачи: BPSK, CC ⅓.
def mod_ping_signal(self, conn_id: int, trans_id: int, time: int) -> npt.NDArray: packet = Ping(conn_id, trans_id, time) return self.mod_packet_signal(packet, PacketType.PING, mod_type=ModType.BPSK, cc_speed=CCSpeed.R13)
Pong
Пакет-ответ на ping-запрос. Отправляется только в ответ на получение пакета ping. В качестве полезной нагрузки возвращает исходное значение времени из ping и добавляет измеренное значение времени после получения и декодирования пакета.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
remote_time | int | 20 | Отметка времени |
local_delta | int | 17 | Время декодирования пакета |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 61 бит (7.625 байт).
Реализация пакета:
@bit_packet @dataclass class Pong(ConnTransPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.PONG remote_time: int = field(default=IntField(20)) local_delta: int = field(default=IntField(17))
Пример структуры сформированного пакета: Pong(conn_id=1, trans_id=2, remote_time=86300, local_delta=46300).
Битовое представление: 00000001_00000010_00010101000100011100_01011010011011100_[11011110].
Параметры передачи: BPSK, CC ⅓.
def mod_pong_signal(self, conn_id: int, trans_id: int, remote_time: int, local_delta: int) -> npt.NDArray: packet = Pong(conn_id, trans_id, remote_time, local_delta) return self.mod_packet_signal(packet, PacketType.PONG, mod_type=ModType.BPSK, cc_speed=CCSpeed.R13)
Request To Send
Пакет-запрос на подтверждение начала передачи блока данных. В пакете передается размер фрейма передаваемых данных в битах и желаемое количество фреймов, которые узел собирается отправить. Также задается тип передаваемых данных: текстовые данные, RSSI, либо же произвольный набор байт.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
frame_len | int | 10 | Размер передаваемо за раз фрейма в битах |
frames_count | int | 8 | Количество передаваемых фреймов |
data_type | RTSDataType | 2 | Тип передаваемых данных |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 44 бит (5.5 байт).
Реализация пакета:
@bit_packet @dataclass class RTS(ConnTransPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.RTS frame_len: int = field(default=IntField(10)) frames_count: int = field(default=IntField(8)) data_type: RTSDataType = field(default=EnumField(RTSDataType, 2))
Значения поля data_type:
class RTSDataType(Enum): TEXT = 0 COMPRESSED_TEXT = 1 RSSI = 2 RAW_BYTES = 3
Пример структуры сформированного пакета: RTS(conn_id=1, trans_id=2, frame_len=10, frames_count=5, data_type=<RTSDataType.RAW_BYTES: 3>).
Битовое представление: 00000001_00000010_0000001010_00000101_11_[01110100].
Параметры передачи: BPSK, CC ⅓.
def mod_rts_signal(self, conn_id: int, trans_id: int, frame_len: int, frames_count: int, data_type: RTSDataType) -> npt.NDArray: packet = RTS(conn_id, trans_id, frame_len=frame_len, frames_count=frames_count, data_type=data_type) return self.mod_packet_signal(packet, PacketType.RTS, mod_type=ModType.BPSK, cc_speed=CCSpeed.R13)
Clear To Send
Пакет-ответ на RTS запрос, подтверждающий или опровергающий готовность принимать данные от отправителя. В пакете передается CTS статус, определяющий готовность или неготовность принимать данные. Также в ответе узел может потребовать от передатчика задать параметры передачи, такие как модуляции и скорость FEC-кодирования.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
status | CTSStatus | 3 | Статус готовности приема данных |
mod | ModType | 2 | Ожидаемый тип модуляции |
speed | CCSpeed | 2 | Ожидаемая скорость кодирования |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 31 бит (3.875 байт).
Реализация пакета:
@bit_packet @dataclass class CTS(ConnTransPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.CTS status: CTSStatus = field(default=EnumField(CTSStatus, 3)) mod: ModType = field(default=EnumField(ModType, 2)) speed: CCSpeed = field(default=EnumField(CCSpeed, 2))
Перечень статусов готовности приема:
class CTSStatus(Enum): SEND_BULK = 0 SEND_SIMPLE = 1 REDUCE = 2 # Reduce frame size BUSY = 3 # Wait for next retry REJECT = 4 # This data is not interest for me, delete it
Значение SEND_BULK задает режим передачи данных в режиме кучи, при котором передатчик выдает пачкой фреймы данных, при этом приемник единоразово подтверждает какие пакеты он принял.
Статус SEND_SIMPLE задает симплексный режим передачи данных, передатчик работает в режиме Stop-and-Wait, ожидая подтверждения каждого переданного фрейма данных.
REDUCE требует повторного RTS-запроса с измененными данными передачи.
Требование повторить попытку позже определяется значением BUSY .
Статус REJECT — полный отказ и неготовность принимать данные.
Пример структуры сформированного пакета: CTS(conn_id=1, trans_id=2, status=<CTSStatus.SEND_BULK: 0>, mod=<ModType.QAM16: 2>, speed=<CCSpeed.R34: 3>).
Битовое представление: 00000001_00000010_000_10_11_[00000100].
Параметры передачи: BPSK, CC ⅓.
def mod_cts_signal(self, conn_id: int, trans_id: int, status: CTSStatus, mod: ModType = ModType.BPSK, speed: CCSpeed = CCSpeed.R13) -> npt.NDArray: packet = CTS(conn_id, trans_id, status=status, mod=mod, speed=speed) return self.mod_packet_signal(packet, PacketType.CTS, mod_type=ModType.BPSK, cc_speed=CCSpeed.R13)
End Of Transmission
Сигнализирующий пакет, отправляемый после завершения полного цикла передачи фреймов данных, сигнализируя тем самым о конце передачи.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
reason_code | EotReason | 2 | Код завершения передачи |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 26 бит (3.25 байт).
Реализация пакета:
@bit_packet @dataclass class Eot(ConnTransPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.EOT reason_code: EotReason = field(default=EnumField(EotReason, 2))
Перечень кодов завершения передачи:
class EotReason(Enum): SUCCESS = 0 PARTIAL = 1 ABORT = 2
Названия значений EotReason говорят сами за себя. На данном этапе реализации используется только код SUCCESS.
Примечание:
PARTIALпредполагается использовать при передаче потоковых данных,ABORT— при прерывании передачи данных пользователем.
Параметры передачи: BPSK, CC ⅓.
def mod_eot_signal(self, conn_id: int, trans_id: int, mod_type: typing.Optional[ModType] = None, cc_speed: typing.Optional[CCSpeed] = None) -> npt.NDArray: packet = Eot(conn_id, trans_id, EotReason.SUCCESS) return self.mod_packet_signal(packet, PacketType.EOT, mod_type, cc_speed)
Acknowledgment
Пакеты Ack и Nack служат для информирования передающего узла о успешности принятия переданного фрейма данных.
Структура пакетов:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
frame_id | int | 8 | Идентификатор фрейма |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 32 бит (4 байт).
Реализация пакета:
@bit_packet @dataclass class Ack(ConnTransPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.ACK frame_id: int = field(default=IntField(8)) @dataclass class Nack(Ack): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.NACK
Сигналы Ack и Nack имеют абсолютно одинаковую структуру и отличаются только идентификатором в декодере на PHY-уровне.
Пример структуры сформированного пакета: Ack(conn_id=1, trans_id=2, frame_id=3);Nack(conn_id=1, trans_id=2, frame_id=3).
Битовое представление: 00000001_00000010_00000011_[01100011].
Параметры передачи: BPSK, CC ⅓.
def mod_ack_signal(self, conn_id: int, trans_id: int, frame_id: int, mod_type: typing.Optional[ModType] = None, cc_speed: typing.Optional[CCSpeed] = None) -> npt.NDArray: packet = Ack(conn_id, trans_id, frame_id) return self.mod_packet_signal(packet, PacketType.ACK, mod_type, cc_speed) def mod_nack_signal(self, conn_id: int, trans_id: int, frame_id: int, mod_type: typing.Optional[ModType] = None, cc_speed: typing.Optional[CCSpeed] = None) -> npt.NDArray: packet = Nack(conn_id, trans_id, frame_id) return self.mod_packet_signal(packet, PacketType.NACK, mod_type, cc_speed)
Selective Acknowledgment
Пакет Sack предназначен для селективного подтверждения пачки принятых данных.
Структура пакетов:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
frame_id | int | 8 | Идентификатор фрейма |
mask | int | 16 | Булева маска корректности принятых пакетов |
[CRC] | int | 8 | Контрольная сумма |
Суммарный объем информационной емкости пакета составляет 40 бит (5 байт).
Реализация пакета:
@bit_packet @dataclass class Sack(ConnTransPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.SACK frame_id: int = field(default=IntField(8)) mask: int = field(default=IntField(16)) def is_ok(self, index: int) -> bool: return bool(self.mask & (1 << index)) def is_all_ok(self, count: int) -> bool: assert 0 < count <= 16 mask = (1 << count) - 1 return (self.mask & mask) == mask
Для маски выделено 16 бит, тем самым одним Sack-пакетом можно подтвердить за раз до 16 фреймов. Значение выбрано как рациональный оптимум для обычной текстовой передачи. Передача больших объемов информации может быть осуществлена изменение размера фреймов в RTS запросе.
Метод is_ok сопоставляет маску с номером бита, соответствующему номеру фрейма из принятой последовательности.
Метод is_all_ok проверяет, что все биты в маске истинны, то есть вся последовательность была принята без ошибок.
Пример структуры сформированного пакета: Sack(conn_id=1, trans_id=2, frame_id=3, mask=15).
Битовое представление: 00000001_00000010_00000011_0000000000001111_[11100111].
Параметры передачи: BPSK, CC ⅓.
def mod_sack_signal(self, conn_id: int, trans_id: int, frame_id: int, mask: int, mod_type: typing.Optional[ModType] = None, cc_speed: typing.Optional[CCSpeed] = None) -> npt.NDArray: packet = Sack(conn_id, trans_id, frame_id, mask) return self.mod_packet_signal(packet, PacketType.SACK, mod_type, cc_speed)
Data bytes
Транспортный пакет DataBytes, собственно передающий пользовательские данные.
Структура пакета:
Поле | Тип | Размер (бит) | Назначение |
conn_id | int | 8 | ID соединения |
trans_id | int | 8 | Номер транзакции |
frame_id | int | 8 | Идентификатор фрейма |
payload | bits | max 960 | Пользовательские данные |
[CRC] | int | 16 | Контрольная сумма |
Максимальный объем информационной емкости пакета может составлять до 1000 бит (125 байт).
Реализация пакета:
@bit_packet @dataclass class DataBytes(PacketCRC16, ConnPacketABC): PACKET_TYPE: ClassVar[Final[PacketType]] = PacketType.DATA frame_id: int = field(default=IntField(8)) payload: bytes = field(default=VarBitsField(120 * 8, min_len=1))
Поле payload переменной длины, с ограничением размера в 960 бит. Вместе с CRC-16 передается в хвосте пакета.
Пример структуры сформированного пакета: DataBytes(conn_id=12, frame_id=34, payload=b'\xff\xff\xff').
Битовое представление: 00001100_00100010_111111111111111111111111_[1001001001101101].
Параметры передачи: BPSK/QPSK/16-QAM, CC ⅓ ½ ⅔ ¾.
def mod_data_signal(self, conn_id: int, frame_id: int, data: bytes, mod_type: typing.Optional[ModType] = None, cc_speed: typing.Optional[CCSpeed] = None) -> npt.NDArray: packet = DataBytes(conn_id, frame_id, data) return self.mod_packet_signal(packet, PacketType.DATA, mod_type, cc_speed)
Единственный тип пакета, который может передаваться с использованием PSK манипуляций высшего порядка и повышенными скоростями сверточного кодера.
Сетевое соединение
Процесс установки сетевого соединения включает в себя широковещательную отправку сигнала вызова и алгоритм тройного рукопожатия (three-way handshake). Диаграмма состояний конечного автомата, функционирующего при установке соединения между двумя узлами, приведена на рисунке 10.

На рисунке 11 представлена временная диаграмма процесса установления соединения между двумя узлами.
Протокольный регламент определяет, что узел, транслирующий общий вызов, принимает статус ведущего (master), в то время как корреспонденты, устанавливающие с ним соединение, — ведомых (slave).

Стадия ожидания запроса на установление связи на стороне ведущего узла функционально аналогична прослушиванию сетевого сокета, с тем отличием, что ведущему необходимо обозначить свое присутствие путем периодической трансляции пакета Beacon с указанием своего позывного и флага CQ. Ведущий узел посылает пакеты CQ Beacon с интервалом в 30 секунд до момента перевода соединения в установленное состояние.
Пример: Beacon(callsign='R1HAM', qth='KO85TR', mode=<BeaconMode.CQ: 0>).
Ведомы узлы, осуществляющие мониторинг канала, обнаруживают указанный пакет и принимают решение о установке соединения с ведущим узлом. Первый этап рукопожатия заключается в отправке пакета Conn ведомым узлом. В канал отправляются позывные ведущего и ведомого узлов, а также QTH-локатор вызывающего. При этом, ведомый узел выбирает произвольное 8-битное число в диапазоне от 0 до 255 для идентификации исходящего своего исходящего подключения.
Пример: Conn(called='R1HAM', caller='R2HOR', qth='KO59DW', caller_conn_id=14).
В 30-и секундных интервалах, между отправкой сигнала маяка, ведущий узел переводится в режим приема для обнаружения адресованных ему запросов установки соединения.
При получении пакета Conn, ведущий принимает решение о возможности организации соединения. В случае положительного решения, ведущий отправляет ConnAck, в котором взаимно подтверждает позывной ведущего, а также подтверждает факт установления соединения отправкой исходного значения caller_conn_id+1. Данная арифметическая операция подтверждает корректность приема и обработки пакета Conn и подтверждает получателя.
Ведущий узел формирует уникальный идентификатор соединения, выбирая произвольное 8-битное значение, и информирует ведомого о том, что все следующие пакеты будут маркироваться этим значением. Это позволяет однозначно отнести поступающие пакеты с этим к текущей установленной сессии.
Примечание:
Ввиду высокой избыточности передачи 53-битных позывных в каждом пакете, было принято решение использовать цифровой идентификатор сессии, а позывные станций передавать в сообщениях на прикладном уровне.
Механизм тайм-слотов
Так как радиообмен происходит в полудуплексном режиме, ведущий узел регламентирует параметры временного разделения каналов, определяя длительность тайм-слотов, в рамках которых узлы осуществляют инициацию передачи исходящих запросов.
Примечание:
В полудуплексном режиме право на инициацию запроса предоставляется тому узлу, текущие системные часы которого соответствуют границам его тайм-слота. При этом вызываемый узел обязан отправить ответ немедленно, по окончании приема.
Поскольку одновременный прием и передача в рамках одного трансивера невозможны, механизм тайм-слотов предназначен для исключения коллизий, возникающих при встречной трансляции в сигналов эфир.
Пример: ConnAck(caller='R2HOR', called='R1HAM', caller_conn_id=15, called_conn_id=31, mod=60, master_window=30, start_lag=30).
Параметры mod и master_window определяют временные границы тайм-слота. Общая длительность одного слота задается значением поля mod в секундах. Значение поля master_window определяет продолжительность временного диапазона, зарезервированного за ведущим узлом. Таким образом, для ведомого узла выделяются интервалы времени, эквивалентные разности mod - master_window секунд.
Примечание:
Полеmodопределяет модуль циклического деления текущего целочисленного значения секунд системных часов. Таким образом, приmod=60значение внутренних часов будет сбрасываться в нулевое значение с периодичностью в 60 секунд.
На рисунке 12 представлена временная диаграмма распределения интервалов внутри тайм-слота. Помимо строгого соблюдения временных окон, на границах слотов предусмотрены защитные интервалы длительностью в несколько секунд для предотвращения коллизий встречной передачи. Этот интервал задан статически и равен 5 секундам.

В динамически меняющемся канале связи с высокой вероятностью потерь, ConnAck может не дойти до корреспондента. Для исключения зацикливания процедуры рукопожатий, ведущий узел в составе ConnAck отправляет поле start_lag. Значение этого поля определяет интервал временного запрета, в течение которого ведомому узлу разрешен только прием входящих пакетов, без права отправки запросов. Таким образом ведущий узел резервирует временной участок для постобработки события установления связи и также гарантирует синхронизацию, при которой ведомому узлу разрешается отправка запросов по истечении защитного интервала в соответствии с общей политикой тайм-слотов.
TIME_MOD = 60 MASTER_WINDOW = 30 START_LAG = 30 GUARD_TIME = 5.0
Триггер тайм-слота:
@property def tx_allowed(self) -> bool: now = int(get_time() time_pos = now % self._time_mod ...
Тайм-слот задается значением переменной time_pos на основе системного времени.
... is_connecting = self._conn_state.is_connecting if self.is_master: return is_connecting or time_pos < (self._time_master_wnd - self.GUARD_TIME) ...
На этапе установки соединения передача разрешена безусловно. Для ведущего узла дополнительно осуществляется проверка работы в слоте _time_master_wnd.
... if is_connecting or now < self._time_start: return False ...
В соответствии с условиям разделения времени, ведомому узлу запрещен выход на передачу до истечения интервала start_lag.
... wnd_start = self._time_master_wnd wnd_end = self._time_mod - self.GUARD_TIME return wnd_start <= time_pos < wnd_end
Проверка попадания в активный слот с учетом защитного интервала GUARD_TIME.
При приеме пакета ConnAck, ведомый узел подтверждает факт установки соединения отправкой сигнала Ack. На этом этапе оба корреспондента взводят флаг о успешном установлении соединения (рисунок 11).
Состояние LISTENING является инвариантным и остается неизменным до момента явного прерывания режима общего вызова. Переходы между другими состояниями автомата определяются значениями внутренних счетчиков повторных попыток установки соединения и таймаутами, по истечении которых соединение переходит в финальное состояние CLOSED.
MAX_RETRANSMITS = 5 RETRANSMIT_TIMEOUT = 10.0 CQ_INTERVAL = 30.0 RX_ACK_EVERY_PACKETS = 5 RX_ACK_TIMEOUT = 2.0
Логика конечного автомата соединения реализуется на верхнем уровне абстракции над декодером.
def process(self, signal: np.ndarray): super().process(signal) now = get_time() if self._conn_state == ConnectionState.LISTENING: if now - self._last_cq_time >= self.CQ_INTERVAL: self._last_cq_time = now self._send_cq() return ...
В состоянии LISTENING осуществляется контроль интервалов отправки сигналов CQ-маяка.
... if self._conn_state == ConnectionState.CLOSED: return ...
При нахождении в состоянии CLOSED блокируется дальнейшая работа логики.
... if self._conn_state in (ConnectionState.CONNECTING, ConnectionState.HANDSHAKE): if now - self._last_tx_time > self.RETRANSMIT_TIMEOUT: if self._retransmit_count >= self.MAX_RETRANSMITS: self._set_connection_state(ConnectionState.CLOSED) return self._retransmit() return ...
В состоянии CONNECTING и HANDSHAKE выполняется проверка таймаутов и оценка необходимости повторной отправки запроса.
... if self._conn_state == ConnectionState.CONNECTED: self._session_loop()
После успешной установки соединения, управление передается в основной цикл обработки сессионного уровня _session_loop.
Функция повторной отправки запроса:
def _retransmit(self) -> None: if self._last_signal_sent is not None: self._retransmit_count += 1 self._send_signal(self._last_signal_sent)
Метод _setup_connection определяет параметры тайм-слотов и вычисляет метку t_ime_start на основе значения аргумента start_lag (см. условие проверки выше).
def _setup_connection(self, mod: int, master_window: int, start_lag: int) -> None: self._time_mod = mod self._time_master_wnd = master_window self._time_start = self._detector_time + start_lag
Метод _process_packet вызывается при каждой успешной когда демодуляции пакета на физическом уровне. Таким образом реализуется обработчик входящего потока данных от удаленного узла.
def _process_packet(self, packet: PacketCRC, EsN0_carriers_dB: npt.NDArray[np.float64], EsN0_dB: float, SNR: float, BER: float, speed: float) -> None: now = get_time() if self._conn_state == ConnectionState.LISTENING: self._last_cq_time = now if isinstance(packet, Conn) and packet.called == self._callsign: if self._can_connect(packet.caller, packet.qth): self._remote_callsign = packet.caller self._remote_qth = packet.qth self._conn_id = self._gen_conn_id() self._tmp_conn_id = packet.caller_conn_id self._set_connection_state(ConnectionState.HANDSHAKE) self._retransmit_count = 0 self._send_conn_ack() return ...
В состоянии LISTENING (ожидание ответа на CQ-запрос) при получении пакета Conn выполняется процедура проверки адресации пакета текущему узлу. Метод _can_connect выполняет вторичную фильтрацию по критериям соответствия позывного ITU-зонам и географическим координатам QTH. При успешном прохождении проверки автомат переводится в состояние трехстороннего рукопожатия HANDSHAKE.
... if self._conn_state == ConnectionState.HANDSHAKE: if isinstance(packet, Conn) and packet.caller == self._remote_callsign: if self._last_signal_sent is not None: self._send_signal(self._last_signal_sent) return if isinstance(packet, Ack) and packet.conn_id == self._conn_id: self._set_connection_state(ConnectionState.CONNECTED) self._handle_connected() self._time_start = now self._last_signal_sent = None return ...
В состоянии HANDSHAKE узел осуществляет проверку статуса соединения. Если удаленный узел подтверждает соединение отправкой корректного conn_id, соединение переводится в состояние CONNECTED с вызовом события _handle_connected.
В случае, если узел дублирует отправку Conn (например из-за потерь в канале связи), оппонент дублирует Ack-подтверждение.
... if self._conn_state == ConnectionState.CONNECTING: if isinstance(packet, ConnAck) and packet.caller == self._callsign and packet.called == self._remote_callsign: if packet.caller_conn_id != self._tmp_conn_id + 1: self._set_connection_state(ConnectionState.CLOSED) return self._conn_id = packet.called_conn_id signal = self.modem.mod_ack_signal(self._conn_id, self._get_seq_id(), frame_id=0) # target_id here is zero self._send_signal(signal) self._set_connection_state(ConnectionState.CONNECTED) self._handle_connected() self._setup_connection(packet.mod, packet.master_window, packet.start_lag) self._last_signal_sent = None return ...
Состояние CONNECTING устанавливается при формировании исходящего соединения (метод connect_to). В данном состоянии осуществляются проверки ответов от ведущего узла. Пакет ConnAck считается подтвержденным при условии решения задачи с инкрементом +1 к отправляемому _tmp_conn_id.
... if self._conn_state == ConnectionState.CONNECTED: if isinstance(packet, ConnAck) and packet.called_conn_id == self._conn_id: signal = self.modem.mod_ack_signal(self._conn_id, self._get_seq_id(), frame_id=0) # target_id here is zero self._send_signal(signal) return kwargs = dict(EsN0_carriers_dB=EsN0_carriers_dB, EsN0_dB=EsN0_dB, SNR=SNR, BER=BER) if isinstance(packet, ConnPacketABC) and packet.conn_id == self._conn_id: self._handle_session_packet(packet, **kwargs) return self._handle_unknown_packet(packet, **kwargs) return
В состоянии CONNECTED выполняется фильтрация входящих пакетов на соответствие connid текущему идентификатору соединения. Пакеты, прошедшие проверку, передаются в _handle_session_packet для обработки на уровне сессии. Пакеты не прошедшие проверку пересылаются в обработчик _handle_unknown_packet.
Сессия
После успешной установки соединения между узлами устанавливается сессия, реализующая уровень абстракции для организации пакетной передачи данных.
На сессионном уровне реализуется передача данных с применением алгоритма автоматического запроса повторной передачи (ARQ, Automatic Repeat reQuest).
Пакетная передача
По причине низкой надежности передачи значительных объемов пользовательских данных единым массивом в нестабильном радиоканале, передаваемые данные фрагментируются на блоки небольшого размера.
При такой реализации базовым методом передачи сообщения является поочередная отправка каждого блока данных с последующим ожиданием подтверждения его доставки (рисунок 13).

Передача осуществляется с того, что участник в границах своего активного тайм-слота передает RTS запрос. После приема пакета CTS выполняется отправка первого блока данных, после чего передающий узел переходит в состояние ожидания с остановкой (Stop-and-Wait) для проверки доставки пакета. В случае потери пакета или нарушения целостности данных передающий узел инициирует повторную отправку текущего блока данных.
При успешном приеме подтверждения система переходит к передаче следующего блока данных. Этот цикл повторяется до полной отправки всего массива данных.
Финальный EOF-пакет служит индикатором кона передачи. Логически этот пакет интегрирован в общую структуру передаваемых данных и к нему применяется аналогичный алгоритм подтверждения и ARQ-запроса.
Таким образом реализуется режим передачи SEND_SIMPLE. Данный алгоритм характеризуется наименьшей пропускной способностью, поскольку требует строго последовательной отправки блоков и ожидания их подтверждения. Мгновенная скорость передачи данных при этом может быть высокой (в зависимости от размера пакета и применяемой в нем модуляции), но интегральная скорость обмена сильно снижается из-за больших задержек (пинга) и оверхеда, вызываемого служебным трафиком.
Bulk-передача
Bulk-передача (рисунок 14) представляет собой альтернативный метод высокоскоростной передачи данных, при котором последовательность блоков данных передается в эфир непрерывным потоком, а принимающий узел осуществляет групповое подтверждение корректно доставленных блоков.

После принятия EOT-пакета, принимающая сторона формирует Sack-пакет. Содержащаяся в нем битовая маска определяет какие статус доставки каждого отдельного блока.
Передающая сторона анализирует битовую маску и инициирует повторную отправку только тех блоков, доставка которых завершилась ошибкой. Данный цикл итерационно повторяется до момента подтверждения доставки всех блоков передаваемых данных (рисунок 15).

Таким образом, использование bulk-режима позволяет существенно повысить интегральную скорость доставки данных и минимизировать временные издержки на служебный трафик.
Проверка соединения
Поскольку ведущий узел резервирует часть временного интервала в тайм-слоте для собственных нужд, инициализация сессионного цикла начинается с проверки соединения посредством отправки ping-запроса. Ведомый узел формирует pong-ответ (рисунок 16).

Анализ времени задержки ping-запроса позволяет оценить скорость работы модемов при кодировании и декодирования пакетов, а также определить статистическую вероятность потери пакетов в канале.
На рисунке 17 приведен граф состояний работы сессии.

Общий перечень внутренних состояний автомата описывается структурой:
class SessionState(Enum): IDLE = 0 PING_AWAIT = 1 CTS_AWAIT = 2 DATA_TRANSFER = 3 DATA_CONFIRM_AWAIT = 4 DATA_AWAIT = 5 EOT_ACK_AWAIT = 6 @property def is_awaiting(self) -> bool: cls = self.__class__ return self in ( cls.PING_AWAIT, cls.CTS_AWAIT, cls.DATA_CONFIRM_AWAIT, cls.DATA_AWAIT, cls.EOT_ACK_AWAIT )
Параметры работы FSM логики сессии включают в себя следующие значениями:
class Session(Connection): PING_INTERVAL = 60.0 # sec DEFAULT_RTS_INTERVAL = 20.0 # sec DEFAULT_FRAME_LEN = 40 * 8 # bits MAX_FRAMES = 1 << 8 DEFAULT_IDLE_TIMEOUT = 180.0
Значение PING_INTERVAL определяет как часто в состоянии IDLE будет осуществляться отправка Ping-запроса от ведущего узла к ведомому.
Параметр DEFAULT_RTS_INTERVAL задает временной интервал между циклами отправки RTS-запросов.
Значение DEFAULT_FRAME_LEN определяет верхнюю границу размера размера единичного блока данных (соответствующему пакету Data).
Параметр MAX_FRAMES задает максимальное количество блоков выделяемых в рамках одной пакетной сессии.
Значение DEFAULT_IDLE_TIMEOUT задает временной интервал отсутствия активности, по истечении которого сессия переводится в состояние неактивной.
Метод _session_loop реализует алгоритм перехода состояний конечного автомата сессии:
def _session_loop(self): now = get_time() if self._last_tx_tms is not None and now - self._last_tx_tms > self.DEFAULT_IDLE_TIMEOUT: self.close() return if self._session_state != SessionState.IDLE: if now - self._last_progress_tms > self._activity_timeout: if self._session_state == SessionState.DATA_AWAIT: self._tx_block = None self._set_session_state(SessionState.IDLE) self._rx_buffer.clear() self._rx_buffer_dtype = None self._rx_pending_payloads.clear() return elif now - self._last_signal_tms > self._activity_timeout: self._tx_block = None self._set_session_state(SessionState.IDLE) return ...
Контроль тайм-аута сессии: при превышении значения _activity_timeout автомат переводит сессию в состояние IDLE. При преодолении критического порога DEFAULT_IDLE_TIMEOUT сессия завершается.
... is_session_active = self._session_state not in (SessionState.IDLE, SessionState.PING_AWAIT) if is_session_active: self.rx_packets_since_last_ack = 0 if self.is_master: if self._session_state == SessionState.IDLE and now - self._last_ping_time > self.PING_INTERVAL: self._last_ping_time = now signal = self.modem.mod_ping_signal( self._conn_id, trans_id=self._get_seq_id(), time=time_monotonic_ms(self.MONOTONIC_TIME_MOD) ) self._set_session_state(SessionState.PING_AWAIT) self._send_signal(signal) return ...
В первом тайм-слоте ведущий узел осуществляет оценку канала путем отправки Ping-запроса. Ожидание ответного пакета Pong осуществляется в состоянии PING_AWAIT.
... if (self._session_state == SessionState.IDLE and len(self._tx_buffer) > 0 and self.tx_allowed and (now - self._last_ping_time > self.GUARD_TIME )): assert self._tx_block is None assert len(self._tx_buffer) < self._max_tx_block_len self._tx_block = TXBlock( frame_len=self._frame_len, data=self._tx_buffer[0], rts_interval=self.DEFAULT_RTS_INTERVAL, last_rts=now ) self._tx_block.retry_count = 0 signal = self.modem.mod_rts_signal( self._conn_id, trans_id=self._get_seq_id(), **self._tx_block.get_rts_args() ) self._set_session_state(SessionState.CTS_AWAIT) self._send_signal(signal) return ...
В состоянии IDLE происходит циклическая проверка буфера исходящих данных. При совпадении временных интервалов с границами активного тайм-слота формируется объект TXBlock (см. далее), выполняющий фрагментацию исходного массива данных на блоки и инкапсулирующий текущий контекст передачи.
Осуществляется отправка сигнала RTS, после чего автомат переходит в состояние CTS_AWAIT для мониторинга поступающих CTS-ответов.
... elif self._session_state == SessionState.CTS_AWAIT and self.tx_allowed: if self._tx_block is not None and now - self._tx_block.last_rts > self._tx_block.rts_interval: if self._tx_block.retry_count >= self._tx_block.max_retries: self._tx_block = None self._set_session_state(SessionState.IDLE) self.close() return self._tx_block.retry_count += 1 self._tx_block.last_rts = now signal = self.modem.mod_rts_signal( self._conn_id, trans_id=self._get_seq_id(), **self._tx_block.get_rts_args() ) self._send_signal(signal) return ...
Проверка системных таймеров и повторная передача RTS выполняется в состоянии CTS_AWAIT. Переменная retry_count регистрирует число итераций отправки запросов.
... elif self._session_state == SessionState.DATA_TRANSFER and self.tx_allowed: if not self._tx_block.eof: wire_frame_id = self._tx_block.current_frame_idx % 256 signal = self.modem.mod_data_signal( self._conn_id, frame_id=wire_frame_id, data=self._tx_block.current_data_block, **self._get_data_signal_extra_args() ) self._tx_block.last_tx_time = now self._tx_block.retry_count = 0 if self._current_cts_status == CTSStatus.SEND_BULK: self._tx_block.pop_current_frame() else: self._set_session_state(SessionState.DATA_CONFIRM_AWAIT) self._send_signal(signal) return else: self._set_session_state(SessionState.EOT_ACK_AWAIT) if self._tx_block.eot_trans_id == 0: self._tx_block.eot_trans_id = self._gen_trans_id() signal = self.modem.mod_eot_signal(self._conn_id, trans_id=self._tx_block.eot_trans_id) self._tx_block.last_tx_time = now self._tx_block.retry_count = 0 self._send_signal(signal) return ...
В состоянии DATA_TRANSFER, являющимся основным при обмене данными, происходит последовательное извлечение блоков из структуры _tx_block и их отправка в канал. По завершении отправки всего массива данных, при истинности условия self._tx_block.eof отправляется EOT-сигнал, после чего автомат переходит в состояние ожидания подтверждения принятия EOT (EOT_ACK_AWAIT).
На данном этапе реализуются оба режима пакетной передачи: Bulk и Stop-and-Wait, что определяется переменной состояния _current_cts_status.
... elif self._session_state == SessionState.DATA_CONFIRM_AWAIT and self.tx_allowed: if now - self._tx_block.last_tx_time > self._tx_block.retry_timeout: if self._tx_block.retry_count >= self._tx_block.max_retries: self._tx_block = None self._set_session_state(SessionState.IDLE) self.close() return self._tx_block.retry_count += 1 self._tx_block.last_tx_time = now wire_frame_id = self._tx_block.current_frame_idx % 256 signal = self.modem.mod_data_signal( self._conn_id, frame_id=wire_frame_id, data=self._tx_block.current_data_block, **self._get_data_signal_extra_args() ) self._send_signal(signal) return ...
Состояние DATA_CONFIRM_AWAIT управляет итерационным процессом передачи данных в режиме Stop-and-Wait.
... elif self._session_state == SessionState.EOT_ACK_AWAIT and self.tx_allowed: if now - self._tx_block.last_tx_time > self._tx_block.retry_timeout: if self._tx_block.retry_count >= self._tx_block.max_retries: self._tx_block = None self._set_session_state(SessionState.IDLE) self.close() return self._tx_block.retry_count += 1 self._tx_block.last_tx_time = now signal = self.modem.mod_eot_signal(self._conn_id, trans_id=self._tx_block.eot_trans_id) self._send_signal(signal) return
Повторная отправка EOT-пакета и инкремент счетчика retry_count выполняются в состоянии EOT_ACK_AWAIT. При превышении retry_count лимита сессия сбрасывается в исходное состояние IDLE.
Поскольку логика работы конечных автоматов разнесена на разные уровни событий (события внутренних таймеров и событий прерывания от модема), метод _handle_session_packet реализует обработку ответов, поступающих от удаленного узла.
def _handle_session_packet(self, packet: PacketCRC, **kwargs) -> None: now = get_time() self._last_signal_tms = now self._last_tx_tms = now if isinstance(packet, Ping): delta_ms = int((now - self._detector_time) * 1000) signal = self.modem.mod_pong_signal( self._conn_id, trans_id=packet.trans_id, remote_time=packet.time, local_delta=delta_ms ) self._send_signal(signal) return ...
Обработка Ping-запроса. Осуществляется мгновенная отправка пакета Pong.
... if isinstance(packet, Pong): if self._session_state != SessionState.PING_AWAIT: self._handle_unexpected_packet(packet) delta_ms = monotonic_delta( time_monotonic_ms(self.MONOTONIC_TIME_MOD), packet.remote_time, self.MONOTONIC_TIME_MOD ) self._ping_ms = delta_ms - packet.local_delta self._handle_ping(self._ping_ms) self._set_session_state(SessionState.IDLE) return ...
Обработка Ping-ответа: автомат сессии выходит из состояния PING_AWAIT и переходит в состояние IDLE.
Поскольку для вычисления задержки передачи сигнала используется миллисекундная точность, значение монотонного системного таймера вычисляется по модулю MONOTONIC_TIME_MOD (1048576, что эквивалентно временному окну переполнения порядка 30 минут).
... if isinstance(packet, RTS): if self._session_state == SessionState.EOT_ACK_AWAIT: if self._tx_block is not None: self._tx_block.tx_allowed = False self._tx_block.retry_count = 0 status = self._handle_rts(packet.frame_len, packet.frames_count, packet.data_type) if status == CTSStatus.REDUCE: self._set_session_state(SessionState.IDLE) else: self._set_session_state(SessionState.DATA_AWAIT) self._rx_expected_seq = 0 self._rx_expected_count = packet.frames_count self._rx_pending_payloads.clear() self._rx_session_mode = CTSStatus.SEND_BULK signal = self.modem.mod_cts_signal( self._conn_id, trans_id=packet.trans_id, status=status, **self._get_cts_signal_extra_args() ) self._send_signal(signal) return ...
Обработка запроса RTS: принимающий участник осуществляет подготовку приемного буфера для последующего накопления принимаемых данных и отправляет пакет разрешения передачи. Режим bulk-передачи задан статически флагом SEND_BULK. Тем не менее, архитектура предусматривает возможность изменения типа передачи на пользовательском уровне.
... if isinstance(packet, CTS): if packet.status == CTSStatus.REDUCE: self._frame_len = self.DEFAULT_FRAME_LEN self._tx_block = None self._set_session_state(SessionState.IDLE) return if packet.status in (CTSStatus.SEND_BULK, CTSStatus.SEND_SIMPLE): assert self._tx_block is not None self._current_cts_status = packet.status self._tx_block.tx_allowed = True self._set_session_state(SessionState.DATA_TRANSFER) return if packet.status == CTSStatus.REJECT: self._tx_block = None self._set_session_state(SessionState.IDLE) return return ...
На основе анализа статуса CTS ответа, передающий узел принимает решение о начале передачи, переводя конечный автомат в состояние на DATA_TRANSFER. В случае отрицательного ответа, происходит отмена передачи, выделенный буфер освобождается, а автомат сессии возвращается в состояние IDLE.
... if isinstance(packet, (DataBytes, Eot)): if isinstance(packet, Eot) and self._session_state == SessionState.IDLE: signal = self.modem.mod_ack_signal(self._conn_id, trans_id=packet.trans_id, frame_id=0) self._send_signal(signal) return if self._session_state != SessionState.DATA_AWAIT: self._handle_unexpected_packet(packet) return ...
Прием и обработка входящего потока данных. При получении пакетов вне активного состояния DATA_AWAIT вызывается обработчик _handle_unexpected_packet.
... if isinstance(packet, DataBytes): self._last_progress_tms = now diff = (packet.frame_id - self._rx_expected_seq) % 256 if diff > 128: if self._rx_session_mode != CTSStatus.SEND_BULK: signal = self.modem.mod_ack_signal(self._conn_id, trans_id=self._gen_trans_id(), frame_id=packet.frame_id) self._send_signal(signal) return ...
Алгоритм проверка расхождения идентификаторов блоков при их возможной потере и циклического переполнения счетчика при длительных сеансах непрерывной связи.
... if packet.frame_id not in self._rx_pending_payloads: self._rx_pending_payloads[packet.frame_id] = packet.payload moved = False while self._rx_expected_seq in self._rx_pending_payloads: payload = self._rx_pending_payloads.pop(self._rx_expected_seq) self._handle_rx_data(payload) self._rx_expected_seq = (self._rx_expected_seq + 1) % 256 moved = True ...
Принятые блоки данных накапливаются в промежуточном буфере _rx_pending_payloads.
... if packet.frame_id != self._rx_expected_seq and not moved: if self._rx_session_mode != CTSStatus.SEND_BULK: signal = self.modem.mod_nack_signal(self._conn_id, frame_id=self._rx_expected_seq) self._send_signal(signal) return ...
В режиме передачи Stop-and-Wait узел-получатель формирует Nack ответ при обнаружении расхождения между индексом принятого и ожидаемым значением указателя _rx_expected_seq.
... if self._rx_session_mode != CTSStatus.SEND_BULK: signal = self.modem.mod_ack_signal(self._conn_id, trans_id=self._gen_trans_id(), frame_id=packet.frame_id) self._send_signal(signal) return ...
Формирование и отправка подтверждение принятия блока данных.
... else: if self._rx_session_mode == CTSStatus.SEND_BULK: block_mask = 0 for idx in range(self._rx_expected_count): if idx < self._rx_expected_seq or idx in self._rx_pending_payloads: block_mask |= (1 << idx) signal = self.modem.mod_sack_signal( self._conn_id, trans_id=packet.trans_id, frame_id=0, mask=block_mask ) self._send_signal(signal) if self._rx_expected_seq == self._rx_expected_count and len(self._rx_pending_payloads) == 0: self._last_rx_trans_id = packet.trans_id self._last_rx_total_count = self._rx_expected_count self._last_rx_mask = block_mask self._set_session_state(SessionState.IDLE) self._handle_rx_eot() self._last_ping_time = now else: self._set_session_state(SessionState.DATA_AWAIT) ...
Обработка EOT-пакета. При функционировании в режиме Bulk на основе принятых в _rx_pending_payloads ключей формируется битовая маска block_mask.
Сформированная маска отправляется в составе Sack-пакета.
... else: signal = self.modem.mod_ack_signal(self._conn_id, trans_id=packet.trans_id, frame_id=0) self._send_signal(signal) self._set_session_state(SessionState.IDLE) self._handle_rx_eot() self._last_ping_time = now return ...
В режиме Stop-and-Wait выполняется стандартное подтверждение принятия пакета EOT.
... if isinstance(packet, Ack): if self._tx_block is None: self._handle_unexpected_packet(packet) return if self._session_state == SessionState.DATA_CONFIRM_AWAIT: self._tx_block.pop_current_frame() self._set_session_state(SessionState.DATA_TRANSFER) return if self._session_state == SessionState.EOT_ACK_AWAIT: if packet.trans_id == self._tx_block.eot_trans_id: if len(self._tx_buffer) > 0: del self._tx_buffer[0] self._tx_block = None self._set_session_state(SessionState.IDLE) self._last_ping_time = now return self._handle_unexpected_packet(packet) return return ...
Обработка Ack-пакета. При получении этого пакета передающий узел исключает успешно переданный блок данных из очереди отправки и переключается на следующий блок. Если сессия находилась в состоянии EOT_ACK_AWAIT, из буфера _tx_buffer удаляется массив данных, эквивалентный завершенной транзакции в текущей сессии.
... if isinstance(packet, Nack): if self._session_state in (SessionState.DATA_TRANSFER, SessionState.DATA_CONFIRM_AWAIT) and self._tx_block: failed_frame_idx = packet.frame_id if failed_frame_idx not in self._tx_block._frames_to_tx: self._tx_block._frames_to_tx.insert(0, failed_frame_idx) self._tx_block.retry_count = 0 self._set_session_state(SessionState.DATA_TRANSFER) return ...
При получении Nack ответа передаваемый блок данных возвращается обратно в очередь для повторной отправки.
... if isinstance(packet, Sack): if self._tx_block is None: self._handle_unexpected_packet(packet) return if self._session_state == SessionState.EOT_ACK_AWAIT: if packet.trans_id != self._tx_block.eot_trans_id: self._handle_unexpected_packet(packet) return total_frames = self._tx_block.data_blocks_count if packet.is_all_ok(total_frames): if len(self._tx_buffer) > 0: del self._tx_buffer[0] self._tx_block = None self._set_session_state(SessionState.IDLE) self._last_ping_time = now else: failed_frames = [] for idx in range(total_frames): if not packet.is_ok(idx): failed_frames.append(idx) self._tx_block._frames_to_tx = failed_frames self._tx_block.retry_count = 0 self._set_session_state(SessionState.DATA_TRANSFER) return return
Структура TXBlock сохраняет текущее состояние активного сеанса передачи данных:
@dataclass class TXBlock: frame_len: int # bits data: SessionBytes rts_interval: float retry_timeout: float = 10.0 max_retries: int = 5 last_rts: float = 0.0 tx_allowed: bool = False _frames_to_tx: list = None last_tx_time: float = 0.0 retry_count: int = 0 eot_trans_id: int = 0 def __post_init__(self): if self._frames_to_tx is None: self._frames_to_tx = list(range(self.data_blocks_count)) def get_rts_args(self) -> dict: data_type = self.data.RTS_DATA_TYPE return dict(frame_len=self.frame_len, frames_count=self.data_blocks_count, data_type=data_type, ) @property def data_block_len(self) -> int: return math.ceil(self.frame_len / 8) ...
Определение размера блока данных (в байтах) для алгоритма скользящего окна.
... @property def data_blocks_count(self) -> int: return math.ceil(len(self.data) / self.data_block_len) ...
Определение общего количества блоков данных для алгоритма скользящего окна.
... def get_data_block(self, block_num: int) -> bytes: assert 0 <= block_num < self.data_blocks_count return self.data[block_num * self.data_block_len:(block_num + 1) * self.data_block_len] @property def current_frame_idx(self) -> int: assert not self.eof return self._frames_to_tx[0] @property def current_data_block(self) -> bytes: return self.get_data_block(self.current_frame_idx) ...
Получение массива байт, соответствующему текущему индексируемому блоку данных.
... def pop_current_frame(self) -> int: return self._frames_to_tx.pop(0) @property def eof(self) -> bool: return len(self._frames_to_tx) == 0
Метод pop_current_frame извлекает из стека следующий блок данных для отправки.
Свойство eof осуществляет проверку на предмет исчерпания данных в очереди.
Изменение скорости передачи
По умолчанию, передача осуществляется с применением модуляции BPSK и скоростью ⅓ сверточного кодера. Это обеспечивает максимальную избыточность и помехоустойчивость в условиях сильных искажений, но снижает пропускную способность до минимального уровня.
При высоком отношении сигнал/шум использовании минимальной скорости неэффективно. Адаптация скорости обмена реализуется пороговым методом: принимающая сторона оценивает текущие параметры канала и в ответных пакетах CTS указывает оптимальную схему модуляции и скорость кодирования для последующих циклов передачи данных (рисунок 18).

Алгоритм адаптивного управления скоростью функционирует на уровне выше относительно логики сессии.
class AMCSession(Session): SNR_HISTORY_DEEP = 10 BER_HISTORY_DEEP = 10 QPSK_FRAME_LEN = 80 * 8 # bits QAM16_FRAME_LEN = 120 * 8 # bits MOD_FRAME_LEN = { ModType.QPSK: QPSK_FRAME_LEN, ModType.QAM16: QAM16_FRAME_LEN }
Параметры канала оцениваются через вычисления медианных значений BER и SNR по накопленной истории полученных пакетов.
Глубина скользящего окна измерений определяется значениями SNR_HISTORY_DEEP и BER_HISTORY_DEEP.
Использование высокоуровневых схем модуляции увеличивает информационную емкость пакета при той же длительности сигнала. Размер блока данных для режимов QPSK и 16-QAM задается значениями QPSK_FRAME_LEN и QAM16_FRAME_LEN.
Примечание:
Значения подобраны эмпирически.
Метод _handle_session_packet получает от модема метрики BER и SNR, тем самым формируя историю измерений.
def _handle_session_packet(self, packet: PacketCRC, **kwargs) -> None: assert "SNR" in kwargs assert "BER" in kwargs self._snr_history.append(kwargs["SNR"]) self._ber_history.append(kwargs["BER"]) if isinstance(packet, CTS): if packet.status in (CTSStatus.SEND_BULK, CTSStatus.SEND_SIMPLE): self._data_mod_type = packet.mod self._data_cc_speed = packet.speed self._frame_len = self.MOD_FRAME_LEN.get(self._data_mod_type, self.DEFAULT_FRAME_LEN) return super()._handle_session_packet(packet, **kwargs)
При получении CTS ответа, сессионный уровень переключается на принятые параметры модуляции и скорости кодера.
Таблица выбора модуляции и скорости кодера:
SNR (dB) | BER (%) | Mod | CC speed |
- | - | BPSK | ⅓ |
< -4.0 | - | BPSK | ⅓ |
< 1.0 | - | BPSK | ½ |
< 4.5 | >= 0.01 | QPSK | ⅓ |
< 4.5 | < 0.01 | QPSK | ½ |
< 7.5 | - | QPSK | ¾ |
>= 7.5 | > 0.005 | 16-QAM | ⅓ |
Примечание:
Схема 32-QAM была добавлена в процессе окончания написания статьи, вследствии чего она не прошла полный цикл испытаний и не включена в общий алгоритм изменения скорости.
Метод _calc_link_parameters анализирует усредненные метрики BER и SNR, вычисляя оптимальный тип модуляции и скорость кодирования под текущие условия.
@property def median_snr(self) -> typing.Optional[float]: if len(self._snr_history) < 2: return None return float(np.median(list(self._snr_history))) @property def median_ber(self) -> typing.Optional[float]: if len(self._ber_history) < 2: return None return float(np.median(list(self._ber_history))) def _calc_link_parameters(self) -> typing.Tuple[ModType, CCSpeed]: snr = self.median_snr ber = self.median_ber if snr is None or ber is None: return ModType.BPSK, CCSpeed.R13 if snr < -4.0: return ModType.BPSK, CCSpeed.R13 elif snr < 1.0: return ModType.BPSK, CCSpeed.R12 elif snr < 4.5: if ber < 0.01: return ModType.QPSK, CCSpeed.R12 return ModType.QPSK, CCSpeed.R13 elif snr < 7.5: return ModType.QPSK, CCSpeed.R34 else: if ber > 0.005: return ModType.QAM16, CCSpeed.R13 return ModType.QAM16, CCSpeed.R12
Обработка RTS-запроса:
def _handle_rts(self, frame_len: int, frames_count: int, data_type: RTSDataType) -> CTSStatus: base_status = super()._handle_rts(frame_len, frames_count, data_type) if base_status == CTSStatus.REJECT: return base_status mod, _ = self._calc_link_parameters() if frame_len > self.DEFAULT_FRAME_LEN and mod != ModType.QAM16: return CTSStatus.REDUCE return base_status
Если накопленная статистика на принимающей стороне указывает на ухудшение условий для текущего блока, узел формирует ответ со статусом REDUCE, требуя изменить параметры передачи.
Динамика изменения пропускной способности при смене схем модуляции и кодирования представлена на рисунке 19.

При лабораторных испытаниях в условиях отсутствия помех пиковая скорость передачи (рассчитанная как отношение объема полезной нагрузки к длительности пакета) составила 1542.65 бит/с.
Примечание:
При этом пинг составлял 5964 мс. Значение сильно зависит от размера буфера при захвате данных со звуковой карты, а также добавлении принудительных пауз при передаче сигналов в звуковую карту.
Также необходимо отметить, что при 32-QAM модуляции скорость передачи будет еще выше.
Передача данных прикладного уровня
На сессионном уровне передаваемые данные не классифицируются на тип передаваемой информации и обрабатываются как массив байт. На прикладном уровне данные разделяются на две категории: текстовые сообщения и произвольные бинарные данные.
Для повышения скорости обмена текстовой информацией предусмотрен режим сжатия. Юникод-текст сжимается алгоритмом unishox2. При передаче сжатых данных в RTS запросе выставляется флаг COMPRESSED_TEXT.
Предварительная обработка и преобразование данных перед отправкой осуществляется кодеками на базе абстрактного класса SessionBytes.
class SessionBytes(bytes): RTS_DATA_TYPE = None def __new__(cls, payload: typing.Union[str, bytes]): assert cls.RTS_DATA_TYPE is not None assert cls.RTS_DATA_TYPE in RTS_DATA_TYPES data = cls._to_bytes(payload) return super().__new__(cls, data) @classmethod @abstractmethod def _to_bytes(cls, payload: typing.Union[str, bytes]) -> bytes: ... @abstractmethod def from_bytes(self) -> typing.Union[str, bytes]: ...
Текстовые данные формата UTF-8 сериализуются в массив байт, а при декодировании выполняется обратное действие.
class TextBytes(SessionBytes): RTS_DATA_TYPE = RTSDataType.TEXT @classmethod def _to_bytes(cls, payload: typing.Union[str, bytes]) -> bytes: if isinstance(payload, bytes): return payload return payload.encode("utf-8", errors="ignore") def from_bytes(self) -> typing.Union[str, bytes]: return self.decode("utf-8", errors="ignore")
Класс TextCompressedBytes инкапсулирует алгоритмы компрессии и декомпрессии байт в UTF-8 строки.
class TextCompressedBytes(SessionBytes): RTS_DATA_TYPE = RTSDataType.COMPRESSED_TEXT @classmethod def _to_bytes(cls, payload: typing.Union[str, bytes]) -> bytes: if isinstance(payload, bytes): return payload compressed, _ = unishox2.compress(payload) return compressed def from_bytes(self) -> typing.Union[str, bytes]: decompressed = unishox2.decompress(self, 4096) return decompressed
Произвольные двоичные данные передаются без предварительных преобразований. Кодек RawBytes выполняет роль заглушки обеспечивая однозначную идентификацию типа данных при их передаче на прикладной уровень.
class RawBytes(SessionBytes): RTS_DATA_TYPE = RTSDataType.RAW_BYTES @classmethod def _to_bytes(cls, payload: typing.Union[str, bytes]) -> bytes: return payload def from_bytes(self) -> typing.Union[str, bytes]: return self
Запуск диалога между двумя узлами
Работоспособность MAC и LLC уровней проверялась путем запуска двух узлов с общим звуковым каналом, обеспечивающей взаимную передачу звукового сигнала. Для параллельного мониторинга сигнальных созвездий в схему испытаний был интегрирован сниффер (рисунок 20).
Логи ведущего узла:
23:20:26 [ INFO] Start server (R1HAM @ KO85TR)... 23:20:26 [ INFO] Found device: Monitor of Meteor Lake-P HD Audio Controller Speaker 23:20:26 [ INFO] Channels: 2 23:20:26 [ INFO] Init sound output... 23:20:27 [ INFO] Start semiduplex connection... 23:20:35 [ INFO] Received packet: Conn(called='R1HAM', caller='R2HOR', qth='KO59DW', caller_conn_id=184) 23:20:42 [ INFO] --- Hamlet (Master) Stage 1: Sending long text (132 bytes) --- 23:20:42 [ INFO] Received packet: Ack(conn_id=125, trans_id=1, frame_id=0) 23:20:49 [ INFO] PING: 5518ms 23:20:49 [ INFO] Received packet: Pong(conn_id=125, trans_id=1, remote_time=452873, local_delta=698) 23:21:06 [ INFO] Received packet: CTS(conn_id=125, trans_id=2, status=<CTSStatus.SEND_BULK: 0>, mod=<ModType.QPSK: 1>, speed=<CCSpeed.R12: 1>) 23:21:21 [ INFO] Received packet: Sack(conn_id=125, trans_id=184, frame_id=0, mask=3) 23:21:33 [ INFO] Received packet: RTS(conn_id=125, trans_id=2, frame_len=320, frames_count=3, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 23:21:41 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=0, payload=b'\x87\xbfi\x85\x7f\n\xe1C\xf3\xe2\x17\xcdwQ^\xf3\xbe\x94vk\xe3\x8a\xae\x89/?+\x8a\xdfC\xec\x82\xb6\xa2\x94vk\xad\x91A') 23:21:47 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=1, payload=b'\xd5\xbf\xdf\x85\xae\xfe|\xdc*\xf8Q\xd9\xaex\xbf\x8f\x84\x11\xdaazn8zT=\x9e\xd7k\xd2\xf5:j\xf0\xcb)\xd3\x91\xf6A') 23:21:52 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=2, payload=b'\xd5,\xa8^\xd4>\xe1\xf6') 23:21:55 [ INFO] [Buffer Trace] Current buffer size: 139 bytes (Contains 'K': True) 23:21:55 [ INFO] > === RECEIVED FULL TEXT === What if it tempt you toward the flood, my lord? Or to the dreadful summit of the cliff That beetles o'er his base into the sea? Do not go! ===================== 23:21:55 [ INFO] --- Hamlet (Master) Stage 2: Sending text (147 bytes) --- 23:21:55 [ INFO] Received packet: Eot(conn_id=125, trans_id=174, reason_code=<EotReason.SUCCESS: 0>) 23:22:07 [ INFO] Received packet: CTS(conn_id=125, trans_id=3, status=<CTSStatus.REDUCE: 2>, mod=<ModType.QPSK: 1>, speed=<CCSpeed.R34: 3>) 23:22:13 [ INFO] Received packet: CTS(conn_id=125, trans_id=4, status=<CTSStatus.SEND_BULK: 0>, mod=<ModType.QAM16: 2>, speed=<CCSpeed.R12: 1>) 23:22:30 [ INFO] Received packet: Sack(conn_id=125, trans_id=242, frame_id=0, mask=7) 23:22:35 [ INFO] Received packet: RTS(conn_id=125, trans_id=3, frame_len=320, frames_count=2, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 23:22:41 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=0, payload=b'\x87f\xbd\xe7\xf9\xe9t\xf5\xe1\xeea\xaf{\x8e\xca\xfc\xcf\xb5\xe4\xc5\xd6\x13A\x14\xbd\xfbL+\xd6\xbb\xb5\xef|p\xa3\xb5\xe9s^X') 23:22:45 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=1, payload=b'\xfb \xd5yq\xda\xf3\xd9^\x96\xea!\xe2\xbcQ\xd9\xadD\xc3U\xf0\x83\xa7\x9eg\x7fd\xc9\x93\x0f\xb2') 23:22:48 [ INFO] [Buffer Trace] Current buffer size: 114 bytes (Contains 'K': True) 23:22:48 [ INFO] > === RECEIVED FULL TEXT === He waxes desperate with imagination. To what issue will this come? Something is rotten in the state of Denmark... ===================== 23:22:48 [ INFO] --- Hamlet (Master) Stage 3: Sending VERY long text (188 bytes) --- 23:22:48 [ INFO] Received packet: Eot(conn_id=125, trans_id=242, reason_code=<EotReason.SUCCESS: 0>) 23:23:06 [ INFO] Received packet: CTS(conn_id=125, trans_id=5, status=<CTSStatus.SEND_BULK: 0>, mod=<ModType.QAM16: 2>, speed=<CCSpeed.R12: 1>) 23:23:17 [ INFO] Received packet: Sack(conn_id=125, trans_id=181, frame_id=0, mask=1) 23:23:33 [ INFO] Received packet: RTS(conn_id=125, trans_id=4, frame_len=960, frames_count=1, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 23:23:41 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=0, payload=b'\x85.\xa7\xe5Nt\xb2\xfd\xbbA%\xeb\xbc(\xedzW\xa5\xefY\xd6\xeb\xbe\x96\xa3s\x9e\xcc}\xd0Nt\xa3\xb3\xdb\xf8\xad\xb5:Rg\x82m{_\xd3W\x0b\xdd\xf1\xcdyd\xd0Gg\xb6\xa7my\xad\xb5\x1d\xaf=\xb4\xaf\x17g?O\x14\xe7I\xce\xe3\xb1$\x1d\xabs\x17D\x90Gi\xc5;k\xad\xb9\xf3\n\xbe\x15\xe2\xf9\xae\xfb^;_\x15\xaa\xf1\xdb\xe4\x9a\x0fjU\x83\xee\x1fe') 23:23:44 [ INFO] [Buffer Trace] Current buffer size: 190 bytes (Contains 'K': True) 23:23:44 [ INFO] > === RECEIVED FULL TEXT === O day and night, but this is wondrous strange! And therefore as a stranger give it welcome. There are more things in heaven and earth, Horatio, Than are dreamt of in your philosophy. Go on! ===================== 23:23:44 [ INFO] --- Hamlet (Master): Grand Dialogue Finished Successfully! --- 23:23:44 [ INFO] Received packet: Eot(conn_id=125, trans_id=53, reason_code=<EotReason.SUCCESS: 0>) 23:24:51 [ INFO] PING: 5548ms 23:24:51 [ INFO] Received packet: Pong(conn_id=125, trans_id=6, remote_time=694702, local_delta=681) 23:25:28 [ INFO] Finished.
Логи ведомого узла:
Логи ведомого узла:
23:20:32 [ INFO] Start client (R2HOR @ KO59DW)... 23:20:32 [ INFO] Connect to R1HAM 23:20:32 [ INFO] Found device: Monitor of Meteor Lake-P HD Audio Controller Speaker 23:20:32 [ INFO] Channels: 2 23:20:32 [ INFO] Init sound output... 23:20:32 [ INFO] Start semiduplex connection... 23:20:39 [ INFO] --- Horatio (Slave) Stage 1: Waiting for Hamlet's speech... --- 23:20:39 [ INFO] Setup connection mod=60, master_window=30, start_lag=30, tms_str='23:21:08' 23:20:39 [ INFO] Received packet: ConnAck(caller='R2HOR', called='R1HAM', caller_conn_id=185, called_conn_id=125, mod=60, master_window=30, start_lag=30) 23:20:45 [ INFO] Received packet: Ping(conn_id=125, trans_id=1, time=452873) 23:21:03 [ INFO] Received packet: RTS(conn_id=125, trans_id=2, frame_len=320, frames_count=2, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 23:21:10 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=0, payload=b'\x84R\xf4\xc9*\xda\xca\x85\x063Gi\x85zQ\xd9\xaf\xce\xefQu\x83h=\xfb8\xec\xf6\x87\xb4^\x96W\xae\x1e\xd5\xe2\x8e\xcdy') 23:21:14 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=1, payload=b'\xbc\xe9E-w\xf8\xf8{A\x1d\x9a\xd7\x17\x9e\xdaS\x9d)\xde\xeb\xdfJ\xbe\x15w\x8d\xcf\xb3\xae\xfa_\x15\xb8\xef\x8c\x992a\xf6') 23:21:18 [ INFO] [Buffer Trace] Current buffer size: 132 bytes (Contains 'K': True) 23:21:18 [ INFO] > === RECEIVED FULL TEXT === To be, or not to be, that is the question: Whether 'tis nobler in the mind to suffer The slings and arrows of outrageous fortune... ===================== 23:21:18 [ INFO] --- Horatio (Slave) Stage 1: Sending text (139 bytes) --- 23:21:18 [ INFO] Received packet: Eot(conn_id=125, trans_id=184, reason_code=<EotReason.SUCCESS: 0>) 23:21:36 [ INFO] Received packet: CTS(conn_id=125, trans_id=2, status=<CTSStatus.SEND_BULK: 0>, mod=<ModType.BPSK: 0>, speed=<CCSpeed.R12: 1>) 23:21:59 [ INFO] Received packet: Sack(conn_id=125, trans_id=174, frame_id=0, mask=7) 23:22:04 [ INFO] Received packet: RTS(conn_id=125, trans_id=3, frame_len=640, frames_count=2, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 23:22:10 [ INFO] Received packet: RTS(conn_id=125, trans_id=4, frame_len=320, frames_count=3, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 23:22:16 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=0, payload=b'\x85\xc2\xf7\x9f\xa7\xa5\xe5\xadE\xf1\xc14\x1e\xd4\xab\t \xb1\xed\xc7\x0b\xe2\xb8\xe2\xbd\xd4vl}\xd0y\xf9_\x13\rs\xbbzU\xde') 23:22:20 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=1, payload=b"\x14\xe7K\xcc\xfd\xbd'<\xf6^\x1cG\xca\x9d\xc3\xdf\xca\xbcQ\xda\xf4\xbdW_('K\xb4\xef\xaf\x95:Q\xd9\xa0\xc7\xe5\xce..") 23:22:24 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=2, payload=b'\xb0{ic\xdf\xd3\x1fs\x1d\x84}\xc3\xec') 23:22:26 [ INFO] [Buffer Trace] Current buffer size: 147 bytes (Contains 'K': True) 23:22:26 [ INFO] > === RECEIVED FULL TEXT === It waves me still. Go on, I'll follow thee! My fate cries out and makes each petty artery in this body As hardy as the Nemean lion's nerve! Go on! ===================== 23:22:26 [ INFO] --- Horatio (Slave) Stage 2: Sending text (114 bytes) --- 23:22:26 [ INFO] Received packet: Eot(conn_id=125, trans_id=242, reason_code=<EotReason.SUCCESS: 0>) 23:22:38 [ INFO] Received packet: CTS(conn_id=125, trans_id=3, status=<CTSStatus.SEND_BULK: 0>, mod=<ModType.QAM16: 2>, speed=<CCSpeed.R12: 1>) 23:22:51 [ INFO] Received packet: Sack(conn_id=125, trans_id=242, frame_id=0, mask=3) 23:23:03 [ INFO] Received packet: RTS(conn_id=125, trans_id=5, frame_len=960, frames_count=1, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 23:23:10 [ INFO] Received packet: DataBytes(conn_id=125, frame_id=0, payload=b"\x85\xa9\xf2\xa3\xb7\xca\xf8\x98\xec\xf6=\xb4\xb5\xe2\xf7pI\x07U^G\xb7K\xe2\xb6\xa5r\xf7\x13x\xa1\xef\xca\x8a^\xf3\xc7\xda\x8e\xcde\xfbv\x82H'\x0cq(\xec\xd7S\xf2\xb9\xac\xf8\xbc\x1e\xdd(\xa5\xf1:\x85x\xbe/oD\x90E\xf1\xc2\x8e\xcd|W~\x17;\xbf/K\xaa\xc6\xaf\x17\x9f\x95\xd4\xfc\xe9W\xc2\xc9\x8e\xfbh'mz\xef\xbc\x859\xd2\xf1\xdf~\xc3\xdb\xa5>\xf3\xf2L>\xcb") 23:23:14 [ INFO] [Buffer Trace] Current buffer size: 188 bytes (Contains 'K': True) 23:23:14 [ INFO] > === RECEIVED FULL TEXT === I am thy father's spirit, Doom'd for a certain term to walk the night, And for the day confin'd to fast in fires, Till the foul crimes done in my days of nature Are burnt and purg'd away. ===================== 23:23:14 [ INFO] --- Horatio (Slave) Stage 3: Sending final long text (190 bytes) --- 23:23:14 [ INFO] Received packet: Eot(conn_id=125, trans_id=181, reason_code=<EotReason.SUCCESS: 0>) 23:23:36 [ INFO] Received packet: CTS(conn_id=125, trans_id=4, status=<CTSStatus.SEND_BULK: 0>, mod=<ModType.QAM16: 2>, speed=<CCSpeed.R12: 1>) 23:23:47 [ INFO] Received packet: Sack(conn_id=125, trans_id=53, frame_id=0, mask=1) 23:24:47 [ INFO] Received packet: Ping(conn_id=125, trans_id=6, time=694702) 23:25:27 [ INFO] Finished.
В качестве полезной нагрузки в обмене сообщениями использовался сокращенный диалог между Гамлетом (R1HAM) и Горацио (R2HOR). Логика взаимодействия была организована в формате диалога: после принятия сообщения узел автоматически переходил в режим отправки своей следующей реплики.
В логах можно заметить CTS пакеты с модуляциями QPSK и QAM16, а также изменением скорости кодера до ¾.
Для комплексной проверки использовалось сжатие текста. Записи "RECEIVED FULL TEXT" подтверждают правильность конкатенации принятых блоков данных и их декомпрессию в человеко читаемый текст.
Вывод строки "Hamlet (Master): Grand Dialogue Finished Successfully!" подтверждает полную передачу информационного массива и успешное завершение сеанса связи. После этого сессия перешла в состояние удержания соединения Keep-Alive, в котором циклически отправляются Ping-запросы для предотвращения преждевременного разрыва соединения.


Тестирование
Помимо синтетических тестов, показывающих работоспособность скриптов для передачи пакетов в полудуплексном режиме, был разработан графический интерфейс, предоставляющий возможность отслеживать маяки, устанавливать с ними соединение и обмениваться сообщениями.
Обмен данными производился через loopback звуковой карты.
Гамлет выступал в качестве ведущего узла. Его задача заключалась в посылке CQ-сигнала (рисунок 22) и, после установления соединения, передаче знаменитого монолога (рисунок 23).



Также необходимо отметить, что из-за особенностей работы звуковой карты, часто возникала ситуация, когда созвездия сильно искажались (рисунок 25). Механизмы когерентного накопления и эквализации восстанавливают созвездия до приемлемого состояния, а сверточный код позволяет восстановить данные при уровнях BER порядка 5-7%.

Примечание:
Помимо деградации сигнального созвездия, также наблюдался эффект его разворота (примерно на 10 градусов против часовой стрелки).
Причина возникновения такого эффекта долго была не понятна. Были перепроверены эквалайзер, синхронизация (здесь стоит отметить, что сдвиг на 3 семпла делал демодуляцию невозможной, а при сдвиге на 2 семпла поворот был близок к 90 градусам; примечание к тезису о необходимости использования ZC-последовательностей), тайлинг OFDM-символов, и прочей цепочки вычислений. Де-факто, подменив данные звуковой карты на сигнал из wav файла, проблема исчезла, что в итоге оставило единственного подозреваемого — звуковую карту.
После настройки параметров ALSA проблема с разворачивание созвездий пропала.


После того как монолог Гамлета был полностью принят (рисунок 26), из интерфейса было отправлено встречное сообщение (рисунок 30), которое было зафиксировано в логах ведущего узла:
18:53:33 [ INFO] Received packet: RTS(conn_id=235, trans_id=2, frame_len=320, frames_count=1, data_type=<RTSDataType.COMPRESSED_TEXT: 1>) 18:53:39 [ INFO] Received packet: DataBytes(conn_id=235, frame_id=0, payload=b'\x86\xca\x80>\x01\x87q\xb4\x1b\xafg\xb5\xba\xf6{&\x82\xbe\x9e\xc9\x97') 18:53:43 [ INFO] === RECEIVED FULL TEXT === R9FEU: Roger roger. Over. ===================== 18:53:43 [ INFO] Received packet: Eot(conn_id=235, trans_id=86, reason_code=<EotReason.SUCCESS: 0>)
Примечание:
Отметки времени на рисунке 27 и логах не совпадают, так как изначально автор забыл своевременно сделать скриншот.
Тестирование в симуляции эфира
Испытания в условиях, приближенных к реальному радиоэфиру, выполнялись с помощью симулятора каналов связи. Он формировал широкополосный квадратурный (IQ) сигнал, в котором исходящий поток модема подвергался однополосной амплитудной модуляции с верхней боковой полосой (USB).
Код модулятора:
import numpy as np from scipy.signal import resample_poly from scipy.io.wavfile import write, read from scipy.signal import hilbert def mod_DSB(time, freq_carrier, mod_signal): carrier = np.exp(1j * 2 * np.pi * freq_carrier * time) envelope = mod_signal return envelope * carrier def mod_USB(time, freq_carrier, mod_signal): envelope = hilbert(mod_signal) return mod_DSB(time, freq_carrier, envelope) ...
Функция mod_DSB реализует двухполосную амплитудную модуляцию с подавленной несущей (DSB-SC). Комплексная огибающая формируемого сигнала вычисляется путем применения преобразования Гильберта к исходному дискретному сигналу.
... def rand(shape): return np.random.normal(0, 1, size=shape) fs = int(1e6) ...
Функция rand генерирует отсчеты аддитивного белого гауссова шума. Частота дискретизации сигнала задана равной 1.0 МГц.
... src_sr, src_data = read("/tmp/ofdm-signal-src.wav") duration = len(src_data) / src_sr payload = resample_poly(src_data / 32768, fs, src_sr) time = np.arange(fs * duration) / fs usb_signal = mod_USB(time, 50000, payload) ...
Частота несущей USB-сигнала задана равной 50.0 кГц.
... amp = 1.0 iq_signal = usb_signal * amp iq_signal /= np.max(np.abs(iq_signal)) snr_db = 6.0 p_signal = np.mean(np.abs(iq_signal) ** 2) p_noise = p_signal / (10 ** (snr_db / 10)) ...
Отношение сигнал/шум установлено на уровне 6дБ (слабый, едва заметный шум).
... noise = (rand(iq_signal.shape) + 1j * rand(iq_signal.shape)) * np.sqrt(p_noise / 2) iq_signal = iq_signal + noise ...
Выполняется аддитивное смешивание квадратурного сигнала с шумовой составляющей.
... iq_output = np.vstack((iq_signal.real, iq_signal.imag)).T write("/tmp/ofdm-sdr-iq.wav", fs, (iq_output * 32767).astype(np.int16))
На рисунке 28 приведен результат воспроизведения IQ-сигнала в программе sdrpp.

На рисунке 29 приведен результат демодуляции снифером сигнала, воспроизводимого программой sdrpp.

Так как в sdrpp невозможно отключить AGC, для корректного восстановления информационного сигнала из квадратурного потока был реализован собственный демодулятор:
import numpy as np from scipy.signal import resample_poly, butter, lfilter from scipy.io.wavfile import write, read def lowpass_filter(s, cutoff, fs): nyq = 0.5 * fs normal_cutoff = cutoff / nyq b, a = butter(5, normal_cutoff, btype='low') return lfilter(b, a, s) ...
Для подавления внеполосных излучений при демодуляции USB-сигнала применяется фильтр нижних частот Баттерворта. Параметр nyq задает частоту Найквиста, равную половине частоты дискретизации исходного сигнала.
... def demod_USB(fs, fc, s, sr): t = np.arange(len(s)) / fs shifted = s * np.exp(-2j * np.pi * fc * t) filtered = lowpass_filter(shifted, sr * 0.5, fs) signal = resample_poly(filtered, sr, fs) return np.real(signal) ...
Алгоритм работы комплексного USB-демодулятора: умножение на комплексную экспоненту осуществляет перенос спектра сигнала из высокочастотной области в базовую полосу (процесс цифрового гетеродинирования). Последующая фильтрация с помощью ФНЧ подавляет спектральные составляющие, лежащие за пределами полосы пропускания полезного сигнала.
Функция resample_poly выполняет даунсемплинг сигнала до целевой частоты дискретизации.
... fs, data = read("/tmp/ofdm-sdr-iq.wav") data = data / 32768.0 iq_signal = data[:, 0] + 1j * data[:, 1] fc_target = 50000 audio = demod_USB(fs, fc_target, iq_signal, 12000) write("/tmp/out.wav", 12000, (audio * 32767).astype(np.int16))
Переменная fc_target определяет частоту несущей, задействованной при модуляции USB-сигнала. Частота дискретизации выходного потока составляет 12.0 кГц.
Таким образом, на Python/numpy реализуется простейший SDR приемник.

На рисунке 30 приведены результаты анализа демодулированного сигнала.
Примечание:
Можно обратить внимание на качественное отличие в показаниях столбца SNR. Эффект связан с тем, что при воспроизведении сигнала была существенно снижена его амплитуда. То есть достаточно громкий сигнал приводит к фазовым искажениям сигнала и деградации сигнального созвездия (рисунок 34). Также, при высоких значениях амплитуды кривая АЧХ канала вышла за габариты графика.

Тестирование в эфире
Как и в предыдущей статье, тест осуществлялся с применением УКВ трансивера ICOM IC-9700 и SDR-приемника RX-888 под управлением SDR Console.
Перечень пакетов, сформированных в рамках тестирования на звуковой карте, был записан в один wav-файл, воспроизведенный на машине, подключенной к трансиверу.
В программе SDR Console эфир записывался в файл с IQ данными. При записи использовалась частота дискретизации в 1.0 МГц.
Демодулированный из полученного файла сигнал отправлялся на вход сниффера для оценки качества демодуляции.
В качестве эксперимента была использована программа sdrpp для динамического воспроизведения сигнала из IQ потока (рисунок 32).

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

Заключение
В статье были рассмотрены механизмы реализации MAC и LLC уровней в самодельном, радиолюбительском протоколе на базе OFDM. На PHY-уровне были интегрированы новые схемы модуляций, оптимизирована частотная сетка ортогональных поднесущих, модернизирован эквалайзер. Подробно описана реализация алгоритмов установления связи, ведения сессии, динамического регулирования скорости, а также приведены результаты верификации системы как в условиях симуляции, так и при испытаниях в реальном радиоэфире.
В процессе проектирования архитектуры стека протоколов во многом были воспроизведены базовые технологические этапы, характерные для эволюции стандартов беспроводной связи Wi-Fi и 5G.
В статью не вошли 32-QAM модуляция и обмен RSSI между удаленными узлами.
Subscribed it, gave't the impression, placed it safely. Now, the next day was our sea-fight.

