diff --git a/.github/workflows/esp32-radio-gateway.yml b/.github/workflows/esp32-radio-gateway.yml new file mode 100644 index 0000000..2e56117 --- /dev/null +++ b/.github/workflows/esp32-radio-gateway.yml @@ -0,0 +1,57 @@ +name: ESP32 radio gateway firmware + +on: + push: + branches: + - main + paths: + - "firmware/radio_gateway_esp32/**" + - ".github/workflows/esp32-radio-gateway.yml" + pull_request: + branches: + - main + paths: + - "firmware/radio_gateway_esp32/**" + - ".github/workflows/esp32-radio-gateway.yml" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install PlatformIO + run: python -m pip install --upgrade platformio + + - name: Compile ESP32 radio gateway + run: pio run -d firmware/radio_gateway_esp32 + + - name: Prepare firmware artifact + shell: bash + run: | + mkdir -p firmware-artifact + cp firmware/radio_gateway_esp32/.pio/build/esp32dev/firmware.bin firmware-artifact/openmcc-radio-gateway-firmware.bin + cp firmware/radio_gateway_esp32/.pio/build/esp32dev/bootloader.bin firmware-artifact/openmcc-radio-gateway-bootloader.bin + cp firmware/radio_gateway_esp32/.pio/build/esp32dev/partitions.bin firmware-artifact/openmcc-radio-gateway-partitions.bin + cat > firmware-artifact/flash-layout.txt <<'EOF' + ESP32-WROOM-32 / esp32dev flash layout + 0x1000 openmcc-radio-gateway-bootloader.bin + 0x8000 openmcc-radio-gateway-partitions.bin + 0x10000 openmcc-radio-gateway-firmware.bin + EOF + + - name: Upload compiled firmware + uses: actions/upload-artifact@v4 + with: + name: OpenMCC-ESP32-Radio-Gateway + path: firmware-artifact/* + if-no-files-found: error + retention-days: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbf425..e55c308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,26 @@ # Changelog +## Radio gateway firmware v0.1.0 + +- добавлен отдельный PlatformIO-проект `firmware/radio_gateway_esp32` для ESP32-WROOM-32; +- одна прошивка поддерживает альтернативные радиомодули CC1101 и E32-433T30D; +- реализовано применение команд `$CMD,RADIO,...` из существующего RF-интерфейса OpenMCC; +- реализован `RADIO_STATUS`, `GATEWAY_INFO`, `GATEWAY_PING` и `RADIO_OFF`; +- остальные `$CMD,...` автоматически пересылаются в эфир как команды МКА; +- входящие `$TEL/$ACK/$ERR/$INFO` передаются из радиоканала в OpenMCC без изменения; +- для CC1101 реализованы настройка RF, CRC, CCA, RX/TX и выдача RSSI/LQI; +- для E32 реализована запись конфигурации через UART/M0/M1/AUX и прозрачная передача; +- последняя RF-конфигурация сохраняется в NVS ESP32; +- добавлена отдельная GitHub Actions сборка прошивки и готовых `.bin`; +- CI-компиляция прошивки успешно пройдена; физические испытания радиомодулей ещё не выполнены. + ## v0.3.0 — Radio controls, command presets and CubeSat model - добавлена раскрывающаяся схема подключения CC1101 к ESP32-WROOM-32; - добавлена раскрывающаяся схема подключения E32-433T30D к ESP32-WROOM-32; - добавлена настройка частоты, мощности и параметров радиоканала прямо из OpenMCC; -- RF-настройки сохраняются локально и формируют будущую команду `$CMD,RADIO,...` для ESP32-шлюза; -- добавлен запрос `RADIO_STATUS` для будущей прошивки радиошлюза; +- RF-настройки сохраняются локально и формируют команду `$CMD,RADIO,...` для ESP32-шлюза; +- добавлен запрос `RADIO_STATUS`; - добавлен список готовых безопасных команд с пояснениями; - 3D-модель переработана в более реалистичный 2U CubeSat с фиксированными солнечными панелями; - удалена условная одиночная штыревая антенна, добавлена раскрытая рулеточная дипольная антенна с двумя противоположными плечами; diff --git a/README.md b/README.md index 28416eb..45ba19b 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,17 @@ OpenMCC можно запускать двумя способами: - автоматическая сборка установщика через GitHub Actions; - интерфейс выбора CC1101 или E32-433T30D; - раскрывающиеся таблицы подключения радиомодулей к ESP32-WROOM-32; -- настройка RF-параметров в интерфейсе с локальным сохранением и формированием команды для будущего ESP32-шлюза; -- готовые примеры безопасных команд в центре команд. +- настройка RF-параметров в интерфейсе; +- готовые примеры команд в центре команд; +- прошивка наземного радиошлюза `ESP32-WROOM-32` для CC1101 и E32-433T30D; +- автоматическая CI-сборка готовых `.bin` прошивки шлюза. ## Что пока не реализовано полностью -- реальная прошивка радиошлюза ESP32-WROOM-32; -- драйвер CC1101 в ESP32; -- драйвер E32-433T30D в ESP32; -- фактическое применение RF-настроек на радиомодуле; -- единый пакетный протокол радиоканала с CRC/подтверждениями; +- аппаратная проверка прошивки шлюза с реальным CC1101/E32; +- бортовая часть радиоканала на МКА; +- расширенный радиопакет с собственным номером пакета/ACK/повторами поверх возможностей модулей; +- встроенная прошивка ESP32 непосредственно из окна OpenMCC; - TLE/SGP4; - карта орбиты и радиогоризонт; - автоматическое сопровождение спутника; @@ -90,16 +91,25 @@ examples/arduino_avr_demo/OpenMCC_Arduino_AVR/OpenMCC_Arduino_AVR.ino ## Радиоканал -Наземный шлюз планируется на `ESP32-WROOM-32`. +Наземный шлюз реализован на `ESP32-WROOM-32` как отдельный PlatformIO-проект: -В панели **«Радиоканал ЦУПа»** можно выбрать: +```text +firmware/radio_gateway_esp32/ +``` + +Одна прошивка поддерживает альтернативные варианты: - `CC1101` — SPI; - `E32-433T30D` — UART. -После выбора OpenMCC показывает рекомендуемое подключение, питание и параметры радиотракта. Подробно: `docs/RADIO_CONTROL_UI.md`. +После выбора OpenMCC показывает рекомендуемое подключение, питание и параметры радиотракта. Кнопка **«Сохранить / передать в шлюз»** формирует `$CMD,RADIO,...`; прошивка ESP32 принимает эту команду, применяет параметры к выбранному модулю и сохраняет их в NVS. Любые остальные `$CMD,...` пересылаются в радиоканал как команды МКА, а принятые `$TEL/$ACK/$ERR/$INFO` возвращаются в OpenMCC. + +Подробно: + +- `docs/RADIO_CONTROL_UI.md` — интерфейс RF-настроек; +- `firmware/radio_gateway_esp32/README.md` — прошивка, подключение и протокол шлюза. -До появления прошивки ESP32 кнопка **«Сохранить / передать в шлюз»** всегда сохраняет конфигурацию локально. Если ESP32 подключена, интерфейс дополнительно отправляет команду `$CMD,RADIO,...`; фактическое изменение настроек радиомодуля зависит от будущей прошивки шлюза. +Статус: код прошивки успешно компилируется в GitHub Actions; физические испытания ожидают появления конкретного радиомодуля. ## Архитектура @@ -120,7 +130,7 @@ examples/arduino_avr_demo/OpenMCC_Arduino_AVR/OpenMCC_Arduino_AVR.ino МКА ``` -Пока радиошлюз не реализован, основное USB-соединение можно использовать напрямую с демонстрационной Arduino. +Пока физический радиостенд не собран, основное USB-соединение по-прежнему можно проверять напрямую с демонстрационной Arduino. ## Документация @@ -130,27 +140,29 @@ examples/arduino_avr_demo/OpenMCC_Arduino_AVR/OpenMCC_Arduino_AVR.ino - `docs/RADIO_LINK.md` — план двустороннего радиоканала CC1101 / E32; - `docs/RADIO_CONTROL_UI.md` — подключение и управление радиомодулями из OpenMCC; - `docs/DESKTOP_APP.md` — сборка, установка и публикация Windows-приложения; +- `firmware/radio_gateway_esp32/README.md` — наземный ESP32-радиошлюз; - `CHANGELOG.md` — история заметных изменений. ## Основные модули ```text -index.html интерфейс OpenMCC -css/style.css базовые стили -css/layout.css адаптивная компоновка -css/panels.css оформление панелей -css/widgets.css общие элементы управления -css/radio-controls.css RF-настройки и готовые команды v0.3 -js/parser.js разбор входящих пакетов -js/logger.js журналирование -js/serial.js основное последовательное соединение -js/ui.js команды аппарату -js/charts.js графики Chart.js -js/rotator.js отдельное соединение с повороткой -js/satellite.js 3D-модель 2U CubeSat -js/help.js подсказки, RF UI и готовые команды -desktop/main.cjs Electron-host и Serial для desktop-версии -package.json конфигурация desktop-сборки +index.html интерфейс OpenMCC +css/style.css базовые стили +css/layout.css адаптивная компоновка +css/panels.css оформление панелей +css/widgets.css общие элементы управления +css/radio-controls.css RF-настройки и готовые команды v0.3 +js/parser.js разбор входящих пакетов +js/logger.js журналирование +js/serial.js основное последовательное соединение +js/ui.js команды аппарату +js/charts.js графики Chart.js +js/rotator.js отдельное соединение с повороткой +js/satellite.js 3D-модель 2U CubeSat +js/help.js подсказки, RF UI и готовые команды +desktop/main.cjs Electron-host и Serial для desktop-версии +firmware/radio_gateway_esp32/ прошивка ESP32-WROOM-32 радиошлюза +package.json конфигурация desktop-сборки ``` ## Сборка Windows-установщика для разработчика @@ -164,6 +176,14 @@ npm run dist:win GitHub Actions собирает установщик автоматически. После изменения версии `package.json` и слияния в `main` workflow может создать новый GitHub Release с установщиком соответствующей версии. +## Сборка прошивки ESP32 + +```bash +pio run -d firmware/radio_gateway_esp32 +``` + +Или используйте артефакт workflow **ESP32 radio gateway firmware** на GitHub. + ## Важный принцип проекта -OpenMCC должен оставаться независимым от конкретного радиомодуля. CC1101 и E32-433T30D скрываются за ESP32-WROOM-32, а интерфейс телеметрии и команд остаётся единым. +OpenMCC остаётся независимым от конкретного радиомодуля. CC1101 и E32-433T30D скрываются за ESP32-WROOM-32, а интерфейс телеметрии и команд остаётся единым. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index acd78fa..a2ab29c 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,27 +1,33 @@ # OpenMCC — статус проекта -Обновлено для интерфейса v0.2. +Обновлено после реализации наземного радиошлюза ESP32 v0.1. ## Готово и уже работало | Узел | Статус | Примечание | |---|---|---| -| Базовый веб-интерфейс | ✅ Готово | Локальная работа через `localhost` | -| Web Serial | ✅ Готово | Arduino / STM32 / ESP32 | +| Базовый веб-интерфейс | ✅ Готово | Локальная работа через `localhost` и desktop-приложение | +| Web Serial / desktop Serial | ✅ Готово | Arduino / STM32 / ESP32 | | Парсер телеметрии | ✅ Готово | `$TEL`, JSON, простые `KEY=VALUE` | | Команды | ✅ Готово | `$CMD,...` | | ACK/ERR/INFO | ✅ Готово | Обрабатываются отдельно от телеметрии | | Журнал | ✅ Готово | Локальное журналирование событий | | Chart.js | ✅ Готово | TEMP/VOLT/CURR/RSSI/SNR | -| 3D CubeSat | ✅ Готово | Three.js, OrbitControls | +| 3D CubeSat | ✅ Готово | Three.js, 2U, рулеточная дипольная антенна | | Поворотка — интерфейс | ✅ Готово | AZ/EL, STOP, HOME, PARK, STATUS | | Поворотка — симулятор | ✅ Готово | Проверялся без механики | | Arduino Uno demo | ✅ Готово | Генерирует телеметрию и принимает команды | +| RF-панель OpenMCC | ✅ Готово | CC1101 / E32, подключение и параметры | +| ESP32 radio gateway — исходный код | ✅ Готово | Единая прошивка CC1101/E32 | +| ESP32 radio gateway — CI | ✅ Готово | PlatformIO успешно собирает `.bin` | ## Подготовлено, но не проверено на железе | Узел | Статус | Что требуется | |---|---|---| +| ESP32-WROOM-32 radio gateway | 🟡 Software-ready | Прошить реальную плату и проверить USB-протокол | +| CC1101 driver | 🟡 Software-ready | Подключить фактический модуль и проверить TX/RX/RSSI/LQI | +| E32-433T30D driver | 🟡 Software-ready | Подключить фактический модуль, AUX и питание, проверить TX/RX | | SatNOGS Rotator v3 | 🟡 Ожидает сборки | Собрать механику | | Arduino Uno + CNC Shield | 🟡 Ожидает проверки | Подключить драйверы, концевики, NEMA17 | | Прошивка поворотки | 🟡 Ожидает проверки | Калибровка шагов/градус и направлений | @@ -30,11 +36,9 @@ | Узел | Статус | Приоритет | |---|---|---| -| ESP32-WROOM-32 radio gateway | ⬜ Не сделано | Высокий | -| CC1101 driver | ⬜ Не сделано | После закупки | -| E32-433T30D driver | ⬜ Не сделано | После закупки | -| Радиопакет с CRC/ACK | ⬜ Не сделано | Высокий | +| Встроенная прошивка ESP32 из OpenMCC | ⬜ Следующий этап | Высокий | | Бортовая часть радиоканала | ⬜ Не сделано | Высокий | +| Расширенный пакетный протокол с sequence/ACK/retry | ⬜ Не сделано | После первого RF-стенда | | TLE | ⬜ Не сделано | Средний | | SGP4 | ⬜ Не сделано | Средний | | Карта орбиты | ⬜ Не сделано | Средний | @@ -43,13 +47,8 @@ ## Следующий разумный этап -**ESP32-WROOM-32 → радиошлюз OpenMCC.** +**Прошивка ESP32 непосредственно из OpenMCC.** -После закупки радиомодуля реализовать один из драйверов: +Подготовить встроенный flasher для `ESP32-WROOM-32`, использующий заранее собранные `.bin`, чтобы оператору не требовались Arduino IDE или PlatformIO. После этого достаточно будет подключить ESP32 по USB, открыть раздел прошивки и нажать кнопку запуска. -```text -CC1101 → SPI -E32-433T30D → UART -``` - -Внешний протокол OpenMCC при этом оставить одинаковым. +После появления реального CC1101 или E32 необходимо провести аппаратную проверку соответствующего драйвера. До этого нельзя считать радиотракт проверенным, даже несмотря на успешную CI-компиляцию прошивки. diff --git a/firmware/radio_gateway_esp32/README.md b/firmware/radio_gateway_esp32/README.md new file mode 100644 index 0000000..80806aa --- /dev/null +++ b/firmware/radio_gateway_esp32/README.md @@ -0,0 +1,160 @@ +# OpenMCC Radio Gateway — ESP32-WROOM-32 + +Прошивка наземного радиошлюза OpenMCC для `ESP32-WROOM-32`. + +Версия прошивки: **0.1.0**. + +## Назначение + +ESP32 соединяется с OpenMCC по USB (115200 бод) и выполняет роль единого адаптера радиоканала: + +```text +OpenMCC / ПК + │ USB + ▼ +ESP32-WROOM-32 + │ + ├── SPI → CC1101 + │ или + └── UART → E32-433T30D + │ + ▼ + радиоканал + │ + ▼ + МКА +``` + +Одна и та же прошивка содержит поддержку обоих вариантов. В OpenMCC выбирается фактически установленный модуль. + +> CC1101 и E32 в данной конфигурации предполагаются как альтернативы, а не как одновременно подключённые устройства. В частности, GPIO27 используется как GDO2 у CC1101 и AUX у E32. + +## Подключение CC1101 + +| ESP32-WROOM-32 | CC1101 | Назначение | +|---|---|---| +| 3V3 | VCC | питание 3,3 В | +| GND | GND | общая земля | +| GPIO18 | SCK | SPI clock | +| GPIO23 | SI / MOSI | ESP32 → CC1101 | +| GPIO19 | SO / MISO | CC1101 → ESP32 | +| GPIO21 | CSn | Chip Select | +| GPIO4 | GDO0 | прерывание при приёме пакета | +| GPIO27 | GDO2 | дополнительная линия состояния | + +CC1101 нельзя подключать к 5-вольтовой логике. + +Поддерживаются: + +- частота 387–464 МГц в текущей конфигурации OpenMCC; +- скорость 0.025–600 кбит/с; +- мощность `-30, -20, -15, -10, 0, 5, 7, 10 dBm`; +- 2-FSK, GFSK, 4-FSK и OOK; +- ручной или автоматический выбор полосы RX; +- аппаратный CRC; +- проверка занятости канала (CCA) перед передачей. + +`MSK` в версии 0.1.0 шлюза не реализован. Если интерфейс OpenMCC отправит эту настройку, шлюз вернёт понятную ошибку вместо молчаливого применения неверного режима. + +## Подключение E32-433T30D + +| ESP32-WROOM-32 / питание | E32-433T30D | Назначение | +|---|---|---| +| внешний стабилизированный 5 В ≥ 1 А | VCC | питание радиомодуля | +| GND ESP32 + GND источника | GND | общая земля | +| GPIO17 / TX2 | RXD | ESP32 → E32 | +| GPIO16 / RX2 | TXD | E32 → ESP32 | +| GPIO25 | M0 | режим работы | +| GPIO26 | M1 | режим работы | +| GPIO27 | AUX | готовность модуля | + +Для 1-ваттного E32 предусмотрите отдельный источник питания. Земля источника, ESP32 и радиомодуля должна быть общей. + +Поддерживаются: + +- частота 410–441 МГц; +- мощность 21 / 24 / 27 / 30 dBm; +- воздушная скорость 2.4 / 4.8 / 9.6 / 19.2 кбит/с; +- UART 1200…115200 бод; +- FEC; +- прозрачный и фиксированный режим в конфигурации модуля. + +Передача пользовательских команд в `FIXED` режиме пока блокируется, поскольку OpenMCC ещё не задаёт адрес назначения ADDH/ADDL. Для первого стенда используйте `TRANSPARENT`. + +## Команды USB + +### Проверка самого шлюза + +```text +$CMD,GATEWAY_PING +``` + +Ответ: + +```text +$ACK,GATEWAY_PONG +``` + +### Информация о прошивке + +```text +$CMD,GATEWAY_INFO +``` + +### Настройка CC1101 + +```text +$CMD,RADIO,TYPE=CC1101,FREQ=435.000,POWER=10,RATE=38.4,MOD=GFSK,BW=AUTO,CRC=1,CCA=1 +``` + +### Настройка E32 + +```text +$CMD,RADIO,TYPE=E32,FREQ=433,POWER=30,AIR=2.4,UART=9600,MODE=TRANSPARENT,FEC=1 +``` + +### Запрос текущей конфигурации + +```text +$CMD,RADIO_STATUS +``` + +### Выключение радиодрайвера + +```text +$CMD,RADIO_OFF +``` + +Любая другая строка `$CMD,...` автоматически считается командой для МКА и передаётся в эфир без изменения. Полученные из эфира `$TEL`, `$ACK`, `$ERR` и `$INFO` передаются в OpenMCC также без изменения. + +Для CC1101 после каждого принятого пакета шлюз дополнительно выдаёт локальную строку: + +```text +$TEL,RSSI=-72.5,LQI=34 +``` + +## Сохранение настроек + +Последняя успешно применённая RF-конфигурация сохраняется в NVS ESP32 через `Preferences`. После перезапуска шлюз пытается восстановить выбранный радиомодуль и параметры. + +## Сборка + +Проект использует PlatformIO: + +```bash +pio run -d firmware/radio_gateway_esp32 +``` + +Основной бинарный файл после сборки: + +```text +firmware/radio_gateway_esp32/.pio/build/esp32dev/firmware.bin +``` + +GitHub Actions собирает прошивку автоматически и сохраняет готовые `.bin` как артефакт. + +## Важно перед испытанием + +Эта версия прошивки прошла автоматическую компиляцию только после успешного выполнения CI. Реальная работа с конкретными экземплярами CC1101/E32 должна быть проверена на стенде. До такой проверки статус прошивки — **software-ready / hardware-unverified**. + +При работе в эфире используйте только разрешённые частоты, мощности и режимы в соответствии с местными правилами радиосвязи и вашей лицензией/назначением станции. diff --git a/firmware/radio_gateway_esp32/platformio.ini b/firmware/radio_gateway_esp32/platformio.ini new file mode 100644 index 0000000..cffd424 --- /dev/null +++ b/firmware/radio_gateway_esp32/platformio.ini @@ -0,0 +1,13 @@ +[platformio] +default_envs = esp32dev + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 +lib_deps = + jgromes/RadioLib@7.7.1 +build_flags = + -D CORE_DEBUG_LEVEL=0 diff --git a/firmware/radio_gateway_esp32/src/main.cpp b/firmware/radio_gateway_esp32/src/main.cpp new file mode 100644 index 0000000..909a6f2 --- /dev/null +++ b/firmware/radio_gateway_esp32/src/main.cpp @@ -0,0 +1,851 @@ +#include +#include +#include +#include + +/* + * OpenMCC Radio Gateway + * Firmware version: 0.1.0 + * Target: ESP32-WROOM-32 / generic ESP32 DevKit + * + * USB protocol (115200 baud): + * $CMD,RADIO,TYPE=CC1101,FREQ=435.000,POWER=10,RATE=38.4,MOD=GFSK,BW=AUTO,CRC=1,CCA=1 + * $CMD,RADIO,TYPE=E32,FREQ=433,POWER=30,AIR=2.4,UART=9600,MODE=TRANSPARENT,FEC=1 + * $CMD,RADIO_STATUS + * $CMD,GATEWAY_INFO + * $CMD,GATEWAY_PING + * $CMD,RADIO_OFF + * + * Any other $CMD,... line is treated as a spacecraft command and is + * forwarded over the selected RF module. Incoming RF text is forwarded + * unchanged to USB, so $TEL/$ACK/$ERR/$INFO packets remain compatible + * with the existing OpenMCC parser. + */ + +namespace OpenMCC { + +constexpr char FW_VERSION[] = "0.1.0"; +constexpr uint32_t USB_BAUD = 115200; +constexpr size_t MAX_LINE_LENGTH = 220; +constexpr uint32_t E32_AUX_TIMEOUT_MS = 2500; + +// CC1101 wiring used by OpenMCC v0.3 UI. +constexpr uint8_t CC_SCK = 18; +constexpr uint8_t CC_MISO = 19; +constexpr uint8_t CC_MOSI = 23; +constexpr uint8_t CC_CS = 21; +constexpr uint8_t CC_GDO0 = 4; +constexpr uint8_t CC_GDO2 = 27; + +// E32-433T30D wiring used by OpenMCC v0.3 UI. +constexpr uint8_t E32_RX = 16; // ESP32 RX2 <- E32 TXD +constexpr uint8_t E32_TX = 17; // ESP32 TX2 -> E32 RXD +constexpr uint8_t E32_M0 = 25; +constexpr uint8_t E32_M1 = 26; +constexpr uint8_t E32_AUX = 27; + +enum class RadioType : uint8_t { + None = 0, + CC1101 = 1, + E32 = 2, +}; + +struct CC1101Config { + float frequency = 435.000f; + int8_t power = 10; + float bitRate = 38.4f; + String modulation = "GFSK"; + bool bandwidthAuto = true; + float bandwidth = 58.0f; + bool crc = true; + bool cca = true; +}; + +struct E32Config { + uint16_t frequency = 433; + uint8_t power = 30; + float airRate = 2.4f; + uint32_t uartBaud = 9600; + String mode = "TRANSPARENT"; + bool fec = true; +}; + +Preferences preferences; +HardwareSerial e32Serial(2); +CC1101 cc1101 = new Module(CC_CS, CC_GDO0, RADIOLIB_NC, CC_GDO2); + +RadioType activeType = RadioType::None; +bool radioReady = false; +CC1101Config ccConfig; +E32Config e32Config; +String usbLineBuffer; +String e32LineBuffer; +volatile bool ccPacketReceived = false; + +void IRAM_ATTR onCcPacketReceived() { + ccPacketReceived = true; +} + +void sendAck(const String &payload) { + Serial.print("$ACK,"); + Serial.println(payload); +} + +void sendError(const String &payload) { + Serial.print("$ERR,"); + Serial.println(payload); +} + +void sendInfo(const String &payload) { + Serial.print("$INFO,"); + Serial.println(payload); +} + +String radioTypeName(RadioType type) { + switch (type) { + case RadioType::CC1101: return "CC1101"; + case RadioType::E32: return "E32"; + default: return "NONE"; + } +} + +bool parseParameter(const String &line, const char *key, String &value) { + String marker = String(key) + "="; + int start = line.indexOf(marker); + if (start < 0) return false; + start += marker.length(); + int end = line.indexOf(',', start); + if (end < 0) end = line.length(); + value = line.substring(start, end); + value.trim(); + return value.length() > 0; +} + +bool parseBoolValue(const String &text, bool &value) { + String normalized = text; + normalized.trim(); + normalized.toUpperCase(); + if (normalized == "1" || normalized == "TRUE" || normalized == "ON") { + value = true; + return true; + } + if (normalized == "0" || normalized == "FALSE" || normalized == "OFF") { + value = false; + return true; + } + return false; +} + +bool nearlyEqual(float a, float b) { + return fabsf(a - b) < 0.01f; +} + +bool validCcPower(int value) { + static const int8_t allowed[] = {-30, -20, -15, -10, 0, 5, 7, 10}; + for (int8_t item : allowed) { + if (value == item) return true; + } + return false; +} + +bool validCcBandwidth(float value) { + static const float allowed[] = {58, 68, 81, 102, 116, 135, 162, 203, 232, 270, 325, 406, 464, 541, 650, 812}; + for (float item : allowed) { + if (nearlyEqual(value, item)) return true; + } + return false; +} + +bool validE32Power(int value) { + return value == 21 || value == 24 || value == 27 || value == 30; +} + +bool validE32AirRate(float value) { + return nearlyEqual(value, 2.4f) || nearlyEqual(value, 4.8f) || + nearlyEqual(value, 9.6f) || nearlyEqual(value, 19.2f); +} + +bool validE32Uart(uint32_t baud) { + switch (baud) { + case 1200: + case 2400: + case 4800: + case 9600: + case 19200: + case 38400: + case 57600: + case 115200: + return true; + default: + return false; + } +} + +void persistRadioType() { + preferences.putUChar("type", static_cast(activeType)); +} + +void saveCcConfig() { + preferences.putFloat("ccFreq", ccConfig.frequency); + preferences.putChar("ccPower", ccConfig.power); + preferences.putFloat("ccRate", ccConfig.bitRate); + preferences.putString("ccMod", ccConfig.modulation); + preferences.putBool("ccBwAuto", ccConfig.bandwidthAuto); + preferences.putFloat("ccBw", ccConfig.bandwidth); + preferences.putBool("ccCrc", ccConfig.crc); + preferences.putBool("ccCca", ccConfig.cca); +} + +void saveE32Config() { + preferences.putUShort("e32Freq", e32Config.frequency); + preferences.putUChar("e32Power", e32Config.power); + preferences.putFloat("e32Air", e32Config.airRate); + preferences.putULong("e32Uart", e32Config.uartBaud); + preferences.putString("e32Mode", e32Config.mode); + preferences.putBool("e32Fec", e32Config.fec); +} + +void loadStoredConfig() { + activeType = static_cast(preferences.getUChar("type", 0)); + if (activeType != RadioType::CC1101 && activeType != RadioType::E32) { + activeType = RadioType::None; + } + + ccConfig.frequency = preferences.getFloat("ccFreq", 435.000f); + ccConfig.power = preferences.getChar("ccPower", 10); + ccConfig.bitRate = preferences.getFloat("ccRate", 38.4f); + ccConfig.modulation = preferences.getString("ccMod", "GFSK"); + ccConfig.bandwidthAuto = preferences.getBool("ccBwAuto", true); + ccConfig.bandwidth = preferences.getFloat("ccBw", 58.0f); + ccConfig.crc = preferences.getBool("ccCrc", true); + ccConfig.cca = preferences.getBool("ccCca", true); + + e32Config.frequency = preferences.getUShort("e32Freq", 433); + e32Config.power = preferences.getUChar("e32Power", 30); + e32Config.airRate = preferences.getFloat("e32Air", 2.4f); + e32Config.uartBaud = preferences.getULong("e32Uart", 9600); + e32Config.mode = preferences.getString("e32Mode", "TRANSPARENT"); + e32Config.fec = preferences.getBool("e32Fec", true); +} + +void stopCurrentRadio() { + radioReady = false; + + if (activeType == RadioType::CC1101) { + cc1101.clearPacketReceivedAction(); + cc1101.standby(); + } + + if (activeType == RadioType::E32) { + e32Serial.end(); + pinMode(E32_M0, OUTPUT); + pinMode(E32_M1, OUTPUT); + digitalWrite(E32_M0, LOW); + digitalWrite(E32_M1, LOW); + } +} + +bool initializeCc1101(bool verbose = true) { + stopCurrentRadio(); + activeType = RadioType::CC1101; + + if (ccConfig.frequency < 387.0f || ccConfig.frequency > 464.0f) { + if (verbose) sendError("RADIO_CONFIG,CC1101_FREQ_RANGE=387..464"); + return false; + } + if (!validCcPower(ccConfig.power)) { + if (verbose) sendError("RADIO_CONFIG,CC1101_POWER=-30|-20|-15|-10|0|5|7|10"); + return false; + } + if (ccConfig.bitRate < 0.025f || ccConfig.bitRate > 600.0f) { + if (verbose) sendError("RADIO_CONFIG,CC1101_RATE_RANGE=0.025..600"); + return false; + } + if (!ccConfig.bandwidthAuto && !validCcBandwidth(ccConfig.bandwidth)) { + if (verbose) sendError("RADIO_CONFIG,CC1101_BW_UNSUPPORTED"); + return false; + } + + ccConfig.modulation.toUpperCase(); + if (ccConfig.modulation == "MSK") { + if (verbose) sendError("RADIO_CONFIG,CC1101_MSK_NOT_IMPLEMENTED"); + return false; + } + if (ccConfig.modulation != "2FSK" && ccConfig.modulation != "GFSK" && + ccConfig.modulation != "4FSK" && ccConfig.modulation != "OOK") { + if (verbose) sendError("RADIO_CONFIG,CC1101_MOD=2FSK|GFSK|4FSK|OOK"); + return false; + } + + SPI.begin(CC_SCK, CC_MISO, CC_MOSI, CC_CS); + + const float frequencyDeviation = constrain(ccConfig.bitRate * 0.5f, 5.0f, 380.0f); + const float initialBandwidth = ccConfig.bandwidthAuto ? 812.0f : ccConfig.bandwidth; + int16_t state; + + if (ccConfig.modulation == "4FSK") { + state = cc1101.beginFSK4(ccConfig.frequency, ccConfig.bitRate, frequencyDeviation, + initialBandwidth, ccConfig.power, 16); + } else { + state = cc1101.begin(ccConfig.frequency, ccConfig.bitRate, frequencyDeviation, + initialBandwidth, ccConfig.power, 16); + } + + if (state != RADIOLIB_ERR_NONE) { + if (verbose) sendError("CC1101_INIT,CODE=" + String(state)); + return false; + } + + if (ccConfig.modulation == "OOK") { + state = cc1101.setOOK(true); + } else { + state = cc1101.setOOK(false); + if (state == RADIOLIB_ERR_NONE) { + state = cc1101.setDataShaping( + ccConfig.modulation == "GFSK" ? RADIOLIB_SHAPING_0_5 : RADIOLIB_SHAPING_NONE + ); + } + } + if (state != RADIOLIB_ERR_NONE) { + if (verbose) sendError("CC1101_MODULATION,CODE=" + String(state)); + return false; + } + + if (ccConfig.bandwidthAuto) { + state = cc1101.autoSetRxBandwidth(); + if (state != RADIOLIB_ERR_NONE) { + if (verbose) sendError("CC1101_AUTO_BW,CODE=" + String(state)); + return false; + } + } + + state = cc1101.setCrcFiltering(ccConfig.crc); + if (state != RADIOLIB_ERR_NONE) { + if (verbose) sendError("CC1101_CRC,CODE=" + String(state)); + return false; + } + + ccPacketReceived = false; + cc1101.setPacketReceivedAction(onCcPacketReceived); + state = cc1101.startReceive(); + if (state != RADIOLIB_ERR_NONE) { + if (verbose) sendError("CC1101_RX_START,CODE=" + String(state)); + return false; + } + + radioReady = true; + if (verbose) { + sendInfo("RADIO_READY,TYPE=CC1101,CHIP=" + String(cc1101.getChipVersion())); + } + return true; +} + +bool waitE32Aux(uint32_t timeoutMs = E32_AUX_TIMEOUT_MS) { + const uint32_t start = millis(); + while (digitalRead(E32_AUX) != HIGH) { + if (millis() - start >= timeoutMs) return false; + delay(2); + } + delay(3); + return true; +} + +void setE32Mode(bool programming) { + digitalWrite(E32_M0, programming ? HIGH : LOW); + digitalWrite(E32_M1, programming ? HIGH : LOW); + delay(45); +} + +uint8_t e32UartCode(uint32_t baud) { + switch (baud) { + case 1200: return 0; + case 2400: return 1; + case 4800: return 2; + case 9600: return 3; + case 19200: return 4; + case 38400: return 5; + case 57600: return 6; + case 115200: return 7; + default: return 3; + } +} + +uint8_t e32AirCode(float airRate) { + if (nearlyEqual(airRate, 2.4f)) return 2; + if (nearlyEqual(airRate, 4.8f)) return 3; + if (nearlyEqual(airRate, 9.6f)) return 4; + return 5; // 19.2 kbps +} + +uint8_t e32PowerCode(uint8_t power) { + switch (power) { + case 30: return 0; + case 27: return 1; + case 24: return 2; + default: return 3; // 21 dBm + } +} + +bool configureE32Hardware(uint32_t oldBaud, bool verbose = true) { + if (e32Config.frequency < 410 || e32Config.frequency > 441) { + if (verbose) sendError("RADIO_CONFIG,E32_FREQ_RANGE=410..441"); + return false; + } + if (!validE32Power(e32Config.power)) { + if (verbose) sendError("RADIO_CONFIG,E32_POWER=21|24|27|30"); + return false; + } + if (!validE32AirRate(e32Config.airRate)) { + if (verbose) sendError("RADIO_CONFIG,E32_AIR=2.4|4.8|9.6|19.2"); + return false; + } + if (!validE32Uart(e32Config.uartBaud)) { + if (verbose) sendError("RADIO_CONFIG,E32_UART_UNSUPPORTED"); + return false; + } + + e32Config.mode.toUpperCase(); + if (e32Config.mode != "TRANSPARENT" && e32Config.mode != "FIXED") { + if (verbose) sendError("RADIO_CONFIG,E32_MODE=TRANSPARENT|FIXED"); + return false; + } + + pinMode(E32_M0, OUTPUT); + pinMode(E32_M1, OUTPUT); + pinMode(E32_AUX, INPUT); + digitalWrite(E32_M0, LOW); + digitalWrite(E32_M1, LOW); + + e32Serial.end(); + e32Serial.begin(oldBaud, SERIAL_8N1, E32_RX, E32_TX); + delay(80); + + if (!waitE32Aux()) { + if (verbose) sendError("E32_AUX_TIMEOUT,PHASE=BEFORE_CONFIG"); + return false; + } + + setE32Mode(true); + if (!waitE32Aux()) { + if (verbose) sendError("E32_AUX_TIMEOUT,PHASE=PROGRAM_MODE"); + setE32Mode(false); + return false; + } + + while (e32Serial.available()) e32Serial.read(); + + const uint8_t sped = static_cast((e32UartCode(e32Config.uartBaud) << 3) | + e32AirCode(e32Config.airRate)); + const uint8_t channel = static_cast(e32Config.frequency - 410); + uint8_t option = 0; + if (e32Config.mode == "FIXED") option |= 0x80; + option |= 0x40; // push-pull UART output + if (e32Config.fec) option |= 0x04; + option |= e32PowerCode(e32Config.power); + + const uint8_t frame[6] = { + 0xC0, // save parameters + 0x00, // ADDH + 0x00, // ADDL + sped, + channel, + option + }; + + e32Serial.write(frame, sizeof(frame)); + e32Serial.flush(); + + if (!waitE32Aux()) { + if (verbose) sendError("E32_AUX_TIMEOUT,PHASE=WRITE_CONFIG"); + setE32Mode(false); + return false; + } + + uint8_t response[6] = {0}; + size_t responseLength = 0; + const uint32_t responseStart = millis(); + while (responseLength < sizeof(response) && millis() - responseStart < 500) { + while (e32Serial.available() && responseLength < sizeof(response)) { + response[responseLength++] = static_cast(e32Serial.read()); + } + delay(2); + } + + setE32Mode(false); + e32Serial.end(); + e32Serial.begin(e32Config.uartBaud, SERIAL_8N1, E32_RX, E32_TX); + delay(80); + + if (!waitE32Aux()) { + if (verbose) sendError("E32_AUX_TIMEOUT,PHASE=NORMAL_MODE"); + return false; + } + + if (responseLength != 6 || (response[0] != 0xC0 && response[0] != 0xC1)) { + if (verbose) sendError("E32_CONFIG_NO_VALID_RESPONSE"); + return false; + } + + return true; +} + +bool initializeE32(bool writeConfiguration, uint32_t oldBaud, bool verbose = true) { + stopCurrentRadio(); + activeType = RadioType::E32; + + pinMode(E32_M0, OUTPUT); + pinMode(E32_M1, OUTPUT); + pinMode(E32_AUX, INPUT); + digitalWrite(E32_M0, LOW); + digitalWrite(E32_M1, LOW); + + if (writeConfiguration) { + if (!configureE32Hardware(oldBaud, verbose)) return false; + } else { + e32Serial.begin(e32Config.uartBaud, SERIAL_8N1, E32_RX, E32_TX); + delay(80); + if (!waitE32Aux(800)) { + if (verbose) sendError("E32_AUX_NOT_READY"); + return false; + } + } + + radioReady = true; + if (verbose) sendInfo("RADIO_READY,TYPE=E32"); + return true; +} + +void sendRadioStatus() { + String prefix = "RADIO,TYPE=" + radioTypeName(activeType) + + ",READY=" + String(radioReady ? 1 : 0); + + if (activeType == RadioType::CC1101) { + sendInfo(prefix + + ",FREQ=" + String(ccConfig.frequency, 3) + + ",POWER=" + String(ccConfig.power) + + ",RATE=" + String(ccConfig.bitRate, 1) + + ",MOD=" + ccConfig.modulation + + ",BW=" + (ccConfig.bandwidthAuto ? String("AUTO") : String(ccConfig.bandwidth, 0)) + + ",CRC=" + String(ccConfig.crc ? 1 : 0) + + ",CCA=" + String(ccConfig.cca ? 1 : 0)); + return; + } + + if (activeType == RadioType::E32) { + sendInfo(prefix + + ",FREQ=" + String(e32Config.frequency) + + ",POWER=" + String(e32Config.power) + + ",AIR=" + String(e32Config.airRate, 1) + + ",UART=" + String(e32Config.uartBaud) + + ",MODE=" + e32Config.mode + + ",FEC=" + String(e32Config.fec ? 1 : 0)); + return; + } + + sendInfo(prefix); +} + +bool applyCcCommand(const String &line) { + String value; + CC1101Config candidate = ccConfig; + + if (parseParameter(line, "FREQ", value)) candidate.frequency = value.toFloat(); + if (parseParameter(line, "POWER", value)) candidate.power = static_cast(value.toInt()); + if (parseParameter(line, "RATE", value)) candidate.bitRate = value.toFloat(); + if (parseParameter(line, "MOD", value)) { + value.toUpperCase(); + value.replace("-", ""); + candidate.modulation = value; + } + if (parseParameter(line, "BW", value)) { + value.toUpperCase(); + candidate.bandwidthAuto = value == "AUTO"; + if (!candidate.bandwidthAuto) candidate.bandwidth = value.toFloat(); + } + if (parseParameter(line, "CRC", value)) { + if (!parseBoolValue(value, candidate.crc)) { + sendError("RADIO_CONFIG,CRC=0|1"); + return false; + } + } + if (parseParameter(line, "CCA", value)) { + if (!parseBoolValue(value, candidate.cca)) { + sendError("RADIO_CONFIG,CCA=0|1"); + return false; + } + } + + ccConfig = candidate; + if (!initializeCc1101(true)) return false; + saveCcConfig(); + persistRadioType(); + sendAck("RADIO_CONFIG_APPLIED,TYPE=CC1101"); + sendRadioStatus(); + return true; +} + +bool applyE32Command(const String &line) { + String value; + E32Config candidate = e32Config; + const uint32_t previousUartBaud = e32Config.uartBaud; + + if (parseParameter(line, "FREQ", value)) candidate.frequency = static_cast(value.toInt()); + if (parseParameter(line, "POWER", value)) candidate.power = static_cast(value.toInt()); + if (parseParameter(line, "AIR", value)) candidate.airRate = value.toFloat(); + if (parseParameter(line, "UART", value)) candidate.uartBaud = static_cast(value.toInt()); + if (parseParameter(line, "MODE", value)) { + value.toUpperCase(); + candidate.mode = value; + } + if (parseParameter(line, "FEC", value)) { + if (!parseBoolValue(value, candidate.fec)) { + sendError("RADIO_CONFIG,FEC=0|1"); + return false; + } + } + + e32Config = candidate; + if (!initializeE32(true, previousUartBaud, true)) return false; + saveE32Config(); + persistRadioType(); + sendAck("RADIO_CONFIG_APPLIED,TYPE=E32"); + sendRadioStatus(); + return true; +} + +void processRadioConfigCommand(const String &line) { + String type; + if (!parseParameter(line, "TYPE", type)) { + sendError("RADIO_CONFIG,TYPE_REQUIRED"); + return; + } + type.toUpperCase(); + + if (type == "CC1101") { + applyCcCommand(line); + return; + } + if (type == "E32" || type == "E32-433T30D") { + applyE32Command(line); + return; + } + + sendError("RADIO_CONFIG,TYPE=CC1101|E32"); +} + +bool transmitCc1101(const String &payload) { + if (!radioReady) return false; + + cc1101.standby(); + + if (ccConfig.cca) { + const int16_t scan = cc1101.scanChannel(); + if (scan != RADIOLIB_CHANNEL_FREE) { + cc1101.startReceive(); + sendError("RADIO_BUSY"); + return false; + } + } + + const int16_t state = cc1101.transmit(payload.c_str()); + cc1101.startReceive(); + + if (state != RADIOLIB_ERR_NONE) { + sendError("RADIO_TX,TYPE=CC1101,CODE=" + String(state)); + return false; + } + return true; +} + +bool transmitE32(const String &payload) { + if (!radioReady) return false; + if (e32Config.mode == "FIXED") { + sendError("E32_FIXED_DESTINATION_NOT_CONFIGURED"); + return false; + } + if (!waitE32Aux()) { + sendError("E32_AUX_TIMEOUT,PHASE=BEFORE_TX"); + return false; + } + + e32Serial.print(payload); + e32Serial.print('\n'); + e32Serial.flush(); + + if (!waitE32Aux()) { + sendError("E32_AUX_TIMEOUT,PHASE=AFTER_TX"); + return false; + } + return true; +} + +void forwardToRadio(const String &line) { + if (!radioReady || activeType == RadioType::None) { + sendError("RADIO_NOT_READY"); + return; + } + if (line.length() > MAX_LINE_LENGTH) { + sendError("RADIO_PAYLOAD_TOO_LONG"); + return; + } + + bool ok = false; + if (activeType == RadioType::CC1101) ok = transmitCc1101(line); + if (activeType == RadioType::E32) ok = transmitE32(line); + + if (ok) { + sendAck("RADIO_TX,TYPE=" + radioTypeName(activeType) + ",BYTES=" + String(line.length())); + } +} + +void processUsbLine(String line) { + line.trim(); + if (line.isEmpty()) return; + + String upper = line; + upper.toUpperCase(); + + if (upper == "$CMD,GATEWAY_PING") { + sendAck("GATEWAY_PONG"); + return; + } + + if (upper == "$CMD,GATEWAY_INFO") { + sendInfo("GATEWAY=OPENMCC,DEVICE=ESP32-WROOM-32,FW=" + String(FW_VERSION)); + return; + } + + if (upper == "$CMD,RADIO_STATUS") { + sendRadioStatus(); + return; + } + + if (upper == "$CMD,RADIO_OFF") { + stopCurrentRadio(); + activeType = RadioType::None; + persistRadioType(); + sendAck("RADIO_OFF"); + return; + } + + if (upper.startsWith("$CMD,RADIO,")) { + processRadioConfigCommand(upper); + return; + } + + if (upper.startsWith("$CMD,")) { + forwardToRadio(line); + return; + } + + sendError("UNKNOWN_USB_LINE"); +} + +void pollUsb() { + while (Serial.available()) { + const char c = static_cast(Serial.read()); + if (c == '\r') continue; + if (c == '\n') { + processUsbLine(usbLineBuffer); + usbLineBuffer = ""; + continue; + } + if (usbLineBuffer.length() < MAX_LINE_LENGTH) { + usbLineBuffer += c; + } else { + usbLineBuffer = ""; + sendError("USB_LINE_TOO_LONG"); + } + } +} + +void pollCc1101() { + if (!radioReady || activeType != RadioType::CC1101 || !ccPacketReceived) return; + ccPacketReceived = false; + + String payload; + const int16_t state = cc1101.readData(payload); + const float rssi = cc1101.getRSSI(); + const uint8_t lqi = cc1101.getLQI(); + + if (state == RADIOLIB_ERR_NONE) { + payload.trim(); + if (!payload.isEmpty()) Serial.println(payload); + Serial.print("$TEL,RSSI="); + Serial.print(rssi, 1); + Serial.print(",LQI="); + Serial.println(lqi); + } else if (state == RADIOLIB_ERR_CRC_MISMATCH) { + sendError("RADIO_RX_CRC"); + } else { + sendError("RADIO_RX,TYPE=CC1101,CODE=" + String(state)); + } + + cc1101.startReceive(); +} + +void pollE32() { + if (!radioReady || activeType != RadioType::E32) return; + + while (e32Serial.available()) { + const char c = static_cast(e32Serial.read()); + if (c == '\r') continue; + if (c == '\n') { + e32LineBuffer.trim(); + if (!e32LineBuffer.isEmpty()) Serial.println(e32LineBuffer); + e32LineBuffer = ""; + continue; + } + if (e32LineBuffer.length() < MAX_LINE_LENGTH) { + e32LineBuffer += c; + } else { + e32LineBuffer = ""; + sendError("E32_RX_LINE_TOO_LONG"); + } + } +} + +void restoreRadioAfterBoot() { + if (activeType == RadioType::CC1101) { + if (!initializeCc1101(false)) { + radioReady = false; + sendError("RADIO_RESTORE_FAILED,TYPE=CC1101"); + } + return; + } + + if (activeType == RadioType::E32) { + if (!initializeE32(false, e32Config.uartBaud, false)) { + radioReady = false; + sendError("RADIO_RESTORE_FAILED,TYPE=E32"); + } + } +} + +} // namespace OpenMCC + +void setup() { + using namespace OpenMCC; + + Serial.begin(USB_BAUD); + delay(350); + + usbLineBuffer.reserve(MAX_LINE_LENGTH + 8); + e32LineBuffer.reserve(MAX_LINE_LENGTH + 8); + + preferences.begin("openmcc-rf", false); + loadStoredConfig(); + + sendInfo("GATEWAY=OPENMCC,DEVICE=ESP32-WROOM-32,FW=" + String(FW_VERSION) + ",BOOT=1"); + restoreRadioAfterBoot(); + sendRadioStatus(); +} + +void loop() { + using namespace OpenMCC; + + pollUsb(); + pollCc1101(); + pollE32(); + delay(1); +}