В предыдущей части статьи были рассмотрены принципы и механизмы работы 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 приведены примеры сигнальных созвездий и соответствующее им кодирование бит.

Рисунок 1. Сигнальное созвездие BPSK.
Рисунок 1. Сигнальное созвездие BPSK.

Таблица кодирования BPSK:

_MAPPING_TABLE = {
    (0,): -1 + 0j,
    (1,): 1 + 0j,
}
Рисунок 2. Сигнальное созвездие QPSK.
Рисунок 2. Сигнальное созвездие QPSK.

Таблица кодирования 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,
}
Рисунок 3. Сигнальное созвездие 16-QAM.
Рисунок 3. Сигнальное созвездие 16-QAM.

Таблица кодирования 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 бит информации в сигнальном симовле.

Рисунок 4. Сигнальное созвездие 32-QAM.
Рисунок 4. Сигнальное созвездие 32-QAM.

Таблица кодирования 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).

Рисунок 5. Деградация сигнального созвездия после применения фильтра Винера при высоком SNR.
Рисунок 5. Деградация сигнального созвездия после применения фильтра Винера при высоком SNR.
Рисунок 6. Влияние фильтра Винера при сильной деградации сигнального созвездия.
Рисунок 6. Влияние фильтра Винера при сильной деградации сигнального созвездия.

Данный эффект был вызван некорректной регуляризацией в алгоритме расчета матрицы весов Винера. При высоких значениях 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 из-за эффекта усиления шума, что обуславливает необходимость применения адаптивного фильтра Винера.

Рисунок 7. Сигнальное созвездие 16-QAM после применения ZF-эквалайзера.
Рисунок 7. Сигнальное созвездие 16-QAM после применения ZF-эквалайзера.

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

Декодирование пакетов

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

Рисунок 8. Граф состояний автомата декодирования.
Рисунок 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. Иерархия сетевых уровней.
Рисунок 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.

Рисунок 10. Граф состояний соединения.
Рисунок 10. Граф состояний соединения.

На рисунке 11 представлена временная диаграмма процесса установления соединения между двумя узлами.

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

Рисунок 11. Временная диаграмма установки соединения между двумя узлами.
Рисунок 11. Временная диаграмма установки соединения между двумя узлами.

Стадия ожидания запроса на установление связи на стороне ведущего узла функционально аналогична прослушиванию сетевого сокета, с тем отличием, что ведущему необходимо обозначить свое присутствие путем периодической трансляции пакета 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 секундам.

Рисунок 12. Распределение участков тайм-слота между участниками.
Рисунок 12. Распределение участков тайм-слота между участниками.

В динамически меняющемся канале связи с высокой вероятностью потерь, 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).

Рисунок 13. Пакетная передача данных с подтверждением каждого пакета.
Рисунок 13. Пакетная передача данных с подтверждением каждого пакета.

Передача осуществляется с того, что участник в границах своего активного тайм-слота передает RTS запрос. После приема пакета CTS выполняется отправка первого блока данных, после чего передающий узел переходит в состояние ожидания с остановкой (Stop-and-Wait) для проверки доставки пакета. В случае потери пакета или нарушения целостности данных передающий узел инициирует повторную отправку текущего блока данных.

При успешном приеме подтверждения система переходит к передаче следующего блока данных. Этот цикл повторяется до полной отправки всего массива данных.

Финальный EOF-пакет служит индикатором кона передачи. Логически этот пакет интегрирован в общую структуру передаваемых данных и к нему применяется аналогичный алгоритм подтверждения и ARQ-запроса.

Таким образом реализуется режим передачи SEND_SIMPLE. Данный алгоритм характеризуется наименьшей пропускной способностью, поскольку требует строго последовательной отправки блоков и ожидания их подтверждения. Мгновенная скорость передачи данных при этом может быть высокой (в зависимости от размера пакета и применяемой в нем модуляции), но интегральная скорость обмена сильно снижается из-за больших задержек (пинга) и оверхеда, вызываемого служебным трафиком.

Bulk-передача

Bulk-передача (рисунок 14) представляет собой альтернативный метод высокоскоростной передачи данных, при котором последовательность блоков данных передается в эфир непрерывным потоком, а принимающий узел осуществляет групповое подтверждение корректно доставленных блоков.

Рисунок 14. Bulk-передача данных с групповым подтверждением пакетов.
Рисунок 14. Bulk-передача данных с групповым подтверждением пакетов.

После принятия EOT-пакета, принимающая сторона формирует Sack-пакет. Содержащаяся в нем битовая маска определяет какие статус доставки каждого отдельного блока.

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

Рисунок 15. Повторная отправка потерянных блоков.
Рисунок 15. Повторная отправка потерянных блоков.

Таким образом, использование bulk-режима позволяет существенно повысить интегральную скорость доставки данных и минимизировать временные издержки на служебный трафик.

Проверка соединения

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

Рисунок 16. Пинг-запросы в таймслоте мастера.
Рисунок 16. Пинг-запросы в таймслоте мастера.

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

На рисунке 17 приведен граф состояний работы сессии.

Рисунок 17. Граф состояний сессии.
Рисунок 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).

Рисунок 18. Динамическое управление скоростью передачи данных на уровне RTS/CTS-пакетов.
Рисунок 18. Динамическое управление скоростью передачи данных на уровне RTS/CTS-пакетов.

Алгоритм адаптивного управления скоростью функционирует на уровне выше относительно логики сессии.

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.

Рисунок 19. Динамика скорости в условиях идеального канала.
Рисунок 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-запросы для предотвращения преждевременного разрыва соединения.

Рисунок 20. Сниффер для анализа пакетов.
Рисунок 20. Сниффер для анализа пакетов.
Рисунок 21. Искажения созвездий вызванные процессами в звуковой карте.
Рисунок 21. Искажения созвездий вызванные процессами в звуковой карте.

Тестирование

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

Обмен данными производился через loopback звуковой карты.

Гамлет выступал в качестве ведущего узла. Его задача заключалась в посылке CQ-сигнала (рисунок 22) и, после установления соединения, передаче знаменитого монолога (рисунок 23).

Рисунок 22. Отслеживание CQ-запросов.
Рисунок 22. Отслеживание CQ-запросов.
Рисунок 23. Прием текста.
Рисунок 23. Прием текста.
Рисунок 24. АЧХ эквалайзера и созвездия сигналов.
Рисунок 24. АЧХ эквалайзера и созвездия сигналов.

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

Рисунок 25. Искажения возникающие в звуковой карте.
Рисунок 25. Искажения возникающие в звуковой карте.

Примечание:
Помимо деградации сигнального созвездия, также наблюдался эффект его разворота (примерно на 10 градусов против часовой стрелки). 

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

После настройки параметров ALSA проблема с разворачивание созвездий пропала.

Рисунок 26. Лог пакетов на стороне приемника.
Рисунок 26. Лог пакетов на стороне приемника.
Рисунок 27. Пример отправки сообщения.
Рисунок 27. Пример отправки сообщения.

После того как монолог Гамлета был полностью принят (рисунок 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.

Рисунок 28. Воспроизведение IQ-сигнала в sdrpp.
Рисунок 28. Воспроизведение IQ-сигнала в sdrpp.

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

Рисунок 29. Прием сниффером сигнала из 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. Анализ демодулированного сигнала сниффером.
Рисунок 30. Анализ демодулированного сигнала сниффером.

На рисунке 30 приведены результаты анализа демодулированного сигнала.

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

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

Тестирование в эфире

Как и в предыдущей статье, тест осуществлялся с применением УКВ трансивера ICOM IC-9700 и SDR-приемника RX-888 под управлением SDR Console.

Перечень пакетов, сформированных в рамках тестирования на звуковой карте, был записан в один wav-файл, воспроизведенный на машине, подключенной к трансиверу. 

В программе SDR Console эфир записывался в файл с IQ данными. При записи использовалась частота дискретизации в 1.0 МГц.

Демодулированный из полученного файла сигнал отправлялся на вход сниффера для оценки качества демодуляции.

В качестве эксперимента была использована программа sdrpp для динамического воспроизведения сигнала из IQ потока (рисунок 32).

Рисунок 32. Спектр сигнала в srpp (контрастность эфирных шума снижена).
Рисунок 32. Спектр сигнала в srpp (контрастность эфирных шума снижена).

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

Рисунок 33. Демодуляция пактов из записи эфира.
Рисунок 33. Демодуляция пактов из записи эфира.

Заключение

В статье были рассмотрены механизмы реализации 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.

Комментарии (0)