commit b5a8e92e45518c2d0ff0b7f935091f6f64e653b4 Author: Виктор Date: Fri Sep 11 02:39:24 2026 +0900 DMR-данные (SMS) на TYT MD-UV380 под OpenGD77 Восстановлено из транскрипта сессии после потери рабочего дерева на сервере сборки. Что внутри: - четыре новых модуля прошивки (приём, сборка исходящего, меню Messages); - правки существующих файлов в виде скриптов, с порядком применения; - инструменты стенда: снятие экрана рации по USB, дамп кадров, эталонная сборка пакета; - NOTES.md: что проверено в эфире, что не доделано, где грабли. Работало в эфире в августе 2026: данные ходят в обе стороны в режиме хотспота, рация принимает SMS и показывает на экране. Отправка собрана и сверена с эталоном сервера побайтово, но в эфире не проверялась. Claude-Session: https://claude.ai/code/session_01GJtvG5WwqYNe1b7s9LLtgy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cd5496 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.bin +*.elf +*.o diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..9d8cb7e --- /dev/null +++ b/NOTES.md @@ -0,0 +1,160 @@ +# DMR-данные (SMS) на TYT MD-UV380 под OpenGD77 + +Что это: доработка прошивки OpenGD77, которая учит рацию передавать и принимать +пакетные данные DMR — текстовые сообщения. В апстриме этого нет: в официальном +README прямо написано, что отправка и приём SMS не поддерживаются. + +Базовый исходник — официальный релиз **R20260131** (ветка `MDUV380_DM1701`), +скачан с opengd77.com. Первый коммит в этом репозитории — он же, без единого +изменения. Всё, что дальше — наши правки, так что `git diff` первого и второго +коммита показывает ровно их. + +--- + +## Что работает (проверено в эфире) + +**Режим модема (хотспот), обе стороны.** Раньше SMS через рацию-модем молча +пропадали: модем подтверждал кадры хосту, а в эфир ничего не уходило. Теперь +данные проходят насквозь в обе стороны. Проверено с настоящим сервером и вторым +приёмником. + +**Приём SMS на самой рации.** В обычном режиме (не модем) рация принимает +сообщение из эфира, собирает его из кадров, разбирает сетевую обёртку и +показывает на экране: отправитель, текст, кнопка OK. + +**Меню Messages** в главном меню: `New msg`, `Inbox`, `Outbox`; у папок — +список сообщений и `Delete All`. Хранится по 6 сообщений на папку, текст до 64 +символов (ограничение оперативной памяти, см. ниже). + +**Отправка с рации** — код написан, собран, но **в эфире не проверялась**. +Сборка пакета при этом сверена побайтово с эталоном сервера (два разных +сообщения, приватное и групповое, включая контрольные суммы и нарезку на кадры). + +## Чего нет + +- Отправка не проверена в эфире, ввод текста с клавиатуры не написан: + `New msg` шлёт фиксированный текст, кнопка «Ответить» — короткое `OK`. +- Подтверждение доставки (TMS-ACK) не отправляется: отправитель, запросивший + его, покажет «не доставлено», даже если текст на экране появился. +- Контрольные суммы принятых блоков не проверяются. +- Кириллица не поддержана — символы вне ASCII заменяются на `?`. Нужен + кириллический шрифт (в проекте есть образец: отдельный набор для японского). +- Режим `rate 3/4` не декодируется (там иное кодирование). Практическое + следствие: приватные сообщения от AnyTone напрямую не читаются, групповые + `rate 1/2` — читаются. + +--- + +## Как это устроено + +Ключевая находка: **чип HR-C6000 умеет собирать кадры данных сам** — это уже +использовалось прошивкой для пробуждения репитера. Нужно положить 12 байт +полезной нагрузки на страницу регистров `0x02` и записать тип кадра в регистр +`0x50` (значение — тип из стандарта, сдвинутый на 4 бита). Чип добавит +помехоустойчивое кодирование и синхронизацию. + +Приём симметричен: принятые данные читаются с той же страницы `0x02`. + +Новые файлы: + +- `application/source/functions/sms.c` + `include/functions/sms.h` — сборка + входящих сообщений, хранилище Inbox/Outbox, очередь передачи. +- `application/source/functions/smsSend.c` — построение исходящего пакета: + TMS, IP/UDP, контрольная сумма сообщения, заголовок данных, преамбулы. +- `application/source/user_interface/menuMessages.c` — экраны меню. + +Изменённые файлы: + +- `hardware/HR-C6000.c` — захват кадров данных в обоих режимах, передача + данных вместо голоса. +- `functions/hotspot.c` — разбор типа кадра от хоста, отправка данных хосту. +- `applicationMain.c` — разбор принятого и показ на экране. +- `user_interface/menuSystem.{c,h}` — регистрация экранов, пункт меню. +- языковые файлы — добавлена строка `messages` (во все 21). + +--- + +## Сборка + +Проект рассчитан на STM32CubeIDE, Makefile в нём нет — он написан нами: +`MDUV380_firmware/Makefile.opengd77`. Нужен `arm-none-eabi-gcc` (проверено на 14.2). + +``` +cd MDUV380_firmware +tools/codec_cleaner.Linux -C # из каталога application/source/linkerdata +make -f Makefile.opengd77 -j$(nproc) +``` + +Первый шаг обязателен: он создаёт пустую секцию под AMBE-кодек, которого в +исходниках нет. + +**Про кодек.** Официально его «донорит» заводская прошивка TYT. Если её нет, +кодек можно взять из дампа самой рации (она уже работает в DMR): секция лежит +по смещению `0x6937C` от начала образа, длина `0x48BB0`, и в дампе она в +открытом виде. Скрипт слияния — в истории работ, логика тривиальна: заменить +эту область в собранном `.bin` на область из дампа. + +Без кодека прошивка соберётся, но DMR работать не будет (только FM). + +## Прошивка + +``` +sudo python3 MDUV380_firmware/tools/opengd77_stm32_firmware_loader.py \ + -f OpenMDUV380.bin -m MD-UV380 +``` + +Рация должна быть в режиме DFU (`0483:df11`) — вход только кнопками при +включении, программного способа нет. Нужен `python3-usb` и root. + +Калибровка и codeplug при прошивке не страдают: они в отдельной микросхеме +памяти, прошивается только флеш микроконтроллера. Проверено побайтовым +сравнением до и после. + +--- + +## Ловушки, на которые мы потратили время + +**Не то дерево.** Для MD-UV380 нужна ветка `MDUV380_DM1701` (STM32). Ветка +`MK22` — это GD-77/DM-1801/RD-5R, там другая структура и другой код хотспота. +Исходники OpenGD77 не публикуются на GitHub — только zip-архивами на сайте. + +**Линковка кодека.** Современный `ld` отвергает вызов по абсолютному числовому +адресу. Адреса AMBE заменены на символы через `.thumb_set` — машинный код +получается идентичный (проверено дизассемблером). + +**Уведомление на экране не годится для показа.** `uiNotificationShow()` из +главного цикла затирается штатной перерисовкой экрана. Из-за этого несколько +часов казалось, что приём не работает, хотя он работал. Правильно — +`menuSystemPushNewMenu(UI_MESSAGE_BOX)`: экран держится до нажатия кнопки. + +**Правка передачи может сломать голос.** Признак «это кадр данных» ставится +только на пути хотспота, а сбрасывается после отправки. Если передача +оборвалась, признак залипал — и обычная голосовая передача уходила без +заголовка вызова, то есть рация излучала, а приёмники её не понимали. +Вывод: любую правку передающего тракта проверять голосом с самой рации, а не +только в режиме модема. + +**Оперативной памяти мало.** Дважды упирались в переполнение при линковке +(360 и 240 байт). Отсюда ограничения: 6 сообщений на папку, текст 64 символа. +Если понадобится больше — хранить сообщения во флеш-памяти, а не в ОЗУ. + +**Диагностика.** Счётчики сборщика публикуются в хвост звукового буфера +(сигнатура `SMSD`) и читаются по USB штатным протоколом CPS (область 7). Это +надёжнее экрана. Тем же протоколом (область 6) можно снимать содержимое экрана +рации — удобно для отладки. Осторожно: частый опрос подпирает главный цикл +прошивки и мешает приёму. + +**Режим модема.** Рация входит в него автоматически от первого же кадра MMDVM +по USB. Штатно она выходила из него после 20 секунд молчания хоста; по просьбе +владельца это заменено на выход при отсоединении USB-хоста — так режим не +отваливается при живой программе, но рация сама возвращается в норму, когда +кабель вынут. + +--- + +## Лицензия + +Исходный код OpenGD77 распространяется под собственной лицензией авторов +(BSD-подобная, но пункт 4 прямо запрещает коммерческое использование и +включение в проекты, допускающие коммерческое использование). Эти правки — +производная работа и наследуют те же условия. diff --git a/README.md b/README.md new file mode 100644 index 0000000..275a5ec --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# OpenGD77 для TYT MD-UV380: DMR-данные (SMS) + +Доработка прошивки OpenGD77, которая учит рацию передавать и принимать текстовые +сообщения DMR. В апстриме этого нет - в официальном README прямо сказано, что +отправка и приём SMS не поддерживаются. + +Базовый исходник: официальный релиз **R20260131**, ветка `MDUV380_DM1701`, +скачивается с opengd77.com/downloads/releases/MDUV380_DM1701/R20260131/ + +Подробное описание - что сделано, что проверено в эфире, где ловушки - в +[NOTES.md](NOTES.md). Прочитать до начала работы, там собраны грабли, на которые +ушла большая часть времени. + +## Важно про состояние этого репозитория + +Рабочее дерево с уже применёнными правками было утрачено (сервер сборки почистили +через месяц после работы). Здесь лежит восстановленное: новые модули целиком плюс +правки существующих файлов в виде скриптов. Собранной прошивки в репозитории нет, +её нужно получить, применив правки к официальному релизу. + +Код проверялся в эфире в августе 2026 и работал. После восстановления сборка +заново не проверялась - это первое, что стоит сделать. + +## Что где лежит + +``` +firmware/application/source/functions/sms.c новый модуль: приём, разбор, хранение +firmware/application/include/functions/sms.h его заголовок +firmware/application/source/functions/smsSend.c сборка исходящего пакета +firmware/application/source/user_interface/menuMessages.c экраны меню Messages +patches/ правки существующих файлов прошивки +tools/ вспомогательные инструменты для стенда +docs/memory/ рабочие заметки по ходу разработки +``` + +## Как применить к чистому релизу + +Распаковать официальный релиз, положить четыре новых файла по указанным путям, +затем прогнать скрипты из `patches/` по порядку. Каждый скрипт содержит описание +того, что и зачем меняет, и проверяет, что правка ещё не применена. + +Порядок важен, часть правок опирается на предыдущие: + +1. `00-inline-edits.md` - правки, сделанные командами (передача данных в режиме + хотспота, приём данных с чипа, типы кадров, слияние кодека). Это не скрипт, + а список команд с пояснениями, применять вручную по порядку. +2. `patch_rx1.py`, `patch_rx2.py`, `patch_rx3.py` - приём данных в режиме хотспота + и отдача их управляющей программе. +3. `patch_txclean.py` - убрать голосовые заголовки перед кадрами данных. +4. `patch_notimeout.py` - не выходить из режима модема по молчанию хоста. +5. `patch_txfix.py` - **обязательно**, иначе ломается обычная голосовая передача. + Подробности в NOTES.md, раздел про правку передачи. +6. `patch_sms1.py` - захват кадров данных в обычном режиме рации. +7. `patch_sms2.py` - запуск сборщика из главного цикла. +8. `patch_store.py` - хранилище Inbox/Outbox. +9. `patch_show.py` - показ принятого сообщения на экране (заменяет `patch_sms3.py`, + который использовал ненадёжный способ вывода). +10. `patch_menufix.py` - меню Messages целиком (переписывает `menuMessages.c` + на штатную навигацию, исправляет номер языковой строки). +11. `patch_tx.py` - очередь передачи и отправка данных в обычном режиме. +12. `patch_ui_send.py` - пункт `New msg` и кнопка «Ответить». +13. `patch_usbexit.py` - выход из режима модема при отсоединении USB. + +Отладочные, применять по необходимости: `patch_diag.py` (счётчики на экране, +мешают показу сообщений), `patch_diag4.py` (счётчики в память, читаются по USB - +этот вариант надёжнее). + +Сборка и прошивка описаны в NOTES.md. + +## Инструменты + +`tools/screen_grab.py` снимает экран рации по USB и сохраняет PNG. Удобно, когда +нужно увидеть, что показала рация, а рядом никого нет. Осторожно с частотой +опроса: частые запросы подпирают главный цикл прошивки и мешают приёму. + +`tools/dmr_dump.py` слушает порт рации в режиме модема и печатает каждый принятый +кадр целиком, в шестнадцатеричном виде. Нужен, когда надо понять, что реально +пришло из эфира, а не что показал разбор. + +`tools/sms_build_test.py` - эталонная сборка исходящего пакета на Python. Ею +проверялась реализация на C: оба варианта дают байт в байт то же, что сервер. +Если будете менять формат, сверяйтесь этим скриптом. + +## Лицензия + +Исходный код OpenGD77 распространяется под лицензией авторов проекта: BSD-подобная, +но пункт 4 прямо запрещает коммерческое использование и включение в проекты, которые +такое использование допускают. Эти правки - производная работа с теми же условиями. diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md new file mode 100644 index 0000000..2c1e86b --- /dev/null +++ b/docs/memory/MEMORY.md @@ -0,0 +1,3 @@ +- [Где лежат исходники OpenGD77](opengd77-sources-location.md) — не на GitHub, а zip'ами на сайте; для UV380 нужна ветка MDUV380_DM1701, не MK22. +- [Почему хотспот не шлёт SMS](uv390-dmr-data-path.md) — диагноз в hotspot.c/HR-C6000.c и готовый рабочий образец передачи data-burst. +- [Стенд для UV390](uv390-test-bench.md) — .200 сборка, .84 рация-модем + airtest, .85 эталонный приёмник. diff --git a/docs/memory/opengd77-sources-location.md b/docs/memory/opengd77-sources-location.md new file mode 100644 index 0000000..8b8eaf7 --- /dev/null +++ b/docs/memory/opengd77-sources-location.md @@ -0,0 +1,25 @@ +--- +name: opengd77-sources-location +description: Где лежат исходники OpenGD77 и почему их нет на GitHub; какая ветка нужна для MD-UV380 +metadata: + type: reference +--- + +Исходники OpenGD77 публикуются ZIP-архивами на https://www.opengd77.com/downloads/releases/ , а НЕ на GitHub — авторы намеренно не выкладывают актуальный код в git (форки на GitHub вроде open-ham/OpenGD77 — устаревшие слепки). + +Семейства раций разделены по каталогам: +- `MK22/` — NXP MK22: GD-77, DM-1801, RD-5R. MD-UV380 там НЕТ. +- `MDUV380_DM1701/` — STM32F405: **TYT MD-UV380/UV390**, DM-1701, RT-84. Это нужная ветка. + +Релизы MDUV380_DM1701 и их GitID (зашит в .bin, виден в строке версии hotspot): +- R20231231 — OpenGD77_HS v0.1.17, GitID #140939c +- R20240908 — v0.1.18, #9947546 +- R20260131 — v0.1.18, #5797626 (самый свежий) + +`GITVERSION` в сборке = `git rev-parse --short HEAD` дерева сборщика, поэтому чужой GitID (напр. #2dc33f6) в публичных репозиториях не ищется — это личный билд. + +Лицензия — НЕ GPLv2. BSD-подобная, но пункт 4 прямо запрещает коммерческое использование и включение в проекты, допускающие коммерческое использование. Для личных раций ограничений нет. + +Особенность сборки: исходник не содержит AMBE-кодек (мержится загрузчиком из официальной прошивки). Перед сборкой обязателен шаг `prepare` → `MDUV380_firmware/tools/codec_cleaner.Linux -C` → создаёт `application/source/linkerdata/codec_bin_section_1.bin`. Скрипт `prepare` идёт с CRLF-переносами и под Linux падает — вызывать codec_cleaner напрямую. + +Связано: [[uv390-dmr-data-path]] diff --git a/docs/memory/uv390-dmr-data-path.md b/docs/memory/uv390-dmr-data-path.md new file mode 100644 index 0000000..30ab26c --- /dev/null +++ b/docs/memory/uv390-dmr-data-path.md @@ -0,0 +1,31 @@ +--- +name: uv390-dmr-data-path +description: Почему OpenGD77 в режиме хотспота не передаёт SMS/данные и где в коде это чинится +metadata: + type: project +--- + +Задача (с августа 2026): научить OpenGD77 на MD-UV380 в режиме хотспота проводить пакетные ДАННЫЕ (SMS, координаты), а не только голос. Рация досталась Виктору уже с прошивкой OpenGD77_HS v0.1.18 — он её не собирал. + +Диагноз (дерево R20260131, `MDUV380_firmware/application/source/`): +- `functions/hotspot.c:2071` `hotspotModeReceiveNetFrame` декодирует кадр ТОЛЬКО как голосовой LC. Всё нераспознанное (включая SMS) уходит в `else` → `storeNetFrame` (2026) → копируется в аудиобуфер как звук. Отсюда симптом «модем ACK'ает кадры, в эфире ничего». +- В `hotspot.h` определены только `DT_VOICE_LC_HEADER` и `DT_TERMINATOR_WITH_LC` — типов данных нет как класса. В старой MK22-ветке был закомментированный switch по DT_*, в актуальной STM32-ветке он удалён совсем. +- На приёме `hrc6000SysReceivedDataInt` (HR-C6000.c:939) классифицирует кадр по `reg_0x51` (`rxSyncClass` 2=data, `rxDataType`), но payload данных с чипа не читает вообще (для голоса читает 27 байт AMBE с page 0x03). + +Ключ к решению — механизм передачи данных В ПРОШИВКЕ УЖЕ РАБОТАЕТ: +`HR-C6000.c:1889` `DMR_STATE_REPEATER_WAKE_1` шлёт настоящий CSBK-burst: +``` +SPI0WritePageRegByteArray(0x02, 0x00, payload, LC_DATA_LENGTH); // 12 байт на page 0x02 +SPI0WritePageRegByte(0x04, 0x50, 0x30); // reg 0x50 = селектор типа burst'а (DT<<4), 0x30 = CSBK +SPI0WritePageRegByte(0x04, 0x41, 0x80); // передать в следующем слоте +``` +Другие типы: Voice LC Header 0x10, Terminator 0x20, Data Header 0x60, rate 1/2 0x70, rate 3/4 0x90. +Размерность сходится: CSBK, Data Header и rate 1/2 — все 96 бит = 12 байт = `LC_DATA_LENGTH`. Чип сам накладывает CRC/FEC. + +Мост к MMDVM тоже уже есть: `BPTCencode`/`BPTCdecode` в `hotspot.c` (545/551). MMDVMHost шлёт полный 33-байтный burst, чипу нужны 12 байт: +- TX: burst хоста → BPTCdecode → 12 байт → page 0x02 + reg 0x50 +- RX: 12 байт с чипа → BPTCencode → + slot type + sync → `MMDVM_DMR_DATA2` хосту + +Главный нерешённый вопрос: с какой страницы/регистра C6000 читать ПРИНЯТЫЙ data-payload. + +Связано: [[opengd77-sources-location]], [[uv390-test-bench]] diff --git a/docs/memory/uv390-test-bench.md b/docs/memory/uv390-test-bench.md new file mode 100644 index 0000000..93bfa20 --- /dev/null +++ b/docs/memory/uv390-test-bench.md @@ -0,0 +1,22 @@ +--- +name: uv390-test-bench +description: Машины и инструменты стенда для проверки прошивки UV390 (доступы, роли) +metadata: + type: project +--- + +Стенд для работы над DMR-данными на MD-UV380. Вход везде viktor / Adelina@2015 (ssh). + +- **10.0.0.200** (ubuntu-server, x86_64) — машина сборки. Тулчейн `arm-none-eabi-gcc` 14.2.1 + objcopy/size/make. Исходники релизов в `~/ogd77rel/` (скачаны официальные zip), рабочее дерево `~/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130`. Устаревший клон MK22-ветки — `~/OpenGD77` (НЕ то дерево, см. [[opengd77-sources-location]]). +- **10.0.0.84** (pi5, aarch64) — к нему по USB подключена рация-модем (`/dev/ttyACM0`, USB id `1fc9:0094 OpenMDUV380`). Здесь же `~/airtest` — инструмент на Go: шлёт в модем голос и SMS-передачи, печатает ACK/NAK модема. Флаги: `--port --dst --src --cc --slot --rx --text --bursts --secs`, команды `sms`/`voice`/`listen`. Прошивать рацию можно прямо отсюда. +- **10.0.0.85** (radiostar-hotspot, armv7l) — эталонный приёмник MMDVM_HS Dual с прошивкой RadioStar-HS: журналит каждый принятый кадр с типом. Панель на порту 8080 (снаружи может быть закрыт). + +Условия эфирных опытов: DMO симплекс, 433.525 МГц, CC1, слот 2. + +Прошивка рации: `MDUV380_firmware/tools/opengd77_stm32_firmware_loader.py` — работает через DFU и умеет ТОЛЬКО запись, чтения/бэкапа прошивки нет. Откат обеспечен официальным образом `OpenMDUV380.bin` из релиза. `python_tools.zip` с сайта содержит утилиты только для GD-77 (MK22), для STM32 они не подходят. + +Виктор разрешил шить тестовые сборки свободно, но требует делать бэкап. + +Соседние сессии по этому проекту: **DMRhub** (стенд и измерения), **nextdmr-81** (прошивка RadioStar-HS Dual — эталон поведения). + +Связано: [[uv390-dmr-data-path]] diff --git a/firmware/application/include/functions/sms.h b/firmware/application/include/functions/sms.h new file mode 100644 index 0000000..ce363c3 --- /dev/null +++ b/firmware/application/include/functions/sms.h @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 OpenGD77 contributors + * + * Receiving DMR packet data (SMS) while the radio is used as a radio, rather than + * as a hotspot modem. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation, + * incorporation in a commercial product or incorporation into a product or project which allows commercial use. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +#ifndef _OPENGD77_SMS_H_ +#define _OPENGD77_SMS_H_ + +#include +#include + +// A single received message, as shown to the user. +#define SMS_MAX_TEXT_LENGTH 160 +// Longest message we are willing to assemble. Each rate 1/2 block carries 10 bytes of +// payload once the confirmed-mode overhead is removed, so this covers a full 160 char +// message with room for the IP/UDP/TMS wrapper around it. +#define SMS_MAX_BLOCKS 32 + +typedef struct +{ + uint32_t srcId; + uint32_t dstId; + bool isPrivate; + bool unread; + char text[SMS_MAX_TEXT_LENGTH + 1]; +} smsMessage_t; + +/* + * Called from the HR-C6000 interrupt for every received data burst while the radio is + * NOT acting as a hotspot modem. Must stay short: it only copies the burst away and + * flags it, all parsing happens later in smsTick(). + * + * payload - the 12 byte (96 bit) burst payload as read from the chip + * dataType - DMR data type of the burst (DT_CSBK / DT_DATA_HEADER / DT_RATE_12_DATA) + */ +void smsRxHandleBurst(const uint8_t *payload, uint8_t dataType); + +/* + * Runs from normal task context. Turns collected bursts into a message and, when one + * completes, stores it and raises smsHasUnread(). + */ +void smsTick(void); + +void smsInit(void); + +// Most recently received message, or NULL when nothing has been received yet. +const smsMessage_t *smsGetLatest(void); + +bool smsHasUnread(void); +void smsMarkRead(void); + +#endif diff --git a/firmware/application/source/functions/sms.c b/firmware/application/source/functions/sms.c new file mode 100644 index 0000000..3842c25 --- /dev/null +++ b/firmware/application/source/functions/sms.c @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2026 OpenGD77 contributors + * + * Assembling received DMR packet data (SMS) while the radio is used as a radio. + * + * Wire format (confirmed against a working reference implementation): + * + * Data header (12 bytes) + * [0] bit7 G/I (0 = private), bit6 A (response requested), bits3..0 DPF + * DPF 2 = unconfirmed rate 1/2, DPF 3 = confirmed rate 1/2 + * [1] bits7..4 SAP (4 = IP/UDP data), bits3..0 number of pad octets + * [2..4] destination id, big endian + * [5..7] source id, big endian + * [8] bit7 full message flag, bits6..0 blocks to follow + * [9] header class/type + * [10..11] header CRC-16 + * + * Blocks (12 bytes each) + * confirmed: [0..1] DBSN + CRC-9, [2..11] ten payload bytes + * unconfirmed: all twelve bytes are payload + * + * Reassembled payload: IPv4 (20) + UDP (8) + TMS. The text sits right behind the + * TMS marker 04 0D 00 0A 00 and is UTF-16 LITTLE endian, terminated by 0x0000. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation, + * incorporation in a commercial product or incorporation into a product or project which allows commercial use. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include "functions/sms.h" +#include "functions/hotspot.h" // DT_* data type values +#include "functions/ticks.h" + +#define SMS_BLOCK_LENGTH 12U +#define SMS_DPF_UNCONFIRMED 2U +#define SMS_DPF_CONFIRMED 3U +// Bursts arrive every 60 ms; this is generously above any realistic gap between them. +#define SMS_ASSEMBLY_TIMEOUT_MS 3000U + +// Marks the start of the text inside the TMS payload. +static const uint8_t TMS_TEXT_MARKER[] = { 0x04, 0x0D, 0x00, 0x0A, 0x00 }; + +typedef enum +{ + SMS_RX_IDLE = 0, + SMS_RX_COLLECTING +} smsRxState_t; + +// Filled from the interrupt, drained by smsTick(). +static volatile uint8_t rxQueue[SMS_MAX_BLOCKS][SMS_BLOCK_LENGTH]; +static volatile uint8_t rxQueueType[SMS_MAX_BLOCKS]; +static volatile uint8_t rxQueueWrite = 0; +static volatile uint8_t rxQueueRead = 0; + +static smsRxState_t rxState = SMS_RX_IDLE; +static uint8_t header[SMS_BLOCK_LENGTH]; +static uint8_t blocks[SMS_MAX_BLOCKS][SMS_BLOCK_LENGTH]; +static uint8_t blocksExpected = 0; +static uint8_t blocksCollected = 0; +static uint32_t assemblyStartedAt = 0; + +static smsMessage_t latest; +static bool haveMessage = false; +static bool unread = false; + + +void smsInit(void) +{ + rxQueueWrite = rxQueueRead = 0; + rxState = SMS_RX_IDLE; + blocksExpected = blocksCollected = 0; + haveMessage = false; + unread = false; + memset(&latest, 0, sizeof(latest)); +} + +void smsRxHandleBurst(const uint8_t *payload, uint8_t dataType) +{ + uint8_t next = (uint8_t)((rxQueueWrite + 1) % SMS_MAX_BLOCKS); + + if (next == rxQueueRead) + { + return;// queue full: drop, the partial message will time out + } + + memcpy((uint8_t *)rxQueue[rxQueueWrite], payload, SMS_BLOCK_LENGTH); + rxQueueType[rxQueueWrite] = dataType; + rxQueueWrite = next; +} + +static void abortAssembly(void) +{ + rxState = SMS_RX_IDLE; + blocksExpected = blocksCollected = 0; +} + +static void startAssembly(const uint8_t *hdr) +{ + uint8_t dpf = (uint8_t)(hdr[0] & 0x0FU); + + if ((dpf != SMS_DPF_CONFIRMED) && (dpf != SMS_DPF_UNCONFIRMED)) + { + abortAssembly();// not a packet data message we can assemble + return; + } + + memcpy(header, hdr, SMS_BLOCK_LENGTH); + blocksExpected = (uint8_t)(hdr[8] & 0x7FU); + blocksCollected = 0; + assemblyStartedAt = ticksGetMillis(); + + if ((blocksExpected == 0) || (blocksExpected > SMS_MAX_BLOCKS)) + { + abortAssembly(); + return; + } + + rxState = SMS_RX_COLLECTING; +} + +static void finishAssembly(void) +{ + uint8_t data[SMS_MAX_BLOCKS * SMS_BLOCK_LENGTH]; + uint16_t length = 0; + uint8_t dpf = (uint8_t)(header[0] & 0x0FU); + // Confirmed blocks carry two bytes of DBSN + CRC-9 before the payload. + uint8_t skip = (dpf == SMS_DPF_CONFIRMED) ? 2U : 0U; + int textStart = -1; + + for (uint8_t i = 0; i < blocksCollected; i++) + { + memcpy(&data[length], &blocks[i][skip], (SMS_BLOCK_LENGTH - skip)); + length = (uint16_t)(length + (SMS_BLOCK_LENGTH - skip)); + } + + // Locate the text rather than counting header bytes: the IP/UDP wrapper varies, + // the marker does not. Take the last match, so a marker-like byte pattern earlier + // in the wrapper cannot fool us. + for (int i = 0; (i + (int)sizeof(TMS_TEXT_MARKER)) <= (int)length; i++) + { + if (memcmp(&data[i], TMS_TEXT_MARKER, sizeof(TMS_TEXT_MARKER)) == 0) + { + textStart = i + (int)sizeof(TMS_TEXT_MARKER); + } + } + + if (textStart < 0) + { + abortAssembly();// not a text message (could be GPS or another TMS payload) + return; + } + + uint16_t t = 0; + for (int i = textStart; ((i + 1) < (int)length) && (t < SMS_MAX_TEXT_LENGTH); i += 2) + { + uint16_t ch = (uint16_t)(data[i] | (data[i + 1] << 8));// UTF-16 little endian + + if (ch == 0) + { + break; + } + + // Latin only for now: anything outside ASCII needs the Cyrillic font work. + latest.text[t++] = (ch < 0x80U) ? (char)ch : '?'; + } + latest.text[t] = '\0'; + + latest.dstId = (uint32_t)((header[2] << 16) | (header[3] << 8) | header[4]); + latest.srcId = (uint32_t)((header[5] << 16) | (header[6] << 8) | header[7]); + latest.isPrivate = ((header[0] & 0x80U) == 0); + latest.unread = true; + + haveMessage = (t > 0); + unread = haveMessage; + + abortAssembly(); +} + +void smsTick(void) +{ + while (rxQueueRead != rxQueueWrite) + { + uint8_t burst[SMS_BLOCK_LENGTH]; + uint8_t type = rxQueueType[rxQueueRead]; + + memcpy(burst, (uint8_t *)rxQueue[rxQueueRead], SMS_BLOCK_LENGTH); + rxQueueRead = (uint8_t)((rxQueueRead + 1) % SMS_MAX_BLOCKS); + + switch (type) + { + case DT_DATA_HEADER: + startAssembly(burst); + break; + + case DT_RATE_12_DATA: + if (rxState == SMS_RX_COLLECTING) + { + if (blocksCollected < SMS_MAX_BLOCKS) + { + memcpy(blocks[blocksCollected], burst, SMS_BLOCK_LENGTH); + blocksCollected++; + } + + if (blocksCollected >= blocksExpected) + { + finishAssembly(); + } + } + break; + + default: + break;// CSBK preambles and everything else + } + } + + if ((rxState == SMS_RX_COLLECTING) && + ((ticksGetMillis() - assemblyStartedAt) > SMS_ASSEMBLY_TIMEOUT_MS)) + { + abortAssembly();// sender stopped mid message, or bursts were lost + } +} + +const smsMessage_t *smsGetLatest(void) +{ + return (haveMessage ? &latest : NULL); +} + +bool smsHasUnread(void) +{ + return unread; +} + +void smsMarkRead(void) +{ + unread = false; + latest.unread = false; +} diff --git a/firmware/application/source/functions/smsSend.c b/firmware/application/source/functions/smsSend.c new file mode 100644 index 0000000..0b9dc08 --- /dev/null +++ b/firmware/application/source/functions/smsSend.c @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2026 OpenGD77 contributors + * + * Building an outgoing DMR text message: TMS payload, IP/UDP wrapper, message CRC, + * data header and the rate 1/2 blocks that carry it. + * + * This is a literal translation of a reference implementation that was verified on a PC + * against a known-good packet produced by the server side, byte for byte (including both + * IP/UDP checksums and the message CRC-32). + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation, + * incorporation in a commercial product or incorporation into a product or project which allows commercial use. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include "functions/sms.h" + +#define TMS_HEADER_OVERHEAD 8U // flag + 0x00 + seq + the five marker bytes +#define IP_HEADER_LENGTH 20U +#define UDP_HEADER_LENGTH 8U +#define TMS_PORT 0x0FA7U + +static const uint8_t TMS_MARKER[5] = { 0x04, 0x0D, 0x00, 0x0A, 0x00 }; + + +/* + * ETSI / MOTOTRBO message CRC-32: bytes are swapped within each 16 bit pair, then an + * MSB-first CRC32 (poly 0x04C11DB7, init 0, no final xor) is run over the result. + */ +static uint32_t smsMessageCRC32(const uint8_t *data, uint16_t length) +{ + uint32_t crc = 0; + + for (uint16_t i = 0; i < length; i++) + { + // consume the bytes in swapped-pair order without copying the buffer + uint16_t idx = ((i & 1U) ? (i - 1U) : (i + 1U)); + uint8_t b = ((idx < length) ? data[idx] : 0x00); + + crc ^= ((uint32_t)b << 24); + + for (uint8_t k = 0; k < 8; k++) + { + crc = ((crc & 0x80000000U) ? ((crc << 1) ^ 0x04C11DB7U) : (crc << 1)); + } + } + + return crc; +} + +static uint16_t onesComplementSum(const uint8_t *data, uint16_t length, uint32_t initial) +{ + uint32_t sum = initial; + + for (uint16_t i = 0; (i + 1U) < length; i += 2U) + { + sum += (uint32_t)((data[i] << 8) | data[i + 1U]); + } + + if (length & 1U) + { + sum += (uint32_t)(data[length - 1U] << 8); + } + + while (sum >> 16) + { + sum = (sum & 0xFFFFU) + (sum >> 16); + } + + return (uint16_t)(~sum); +} + +static uint16_t smsCRC16CCITT(const uint8_t *data, uint16_t length) +{ + uint16_t crc = 0; + + for (uint16_t i = 0; i < length; i++) + { + crc ^= ((uint16_t)data[i] << 8); + + for (uint8_t k = 0; k < 8; k++) + { + crc = ((crc & 0x8000U) ? ((crc << 1) ^ 0x1021U) : (crc << 1)); + } + } + + return crc; +} + + +uint16_t smsBuildPacket(uint8_t *out, uint16_t outSize, uint32_t srcId, uint32_t dstId, + bool isGroup, const char *text, uint8_t flag, uint8_t seq, uint8_t *padOut) +{ + uint16_t textLen = (uint16_t)strlen(text); + uint16_t tmsLen = (uint16_t)(TMS_HEADER_OVERHEAD + (2U * textLen)); + uint16_t udpLen = (uint16_t)(UDP_HEADER_LENGTH + 2U + tmsLen);// 2 = the TMS length field + uint16_t ipLen = (uint16_t)(IP_HEADER_LENGTH + udpLen); + uint32_t srcIp = (0x0CU << 24) | (srcId & 0x00FFFFFFU); + uint32_t dstIp = (((uint32_t)(isGroup ? 0xE1U : 0x0CU)) << 24) | (dstId & 0x00FFFFFFU); + uint16_t pos = 0; + + if ((uint16_t)(ipLen + 12U + 4U) > outSize) + { + return 0;// would not fit + } + + // ---- IP header (checksum filled in afterwards) ---- + out[pos++] = 0x45; + out[pos++] = 0x00; + out[pos++] = (uint8_t)(ipLen >> 8); + out[pos++] = (uint8_t)(ipLen & 0xFF); + out[pos++] = 0x00; + out[pos++] = 0x01; // identification + out[pos++] = 0x00; + out[pos++] = 0x00; + out[pos++] = (isGroup ? 0x01 : 0x40); // TTL + out[pos++] = 0x11; // UDP + out[pos++] = 0x00; // checksum placeholder + out[pos++] = 0x00; + out[pos++] = (uint8_t)(srcIp >> 24); + out[pos++] = (uint8_t)(srcIp >> 16); + out[pos++] = (uint8_t)(srcIp >> 8); + out[pos++] = (uint8_t)(srcIp); + out[pos++] = (uint8_t)(dstIp >> 24); + out[pos++] = (uint8_t)(dstIp >> 16); + out[pos++] = (uint8_t)(dstIp >> 8); + out[pos++] = (uint8_t)(dstIp); + + uint16_t ipChecksum = onesComplementSum(out, IP_HEADER_LENGTH, 0); + out[10] = (uint8_t)(ipChecksum >> 8); + out[11] = (uint8_t)(ipChecksum & 0xFF); + + // ---- UDP header + payload ---- + uint16_t udpStart = pos; + + out[pos++] = (uint8_t)(TMS_PORT >> 8); + out[pos++] = (uint8_t)(TMS_PORT & 0xFF); + out[pos++] = (uint8_t)(TMS_PORT >> 8); + out[pos++] = (uint8_t)(TMS_PORT & 0xFF); + out[pos++] = (uint8_t)(udpLen >> 8); + out[pos++] = (uint8_t)(udpLen & 0xFF); + out[pos++] = 0x00; // checksum placeholder + out[pos++] = 0x00; + + out[pos++] = (uint8_t)(tmsLen >> 8); + out[pos++] = (uint8_t)(tmsLen & 0xFF); + out[pos++] = flag; + out[pos++] = 0x00; + out[pos++] = seq; + memcpy(&out[pos], TMS_MARKER, sizeof(TMS_MARKER)); + pos += sizeof(TMS_MARKER); + + for (uint16_t i = 0; i < textLen; i++) + { + out[pos++] = (uint8_t)text[i]; // UTF-16 little endian, ASCII only for now + out[pos++] = 0x00; + } + + // UDP checksum covers a pseudo header plus the whole UDP segment + { + uint8_t pseudo[12]; + + pseudo[0] = (uint8_t)(srcIp >> 24); pseudo[1] = (uint8_t)(srcIp >> 16); + pseudo[2] = (uint8_t)(srcIp >> 8); pseudo[3] = (uint8_t)(srcIp); + pseudo[4] = (uint8_t)(dstIp >> 24); pseudo[5] = (uint8_t)(dstIp >> 16); + pseudo[6] = (uint8_t)(dstIp >> 8); pseudo[7] = (uint8_t)(dstIp); + pseudo[8] = 0x00; pseudo[9] = 0x11; + pseudo[10] = (uint8_t)(udpLen >> 8); pseudo[11] = (uint8_t)(udpLen & 0xFF); + + uint32_t sum = 0; + + for (uint8_t i = 0; i < sizeof(pseudo); i += 2U) + { + sum += (uint32_t)((pseudo[i] << 8) | pseudo[i + 1U]); + } + + uint16_t udpChecksum = onesComplementSum(&out[udpStart], udpLen, sum); + out[udpStart + 6U] = (uint8_t)(udpChecksum >> 8); + out[udpStart + 7U] = (uint8_t)(udpChecksum & 0xFF); + } + + // ---- pad to a multiple of 12, then append the message CRC as its own tail ---- + uint8_t pad = (uint8_t)((12U - (pos % 12U)) % 12U); + + for (uint8_t i = 0; i < pad; i++) + { + out[pos++] = 0x00; + } + + uint32_t crc = smsMessageCRC32(out, pos); + + out[pos++] = (uint8_t)(crc & 0xFF); // little endian + out[pos++] = (uint8_t)(crc >> 8); + out[pos++] = (uint8_t)(crc >> 16); + out[pos++] = (uint8_t)(crc >> 24); + + if (padOut != NULL) + { + *padOut = pad; + } + + return pos; +} + +void smsBuildDataHeader(uint8_t *header, uint32_t srcId, uint32_t dstId, bool isGroup, + uint8_t blocks, uint8_t pad) +{ + memset(header, 0, 12); + + header[0] = (uint8_t)((isGroup ? 0x80U : 0x00U) | 0x02U);// DPF = 2, unconfirmed + header[1] = (uint8_t)(0x40U | (pad & 0x0FU)); // SAP = 4 (IP based data) + header[2] = (uint8_t)(dstId >> 16); + header[3] = (uint8_t)(dstId >> 8); + header[4] = (uint8_t)(dstId); + header[5] = (uint8_t)(srcId >> 16); + header[6] = (uint8_t)(srcId >> 8); + header[7] = (uint8_t)(srcId); + header[8] = (uint8_t)(0x80U | (blocks & 0x7FU)); + header[9] = 0x10; + + uint16_t crc = (uint16_t)(smsCRC16CCITT(header, 10) ^ 0xCCCCU); + + header[10] = (uint8_t)(crc >> 8); + header[11] = (uint8_t)(crc & 0xFF); +} + +void smsBuildPreamble(uint8_t *preamble, uint32_t srcId, uint32_t dstId, bool isGroup, + uint8_t countdown) +{ + memset(preamble, 0, 12); + + preamble[0] = 0xBD; // Preamble CSBK, LB set + preamble[1] = 0x00; + preamble[2] = (uint8_t)(isGroup ? 0x80U : 0x00U); // data content preamble + preamble[3] = countdown; + preamble[4] = (uint8_t)(dstId >> 16); + preamble[5] = (uint8_t)(dstId >> 8); + preamble[6] = (uint8_t)(dstId); + preamble[7] = (uint8_t)(srcId >> 16); + preamble[8] = (uint8_t)(srcId >> 8); + preamble[9] = (uint8_t)(srcId); + + uint16_t crc = (uint16_t)(smsCRC16CCITT(preamble, 10) ^ 0xA5A5U); + + preamble[10] = (uint8_t)(crc >> 8); + preamble[11] = (uint8_t)(crc & 0xFF); +} diff --git a/firmware/application/source/user_interface/menuMessages.c b/firmware/application/source/user_interface/menuMessages.c new file mode 100644 index 0000000..be1ae9e --- /dev/null +++ b/firmware/application/source/user_interface/menuMessages.c @@ -0,0 +1,408 @@ +/* + * Copyright (C) 2026 OpenGD77 contributors + * + * Messages menu: reading received texts, browsing sent ones, and starting a new one. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation, + * incorporation in a commercial product or incorporation into a product or project which allows commercial use. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include "user_interface/menuSystem.h" +#include "user_interface/uiLocalisation.h" +#include "user_interface/uiUtilities.h" +#include "functions/sms.h" +#include "functions/settings.h" +#include "functions/sound.h" + +// Entries of the top level Messages menu. +enum +{ + MESSAGES_ITEM_NEW = 0, + MESSAGES_ITEM_INBOX, + MESSAGES_ITEM_OUTBOX, + MESSAGES_ITEM_COUNT +}; + +// Entries of the Inbox / Outbox menus. +enum +{ + FOLDER_ITEM_LIST = 0, + FOLDER_ITEM_DELETE_ALL, + FOLDER_ITEM_COUNT +}; + +static int menuIndex = 0; // selection in the Messages menu +static int folderIndex = 0; // selection in an Inbox/Outbox menu +static int listIndex = 0; // selected message in a list +static bool showingOutbox = false; // which folder the list/delete screens act on +static bool viewingMessage = false;// a single message is open + +static menuStatus_t exitCode = MENU_STATUS_SUCCESS; + +static void updateMessagesScreen(void); +static void updateFolderScreen(void); +static void updateListScreen(void); +static bool handleMessagesEvent(uiEvent_t *ev); +static bool handleFolderEvent(uiEvent_t *ev); +static bool handleListEvent(uiEvent_t *ev); + + +// ---------------------------------------------------------------- Messages + +static void updateMessagesScreen(void) +{ + char buf[SCREEN_LINE_BUFFER_SIZE]; + + displayClearBuf(); + menuDisplayTitle(currentLanguage->messages); + + // New msg / Inbox (n) / Outbox (n) + for (int i = 0; i < MESSAGES_ITEM_COUNT; i++) + { + switch (i) + { + case MESSAGES_ITEM_NEW: + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "New msg"); + break; + + case MESSAGES_ITEM_INBOX: + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "Inbox (%u)", (unsigned int)smsInboxCount()); + break; + + case MESSAGES_ITEM_OUTBOX: + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "Outbox (%u)", (unsigned int)smsOutboxCount()); + break; + } + + menuDisplayEntry((i - menuIndex), i, buf, (strlen(buf) - 1), THEME_ITEM_FG_MENU_ITEM, THEME_ITEM_FG_MENU_ITEM_SELECTED, THEME_ITEM_BG); + } + + displayRender(); +} + +menuStatus_t menuMessages(uiEvent_t *ev, bool isFirstRun) +{ + if (isFirstRun) + { + menuIndex = 0; + updateMessagesScreen(); + return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS); + } + + if (ev->hasEvent) + { + if (handleMessagesEvent(ev)) + { + updateMessagesScreen(); + } + } + + return exitCode; +} + +static bool handleMessagesEvent(uiEvent_t *ev) +{ + if (KEYCHECK_PRESS(ev->keys, KEY_DOWN)) + { + menuIndex = ((menuIndex + 1) % MESSAGES_ITEM_COUNT); + return true; + } + + if (KEYCHECK_PRESS(ev->keys, KEY_UP)) + { + menuIndex = ((menuIndex + MESSAGES_ITEM_COUNT - 1) % MESSAGES_ITEM_COUNT); + return true; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN)) + { + switch (menuIndex) + { + case MESSAGES_ITEM_NEW: + // Sending is not implemented yet; say so rather than opening a dead screen. + uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, "Not yet", true); + break; + + case MESSAGES_ITEM_INBOX: + showingOutbox = false; + folderIndex = 0; + menuSystemPushNewMenu(MENU_MESSAGES_FOLDER); + break; + + case MESSAGES_ITEM_OUTBOX: + showingOutbox = true; + folderIndex = 0; + menuSystemPushNewMenu(MENU_MESSAGES_FOLDER); + break; + } + + return true; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_RED)) + { + menuSystemPopPreviousMenu(); + return false; + } + + return false; +} + + +// ------------------------------------------------------- Inbox / Outbox menu + +static void updateFolderScreen(void) +{ + char buf[SCREEN_LINE_BUFFER_SIZE]; + uint8_t count = (showingOutbox ? smsOutboxCount() : smsInboxCount()); + + displayClearBuf(); + menuDisplayTitle(showingOutbox ? "Outbox" : "Inbox"); + + for (int i = 0; i < FOLDER_ITEM_COUNT; i++) + { + if (i == FOLDER_ITEM_LIST) + { + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "%s (%u)", (showingOutbox ? "Outbox List" : "Inbox List"), (unsigned int)count); + } + else + { + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "Delete All"); + } + + menuDisplayEntry((i - folderIndex), i, buf, (strlen(buf) - 1), THEME_ITEM_FG_MENU_ITEM, THEME_ITEM_FG_MENU_ITEM_SELECTED, THEME_ITEM_BG); + } + + displayRender(); +} + +menuStatus_t menuMessagesFolder(uiEvent_t *ev, bool isFirstRun) +{ + if (isFirstRun) + { + updateFolderScreen(); + return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS); + } + + if (ev->hasEvent) + { + if (handleFolderEvent(ev)) + { + updateFolderScreen(); + } + } + + return exitCode; +} + +static bool handleFolderEvent(uiEvent_t *ev) +{ + if (KEYCHECK_PRESS(ev->keys, KEY_DOWN)) + { + folderIndex = ((folderIndex + 1) % FOLDER_ITEM_COUNT); + return true; + } + + if (KEYCHECK_PRESS(ev->keys, KEY_UP)) + { + folderIndex = ((folderIndex + FOLDER_ITEM_COUNT - 1) % FOLDER_ITEM_COUNT); + return true; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN)) + { + if (folderIndex == FOLDER_ITEM_LIST) + { + listIndex = 0; + viewingMessage = false; + menuSystemPushNewMenu(MENU_MESSAGES_LIST); + } + else + { + if (showingOutbox) + { + smsOutboxClear(); + } + else + { + smsInboxClear(); + } + + soundSetMelody(MELODY_ACK_BEEP); + } + + return true; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_RED)) + { + menuSystemPopPreviousMenu(); + return false; + } + + return false; +} + + +// ------------------------------------------------------------- message list + +static void updateListScreen(void) +{ + char buf[SCREEN_LINE_BUFFER_SIZE]; + uint8_t count = (showingOutbox ? smsOutboxCount() : smsInboxCount()); + + displayClearBuf(); + + if (count == 0) + { + menuDisplayTitle(showingOutbox ? "Outbox" : "Inbox"); + displayPrintCentered(32, "Empty", FONT_SIZE_3); + displayRender(); + return; + } + + const smsMessage_t *msg = (showingOutbox ? smsOutboxGet((uint8_t)listIndex) : smsInboxGet((uint8_t)listIndex)); + + if (msg == NULL) + { + displayRender(); + return; + } + + if (viewingMessage) + { + // Full message: correspondent on the title line, text wrapped below. + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "%u", (unsigned int)(showingOutbox ? msg->dstId : msg->srcId)); + menuDisplayTitle(buf); + + // Wrap the text over the available lines rather than letting it run off screen. + const int charsPerLine = 20; + int len = (int)strlen(msg->text); + + for (int line = 0; (line < 4) && ((line * charsPerLine) < len); line++) + { + int n = len - (line * charsPerLine); + + if (n > charsPerLine) + { + n = charsPerLine; + } + + memcpy(buf, &msg->text[line * charsPerLine], n); + buf[n] = '\0'; + displayPrintCore(0, (16 + (line * 12)), buf, FONT_SIZE_2, TEXT_ALIGN_LEFT, false); + } + } + else + { + // List: one line per message, newest first. + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "%s %u/%u", (showingOutbox ? "Outbox" : "Inbox"), + (unsigned int)(listIndex + 1), (unsigned int)count); + menuDisplayTitle(buf); + + for (int i = 0; i < count; i++) + { + const smsMessage_t *m = (showingOutbox ? smsOutboxGet((uint8_t)i) : smsInboxGet((uint8_t)i)); + + if (m == NULL) + { + continue; + } + + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "%s%u %.10s", + ((m->unread) ? "*" : " "), + (unsigned int)(showingOutbox ? m->dstId : m->srcId), + m->text); + + menuDisplayEntry((i - listIndex), i, buf, (strlen(buf) - 1), THEME_ITEM_FG_MENU_ITEM, THEME_ITEM_FG_MENU_ITEM_SELECTED, THEME_ITEM_BG); + } + } + + displayRender(); +} + +menuStatus_t menuMessagesList(uiEvent_t *ev, bool isFirstRun) +{ + if (isFirstRun) + { + updateListScreen(); + return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS); + } + + if (ev->hasEvent) + { + if (handleListEvent(ev)) + { + updateListScreen(); + } + } + + return exitCode; +} + +static bool handleListEvent(uiEvent_t *ev) +{ + uint8_t count = (showingOutbox ? smsOutboxCount() : smsInboxCount()); + + if (KEYCHECK_PRESS(ev->keys, KEY_DOWN) && (count > 0) && (viewingMessage == false)) + { + listIndex = ((listIndex + 1) % count); + return true; + } + + if (KEYCHECK_PRESS(ev->keys, KEY_UP) && (count > 0) && (viewingMessage == false)) + { + listIndex = ((listIndex + count - 1) % count); + return true; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN) && (count > 0)) + { + if (viewingMessage == false) + { + viewingMessage = true; + + // Opening a message marks it read. + if (showingOutbox == false) + { + smsMarkRead(); + } + } + + return true; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_RED)) + { + if (viewingMessage) + { + viewingMessage = false; + return true; + } + + menuSystemPopPreviousMenu(); + return false; + } + + return false; +} diff --git a/patches/00-inline-edits.md b/patches/00-inline-edits.md new file mode 100644 index 0000000..e486117 --- /dev/null +++ b/patches/00-inline-edits.md @@ -0,0 +1,493 @@ +# Правки, сделанные командами (не отдельными файлами) + +Извлечено из транскрипта сессии в порядке применения. +Каждый блок - самостоятельная команда, менявшая исходники прошивки. + +## Правка 1 (строка транскрипта 966) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +W="/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/include/functions/hotspot.h" +s = open(p).read() + +anchor = "#define DMR_SYNC_DATA 0x40U\n" +if anchor not in s: + raise SystemExit("anchor not found in hotspot.h") +if "DT_RATE_12_DATA" in s: + raise SystemExit("already patched") + +add = anchor + """ +// DMR data burst types (ETSI TS 102 361-1, Slot Type "data type" field). +// The hotspot only ever handled the two voice-related ones; the packet data +// types below are needed to carry SMS/position data over the air. +#define DT_VOICE_PI_HEADER 0x00U +#define DT_CSBK 0x03U +#define DT_DATA_HEADER 0x06U +#define DT_RATE_12_DATA 0x07U +#define DT_RATE_34_DATA 0x08U +#define DT_IDLE 0x09U +#define DT_RATE_1_DATA 0x0AU +#define DT_MASK 0x0FU + +// Marker used in hotspotTxDataType[] for "this buffered frame is voice, not data". +#define HOTSPOT_TX_TYPE_VOICE 0xFFU +""" +s = s.replace(anchor, add, 1) + +# expose the per-buffer data-type markers to the HR-C6000 driver +tail = "extern bool hotspotMmdvmHostIsConnected;\n" +if tail not in s: + raise SystemExit("tail anchor not found") +s = s.replace(tail, tail + """ +// For each frame queued in audioAndHotspotDataBuffer.hotspotBuffer[], this holds either +// HOTSPOT_TX_TYPE_VOICE or the DMR data type (DT_*) that the frame has to be sent as. +extern volatile uint8_t hotspotTxDataType[]; +""", 1) + +open(p, "w").write(s) +print("hotspot.h patched") +PYEOF' 2>&1 | tail -5 +``` + +## Правка 2 (строка транскрипта 978) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +W="/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/functions/hotspot.c" +s = open(p).read() +if "hotspotTxDataType" in s: + raise SystemExit("already patched") + +# ---- 1. define the marker array next to the other hotspot state ---- +anchor = "static uint8_t hotspotTxLC[9];\n" +assert anchor in s, "hotspotTxLC anchor missing" +s = s.replace(anchor, anchor + """ +// Per-buffered-frame DMR data type. HOTSPOT_TX_TYPE_VOICE means \"ordinary voice frame\", +// anything else is a packet data burst (SMS etc) that must bypass the vocoder path. +volatile uint8_t hotspotTxDataType[HOTSPOT_BUFFER_COUNT]; +""", 1) + +# ---- 2. voice frames queued by storeNetFrame() must be marked as voice ---- +old = """ memcpy((uint8_t *)&audioAndHotspotDataBuffer.hotspotBuffer[wavbuffer_write_idx], hotspotTxLC, 9);// copy the current LC into the data (mainly for use with the embedded data); + wavbuffer_count++;""" +assert old in s, "storeNetFrame body not found" +s = s.replace(old, """ memcpy((uint8_t *)&audioAndHotspotDataBuffer.hotspotBuffer[wavbuffer_write_idx], hotspotTxLC, 9);// copy the current LC into the data (mainly for use with the embedded data); + hotspotTxDataType[wavbuffer_write_idx] = HOTSPOT_TX_TYPE_VOICE; + wavbuffer_count++;""", 1) + +# ---- 3. new function: queue a packet data burst coming from the host ---- +anchor2 = "static uint8_t hotspotModeReceiveNetFrame(const uint8_t *comBuffer, uint8_t timeSlot)\n" +assert anchor2 in s, "hotspotModeReceiveNetFrame not found" +s = s.replace(anchor2, """// Queue a packet data burst (CSBK / data header / rate 1/2 payload) received from the +// MMDVM host, so that the HR-C6000 sends it as a real data burst rather than as audio. +// +// The host hands us a complete 33 byte DMR burst (BPTC(196,96) coded). The HR-C6000 +// applies the FEC itself and only wants the 96 bit (12 byte) payload, which is exactly +// what BPTCdecode() gives us - the same route the radio already uses for LC headers. +static uint8_t storeNetDataFrame(volatile const uint8_t *comBuffer, uint8_t dataType) +{ + if (wavbuffer_count >= HOTSPOT_BUFFER_COUNT) + { + return 5;// out of space: MMDVMHost treats a non-zero return as a NAK + } + + BPTCglobalsInit(); + + taskENTER_CRITICAL(); + BPTCdecode((uint8_t *)comBuffer + MMDVM_HEADER_LENGTH, + (uint8_t *)&audioAndHotspotDataBuffer.hotspotBuffer[wavbuffer_write_idx]); + hotspotTxDataType[wavbuffer_write_idx] = dataType; + wavbuffer_count++; + wavbuffer_write_idx = ((wavbuffer_write_idx + 1) % HOTSPOT_BUFFER_COUNT); + taskEXIT_CRITICAL(); + + // Data bursts are short and self-contained, so start transmitting straight away + // instead of waiting for the audio pre-buffering to fill up. + if ((hotspotState != HOTSPOT_STATE_TX_START_BUFFERING) && (hotspotState != HOTSPOT_STATE_TRANSMITTING)) + { + timeoutCounter = TX_BUFFERING_TIMEOUT; + hotspotState = HOTSPOT_STATE_TX_START_BUFFERING; + } + + return 0; +} + +""" + anchor2, 1) + +# ---- 4. dispatch data bursts before the voice-only decode ---- +old2 = """ lc.srcId = 0;// zero these values as they are checked later in the function, but only updated if the data type is DT_VOICE_LC_HEADER + lc.dstId = 0; +""" +assert old2 in s, "dispatch anchor not found" +s = s.replace(old2, """ // MMDVM control byte: bit 0x40 marks a data sync burst, low nibble is the DMR data type. + // Voice LC header / terminator keep their existing handling below; the packet data types + // have no path at all in stock firmware, which is why SMS never reached the air. + if ((comBuffer[3] & DMR_SYNC_DATA) == DMR_SYNC_DATA) + { + uint8_t dataType = (comBuffer[3] & DT_MASK); + + switch (dataType) + { + case DT_CSBK: + case DT_DATA_HEADER: + case DT_RATE_12_DATA: + return storeNetDataFrame(comBuffer, dataType); + + default: + break;// fall through to the voice handling + } + } + +""" + old2, 1) + +open(p, "w").write(s) +print("hotspot.c patched") +PYEOF' 2>&1 | tail -5 +``` + +## Правка 3 (строка транскрипта 1008) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +W="/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/hardware/HR-C6000.c" +s = open(p).read() +if "txDataType" in s: + raise SystemExit("already patched") + +# ---- 1. state field: which DMR type the frame currently in deferredUpdateBuffer must be sent as +a = "\tvolatile bool hotspotDMRTxFrameBufferEmpty;\n" +assert a in s +s = s.replace(a, a + "\tvolatile uint8_t txDataType;// HOTSPOT_TX_TYPE_VOICE, or a DT_* packet data type\n", 1) + +a = "\t\t.hotspotDMRTxFrameBufferEmpty = true,\n" +assert a in s +s = s.replace(a, a + "\t\t.txDataType = HOTSPOT_TX_TYPE_VOICE,\n", 1) + +# ---- 2. remember the type whenever a frame is pulled out of the hotspot buffer +a = """ memcpy((uint8_t *)deferredUpdateBuffer, (uint8_t *)&audioAndHotspotDataBuffer.hotspotBuffer[wavbuffer_read_idx], AMBE_AUDIO_LENGTH + LC_DATA_LENGTH); + // Note:""" +assert a in s, "first fetch site missing" +s = s.replace(a, """ memcpy((uint8_t *)deferredUpdateBuffer, (uint8_t *)&audioAndHotspotDataBuffer.hotspotBuffer[wavbuffer_read_idx], AMBE_AUDIO_LENGTH + LC_DATA_LENGTH); + hrc.txDataType = hotspotTxDataType[wavbuffer_read_idx]; + // Note:""", 1) + +a = """ memcpy((uint8_t *)deferredUpdateBuffer, (uint8_t *)&audioAndHotspotDataBuffer.hotspotBuffer[wavbuffer_read_idx], AMBE_AUDIO_LENGTH + LC_DATA_LENGTH); + + wavbuffer_read_idx = ((wavbuffer_read_idx + 1) % HOTSPOT_BUFFER_COUNT);""" +assert a in s, "second fetch site missing" +s = s.replace(a, """ memcpy((uint8_t *)deferredUpdateBuffer, (uint8_t *)&audioAndHotspotDataBuffer.hotspotBuffer[wavbuffer_read_idx], AMBE_AUDIO_LENGTH + LC_DATA_LENGTH); + hrc.txDataType = hotspotTxDataType[wavbuffer_read_idx]; + + wavbuffer_read_idx = ((wavbuffer_read_idx + 1) % HOTSPOT_BUFFER_COUNT);""", 1) + +# ---- 3. TX_2: send a packet data burst instead of pushing the payload through the vocoder +a = """ if (settingsUsbMode == USB_MODE_HOTSPOT) + { + if (hrc.txSequence == 0) + { + SPI0WritePageRegByteArray(0x02, 0x00, (uint8_t*)deferredUpdateBuffer, LC_DATA_LENGTH); // put LC into hardware + } +""" +assert a in s, "TX_2 hotspot branch missing" +s = s.replace(a, """ if (settingsUsbMode == USB_MODE_HOTSPOT) + { + if (hrc.txDataType != HOTSPOT_TX_TYPE_VOICE) + { + // Packet data burst (SMS etc). The 96 bit payload goes into the same + // register page the radio already uses for LC data, and reg 0x50 below + // tells the HR-C6000 which kind of data burst to build around it. + // The vocoder path is left idle - this is what stock firmware lacked. + SPI0WritePageRegByteArray(0x02, 0x00, (uint8_t*)deferredUpdateBuffer, LC_DATA_LENGTH); + SPI1WritePageRegByteArray(0x03, 0x00, SILENCE_AUDIO, AMBE_AUDIO_LENGTH); + hrc.hotspotDMRTxFrameBufferEmpty = true; + + SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot + SPI0WritePageRegByte(0x04, 0x50, (hrc.txDataType << 4)); // Data Type = DT_*, Data sync, LCSS = 0 + hrc.txDataType = HOTSPOT_TX_TYPE_VOICE; // consumed + + slotState = DMR_STATE_TX_1; + break; + } + + if (hrc.txSequence == 0) + { + SPI0WritePageRegByteArray(0x02, 0x00, (uint8_t*)deferredUpdateBuffer, LC_DATA_LENGTH); // put LC into hardware + } +""", 1) + +open(p, "w").write(s) +print("HR-C6000.c patched") +PYEOF' 2>&1 | tail -5 +``` + +## Правка 4 (строка транскрипта 1052) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +W="/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/functions/hotspot.c" +s = open(p).read() +if "hotspotTxDataPending" in s: + raise SystemExit("already patched") + +# flag: there is at least one packet data burst waiting to go out +a = "volatile uint8_t hotspotTxDataType[HOTSPOT_BUFFER_COUNT];\n" +assert a in s +s = s.replace(a, a + "static volatile bool hotspotTxDataPending = false;// a packet data burst is queued\n", 1) + +# set it when queuing a data burst +a = "\t\thotspotTxDataType[wavbuffer_write_idx] = dataType;\n" +assert a in s +s = s.replace(a, a + "\t\thotspotTxDataPending = true;\n", 1) + +# Data bursts are only a few frames long, so the audio pre-buffering threshold +# (5 frames) would never be reached and the transmission would time out instead. +a = "\t\t\t\tif (wavbuffer_count > TX_BUFFER_MIN_BEFORE_TRANSMISSION)\n" +assert a in s, "buffering condition not found" +s = s.replace(a, "\t\t\t\tif ((wavbuffer_count > TX_BUFFER_MIN_BEFORE_TRANSMISSION) || hotspotTxDataPending)\n", 1) + +# clear the flag once the buffer has drained +a = """ case HOTSPOT_STATE_TX_SHUTDOWN: +""" +assert a in s, "TX_SHUTDOWN case not found" +s = s.replace(a, """ case HOTSPOT_STATE_TX_SHUTDOWN: + if (wavbuffer_count == 0) + { + hotspotTxDataPending = false; + } +""", 1) + +# and on (re)initialisation +a = """ case HOTSPOT_STATE_INITIALISE: +""" +assert a in s, "INITIALISE case not found" +s = s.replace(a, """ case HOTSPOT_STATE_INITIALISE: + hotspotTxDataPending = false; +""", 1) + +open(p, "w").write(s) +print("patched: data bursts start transmitting immediately") +PYEOF +cd ~/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware +make -f Makefile.opengd77 -j$(nproc) 2>&1 | grep -iE " error |error:" | head -10 +ls -l build_ogd77/OpenMDUV380.bin && arm-none-eabi-size build_ogd77/OpenMDUV380.elf | tail -1' 2>&1 | tail -8 +``` + +## Правка 5 (строка транскрипта 1069) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +W="/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/functions/hotspot.c" +s = open(p).read() +if "hotspotTxDataPending" in s: + raise SystemExit("already patched") + +a = "volatile uint8_t hotspotTxDataType[HOTSPOT_BUFFER_COUNT];\n" +assert a in s, "array decl" +s = s.replace(a, a + "static volatile bool hotspotTxDataPending = false;// a packet data burst is queued\n", 1) + +a = "\thotspotTxDataType[wavbuffer_write_idx] = dataType;\n" +assert a in s, "queue line" +s = s.replace(a, a + "\thotspotTxDataPending = true;\n", 1) + +# Data bursts are only a few frames long, so the audio pre-buffering threshold +# would never be reached and the transmission would time out instead. +a = "\t\t\t\tif (wavbuffer_count > TX_BUFFER_MIN_BEFORE_TRANSMISSION)\n" +assert a in s, "buffering condition" +s = s.replace(a, "\t\t\t\tif ((wavbuffer_count > TX_BUFFER_MIN_BEFORE_TRANSMISSION) || hotspotTxDataPending)\n", 1) + +a = "\t\tcase HOTSPOT_STATE_TX_SHUTDOWN:\n" +assert a in s, "TX_SHUTDOWN" +s = s.replace(a, a + "\t\t\tif (wavbuffer_count == 0)\n\t\t\t{\n\t\t\t\thotspotTxDataPending = false;\n\t\t\t}\n", 1) + +a = "\t\tcase HOTSPOT_STATE_INITIALISE:\n" +assert a in s, "INITIALISE" +s = s.replace(a, a + "\t\t\thotspotTxDataPending = false;\n", 1) + +open(p, "w").write(s) +print("patched OK") +PYEOF +cd ~/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware +make -f Makefile.opengd77 -j$(nproc) 2>&1 | grep -iE " error |error:" | head -10 +echo "=== build result ===" +ls -l build_ogd77/OpenMDUV380.bin && arm-none-eabi-size build_ogd77/OpenMDUV380.elf | tail -1' 2>&1 | tail -8 +``` + +## Правка 6 (строка транскрипта 1205) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +W="/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# --- 1. new RX event type ------------------------------------------------- +p = W + "/include/functions/hotspot.h" +s = open(p).read() +if "HOTSPOT_RX_DATA_FRAME" not in s: + a = "\tHOTSPOT_RX_STOP,\n" + assert a in s + s = s.replace(a, a + "\tHOTSPOT_RX_DATA_FRAME,// a packet data burst (SMS etc) was received off air\n", 1) + open(p,"w").write(s) + print("hotspot.h: RX event added") + +# --- 2. bring the slot type encoder over from the MK22 branch ------------- +src = open("/home/viktor/OpenGD77/firmware/source/hotspot/DMRSlotType.c").read() +start = src.index("static const uint16_t ENCODING_TABLE_2087[] =") +end = src.index(";", src.index("};", start)) +table = src[start:src.index("};", start)+2] + +p = W + "/source/functions/hotspot.c" +s = open(p).read() +if "ENCODING_TABLE_2087" in s: + raise SystemExit("hotspot.c already has the slot type encoder") + +anchor = "static void BPTCencode(const uint8_t *inputData, uint8_t *outputData)\n{" +assert anchor in s, "BPTCencode definition not found" + +block = """// Slot Type encoder, needed to build received data bursts for the host. +// Golay(20,8) table and logic taken from the project\\'s own DMRSlotType implementation. +""" + table + """ + +// Writes colour code + data type (with FEC) into the Slot Type half-bursts of a frame. +static void DMRSlotTypeEncode(uint32_t colorCode, uint32_t dataType, uint8_t *frame) +{ + uint8_t slotType[3]; + + slotType[0] = (colorCode << 4) & 0xF0U; + slotType[0] |= (dataType << 0) & 0x0FU; + + uint16_t cksum = ENCODING_TABLE_2087[slotType[0]]; + + slotType[1] = (cksum >> 0) & 0xFFU; + slotType[2] = (cksum >> 8) & 0xFFU; + + frame[12] = (frame[12] & 0xC0U) | ((slotType[0] >> 2) & 0x3FU); + frame[13] = (frame[13] & 0x0FU) | ((slotType[0] << 6) & 0xC0U) | ((slotType[1] >> 2) & 0x30U); + frame[19] = (frame[19] & 0xF0U) | ((slotType[1] >> 2) & 0x0FU); + frame[20] = (frame[20] & 0x03U) | ((slotType[1] << 6) & 0xC0U) | ((slotType[2] >> 2) & 0x3CU); +} + +""" + anchor + +s = s.replace(anchor, block, 1) +open(p,"w").write(s) +print("hotspot.c: slot type encoder added (%d bytes of table)" % len(table)) +PYEOF' 2>&1 | tail -6 +``` + +## Правка 7 (строка транскрипта 3341) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +import glob, os +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# 1. new field at the end of the strings table +p = W + "/include/user_interface/languages/uiLanguage.h" +s = open(p).read() +if "messages[LANGUAGE_TEXTS_LENGTH]" in s: + print("uiLanguage.h: field already present") +else: + s = s.replace(" const char mute[LANGUAGE_TEXTS_LENGTH];\n} stringsTable_t;", + " const char mute[LANGUAGE_TEXTS_LENGTH];\n" + " const char messages[LANGUAGE_TEXTS_LENGTH];\n} stringsTable_t;", 1) + # the struct changed, so the language file version has to move on + s = s.replace("#define LANGUAGE_TAG_VERSION { 0x00, 0x00, 0x00, 0x05 }", + "#define LANGUAGE_TAG_VERSION { 0x00, 0x00, 0x00, 0x06 }", 1) + open(p, "w").write(s) + print("uiLanguage.h: added .messages, bumped tag version to 6") + +# 2. give every language a value; designated initialisers mean a missing one would +# simply render as an empty menu entry, which looks broken. +done = 0 +for f in sorted(glob.glob(W + "/include/user_interface/languages/*.h")): + if f.endswith("uiLanguage.h"): + continue + t = open(f, "r", encoding="latin-1").read() + if ".messages" in t: + continue + if ".mute" not in t: + print(" skipped %s (no .mute anchor)" % os.path.basename(f)) + continue + idx = t.index(".mute") + eol = t.index("\n", idx) + t = t[:eol + 1] + ".messages\t\t\t\t= \"Messages\", // MaxLen: 16\n" + t[eol + 1:] + open(f, "w", encoding="latin-1").write(t) + done += 1 +print("added .messages to %d language files" % done) +PYEOF' 2>&1 | tail -6 +``` + +## Правка 8 (строка транскрипта 3357) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e scp -o StrictHostKeyChecking=no /tmp/claude-1000/-home-viktor-claude-uv390/44e0d3e2-595b-4c07-94e1-73e0e3f7531a/menuMessages.c viktor@10.0.0.200:'~/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application/source/user_interface/menuMessages.c' >/dev/null 2>&1 +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 'python3 - <<"PYEOF" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# --- declare the three screens and their menu ids ------------------------- +p = W + "/include/user_interface/menuSystem.h" +s = open(p).read() +if "MENU_MESSAGES" not in s: + s = s.replace("\tMENU_LAST_HEARD,\n", + "\tMENU_LAST_HEARD,\n\tMENU_MESSAGES,\n\tMENU_MESSAGES_FOLDER,\n\tMENU_MESSAGES_LIST,\n", 1) + s = s.replace("menuStatus_t uiMessageBox(uiEvent_t *event, bool isFirstRun);", + "menuStatus_t uiMessageBox(uiEvent_t *event, bool isFirstRun);\n" + "menuStatus_t menuMessages(uiEvent_t *ev, bool isFirstRun);\n" + "menuStatus_t menuMessagesFolder(uiEvent_t *ev, bool isFirstRun);\n" + "menuStatus_t menuMessagesList(uiEvent_t *ev, bool isFirstRun);", 1) + open(p, "w").write(s) + print("menuSystem.h: ids and prototypes added") +else: + print("menuSystem.h: already declared") + +# --- register the screens in the dispatch table --------------------------- +p = W + "/source/user_interface/menuSystem.c" +s = open(p).read() +if "menuMessages," in s: + print("menuSystem.c: already registered") +else: + # the table order must match the enum order: our ids come right after MENU_LAST_HEARD + idx = s.index("static menuFunctionData_t menuFunctions[] =") + marker = "{ menuLastHeard," + j = s.index(marker, idx) + eol = s.index("\n", j) + 1 + s = s[:eol] + ("\t\t{ menuMessages, NULL, NULL, 0 },\n" + "\t\t{ menuMessagesFolder, NULL, NULL, 0 },\n" + "\t\t{ menuMessagesList, NULL, NULL, 0 },\n") + s[eol:] + + # and a Messages entry in the main menu, using the new language string + s = s.replace("\t{ 7, MENU_LAST_HEARD },\n", + "\t{ 7, MENU_LAST_HEARD },\n\t{ 281, MENU_MESSAGES },\n", 1) + open(p, "w").write(s) + print("menuSystem.c: screens registered and main menu entry added") +PYEOF +cd ~/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware +make -f Makefile.opengd77 -j$(nproc) 2>&1 | grep -iE "error|undefined" | head -10 +arm-none-eabi-size build_ogd77/OpenMDUV380.elf 2>/dev/null | tail -1' 2>&1 | tail -12 +``` + +## Правка 9 (строка транскрипта 3364) + +```bash +export SSHPASS='Adelina@2015' +sshpass -e ssh -o StrictHostKeyChecking=no viktor@10.0.0.200 ' +W=~/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application +sed -i "s/THEME_ITEM_FG_MENU_ITEM_SELECTED/THEME_ITEM_COLOUR_NONE/g" $W/source/user_interface/menuMessages.c +sed -i "s/, (strlen(buf) - 1), THEME_ITEM_FG_MENU_ITEM,/, 0, THEME_ITEM_FG_MENU_ITEM,/g" $W/source/user_interface/menuMessages.c +cd ~/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware +make -f Makefile.opengd77 -j$(nproc) 2>&1 | grep -iE "error|undefined" | head -10 +arm-none-eabi-size build_ogd77/OpenMDUV380.elf 2>/dev/null | tail -1' 2>&1 | tail -10 +``` + diff --git a/patches/patch_diag.py b/patches/patch_diag.py new file mode 100644 index 0000000..c59b868 --- /dev/null +++ b/patches/patch_diag.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Diagnostic build: make the radio show what its SMS assembler actually sees. + +Three counters are surfaced on screen, so we can tell where reception stops: + RX - data bursts handed to the assembler by the driver + HDR - data headers parsed (with the announced block count) + ASM - completed assemblies (all blocks collected) + +If RX stays at zero the bursts never reach the module and the problem is in the +driver-side capture conditions. If RX counts up but HDR does not, header parsing is +rejecting them. If HDR appears but ASM does not, blocks are being lost. +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# ---------------------------------------------------------------- sms.h +p = W + "/include/functions/sms.h" +s = open(p).read() +if "smsGetDiagCounters" not in s: + a = "bool smsHasUnread(void);\n" + assert a in s + s = s.replace(a, a + """ +// Diagnostics: how many bursts reached the assembler, how many headers were parsed, +// and how many messages assembled completely. +void smsGetDiagCounters(uint16_t *bursts, uint16_t *headers, uint16_t *assembled, uint8_t *lastBlocks); +""", 1) + open(p, "w").write(s) + print("sms.h: diagnostics declared") + +# ---------------------------------------------------------------- sms.c +p = W + "/source/functions/sms.c" +s = open(p).read() +if "diagBursts" in s: + print("sms.c: already instrumented") +else: + a = "static bool unread = false;\n" + assert a in s + s = s.replace(a, a + """ +static volatile uint16_t diagBursts = 0; +static uint16_t diagHeaders = 0; +static uint16_t diagAssembled = 0; +static uint8_t diagLastBlocks = 0; +""", 1) + + # count every burst the driver hands us + a = "\tmemcpy((uint8_t *)rxQueue[rxQueueWrite], payload, SMS_BLOCK_LENGTH);\n" + assert a in s + s = s.replace(a, "\tdiagBursts++;\n" + a, 1) + + # count parsed headers, remembering the announced block count + a = "\tblocksExpected = (uint8_t)(hdr[8] & 0x7FU);\n" + assert a in s + s = s.replace(a, a + "\tdiagHeaders++;\n\tdiagLastBlocks = blocksExpected;\n", 1) + + # count completed assemblies + a = "static void finishAssembly(void)\n{\n" + assert a in s + s = s.replace(a, a + "\tdiagAssembled++;\n", 1) + + s += """ +void smsGetDiagCounters(uint16_t *bursts, uint16_t *headers, uint16_t *assembled, uint8_t *lastBlocks) +{ + *bursts = diagBursts; + *headers = diagHeaders; + *assembled = diagAssembled; + *lastBlocks = diagLastBlocks; +} +""" + open(p, "w").write(s) + print("sms.c: counters added") + +# ------------------------------------------------------- applicationMain.c +p = W + "/source/applicationMain.c" +s = open(p).read() +if "smsGetDiagCounters" in s: + print("applicationMain.c: already showing diagnostics") +else: + a = "\t\tif (smsHasUnread())\n" + assert a in s, "unread check not found" + s = s.replace(a, """\t\t{ +\t\t\t// Diagnostics: show the assembler counters whenever they change, so we can see +\t\t\t// how far a received message gets even when no text is produced. +\t\t\tstatic uint16_t lastShownBursts = 0; +\t\t\tstatic uint16_t lastShownHeaders = 0; +\t\t\tuint16_t dBursts, dHeaders, dAssembled; +\t\t\tuint8_t dBlocks; + +\t\t\tsmsGetDiagCounters(&dBursts, &dHeaders, &dAssembled, &dBlocks); + +\t\t\tif ((dBursts != lastShownBursts) || (dHeaders != lastShownHeaders)) +\t\t\t{ +\t\t\t\tchar diagLine[40]; + +\t\t\t\tlastShownBursts = dBursts; +\t\t\t\tlastShownHeaders = dHeaders; +\t\t\t\tsnprintf(diagLine, sizeof(diagLine), "RX%u HDR%u/%u ASM%u", +\t\t\t\t\t\t(unsigned int)dBursts, (unsigned int)dHeaders, +\t\t\t\t\t\t(unsigned int)dBlocks, (unsigned int)dAssembled); +\t\t\t\tuiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 8000, diagLine, true); +\t\t\t\tsoundSetMelody(MELODY_KEY_BEEP); +\t\t\t} +\t\t} + +""" + a, 1) + open(p, "w").write(s) + print("applicationMain.c: diagnostics displayed") diff --git a/patches/patch_diag4.py b/patches/patch_diag4.py new file mode 100644 index 0000000..8e63bc1 --- /dev/null +++ b/patches/patch_diag4.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Expose the SMS diagnostics through a RAM window that can be read over USB. + +Drawing counters on the screen turned out to be unreliable: the notification is +overwritten by the normal screen redraw, so a screenshot usually shows nothing even +when reception is happening. Instead the counters are mirrored into the tail of the +raw audio buffer, which the CPS protocol can read out at any moment (area 7). + +Layout at rawBuffer[2376..2399]: + 'S' 'M' 'S' 'D' magic, so a stale buffer cannot be mistaken for data + u16 bursts data bursts handed to the assembler + u16 headers data headers parsed + u16 assembled messages assembled completely + u16 lastBlocks block count announced by the last header + u16 collected blocks collected for the message in progress + u16 state 0 = idle, 1 = collecting + u16 textLen length of the last extracted text +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +p = W + "/source/functions/sms.c" +s = open(p).read() + +if "SMS_DIAG_OFFSET" in s: + print("already patched") + raise SystemExit(0) + +s = s.replace('#include "functions/ticks.h"\n', + '#include "functions/ticks.h"\n#include "functions/sound.h"\n', 1) + +anchor = "void smsInit(void)\n" +assert anchor in s +helper = """// Diagnostics mirror: the tail of the raw audio buffer is readable over USB (CPS area 7), +// which is far more dependable than trying to keep text on the screen. +#define SMS_DIAG_OFFSET 2376 + +static void smsDiagPublish(void) +{ + volatile uint8_t *d = &audioAndHotspotDataBuffer.rawBuffer[SMS_DIAG_OFFSET]; + uint16_t values[7]; + + values[0] = diagBursts; + values[1] = diagHeaders; + values[2] = diagAssembled; + values[3] = diagLastBlocks; + values[4] = blocksCollected; + values[5] = (uint16_t)rxState; + values[6] = diagTextLen; + + d[0] = 'S'; d[1] = 'M'; d[2] = 'S'; d[3] = 'D'; + + for (uint8_t i = 0; i < 7; i++) + { + d[4 + (i * 2)] = (uint8_t)(values[i] & 0xFF); + d[5 + (i * 2)] = (uint8_t)(values[i] >> 8); + } +} + +""" +s = s.replace(anchor, helper + anchor, 1) + +# track the extracted text length too +s = s.replace("static uint8_t diagLastBlocks = 0;\n", + "static uint8_t diagLastBlocks = 0;\nstatic uint16_t diagTextLen = 0;\n", 1) +s = s.replace("\tlatest.text[t] = '\\0';\n", + "\tlatest.text[t] = '\\0';\n\tdiagTextLen = t;\n", 1) + +# publish on every tick, so a read over USB always sees the current state +s = s.replace("void smsTick(void)\n{\n", "void smsTick(void)\n{\n\tsmsDiagPublish();\n\n", 1) + +open(p, "w").write(s) +print("sms.c: diagnostics mirrored into the readable RAM window") diff --git a/patches/patch_menufix.py b/patches/patch_menufix.py new file mode 100644 index 0000000..75aedbd --- /dev/null +++ b/patches/patch_menufix.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Fix the Messages menus: wrong language string index, and hand-rolled navigation. + +Two defects: + - the main menu entry pointed at string 281 while .messages is index 280, so the + entry rendered as "GD77LANG" (it picked up a neighbouring field); + - the screens tracked the selection in their own variables and drew entries at a + fixed offset, instead of using the menu system's list handling. That makes the + cursor behave wrongly (no scrolling, wrong highlight). + +Both screens are rewritten to the pattern used by the stock list menus: +menuDataGlobal.currentItemIndex + menuGetMenuOffset() + menuSystemMenuIncrement(). +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# ---- 1. correct the main menu string index ------------------------------ +p = W + "/source/user_interface/menuSystem.c" +s = open(p).read() +if "{ 281, MENU_MESSAGES" in s: + s = s.replace("{ 281, MENU_MESSAGES },", "{ 280, MENU_MESSAGES },", 1) + open(p, "w").write(s) + print("menuSystem.c: string index corrected 281 -> 280") +else: + print("menuSystem.c: index already correct") + +# ---- 2. rewrite the screens using the standard list handling ------------ +p = W + "/source/user_interface/menuMessages.c" +src = open(p).read() +header_end = src.index("// Entries of the top level Messages menu.") +header = src[:header_end] + +body = '''// Entries of the top level Messages menu. +enum +{ + MESSAGES_ITEM_NEW = 0, + MESSAGES_ITEM_INBOX, + MESSAGES_ITEM_OUTBOX, + NUM_MESSAGES_ITEMS +}; + +// Entries of the Inbox / Outbox menus. +enum +{ + FOLDER_ITEM_LIST = 0, + FOLDER_ITEM_DELETE_ALL, + NUM_FOLDER_ITEMS +}; + +static bool showingOutbox = false; // which folder the list screens act on +static bool viewingMessage = false;// a single message is open + +static menuStatus_t exitCode = MENU_STATUS_SUCCESS; + +static void messagesUpdateScreen(void); +static void messagesHandleEvent(uiEvent_t *ev); +static void folderUpdateScreen(void); +static void folderHandleEvent(uiEvent_t *ev); +static void listUpdateScreen(void); +static void listHandleEvent(uiEvent_t *ev); + + +// ---------------------------------------------------------------- Messages + +menuStatus_t menuMessages(uiEvent_t *ev, bool isFirstRun) +{ + if (isFirstRun) + { + menuDataGlobal.numItems = NUM_MESSAGES_ITEMS; + menuDataGlobal.currentItemIndex = 0; + messagesUpdateScreen(); + return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS); + } + + exitCode = MENU_STATUS_SUCCESS; + + if (ev->hasEvent) + { + messagesHandleEvent(ev); + } + + return exitCode; +} + +static void messagesUpdateScreen(void) +{ + char buf[SCREEN_LINE_BUFFER_SIZE]; + int mNum; + + displayClearBuf(); + menuDisplayTitle(currentLanguage->messages); + + for (int i = MENU_START_ITERATION_VALUE; i <= MENU_END_ITERATION_VALUE; i++) + { + mNum = menuGetMenuOffset(NUM_MESSAGES_ITEMS, i); + + if (mNum == MENU_OFFSET_BEFORE_FIRST_ENTRY) + { + continue; + } + else if (mNum == MENU_OFFSET_AFTER_LAST_ENTRY) + { + break; + } + + switch (mNum) + { + case MESSAGES_ITEM_NEW: + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "New msg"); + break; + + case MESSAGES_ITEM_INBOX: + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "Inbox (%u)", (unsigned int)smsInboxCount()); + break; + + case MESSAGES_ITEM_OUTBOX: + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "Outbox (%u)", (unsigned int)smsOutboxCount()); + break; + + default: + buf[0] = 0; + break; + } + + menuDisplayEntry(i, mNum, buf, 0, THEME_ITEM_FG_MENU_ITEM, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG); + } + + displayRender(); +} + +static void messagesHandleEvent(uiEvent_t *ev) +{ + if (KEYCHECK_PRESS(ev->keys, KEY_DOWN)) + { + menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, NUM_MESSAGES_ITEMS); + messagesUpdateScreen(); + exitCode |= MENU_STATUS_LIST_TYPE; + return; + } + + if (KEYCHECK_PRESS(ev->keys, KEY_UP)) + { + menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, NUM_MESSAGES_ITEMS); + messagesUpdateScreen(); + exitCode |= MENU_STATUS_LIST_TYPE; + return; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN)) + { + switch (menuDataGlobal.currentItemIndex) + { + case MESSAGES_ITEM_NEW: + // Sending is not implemented yet; say so rather than opening a dead screen. + uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, "Not yet", true); + break; + + case MESSAGES_ITEM_INBOX: + showingOutbox = false; + menuSystemPushNewMenu(MENU_MESSAGES_FOLDER); + break; + + case MESSAGES_ITEM_OUTBOX: + showingOutbox = true; + menuSystemPushNewMenu(MENU_MESSAGES_FOLDER); + break; + } + + return; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_RED)) + { + menuSystemPopPreviousMenu(); + } +} + + +// ------------------------------------------------------- Inbox / Outbox menu + +menuStatus_t menuMessagesFolder(uiEvent_t *ev, bool isFirstRun) +{ + if (isFirstRun) + { + menuDataGlobal.numItems = NUM_FOLDER_ITEMS; + menuDataGlobal.currentItemIndex = 0; + folderUpdateScreen(); + return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS); + } + + exitCode = MENU_STATUS_SUCCESS; + + if (ev->hasEvent) + { + folderHandleEvent(ev); + } + + return exitCode; +} + +static void folderUpdateScreen(void) +{ + char buf[SCREEN_LINE_BUFFER_SIZE]; + uint8_t count = (showingOutbox ? smsOutboxCount() : smsInboxCount()); + int mNum; + + displayClearBuf(); + menuDisplayTitle(showingOutbox ? "Outbox" : "Inbox"); + + for (int i = MENU_START_ITERATION_VALUE; i <= MENU_END_ITERATION_VALUE; i++) + { + mNum = menuGetMenuOffset(NUM_FOLDER_ITEMS, i); + + if (mNum == MENU_OFFSET_BEFORE_FIRST_ENTRY) + { + continue; + } + else if (mNum == MENU_OFFSET_AFTER_LAST_ENTRY) + { + break; + } + + if (mNum == FOLDER_ITEM_LIST) + { + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "%s (%u)", (showingOutbox ? "Outbox List" : "Inbox List"), (unsigned int)count); + } + else + { + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "Delete All"); + } + + menuDisplayEntry(i, mNum, buf, 0, THEME_ITEM_FG_MENU_ITEM, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG); + } + + displayRender(); +} + +static void folderHandleEvent(uiEvent_t *ev) +{ + if (KEYCHECK_PRESS(ev->keys, KEY_DOWN)) + { + menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, NUM_FOLDER_ITEMS); + folderUpdateScreen(); + exitCode |= MENU_STATUS_LIST_TYPE; + return; + } + + if (KEYCHECK_PRESS(ev->keys, KEY_UP)) + { + menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, NUM_FOLDER_ITEMS); + folderUpdateScreen(); + exitCode |= MENU_STATUS_LIST_TYPE; + return; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN)) + { + if (menuDataGlobal.currentItemIndex == FOLDER_ITEM_LIST) + { + viewingMessage = false; + menuSystemPushNewMenu(MENU_MESSAGES_LIST); + } + else + { + if (showingOutbox) + { + smsOutboxClear(); + } + else + { + smsInboxClear(); + } + + soundSetMelody(MELODY_ACK_BEEP); + folderUpdateScreen(); + } + + return; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_RED)) + { + menuSystemPopPreviousMenu(); + } +} + + +// ------------------------------------------------------------- message list + +menuStatus_t menuMessagesList(uiEvent_t *ev, bool isFirstRun) +{ + if (isFirstRun) + { + menuDataGlobal.numItems = (showingOutbox ? smsOutboxCount() : smsInboxCount()); + menuDataGlobal.currentItemIndex = 0; + viewingMessage = false; + listUpdateScreen(); + return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS); + } + + exitCode = MENU_STATUS_SUCCESS; + + if (ev->hasEvent) + { + listHandleEvent(ev); + } + + return exitCode; +} + +static void listUpdateScreen(void) +{ + char buf[SCREEN_LINE_BUFFER_SIZE]; + uint8_t count = (showingOutbox ? smsOutboxCount() : smsInboxCount()); + int mNum; + + displayClearBuf(); + + if (count == 0) + { + menuDisplayTitle(showingOutbox ? "Outbox" : "Inbox"); + displayPrintCentered(32, "Empty", FONT_SIZE_3); + displayRender(); + return; + } + + if (viewingMessage) + { + const smsMessage_t *msg = (showingOutbox + ? smsOutboxGet((uint8_t)menuDataGlobal.currentItemIndex) + : smsInboxGet((uint8_t)menuDataGlobal.currentItemIndex)); + + if (msg != NULL) + { + const int charsPerLine = 20; + int len = (int)strlen(msg->text); + + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "%u", (unsigned int)(showingOutbox ? msg->dstId : msg->srcId)); + menuDisplayTitle(buf); + + // Wrap the text so long messages are readable instead of being cut off. + for (int line = 0; (line < 4) && ((line * charsPerLine) < len); line++) + { + int n = len - (line * charsPerLine); + + if (n > charsPerLine) + { + n = charsPerLine; + } + + memcpy(buf, &msg->text[line * charsPerLine], n); + buf[n] = 0; + displayPrintCore(0, (16 + (line * 12)), buf, FONT_SIZE_2, TEXT_ALIGN_LEFT, false); + } + } + + displayRender(); + return; + } + + menuDisplayTitle(showingOutbox ? "Outbox" : "Inbox"); + + for (int i = MENU_START_ITERATION_VALUE; i <= MENU_END_ITERATION_VALUE; i++) + { + mNum = menuGetMenuOffset(count, i); + + if (mNum == MENU_OFFSET_BEFORE_FIRST_ENTRY) + { + continue; + } + else if (mNum == MENU_OFFSET_AFTER_LAST_ENTRY) + { + break; + } + + const smsMessage_t *m = (showingOutbox ? smsOutboxGet((uint8_t)mNum) : smsInboxGet((uint8_t)mNum)); + + if (m == NULL) + { + continue; + } + + snprintf(buf, SCREEN_LINE_BUFFER_SIZE, "%s%u %.8s", + (m->unread ? "*" : ""), + (unsigned int)(showingOutbox ? m->dstId : m->srcId), + m->text); + + menuDisplayEntry(i, mNum, buf, 0, THEME_ITEM_FG_MENU_ITEM, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG); + } + + displayRender(); +} + +static void listHandleEvent(uiEvent_t *ev) +{ + uint8_t count = (showingOutbox ? smsOutboxCount() : smsInboxCount()); + + if ((viewingMessage == false) && (count > 0)) + { + if (KEYCHECK_PRESS(ev->keys, KEY_DOWN)) + { + menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, count); + listUpdateScreen(); + exitCode |= MENU_STATUS_LIST_TYPE; + return; + } + + if (KEYCHECK_PRESS(ev->keys, KEY_UP)) + { + menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, count); + listUpdateScreen(); + exitCode |= MENU_STATUS_LIST_TYPE; + return; + } + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN) && (count > 0)) + { + if (viewingMessage == false) + { + viewingMessage = true; + + if (showingOutbox == false) + { + smsMarkRead(); + } + + listUpdateScreen(); + } + + return; + } + + if (KEYCHECK_SHORTUP(ev->keys, KEY_RED)) + { + if (viewingMessage) + { + viewingMessage = false; + listUpdateScreen(); + return; + } + + menuSystemPopPreviousMenu(); + } +} +''' + +open(p, "w").write(header + body) +print("menuMessages.c: rewritten with standard list navigation") diff --git a/patches/patch_notimeout.py b/patches/patch_notimeout.py new file mode 100644 index 0000000..db484c1 --- /dev/null +++ b/patches/patch_notimeout.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Stop the radio from dropping out of hotspot (modem) mode when the host goes quiet. + +Stock behaviour: if the USB host does not talk to the radio for MMDVMHOST_TIMEOUT +(20 s), the firmware calls hotspotExit() and leaves modem mode by itself. That is a +safety net for a crashed host, but it makes the radio unusable as a permanently +attached modem - which is exactly how it is used here. + +With the timeout set to 0 the inactivity check is disabled; leaving modem mode is +then only done deliberately (by the user from the radio, or by the host asking the +modem to go idle). +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/functions/hotspot.c" +s = open(p).read() + +if "MMDVMHOST_TIMEOUT_DISABLED" in s: + print("already patched") + raise SystemExit(0) + +old_const = "static const uint32_t MMDVMHOST_TIMEOUT = 20000; // 20s timeout (MMDVMHost mode only, there is no timeout for BlueDV)\n" +assert old_const in s, "timeout constant not found" + +new_const = ( + "// 0 disables the host-inactivity timeout, so the radio stays in modem mode until it is\n" + "// told to leave. Set this back to 20000 to restore the stock 20 second auto-exit.\n" + "#define MMDVMHOST_TIMEOUT_DISABLED 0\n" + "static const uint32_t MMDVMHOST_TIMEOUT = MMDVMHOST_TIMEOUT_DISABLED;\n" +) +s = s.replace(old_const, new_const, 1) + +# Guard both inactivity checks so a zero timeout means "never time out". +old_check = """ if ((nonVolatileSettings.hotspotType == HOTSPOT_TYPE_MMDVM) && + ((ticksGetMillis() - mmdvmHostLastActiveTime) > MMDVMHOST_TIMEOUT)) +""" +new_check = """ if ((MMDVMHOST_TIMEOUT != 0) && (nonVolatileSettings.hotspotType == HOTSPOT_TYPE_MMDVM) && + ((ticksGetMillis() - mmdvmHostLastActiveTime) > MMDVMHOST_TIMEOUT)) +""" +count = s.count(old_check) +assert count == 2, "expected 2 inactivity checks, found %d" % count +s = s.replace(old_check, new_check) + +open(p, "w").write(s) +print("hotspot.c: host-inactivity auto-exit disabled (%d checks guarded)" % count) diff --git a/patches/patch_rx1.py b/patches/patch_rx1.py new file mode 100644 index 0000000..635f58e --- /dev/null +++ b/patches/patch_rx1.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Patch 1 of RX support: new RX event type + Slot Type encoder.""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# --- 1. new RX event type ------------------------------------------------- +p = W + "/include/functions/hotspot.h" +s = open(p).read() +if "HOTSPOT_RX_DATA_FRAME" not in s: + a = "\tHOTSPOT_RX_STOP,\n" + assert a in s, "RX enum anchor missing" + s = s.replace(a, a + "\tHOTSPOT_RX_DATA_FRAME,// a packet data burst (SMS etc) was received off air\n", 1) + open(p, "w").write(s) + print("hotspot.h: RX event added") +else: + print("hotspot.h: already has RX event") + +# --- 2. bring the Slot Type encoder over from the older MK22 branch ------- +src = open("/home/viktor/OpenGD77/firmware/source/hotspot/DMRSlotType.c").read() +start = src.index("static const uint16_t ENCODING_TABLE_2087[] =") +table = src[start:src.index("};", start) + 2] + +p = W + "/source/functions/hotspot.c" +s = open(p).read() +if "ENCODING_TABLE_2087" in s: + print("hotspot.c: encoder already present") + raise SystemExit(0) + +anchor = "static void BPTCencode(const uint8_t *inputData, uint8_t *outputData)\n{" +assert anchor in s, "BPTCencode definition not found" + +header = ( + "// Slot Type encoder, needed to build received data bursts for the host.\n" + "// Golay(20,8) table and logic come from the project's own DMRSlotType implementation\n" + "// (present in the MK22 branch, absent here because stock firmware never sent data).\n" +) + +func = """ + +// Writes colour code + data type (FEC protected) into the Slot Type half-bursts of a frame. +static void DMRSlotTypeEncode(uint32_t colorCode, uint32_t dataType, uint8_t *frame) +{ +\tuint8_t slotType[3]; + +\tslotType[0] = (colorCode << 4) & 0xF0U; +\tslotType[0] |= (dataType << 0) & 0x0FU; + +\tuint16_t cksum = ENCODING_TABLE_2087[slotType[0]]; + +\tslotType[1] = (cksum >> 0) & 0xFFU; +\tslotType[2] = (cksum >> 8) & 0xFFU; + +\tframe[12] = (frame[12] & 0xC0U) | ((slotType[0] >> 2) & 0x3FU); +\tframe[13] = (frame[13] & 0x0FU) | ((slotType[0] << 6) & 0xC0U) | ((slotType[1] >> 2) & 0x30U); +\tframe[19] = (frame[19] & 0xF0U) | ((slotType[1] >> 2) & 0x0FU); +\tframe[20] = (frame[20] & 0x03U) | ((slotType[1] << 6) & 0xC0U) | ((slotType[2] >> 2) & 0x3CU); +} + +""" + +s = s.replace(anchor, header + table + func + anchor, 1) +open(p, "w").write(s) +print("hotspot.c: slot type encoder added (table %d bytes)" % len(table)) diff --git a/patches/patch_rx2.py b/patches/patch_rx2.py new file mode 100644 index 0000000..f9d25a5 --- /dev/null +++ b/patches/patch_rx2.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Patch 2 of RX support: hand received data bursts to the MMDVM host.""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/functions/hotspot.c" +s = open(p).read() + +if "sendDataFrameToHost" in s: + print("already patched") + raise SystemExit(0) + +# --- 1. MS sourced data sync pattern (frames we originate towards the host) --- +a = "static const uint8_t MS_SOURCED_AUDIO_SYNC[] = { 0x07, 0xF7, 0xD5, 0xDD, 0x57, 0xDF, 0xD0 };\n" +assert a in s, "sync constant anchor missing" +s = s.replace(a, a + "static const uint8_t MS_SOURCED_DATA_SYNC[] = { 0x0D, 0x5D, 0x7F, 0x77, 0xFD, 0x75, 0x70 };\n", 1) + +# --- 2. the function itself, placed right before the voice header builder --- +anchor = "static bool sendVoiceHeaderLC_Frame(volatile const uint8_t *receivedDMRDataAndAudio)\n{" +assert anchor in s, "sendVoiceHeaderLC_Frame not found" + +func = '''// Rebuild a packet data burst that was received off air and pass it to the MMDVM host. +// +// The HR-C6000 hands us the decoded 96 bit payload (it strips the FEC itself), while the +// host expects a complete 33 byte DMR burst. So we run the payload back through BPTC, +// stamp the data sync pattern and the Slot Type (colour code + data type) into the middle +// of the burst, and send it as a normal DMR data frame. +static void sendDataFrameToHost(volatile const uint8_t *receivedData, uint8_t dataType) +{ +\tuint8_t frameData[DMR_FRAME_LENGTH_BYTES + MMDVM_HEADER_LENGTH + 2] = {MMDVM_FRAME_START, (DMR_FRAME_LENGTH_BYTES + MMDVM_HEADER_LENGTH + 2), MMDVM_DMR_DATA2, DMR_SYNC_DATA | dataType}; + +\tBPTCglobalsInit(); +\tBPTCencode((uint8_t *)receivedData, frameData + MMDVM_HEADER_LENGTH); + +\t// data sync pattern goes into the middle of the burst +\tfor (uint8_t i = 0; i < 7; i++) +\t{ +\t\tframeData[i + EMBEDDED_DATA_OFFSET + MMDVM_HEADER_LENGTH] = +\t\t\t\t(frameData[i + EMBEDDED_DATA_OFFSET + MMDVM_HEADER_LENGTH] & ~SYNC_MASK[i]) | MS_SOURCED_DATA_SYNC[i]; +\t} + +\tDMRSlotTypeEncode(trxGetDMRColourCode(), dataType, frameData + MMDVM_HEADER_LENGTH); + +\tsetRSSIToFrame(frameData); + +\tenqueueUSBData(frameData, frameData[1]); +} + +''' + +s = s.replace(anchor, func + anchor, 1) +open(p, "w").write(s) +print("hotspot.c: sendDataFrameToHost added") diff --git a/patches/patch_rx3.py b/patches/patch_rx3.py new file mode 100644 index 0000000..fc7ef2b --- /dev/null +++ b/patches/patch_rx3.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Patch 3 of RX support: dispatch in the hotspot state machine, and read the +payload out of the HR-C6000 when a data burst arrives off air.""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# ---------------------------------------------------------------- hotspot.c +p = W + "/source/functions/hotspot.c" +s = open(p).read() + +if "case HOTSPOT_RX_DATA_FRAME:" in s: + print("hotspot.c: dispatch already present") +else: + a = "\t\t\t\t\t\tcase HOTSPOT_RX_STOP:\n" + assert a in s, "RX_STOP case not found" + case = """\t\t\t\t\t\tcase HOTSPOT_RX_DATA_FRAME: +\t\t\t\t\t\t\t// Packet data burst (SMS, position report...) received off air. +\t\t\t\t\t\t\t// The data type the HR-C6000 reported is stored right after the frame. +\t\t\t\t\t\t\tsendDataFrameToHost(audioAndHotspotDataBuffer.hotspotBuffer[rfFrameBufReadIdx], +\t\t\t\t\t\t\t\t\taudioAndHotspotDataBuffer.hotspotBuffer[rfFrameBufReadIdx][AMBE_AUDIO_LENGTH + LC_DATA_LENGTH + 1]); +\t\t\t\t\t\t\tlastRxState = HOTSPOT_RX_DATA_FRAME; +\t\t\t\t\t\t\trxFrameTime = ticksGetMillis(); +\t\t\t\t\t\t\tbreak; + +""" + s = s.replace(a, case + a, 1) + open(p, "w").write(s) + print("hotspot.c: RX_DATA_FRAME dispatch added") + +# ------------------------------------------------------------- HR-C6000.c +p = W + "/source/hardware/HR-C6000.c" +s = open(p).read() + +if "HOTSPOT_RX_DATA_FRAME" in s: + print("HR-C6000.c: already patched") + raise SystemExit(0) + +# Hook in right after the terminator handling, which is the existing example of +# "this is a data sync burst" handling in this interrupt. +a = """ if (hrc6000CrcIsValid() && (slotState != DMR_STATE_IDLE) && (hrc.skipCount > 0) && (rxSyncClass != SYNC_CLASS_DATA) && ((rxDataType & 0x07) == 0x01)) + { + hrc.skipCount--; + } +""" +assert a in s, "skipCount anchor missing" + +block = """ // Packet data bursts (SMS, position reports, CSBK). Stock firmware reads nothing off the + // chip for these, which is why data never reached the host. The HR-C6000 has already + // removed the FEC, so the 96 bit payload can be read from the same register page that + // carries LC data, and handed to the hotspot code to be rebuilt into a full burst. + if ((settingsUsbMode == USB_MODE_HOTSPOT) && hrc6000CrcIsValid() && (rxSyncClass == SYNC_CLASS_DATA) && + ((rxDataType == DT_CSBK) || (rxDataType == DT_DATA_HEADER) || (rxDataType == DT_RATE_12_DATA)) && + hrc6000CheckColourCodeFilter()) + { + if (SPI0ReadPageRegByteArray(0x02, 0x00, (uint8_t *)DMR_frame_buffer, LC_DATA_LENGTH) == kStatus_Success) + { + DMR_frame_buffer[AMBE_AUDIO_LENGTH + LC_DATA_LENGTH] = HOTSPOT_RX_DATA_FRAME; + DMR_frame_buffer[AMBE_AUDIO_LENGTH + LC_DATA_LENGTH + 1] = rxDataType; + hrc.hotspotDMRRxFrameBufferAvailable = true; + } + return; + } + +""" + +s = s.replace(a, block + a, 1) +open(p, "w").write(s) +print("HR-C6000.c: data burst capture added") diff --git a/patches/patch_show.py b/patches/patch_show.py new file mode 100644 index 0000000..29bccd4 --- /dev/null +++ b/patches/patch_show.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Show a received SMS on a proper message screen instead of a transient notification. + +uiNotificationShow() draws over the current screen, but the normal screen redraw wipes +it again - which is why the text never appeared even though the message was received +and decoded correctly. The message box screen is a real menu: it stays until the user +dismisses it. +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/applicationMain.c" +s = open(p).read() + +if "MESSAGEBOX_TYPE_INFO" in s and "smsMarkRead" in s and "MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, \"%u:" in s: + print("already patched") + raise SystemExit(0) + +# Replace the notification-based display with a message box screen. +old_start = "\t\tif (smsHasUnread())\n\t\t{\n" +assert old_start in s, "unread block not found" + +start = s.index(old_start) +# find the end of that block (the closing brace of the if, at the same indent) +end_marker = "\n\t\t}\n" +end = s.index(end_marker, start) + len(end_marker) +old_block = s[start:end] +assert "smsMarkRead" in old_block, "unexpected block contents: %r" % old_block[:200] + +new_block = """ if (smsHasUnread()) + { + const smsMessage_t *msg = smsGetLatest(); + + // Only interrupt the user when they are on a normal operating screen. + int currentMenu = menuSystemGetCurrentMenuNumber(); + + if ((msg != NULL) && ((currentMenu == UI_VFO_MODE) || (currentMenu == UI_CHANNEL_MODE))) + { + snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%u\\n%s", + (unsigned int)msg->srcId, msg->text); + uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO; + uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME; + uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_OK; + uiDataGlobal.MessageBox.validatorCallback = NULL; + menuSystemPushNewMenu(UI_MESSAGE_BOX); + + soundSetMelody(MELODY_ACK_BEEP); + smsMarkRead(); + } + } +""" + +s = s[:start] + new_block + s[end:] +open(p, "w").write(s) +print("applicationMain.c: incoming SMS now opens a message screen") diff --git a/patches/patch_sms1.py b/patches/patch_sms1.py new file mode 100644 index 0000000..1b209c3 --- /dev/null +++ b/patches/patch_sms1.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""SMS step 1: capture data bursts in normal radio mode too, and route them to an +assembler module. In hotspot mode nothing changes - bursts still go to the host.""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +p = W + "/source/hardware/HR-C6000.c" +s = open(p).read() + +if "smsRxHandleBurst" in s: + print("HR-C6000.c already routed to the SMS assembler") +else: + old = """ if ((settingsUsbMode == USB_MODE_HOTSPOT) && hrc6000CrcIsValid() && (rxSyncClass == SYNC_CLASS_DATA) && + ((rxDataType == DT_CSBK) || (rxDataType == DT_DATA_HEADER) || (rxDataType == DT_RATE_12_DATA)) && + hrc6000CheckColourCodeFilter()) + { + if (SPI0ReadPageRegByteArray(0x02, 0x00, (uint8_t *)DMR_frame_buffer, LC_DATA_LENGTH) == kStatus_Success) + { + DMR_frame_buffer[AMBE_AUDIO_LENGTH + LC_DATA_LENGTH] = HOTSPOT_RX_DATA_FRAME; + DMR_frame_buffer[AMBE_AUDIO_LENGTH + LC_DATA_LENGTH + 1] = rxDataType; + hrc.hotspotDMRRxFrameBufferAvailable = true; + } + return; + } +""" + assert old in s, "existing capture block not found" + + new = """ if (hrc6000CrcIsValid() && (rxSyncClass == SYNC_CLASS_DATA) && + ((rxDataType == DT_CSBK) || (rxDataType == DT_DATA_HEADER) || (rxDataType == DT_RATE_12_DATA)) && + hrc6000CheckColourCodeFilter()) + { + uint8_t dataBurst[LC_DATA_LENGTH]; + + if (SPI0ReadPageRegByteArray(0x02, 0x00, dataBurst, LC_DATA_LENGTH) == kStatus_Success) + { + if (settingsUsbMode == USB_MODE_HOTSPOT) + { + // Acting as a modem: hand the burst to the host untouched. + memcpy((uint8_t *)DMR_frame_buffer, dataBurst, LC_DATA_LENGTH); + DMR_frame_buffer[AMBE_AUDIO_LENGTH + LC_DATA_LENGTH] = HOTSPOT_RX_DATA_FRAME; + DMR_frame_buffer[AMBE_AUDIO_LENGTH + LC_DATA_LENGTH + 1] = rxDataType; + hrc.hotspotDMRRxFrameBufferAvailable = true; + } + else + { + // Normal radio operation: collect the bursts into a message. + smsRxHandleBurst(dataBurst, rxDataType); + } + } + return; + } +""" + s = s.replace(old, new, 1) + + # include the new module + anchor = '#include "functions/hotspot.h"\n' + assert anchor in s, "include anchor missing" + s = s.replace(anchor, anchor + '#include "functions/sms.h"\n', 1) + + open(p, "w").write(s) + print("HR-C6000.c: data bursts now captured in normal mode too") diff --git a/patches/patch_sms2.py b/patches/patch_sms2.py new file mode 100644 index 0000000..71dbcd8 --- /dev/null +++ b/patches/patch_sms2.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""SMS step 2: run the assembler from the main loop and initialise it at startup.""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/applicationMain.c" +s = open(p).read() + +if "smsTick" in s: + print("already wired into the main loop") + raise SystemExit(0) + +# include +anchor = None +for cand in ['#include "functions/voicePrompts.h"\n', '#include "functions/settings.h"\n', '#include "functions/ticks.h"\n']: + if cand in s: + anchor = cand + break +assert anchor, "no suitable include anchor in applicationMain.c" +s = s.replace(anchor, anchor + '#include "functions/sms.h"\n', 1) + +# drain the received bursts once per main loop pass, next to the other periodic work +a = "\t\tvoxTick();\n" +assert a in s, "voxTick anchor missing" +s = s.replace(a, a + "\t\tsmsTick();// turn received data bursts into messages\n", 1) + +# initialise alongside the other subsystems +init_anchor = None +for cand in ["\tbatteryUpdate();\n", "\tvoxInit();\n"]: + if cand in s: + init_anchor = cand + break +assert init_anchor, "no init anchor found" +s = s.replace(init_anchor, init_anchor + "\tsmsInit();\n", 1) + +open(p, "w").write(s) +print("applicationMain.c: smsInit() + smsTick() wired in") diff --git a/patches/patch_sms3.py b/patches/patch_sms3.py new file mode 100644 index 0000000..77b3f6c --- /dev/null +++ b/patches/patch_sms3.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""SMS step 3: show a received message on the screen and beep.""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/applicationMain.c" +s = open(p).read() + +if "smsGetLatest" in s: + print("already showing messages") + raise SystemExit(0) + +old = "\t\tsmsTick();// turn received data bursts into messages\n" +assert old in s, "smsTick call not found" + +new = """\t\tsmsTick();// turn received data bursts into messages + +\t\tif (smsHasUnread()) +\t\t{ +\t\t\tconst smsMessage_t *msg = smsGetLatest(); + +\t\t\tif (msg != NULL) +\t\t\t{ +\t\t\t\tchar smsLine[SMS_MAX_TEXT_LENGTH + 16]; + +\t\t\t\tsnprintf(smsLine, sizeof(smsLine), "%u: %s", (unsigned int)msg->srcId, msg->text); +\t\t\t\tuiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 10000, smsLine, true); +\t\t\t\tsoundSetMelody(MELODY_ACK_BEEP); +\t\t\t\tsmsMarkRead(); +\t\t\t} +\t\t} +""" + +s = s.replace(old, new, 1) +open(p, "w").write(s) +print("applicationMain.c: incoming SMS is shown and beeps") diff --git a/patches/patch_store.py b/patches/patch_store.py new file mode 100644 index 0000000..a1d0d0c --- /dev/null +++ b/patches/patch_store.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Add Inbox/Outbox storage to the SMS module.""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# ------------------------------------------------------------------ header +p = W + "/include/functions/sms.h" +s = open(p).read() +if "smsInboxCount" in s: + print("sms.h: already has storage API") +else: + s = s.replace( + "// Most recently received message, or NULL when nothing has been received yet.\n", + """// How many messages each folder keeps. Older ones are dropped when full. +#define SMS_INBOX_SIZE 10 +#define SMS_OUTBOX_SIZE 10 + +// Inbox: messages received off air. Newest first. +uint8_t smsInboxCount(void); +const smsMessage_t *smsInboxGet(uint8_t index); +void smsInboxClear(void); +uint8_t smsInboxUnreadCount(void); + +// Outbox: messages we sent. Newest first. +uint8_t smsOutboxCount(void); +const smsMessage_t *smsOutboxGet(uint8_t index); +void smsOutboxClear(void); +void smsOutboxAdd(uint32_t dstId, bool isPrivate, const char *text); + +// Most recently received message, or NULL when nothing has been received yet. +""", 1) + open(p, "w").write(s) + print("sms.h: storage API declared") + +# ------------------------------------------------------------------ source +p = W + "/source/functions/sms.c" +s = open(p).read() +if "smsInboxCount" in s: + print("sms.c: already has storage") + raise SystemExit(0) + +a = "static smsMessage_t latest;\n" +assert a in s +s = s.replace(a, a + """ +// Message folders. Index 0 is always the newest; adding shifts the rest down. +static smsMessage_t inbox[SMS_INBOX_SIZE]; +static uint8_t inboxCount = 0; +static smsMessage_t outbox[SMS_OUTBOX_SIZE]; +static uint8_t outboxCount = 0; +""", 1) + +# clear folders on init +s = s.replace("\tmemset(&latest, 0, sizeof(latest));\n", + "\tmemset(&latest, 0, sizeof(latest));\n" + "\tinboxCount = 0;\n" + "\toutboxCount = 0;\n", 1) + +# store a completed message into the inbox +a = "\thaveMessage = (t > 0);\n" +assert a in s, "completion point not found" +s = s.replace(a, """ if (t > 0) + { + // Newest first: shift the existing entries down, dropping the oldest if full. + for (int8_t i = (int8_t)((inboxCount < SMS_INBOX_SIZE) ? inboxCount : (SMS_INBOX_SIZE - 1)); i > 0; i--) + { + inbox[i] = inbox[i - 1]; + } + + inbox[0] = latest; + + if (inboxCount < SMS_INBOX_SIZE) + { + inboxCount++; + } + } + +""" + a, 1) + +s += """ + +uint8_t smsInboxCount(void) +{ + return inboxCount; +} + +const smsMessage_t *smsInboxGet(uint8_t index) +{ + return ((index < inboxCount) ? &inbox[index] : NULL); +} + +void smsInboxClear(void) +{ + inboxCount = 0; + unread = false; + haveMessage = false; +} + +uint8_t smsInboxUnreadCount(void) +{ + uint8_t n = 0; + + for (uint8_t i = 0; i < inboxCount; i++) + { + if (inbox[i].unread) + { + n++; + } + } + + return n; +} + +uint8_t smsOutboxCount(void) +{ + return outboxCount; +} + +const smsMessage_t *smsOutboxGet(uint8_t index) +{ + return ((index < outboxCount) ? &outbox[index] : NULL); +} + +void smsOutboxClear(void) +{ + outboxCount = 0; +} + +void smsOutboxAdd(uint32_t dstId, bool isPrivate, const char *text) +{ + for (int8_t i = (int8_t)((outboxCount < SMS_OUTBOX_SIZE) ? outboxCount : (SMS_OUTBOX_SIZE - 1)); i > 0; i--) + { + outbox[i] = outbox[i - 1]; + } + + memset(&outbox[0], 0, sizeof(smsMessage_t)); + outbox[0].dstId = dstId; + outbox[0].isPrivate = isPrivate; + outbox[0].unread = false; + strncpy(outbox[0].text, text, SMS_MAX_TEXT_LENGTH); + outbox[0].text[SMS_MAX_TEXT_LENGTH] = '\\0'; + + if (outboxCount < SMS_OUTBOX_SIZE) + { + outboxCount++; + } +} +""" + +open(p, "w").write(s) +print("sms.c: inbox/outbox storage added") diff --git a/patches/patch_tx.py b/patches/patch_tx.py new file mode 100644 index 0000000..db6de6a --- /dev/null +++ b/patches/patch_tx.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Transmit an SMS from the radio itself. + +The burst mechanism is the one already proven in hotspot mode: the 12 byte payload goes +to register page 0x02 and reg 0x50 selects the burst type, and the HR-C6000 builds the +FEC and sync around it. What is new here is driving it from normal radio operation, +where the state machine is otherwise built around voice. + +A transmission is queued as a list of bursts (preambles, data header, then the rate 1/2 +blocks). The timeslot interrupt pulls one burst per active slot until the queue empties, +then ends the transmission. +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# --------------------------------------------------------------- sms.h +p = W + "/include/functions/sms.h" +s = open(p).read() +if "smsSendText" in s: + print("sms.h: already declared") +else: + s = s.replace("uint8_t smsGetBlockCount(uint16_t packetLength);", """// Queue a text message for transmission. Returns false if a transmission is already +// in progress or the message does not fit. +bool smsSendText(uint32_t dstId, bool isGroup, const char *text); + +// True while bursts are still waiting to go out. +bool smsTxIsActive(void); + +// Called from the timeslot interrupt: fetch the next burst to send. +// Returns false when the transmission is finished. +bool smsTxGetNextBurst(uint8_t *payload, uint8_t *dataType); + +uint8_t smsGetBlockCount(uint16_t packetLength);""", 1) + open(p, "w").write(s) + print("sms.h: transmit API declared") + +# --------------------------------------------------------------- sms.c +p = W + "/source/functions/sms.c" +s = open(p).read() +if "smsSendText" in s: + print("sms.c: already has transmit") +else: + s = s.replace('#include "functions/ticks.h"\n', + '#include "functions/ticks.h"\n#include "functions/trx.h"\n#include "functions/settings.h"\n', 1) + + s += """ + +// ---------------------------------------------------------------- transmit + +#define SMS_TX_MAX_BURSTS 24U // 4 preambles + header + up to 19 blocks +#define SMS_PREAMBLE_COUNT 4U + +static uint8_t txBursts[SMS_TX_MAX_BURSTS][SMS_BLOCK_LENGTH]; +static uint8_t txTypes[SMS_TX_MAX_BURSTS]; +static uint8_t txCount = 0; +static uint8_t txIndex = 0; +static uint8_t txSeq = 0x81; + +bool smsSendText(uint32_t dstId, bool isGroup, const char *text) +{ + uint8_t packet[(SMS_MAX_TEXT_LENGTH * 2) + 64]; + uint8_t pad = 0; + uint16_t packetLength; + uint8_t blocks; + uint8_t n = 0; + + if (txCount != 0) + { + return false;// a transmission is already queued + } + + packetLength = smsBuildPacket(packet, sizeof(packet), trxDMRID, dstId, isGroup, text, + 0xA0, txSeq, &pad);// 0xA0: we are not asking for a delivery report yet + + if (packetLength == 0) + { + return false; + } + + blocks = smsGetBlockCount(packetLength); + + if ((uint8_t)(blocks + SMS_PREAMBLE_COUNT + 1U) > SMS_TX_MAX_BURSTS) + { + return false;// message too long for the queue + } + + // Preambles first, counting down towards the header, as real radios send them. + for (uint8_t i = 0; i < SMS_PREAMBLE_COUNT; i++) + { + smsBuildPreamble(txBursts[n], trxDMRID, dstId, isGroup, (uint8_t)(SMS_PREAMBLE_COUNT - i)); + txTypes[n] = DT_CSBK; + n++; + } + + smsBuildDataHeader(txBursts[n], trxDMRID, dstId, isGroup, blocks, pad); + txTypes[n] = DT_DATA_HEADER; + n++; + + for (uint8_t i = 0; i < blocks; i++) + { + smsGetBlock(packet, packetLength, i, txBursts[n]); + txTypes[n] = DT_RATE_12_DATA; + n++; + } + + txIndex = 0; + txCount = n; + txSeq = (uint8_t)(0x80U | (((txSeq + 1U) & 0x7FU) ? ((txSeq + 1U) & 0x7FU) : 1U)); + + smsOutboxAdd(dstId, (isGroup == false), text); + + // Key the transmitter; the timeslot interrupt will drain the queue. + trxTransmissionEnabled = true; + trxEnableTransmission(); + + return true; +} + +bool smsTxIsActive(void) +{ + return (txCount != 0); +} + +bool smsTxGetNextBurst(uint8_t *payload, uint8_t *dataType) +{ + if ((txCount == 0) || (txIndex >= txCount)) + { + txCount = 0; + txIndex = 0; + return false; + } + + memcpy(payload, txBursts[txIndex], SMS_BLOCK_LENGTH); + *dataType = txTypes[txIndex]; + txIndex++; + + return true; +} +""" + open(p, "w").write(s) + print("sms.c: transmit queue added") + +# --------------------------------------------------------------- HR-C6000.c +p = W + "/source/hardware/HR-C6000.c" +s = open(p).read() +if "smsTxGetNextBurst" in s: + print("HR-C6000.c: already patched") +else: + # In the ongoing-TX slot, send our data bursts instead of voice when a message is queued. + anchor = """ case DMR_STATE_TX_2: // Ongoing TX (active timeslot) + if (hrc.transmissionEnabled) + { +""" + assert anchor in s, "TX_2 anchor not found" + s = s.replace(anchor, """ case DMR_STATE_TX_2: // Ongoing TX (active timeslot) + if (hrc.transmissionEnabled) + { + // A text message queued by the radio itself takes over the slot: its + // payload goes to the LC page and reg 0x50 picks the burst type, exactly + // as in hotspot mode, while the vocoder path stays idle. + if ((settingsUsbMode != USB_MODE_HOTSPOT) && smsTxIsActive()) + { + uint8_t smsPayload[LC_DATA_LENGTH]; + uint8_t smsType; + + if (smsTxGetNextBurst(smsPayload, &smsType)) + { + SPI0WritePageRegByteArray(0x02, 0x00, smsPayload, LC_DATA_LENGTH); + SPI1WritePageRegByteArray(0x03, 0x00, SILENCE_AUDIO, AMBE_AUDIO_LENGTH); + SPI0WritePageRegByte(0x04, 0x41, 0x80); + SPI0WritePageRegByte(0x04, 0x50, (uint8_t)(smsType << 4)); + } + else + { + // Queue drained: stop keying and let the normal shutdown run. + hrc.transmissionEnabled = false; + trxTransmissionEnabled = false; + } + + slotState = DMR_STATE_TX_1; + break; + } + +""", 1) + + # And do not announce a voice call in front of the data when we are the sender. + s = s.replace("static inline bool isHotspotDataTx(void)\n{\n\treturn ((settingsUsbMode == USB_MODE_HOTSPOT) && (hrc.txDataType != HOTSPOT_TX_TYPE_VOICE));\n}", + "static inline bool isHotspotDataTx(void)\n{\n" + "\t// Also true while the radio itself is sending a text message, so that the\n" + "\t// Voice LC Header is suppressed in front of the data bursts.\n" + "\treturn (((settingsUsbMode == USB_MODE_HOTSPOT) && (hrc.txDataType != HOTSPOT_TX_TYPE_VOICE))\n" + "\t\t\t|| smsTxIsActive());\n}", 1) + + open(p, "w").write(s) + print("HR-C6000.c: radio-originated data transmission wired in") diff --git a/patches/patch_txclean.py b/patches/patch_txclean.py new file mode 100644 index 0000000..5af052b --- /dev/null +++ b/patches/patch_txclean.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Stop emitting voice LC headers in front of a packet data transmission. + +TX_START_1/3/5 always sent three Voice LC Headers built from the radio's own DMR ID, +which is correct for a voice call but pollutes the air before an SMS: the receiving +end logs them as "RF user rejected for using TG 0". When the queued frame is a +data burst we send standard Idle data bursts instead - a legitimate filler that +receivers discard silently. +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/hardware/HR-C6000.c" +s = open(p).read() + +if "DT_IDLE << 4" in s: + print("already patched") + raise SystemExit(0) + +# The three start steps share the same two lines; patch each occurrence. +old_variants = [ + # TX_START_1 (preceded by the LED write and header call) + (""" hrc6000SendPcOrTgLCHeader(); + SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot + SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00 +""", + """ if (hrc.txDataType != HOTSPOT_TX_TYPE_VOICE) + { + // A data burst is queued: don't announce a voice call in front of it. + SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot + SPI0WritePageRegByte(0x04, 0x50, (DT_IDLE << 4)); // Idle data burst as filler + } + else + { + hrc6000SendPcOrTgLCHeader(); + SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot + SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00 + } +"""), + # TX_START_3 and TX_START_5 (no header call, just the two register writes) + (""" SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during Next Timeslot + SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00 +""", + """ SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during Next Timeslot + SPI0WritePageRegByte(0x04, 0x50, ((hrc.txDataType != HOTSPOT_TX_TYPE_VOICE) ? (DT_IDLE << 4) : 0x10));// Voice LC Header, or Idle filler ahead of data +"""), + (""" SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot + SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00 +""", + """ SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot + SPI0WritePageRegByte(0x04, 0x50, ((hrc.txDataType != HOTSPOT_TX_TYPE_VOICE) ? (DT_IDLE << 4) : 0x10));// Voice LC Header, or Idle filler ahead of data +"""), +] + +count = 0 +for old, new in old_variants: + if old in s: + s = s.replace(old, new, 1) + count += 1 + +if count == 0: + raise SystemExit("no TX_START patterns matched - aborting") + +open(p, "w").write(s) +print("patched %d of 3 TX start steps" % count) diff --git a/patches/patch_txfix.py b/patches/patch_txfix.py new file mode 100644 index 0000000..bb28b40 --- /dev/null +++ b/patches/patch_txfix.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Fix: a stale "this frame is packet data" marker could suppress the Voice LC Header +of an ordinary voice transmission. + +hrc.txDataType is only ever set on the hotspot path, and was only cleared after a data +burst was actually sent. If a data transmission was cut short before that, the marker +stayed set - and the next PTT from the radio itself started without a Voice LC Header, +so receivers never recognised the call. + +Two guards: + - the start-of-transmission checks now only apply while acting as a hotspot modem; + - the marker is cleared whenever a normal (non-hotspot) transmission begins. +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/hardware/HR-C6000.c" +s = open(p).read() + +if "isHotspotDataTx" in s: + print("already patched") + raise SystemExit(0) + +# 1. helper right after the struct instance, so all three call sites can use it +anchor = "\t\t.txDataType = HOTSPOT_TX_TYPE_VOICE,\n" +assert anchor in s, "hrc initialiser not found" + +# find the end of the initialiser block to place the helper after it +marker = "static void hrc6000SendPcOrTgLCHeader(void);" +assert marker in s, "forward declaration anchor not found" +helper = """// True only when the frame about to be sent is a packet data burst queued by the host. +// Outside hotspot mode there is no such thing, so ordinary voice transmissions are never +// affected by a leftover marker. +static inline bool isHotspotDataTx(void) +{ + return ((settingsUsbMode == USB_MODE_HOTSPOT) && (hrc.txDataType != HOTSPOT_TX_TYPE_VOICE)); +} + +""" +s = s.replace(marker, marker + "\n" + helper, 1) + +# 2. use it in the three start-of-transmission checks +a1 = "\t\t\tif (hrc.txDataType != HOTSPOT_TX_TYPE_VOICE)\n\t\t\t{\n\t\t\t\t// A data burst is queued: don't announce a voice call in front of it." +assert a1 in s, "TX_START_1 check not found" +s = s.replace(a1, "\t\t\tif (isHotspotDataTx())\n\t\t\t{\n\t\t\t\t// A data burst is queued: don't announce a voice call in front of it.", 1) + +a2 = "((hrc.txDataType != HOTSPOT_TX_TYPE_VOICE) ? (DT_IDLE << 4) : 0x10)" +n2 = s.count(a2) +assert n2 == 2, "expected 2 shared checks, found %d" % n2 +s = s.replace(a2, "(isHotspotDataTx() ? (DT_IDLE << 4) : 0x10)") + +# 3. and in the ongoing-TX branch, so a stale marker cannot divert voice frames either +a3 = "\t\t\t\t\tif (hrc.txDataType != HOTSPOT_TX_TYPE_VOICE)\n" +assert a3 in s, "TX_2 data branch not found" +s = s.replace(a3, "\t\t\t\t\tif (isHotspotDataTx())\n", 1) + +# 4. belt and braces: clear the marker when a non-hotspot transmission starts +a4 = """ if (settingsUsbMode != USB_MODE_HOTSPOT) + { + codecInit(false); + } + else + { + hrc.hotspotPostponedFrameHandling = (HS_NUM_OF_SILENCE_SEQ_ON_STARTUP * 6);""" +assert a4 in s, "TX start branch not found" +s = s.replace(a4, """ if (settingsUsbMode != USB_MODE_HOTSPOT) + { + hrc.txDataType = HOTSPOT_TX_TYPE_VOICE;// plain voice call: never a data burst + codecInit(false); + } + else + { + hrc.hotspotPostponedFrameHandling = (HS_NUM_OF_SILENCE_SEQ_ON_STARTUP * 6);""", 1) + +open(p, "w").write(s) +print("HR-C6000.c: voice transmissions can no longer be suppressed by a stale data marker") diff --git a/patches/patch_ui_send.py b/patches/patch_ui_send.py new file mode 100644 index 0000000..f5a08e0 --- /dev/null +++ b/patches/patch_ui_send.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Hook sending into the interface: a Reply button on an incoming message, and a +working New msg entry. + +For this first step the text is a fixed quick reply, so the whole transmit path can be +exercised on air before the keypad editor is written. +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" + +# ---- Reply button on the incoming message popup ------------------------- +p = W + "/source/applicationMain.c" +s = open(p).read() + +if "smsReplyValidator" in s: + print("applicationMain.c: reply already wired") +else: + # remember who to answer, and send the quick reply when the user confirms + anchor = "\t\tif (smsHasUnread())\n" + assert anchor in s, "unread block not found" + helper = """static uint32_t smsReplyToId = 0; +static bool smsReplyIsPrivate = true; + +// Called when the user confirms the Reply prompt on an incoming message. +static bool smsReplyValidator(void) +{ + smsSendText(smsReplyToId, (smsReplyIsPrivate == false), "OK"); + return true; +} + +""" + # place the helper just before the function that contains the unread block + fn_start = s.rindex("\nvoid ", 0, s.index(anchor)) + s = s[:fn_start + 1] + helper + s[fn_start + 1:] + + old_show = """ uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_OK; + uiDataGlobal.MessageBox.validatorCallback = NULL;""" + assert old_show in s, "message box setup not found" + s = s.replace(old_show, """ // Yes replies to the sender, No just dismisses the message. + uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_YESNO; + uiDataGlobal.MessageBox.validatorCallback = smsReplyValidator; + smsReplyToId = msg->srcId; + smsReplyIsPrivate = msg->isPrivate;""", 1) + open(p, "w").write(s) + print("applicationMain.c: Reply wired into the incoming message popup") + +# ---- New msg actually sends -------------------------------------------- +p = W + "/source/user_interface/menuMessages.c" +s = open(p).read() + +if "smsSendText" in s: + print("menuMessages.c: already sends") +else: + old = """ case MESSAGES_ITEM_NEW: + // Sending is not implemented yet; say so rather than opening a dead screen. + uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, "Not yet", true); + break;""" + assert old in s, "New msg handler not found" + s = s.replace(old, """ case MESSAGES_ITEM_NEW: + // First step: send a fixed text to the current talkgroup, so the transmit + // path can be exercised on air. The keypad editor comes next. + if (smsSendText((trxTalkGroupOrPcId & 0x00FFFFFF), ((trxTalkGroupOrPcId >> 24) == TG_CALL_FLAG), "TEST FROM RADIO")) + { + uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, "Sending...", true); + } + else + { + uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, "Busy", true); + } + break;""", 1) + + s = s.replace('#include "functions/sound.h"', + '#include "functions/sound.h"\n#include "functions/trx.h"\n#include "user_interface/uiUtilities.h"', 1) + open(p, "w").write(s) + print("menuMessages.c: New msg now transmits") diff --git a/patches/patch_usbexit.py b/patches/patch_usbexit.py new file mode 100644 index 0000000..f871330 --- /dev/null +++ b/patches/patch_usbexit.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Leave modem mode when the USB host goes away, instead of after a fixed timeout. + +Stock firmware dropped out of hotspot mode if the host was quiet for 20 seconds, which +made the radio unusable as a permanently attached modem - so that timeout was disabled +on request. The side effect was that the radio then stayed a modem forever and had to be +power cycled by hand after every test. + +This ties the exit to the actual USB connection state instead: while the host is present +the radio stays a modem no matter how quiet it is, and as soon as the cable is pulled (or +the host stops enumerating it) the radio returns to normal operation by itself. +""" +W = "/home/viktor/ogd77rel/uv_src/OpenGD77_MDUV380_DM1701_20260130/MDUV380_firmware/application" +p = W + "/source/functions/hotspot.c" +s = open(p).read() + +if "usbHostIsAttached" in s: + print("already patched") + raise SystemExit(0) + +# a small helper next to the other statics +anchor = "static const uint32_t MMDVMHOST_TIMEOUT = MMDVMHOST_TIMEOUT_DISABLED;\n" +assert anchor in s, "timeout constant not found" + +helper = anchor + """ +// The USB device handle tells us whether a host is actually enumerating us. Losing that +// is what "the computer went away" really means; host silence alone is not. +extern USBD_HandleTypeDef hUsbDeviceFS; + +static bool usbHostIsAttached(void) +{ + return (hUsbDeviceFS.dev_state == USBD_STATE_CONFIGURED); +} +""" +s = s.replace(anchor, helper, 1) + +# make sure the USB device header is included +if '#include "usb_device.h"' not in s: + inc_anchor = '#include "functions/ticks.h"\n' + assert inc_anchor in s, "include anchor missing" + s = s.replace(inc_anchor, inc_anchor + '#include "usb_device.h"\n', 1) + +# both inactivity checks now also fire when the host has disconnected +old = """ if ((MMDVMHOST_TIMEOUT != 0) && (nonVolatileSettings.hotspotType == HOTSPOT_TYPE_MMDVM) && + ((ticksGetMillis() - mmdvmHostLastActiveTime) > MMDVMHOST_TIMEOUT)) +""" +new = """ if ((nonVolatileSettings.hotspotType == HOTSPOT_TYPE_MMDVM) && + ((usbHostIsAttached() == false) || + ((MMDVMHOST_TIMEOUT != 0) && ((ticksGetMillis() - mmdvmHostLastActiveTime) > MMDVMHOST_TIMEOUT)))) +""" +count = s.count(old) +assert count == 2, "expected 2 inactivity checks, found %d" % count +s = s.replace(old, new) + +open(p, "w").write(s) +print("hotspot.c: modem mode now ends when the USB host disconnects (%d checks)" % count) diff --git a/tools/dmr_dump.py b/tools/dmr_dump.py new file mode 100644 index 0000000..1572d6a --- /dev/null +++ b/tools/dmr_dump.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Listen on the radio's MMDVM port and dump every received DMR burst verbatim. + +Prints the MMDVM control byte and the raw 33-byte burst as hex, so the frames can be +decoded and compared byte for byte elsewhere. Read-only towards the air: the only thing +sent to the radio is a periodic GET_STATUS, which is required to put/keep it in modem +mode (the firmware leaves modem mode if the host goes quiet, and only enters it when it +sees an MMDVM frame). +""" +import os, sys, termios, time, argparse + +MMDVM_FRAME_START = 0xE0 +MMDVM_GET_STATUS = 0x01 +MMDVM_DMR_DATA1 = 0x18 +MMDVM_DMR_DATA2 = 0x1A + +DATA_TYPES = { + 0x0: "PI header", 0x1: "voice LC header", 0x2: "terminator with LC", + 0x3: "CSBK", 0x4: "MBC header", 0x5: "MBC continuation", + 0x6: "data header", 0x7: "rate 1/2 data", 0x8: "rate 3/4 data", + 0x9: "idle", 0xA: "rate 1 data", +} + + +def open_port(path): + fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + a = termios.tcgetattr(fd) + a[0] = 0 + a[1] = 0 + a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL + a[3] = 0 + a[6][termios.VMIN] = 0 + a[6][termios.VTIME] = 0 + termios.tcsetattr(fd, termios.TCSANOW, a) + termios.tcflush(fd, termios.TCIOFLUSH) + return fd + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", default="/dev/ttyACM0") + ap.add_argument("--secs", type=int, default=600) + args = ap.parse_args() + + fd = open_port(args.port) + buf = b"" + deadline = time.time() + args.secs + next_poll = 0.0 + counts = {} + total = 0 + + print("dumping raw DMR bursts for %d s (GET_STATUS keepalive every 3 s)" % args.secs, flush=True) + try: + while time.time() < deadline: + now = time.time() + if now >= next_poll: + # keepalive: also what puts the radio into modem mode in the first place + try: + os.write(fd, bytes([MMDVM_FRAME_START, 3, MMDVM_GET_STATUS])) + except OSError: + pass + next_poll = now + 3.0 + + try: + chunk = os.read(fd, 4096) + except (BlockingIOError, OSError): + chunk = b"" + if chunk: + buf += chunk + else: + time.sleep(0.002) + + # parse MMDVM frames: E0 + while len(buf) >= 3: + if buf[0] != MMDVM_FRAME_START: + buf = buf[1:] + continue + length = buf[1] + if length < 3: + buf = buf[1:] + continue + if len(buf) < length: + break + frame, buf = buf[:length], buf[length:] + ftype = frame[2] + + if ftype in (MMDVM_DMR_DATA1, MMDVM_DMR_DATA2): + total += 1 + control = frame[3] if len(frame) > 3 else 0 + burst = frame[4:37] + slot = 1 if ftype == MMDVM_DMR_DATA1 else 2 + if control & 0x40: # data sync burst + dt = control & 0x0F + label = "data/%s" % DATA_TYPES.get(dt, "type %d" % dt) + elif control & 0x20: + label = "voice sync" + else: + label = "voice seq %d" % (control & 0x0F) + counts[label] = counts.get(label, 0) + 1 + print("%s slot%d ctrl=%02X %-28s %s" % ( + time.strftime("%H:%M:%S", time.gmtime()), slot, control, label, burst.hex()), + flush=True) + finally: + os.close(fd) + + print("\n--- summary: %d DMR bursts ---" % total, flush=True) + for k in sorted(counts, key=lambda x: -counts[x]): + print(" %-28s %d" % (k, counts[k]), flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/screen_grab.py b/tools/screen_grab.py new file mode 100644 index 0000000..d6ec135 --- /dev/null +++ b/tools/screen_grab.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Grab the radio's screen over USB and save it as a PNG. + +Uses the CPS read command (area 6 = display buffer), which the firmware answers only +while the radio is NOT in modem mode - and, importantly, such requests do not switch it +into modem mode either. So the screen can be watched while the radio does its normal job. + +Display is 160x128, RGB565, 2 bytes per pixel. +""" +import os, sys, termios, time, zlib, struct, argparse + +WIDTH, HEIGHT = 160, 128 +CPS_ACCESS_DISPLAY_BUFFER = 6 +CHUNK = 1024 + + +def open_port(path): + fd = os.open(path, os.O_RDWR | os.O_NOCTTY) + a = termios.tcgetattr(fd) + a[0] = 0 + a[1] = 0 + a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL + a[3] = 0 + a[6][termios.VMIN] = 0 + a[6][termios.VTIME] = 20 + termios.tcsetattr(fd, termios.TCSANOW, a) + termios.tcflush(fd, termios.TCIOFLUSH) + return fd + + +def read_exact(fd, n, timeout=3.0): + buf = b"" + deadline = time.time() + timeout + while len(buf) < n and time.time() < deadline: + try: + part = os.read(fd, n - len(buf)) + except BlockingIOError: + part = b"" + if part: + buf += part + else: + time.sleep(0.003) + return buf + + +def read_block(fd, area, addr, length): + req = bytes([ord('R'), area, + (addr >> 24) & 0xFF, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF, + (length >> 8) & 0xFF, length & 0xFF]) + termios.tcflush(fd, termios.TCIFLUSH) + os.write(fd, req) + head = read_exact(fd, 3) + if len(head) < 3 or head[0:1] != b'R': + return None + got = (head[1] << 8) | head[2] + data = read_exact(fd, got) + return data if len(data) == got else None + + +def write_png(path, rgb_rows): + """Minimal PNG writer (stdlib only).""" + raw = b"".join(b"\x00" + row for row in rgb_rows) + + def chunk(tag, data): + c = struct.pack(">I", len(data)) + tag + data + return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + png = b"\x89PNG\r\n\x1a\n" + png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0)) + png += chunk(b"IDAT", zlib.compress(raw, 6)) + png += chunk(b"IEND", b"") + open(path, "wb").write(png) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", default="/dev/ttyACM0") + ap.add_argument("--out", default="/home/viktor/screen.png") + args = ap.parse_args() + + fd = open_port(args.port) + try: + total = WIDTH * HEIGHT * 2 + data = b"" + while len(data) < total: + n = min(CHUNK, total - len(data)) + part = read_block(fd, CPS_ACCESS_DISPLAY_BUFFER, len(data), n) + if part is None: + print("read failed at offset %d - is the radio in modem mode?" % len(data)) + return 2 + data += part + finally: + os.close(fd) + + rows = [] + for y in range(HEIGHT): + row = bytearray() + for x in range(WIDTH): + i = (y * WIDTH + x) * 2 + # RGB565, little endian as stored by the display driver + v = data[i] | (data[i + 1] << 8) + r = ((v >> 11) & 0x1F) << 3 + g = ((v >> 5) & 0x3F) << 2 + b = (v & 0x1F) << 3 + row += bytes((r, g, b)) + rows.append(bytes(row)) + + write_png(args.out, rows) + nonblack = sum(1 for i in range(0, len(data), 2) if data[i] or data[i + 1]) + print("saved %s (%d of %d pixels non-black)" % (args.out, nonblack, WIDTH * HEIGHT)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/sms_build_test.py b/tools/sms_build_test.py new file mode 100644 index 0000000..8451b1c --- /dev/null +++ b/tools/sms_build_test.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Reference implementation of outgoing SMS packet assembly, mirrored from the spec. + +Written on the PC first so the tricky parts (CRC-32 with its byte-pair swap, the IP and +UDP checksums, the block accounting) can be checked against a known-good packet before +any of it goes into firmware. The firmware version will be a literal translation of this. +""" + + +def crc32_dmr(data: bytes) -> bytes: + """ETSI/MOTOTRBO CRC-32: swap bytes within each 16-bit pair, then MSB-first + CRC32 (poly 0x04C11DB7, init 0, no final xor). Returned little endian.""" + swapped = bytearray(data) + for i in range(0, len(swapped) - 1, 2): + swapped[i], swapped[i + 1] = swapped[i + 1], swapped[i] + + crc = 0 + for b in swapped: + crc ^= b << 24 + for _ in range(8): + crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if (crc & 0x80000000) else ((crc << 1) & 0xFFFFFFFF) + return crc.to_bytes(4, "little") + + +def ones_complement(data: bytes) -> int: + if len(data) % 2: + data += b"\x00" + total = 0 + for i in range(0, len(data), 2): + total += (data[i] << 8) | data[i + 1] + total = (total & 0xFFFF) + (total >> 16) + return (~total) & 0xFFFF + + +def crc16_ccitt(data: bytes) -> int: + crc = 0x0000 + for b in data: + crc ^= b << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) & 0xFFFF if (crc & 0x8000) else ((crc << 1) & 0xFFFF) + return crc + + +def build_tms(text: str, flag: int = 0xA0, seq: int = 0x81) -> bytes: + """[len 2B BE][flag][00][seq][04 0D 00 0A 00][text UTF-16LE]""" + body = bytes([flag, 0x00, seq]) + bytes([0x04, 0x0D, 0x00, 0x0A, 0x00]) + text.encode("utf-16-le") + tms_len = 8 + 2 * len(text) + assert tms_len == len(body), "length mismatch: %d vs %d" % (tms_len, len(body)) + return tms_len.to_bytes(2, "big") + body + + +def build_packet(src_id: int, dst_id: int, is_group: bool, text: str, + flag: int = 0xA0, seq: int = 0x81, ip_id: int = 0x0001) -> bytes: + tms = build_tms(text, flag, seq) + + udp_len = 8 + len(tms) + src_ip = (0x0C << 24) | src_id + dst_ip = ((0xE1 if is_group else 0x0C) << 24) | dst_id + ttl = 0x01 if is_group else 0x40 + + udp = bytearray() + udp += (0x0FA7).to_bytes(2, "big") # source port 4007 + udp += (0x0FA7).to_bytes(2, "big") # destination port 4007 + udp += udp_len.to_bytes(2, "big") + udp += b"\x00\x00" # checksum placeholder + udp += tms + + pseudo = src_ip.to_bytes(4, "big") + dst_ip.to_bytes(4, "big") + b"\x00\x11" + udp_len.to_bytes(2, "big") + udp_csum = ones_complement(pseudo + bytes(udp)) + udp[6:8] = udp_csum.to_bytes(2, "big") + + ip_len = 20 + udp_len + ip = bytearray() + ip += b"\x45\x00" + ip += ip_len.to_bytes(2, "big") + ip += ip_id.to_bytes(2, "big") + ip += b"\x00\x00" + ip += bytes([ttl, 0x11]) + ip += b"\x00\x00" # header checksum placeholder + ip += src_ip.to_bytes(4, "big") + ip += dst_ip.to_bytes(4, "big") + ip[10:12] = ones_complement(bytes(ip)).to_bytes(2, "big") + + packet = bytes(ip) + bytes(udp) + + # pad to a multiple of 12, then append the CRC-32 as its own tail + pad = (-len(packet)) % 12 + packet += b"\x00" * pad + packet += crc32_dmr(packet) + return packet, pad + + +def build_data_header(src_id: int, dst_id: int, is_group: bool, blocks: int, pad: int) -> bytes: + h = bytearray(12) + h[0] = (0x80 if is_group else 0x00) | 0x02 # DPF=2 unconfirmed + h[1] = 0x40 | (pad & 0x0F) # SAP=4 (IP), pad octets + h[2:5] = dst_id.to_bytes(3, "big") + h[5:8] = src_id.to_bytes(3, "big") + h[8] = 0x80 | (blocks & 0x7F) + h[9] = 0x10 + crc = crc16_ccitt(bytes(h[0:10])) ^ 0xCCCC + h[10] = (crc >> 8) & 0xFF + h[11] = crc & 0xFF + return bytes(h) + + +if __name__ == "__main__": + text = "PRIVET" + packet, pad = build_packet(777777, 6, True, text) + blocks = len(packet) // 12 + hdr = build_data_header(777777, 6, True, blocks, pad) + + print("text : %r" % text) + print("packet len : %d bytes, pad %d, blocks %d" % (len(packet), pad, blocks)) + print("packet hex : %s" % packet.hex(" ")) + print("last 4 bytes: %s (reference ends with 92 ba 1c 62)" % packet[-4:].hex(" ")) + print("data header : %s" % hdr.hex(" "))