Compare commits

...
9 Commits
Author SHA1 Message Date
shade 2d832915f7 документация для пользователей: README — только суть изменений, из ci/README убрана история граблей 2026-10-12 13:44:27 +03:00
shade e5ddf1700f meshadventurer: включён BME280 (SDA 21 / SCL 22); README: версия форка 0.1.1 2026-10-12 10:35:30 +03:00
shade e58059c24b стабильное напряжение батареи: ADC настраивается один раз, серия отсчётов и сглаживание
T096Board: analogReference/analogReadResolution убраны из getBattMilliVolts (первое чтение после смены reference мусорное), замер — среднее из 8 отсчётов.
ui-new: HomeScreen::battMilliVolts() — одно измерение на кадр + сглаживание; индикатор и страница POWER берут одно значение.
2026-10-12 10:29:42 +03:00
shade 9a89c11a1b ui-new: страница POWER опущена ниже точек страниц 2026-10-12 10:16:28 +03:00
shade 759a1bf340 ui-new: тема сохраняется только длинным нажатием (превью откатывается), страница POWER ниже статус-бара и по центру 2026-10-12 10:10:35 +03:00
shade 440aa8a512 ui-new: темы сохраняются сразу, POWER: ADC от текущего значения и крупная батарея, рамка частоты по фактическим ширинам
T096Board: множитель АЦП вынесен в поле с set/get вместо захардкоженного 4.9
2026-10-12 09:59:09 +03:00
shade c58782aeeb ui-new: страница POWER — батарея (Volt/Perc/Off), коррекция АЦП, reboot/shutdown 2026-10-12 09:44:06 +03:00
shade e59a5f1759 ui-new: частота в настройках радио правится по разрядам (МГц шагом 1, кГц по символам) 2026-10-12 09:03:02 +03:00
shade 24e2e48e1d CI: автообновление прошивальщика после публикации релиза 2026-10-12 08:49:13 +03:00
10 changed files with 665 additions and 171 deletions

No files matched your search

+46 -75
View File
@@ -3,95 +3,66 @@
Это личный форк оригинальной прошивки **MeshCore**: Это личный форк оригинальной прошивки **MeshCore**:
- **оригинал (upstream):** https://github.com/meshcore-dev/MeshCore - **оригинал (upstream):** https://github.com/meshcore-dev/MeshCore
- **этот форк:** https://git.meshinfo.ru/shade/MeshCore (рабочая ветка `main`) - **этот форк:** https://git.meshinfo.ru/shade/MeshCore
- **готовые прошивки:** https://meshinfo.ru/flasher/
За основу взята оригинальная прошивка: архитектура, протокол, роли узлов, примеры Архитектура, протокол, роли узлов и большинство вариантов плат — как в оригинале.
приложений и большинство вариантов плат остаются как в оригинале. Форк добавляет Форк добавляет доработки под свои устройства: Heltec T096, ProMicro (SSD1306 и SH1106),
доработки под свои устройства — Heltec T096, ProMicro (SSD1306/SH1106), Heltec v3 и Heltec v3 и v4, LilyGo T-Deck и T-Echo Card, Heltec T114 и T190, ThinkNode M9,
heltec_v4, LilyGo T-Deck и T-Echo Card, Heltec T114/T190, ThinkNode M9, Meshadventurer Meshadventurer на E22. Ниже — что именно отличается от оригинала.
на E22. Ниже перечислено, чем форк отличается от upstream.
Синхронизация с оригиналом:
```bash
git remote add upstream https://github.com/meshcore-dev/MeshCore
git fetch upstream
git merge upstream/main # правки для upstream отправляются в ветку dev
```
## Отличия от оригинальной прошивки ## Отличия от оригинальной прошивки
> Раздел живой: он дополняется с каждой доработкой форка. Если правка меняет поведение,
> которое есть в upstream, это отмечается — чтобы при обновлении из оригинала было видно,
> что нельзя затирать автоматическим мержем.
### Дисплеи ### Дисплеи
- **Драйвер выбирается значением `DISPLAY_CLASS`** ([`src/helpers/ui/DisplaySelect.h`](./src/helpers/ui/DisplaySelect.h)): - **Поддерживаемые панели:** SSD1306, SH1106 (свой драйвер, в оригинале его нет —
панель меняется в `platformio.ini` варианта, а не правкой `target.h`. Добавлен есть отдельная сборка ProMicro под SH1106), ST7735, ST7789 (в двух вариантах).
**свой драйвер `SH1106Display`** (в оригинале его нет) и сборка - **Русский язык в интерфейсе** (`OLED_RU`): пункты меню, названия страниц, имена
`ProMicro_SH1106_companion_radio_ble` — та же плата, но с SH1106 вместо SSD1306. узлов и сообщения. Работает на всех поддерживаемых дисплеях, включая Heltec T114,
- **Русский язык на дисплеях (`-D OLED_RU=1`).** Строка UTF-8 проходит через T190 и ThinkNode M9.
`DisplayDriver::translateUTF8ToBlocks()` (UTF-8 → CP1251) и рисуется шрифтом - **Темы цветных TFT** (пять палитр) переключаются прямо с устройства, без пересборки:
[`glcdfont6x8.h`](./src/helpers/ui/glcdfont6x8.h) с картой CP1251. Поддержаны страница `THEME` с живым предпросмотром и образцами цветов. Тема сохраняется длинным
`SSD1306Display`, `SH1106Display`, `ST7735Display`, `ST7789LCDDisplay`, а также нажатием на выбранной строке и переживает перезагрузку, а короткое нажатие только
`ST7789Display` (Heltec T114/T190, ThinkNode M9): для него кириллицы в шрифтах не было, показывает вариант, ничего не меняя. По умолчанию — янтарная тема на T096 и светлая
поэтому сгенерированы `ArialMT_Plain_16_RU` / `ArialMT_Plain_24_RU` и отключён встроенный классическая на остальных платах.
UTF-8-декодер библиотеки `OLEDDisplay`.
- **Темы цветных TFT переключаются в рантайме, без пересборки.** Пять палитр
(`amber`, `teal`, `signal`, `neon`, `classic`) лежат в общей таблице
[`src/helpers/ui/ColorThemes.h`](./src/helpers/ui/ColorThemes.h) и работают и на ST7735,
и на ST7789LCD. В `ui-new` есть страница `THEME`: живой предпросмотр, образцы цветов,
сохранение выбора в prefs. По умолчанию — янтарь на Heltec T096 и официальная светлая
схема MeshCore на остальных платах (как в upstream).
- **Индикатор страниц** в `ui-new` строится на `warning_txt` / `secondary_txt`: в upstream
цвета брались из `title_bkg`, из-за чего на тёмных темах активная точка сливалась с фоном.
### Стартовый экран ### Стартовый экран
- Показывает версию **MeshCore** (`v1.17.1` — из исходников), строку **`MESHINFO v0.1.0`** - Показывает версию MeshCore, версию форка (`MESHINFO v0.1.1`) и сайт `meshinfo.ru`.
(версия форка) и сайт **`meshinfo.ru`**. Дата сборки с экрана убрана — она только путала, Дата сборки с экрана убрана — рядом с версиями она только путала.
потому что рядом стояла версия.
- Версия форка передаётся отдельным макросом `MESHINFO_VERSION` (`build.sh`), поэтому
`FIRMWARE_VERSION` остаётся версией MeshCore: её же отдаёт приложению команда версии
и она попадает в стартовый экран. Так на устройстве видно и базу, и форк.
- Правка сделана во всех стартовых экранах: `companion_radio/ui-new`, а также в `UITask.cpp`
у `simple_repeater`, `simple_room_server` и `simple_sensor`.
### Интерфейс (companion_radio / ui-new) ### Интерфейс (прошивка companion)
- **Настройка радио прямо с устройства** — страница RADIO, длинное нажатие: частота - **Настройка радио с устройства** — страница RADIO (длинное нажатие): частота, ширина
(шаг 25 кГц), ширина полосы, SF, CR и мощность передатчика. Значения применяются к радио полосы, SF, CR и мощность передатчика. Частота правится по разрядам: целые МГц шагом
сразу, в память пишутся по пункту `Save & exit`; через 5 минут простоя меню закрывается 1 МГц, килогерцы — по символам (сотни, десятки, единицы), активный разряд подсвечен.
без сохранения и экран гаснет, а пока меню открыто — входящие сообщения не всплывают Значения применяются сразу, в память записываются по пункту `Save & exit`; через
поверх него. 5 минут простоя меню закрывается само, а пока оно открыто — входящие сообщения
- **Меню уведомлений** (длинное нажатие на первом экране): `Popups`, `Wake screen` не всплывают поверх.
(запретить автовключение дисплея по входящему сообщению; по кнопке экран работает), - **Меню уведомлений** (длинное нажатие на первом экране): `Popups` — всплывать ли
`Alerts` (звук+вибрация / только звук / только вибрация / выключено), `Channels` на входящее, `Wake screen` — зажигать ли экран, `Alerts` — звук и вибрация
(для каждого канала свой режим: `popup` / `silent` / `off`), `Quiet hours` с часовым (вместе / только звук / только вибрация / выключено), `Channels` — режим для каждого
поясом, `Unread` для накопленных сообщений. канала (`popup` / `silent` / `off`), `Quiet hours` с часовым поясом и `Unread`
Режимы канала живут в `NodePrefs::ch_mode` (2 бита на канал, до 40 каналов), для накопленных сообщений. В тихие часы звук, вибрация и всплытия отключены,
тихие часы — в `NodePrefs::isQuietNow()`, «тихая» доставка — через но сообщения не теряются.
`AbstractUITask::newMsgSilent()`. В тихие часы глушатся звук, вибрация и всплытия для - **Страница POWER** — питание и батарея. Раньше это был пункт `Hibernation`,
каналов, личных сообщений и уведомлений о новых контактах, но сообщения не теряются. который просто выключал устройство.
- Крупно показаны заряд батареи, процент и напряжение.
### Сборка и CI - `Battery` — что показывать в статус-баре рядом с иконкой: напряжение (`Volt`),
процент (`Perc`) или ничего (`Off`).
- **Сборка прошивок на своём Gitea** ([`.gitea/workflows/firmware.yml`](./.gitea/workflows/firmware.yml), - `ADC corr` — подстройка измеряемого напряжения: `+0.01` коротким нажатием,
[`ci/build-firmwares.sh`](./ci/build-firmwares.sh)): workflow собирает выбранный набор устройств `-0.01` двойным, отсчёт идёт от текущего значения. Работает на платах, которые
через `build.sh`, поэтому версия, `merge-bin` для ESP32 и `.uf2` для nRF52 получаются такими же, её поддерживают: Heltec V4 и V4 R8, Heltec T096, ProMicro, Heltec RC32,
как при локальной сборке. Запускается **только вручную** (Actions → Firmware build → Run workflow); Meshnology W12.
автоматических сборок по push и тегам нет. Как поднять раннер — [`ci/README.md`](./ci/README.md). - `Reboot/Shut` — подменю `Reboot`, `Shutdown`, `Back`.
- **Локальная сборка с публикацией релиза** ([`ci/build-local.ps1`](./ci/build-local.ps1)):
набор прошивок собирается на своей машине (тот же `build.sh` через Git Bash), а готовые
файлы прикрепляются к релизу Gitea — сервер только принимает их и не тратит CPU на сборку.
Это основной способ выпустить прошивки: на VPS всего одно ядро.
### Варианты плат ### Варианты плат
- Свои варианты: `generic-e22` (E22-900M30S / E22-400M30S), `meshadventurer` - Свои варианты: `generic-e22` (E22-900M30S и E22-400M30S), `meshadventurer`
(SX1262 / SX1268 и E22P 433M30S / 868M30S), `thinknode_m9`, `gat562_*`, (SX1262 / SX1268 и E22P 433M30S / 868M30S), `thinknode_m9`, `gat562_*`, а также
а также сборка ProMicro под SH1106. сборка ProMicro под SH1106.
- В набор сборки входят 26 прошивок: 15 companion, 8 репетитеров и 3 room server /
sensor. Как собрать и опубликовать — [`ci/README.md`](./ci/README.md).
--- ---
+31 -36
View File
@@ -22,9 +22,8 @@ apt-get update
apt-get install -y git python3 python3-venv python3-pip curl ca-certificates build-essential apt-get install -y git python3 python3-venv python3-pip curl ca-certificates build-essential
``` ```
- **`python3-pip` обязателен.** Без него `python3 -m venv` создаёт окружение **без pip** - **`python3-pip` обязателен:** без него `python3 -m venv` создаёт окружение без pip,
(в `bin/` только симлинки python), и шаг установки PlatformIO падает с и шаг установки PlatformIO падает. Готовое окружение workflow пересоздаст сам.
`No such file or directory`. Если venv уже создан без pip, workflow пересоздаст его сам.
- Свободного места нужно **5 ГБ**: тулчейны PlatformIO занимают ~800 МБ на одну платформу, - Свободного места нужно **5 ГБ**: тулчейны PlatformIO занимают ~800 МБ на одну платформу,
а при сборке и ESP32, и nRF52 — заметно больше. а при сборке и ESP32, и nRF52 — заметно больше.
- **Swap желателен.** На VPS с 2 ГБ RAM линковка ESP32 упирается в память: - **Swap желателен.** На VPS с 2 ГБ RAM линковка ESP32 упирается в память:
@@ -40,12 +39,9 @@ apt-get install -y git python3 python3-venv python3-pip curl ca-certificates bui
```ini ```ini
[actions] [actions]
ENABLED = true ENABLED = true
# Gitea 1.27 принимает значение github или gitea, но НЕ ссылку: # значение — github или gitea (ссылка не поддерживается)
# "https://github.com" даёт ошибку DEFAULT_ACTIONS_URL does not support ...
DEFAULT_ACTIONS_URL = github DEFAULT_ACTIONS_URL = github
# Искать workflow только в .gitea/workflows: иначе Gitea подхватит upstream-файлы # workflow ищем только здесь, чтобы не подхватывались upstream-файлы
# из .github/workflows, а у них триггер push — на каждый push запустится сборка
# сотен env и деплой Pages.
WORKFLOW_DIRS = .gitea/workflows WORKFLOW_DIRS = .gitea/workflows
``` ```
@@ -58,7 +54,6 @@ WORKFLOW_DIRS = .gitea/workflows
useradd --create-home --home-dir /var/lib/act-runner --shell /bin/bash act-runner useradd --create-home --home-dir /var/lib/act-runner --shell /bin/bash act-runner
# актуальный релиз: https://gitea.com/gitea/runner/releases # актуальный релиз: https://gitea.com/gitea/runner/releases
# (проект переименован: раньше act_runner, теперь Gitea Runner; на момент настройки — v5.0.0)
curl -fsSL -o /usr/local/bin/gitea-runner \ curl -fsSL -o /usr/local/bin/gitea-runner \
https://gitea.com/gitea/runner/releases/download/v5.0.0/gitea-runner-5.0.0-linux-amd64 https://gitea.com/gitea/runner/releases/download/v5.0.0/gitea-runner-5.0.0-linux-amd64
chmod +x /usr/local/bin/gitea-runner chmod +x /usr/local/bin/gitea-runner
@@ -69,8 +64,8 @@ cp ci/act_runner/config.yaml /var/lib/act-runner/config.yaml
chown act-runner:act-runner /var/lib/act-runner/config.yaml chown act-runner:act-runner /var/lib/act-runner/config.yaml
``` ```
**Токен — обязательно со scope репозитория.** Без `--scope` раннер становится глобальным **Токен — обязательно со scope репозитория**: без `--scope` раннер становится глобальным
и начинает брать задачи **всех** репозиториев инстанса (в том числе чужие stale-bot): и начинает брать задачи всех репозиториев инстанса:
```bash ```bash
# от имени пользователя Gitea (CLI не работает под root) # от имени пользователя Gitea (CLI не работает под root)
@@ -105,8 +100,6 @@ journalctl -u gitea-runner -f # ждём "declare successfully"
cd ci/act_runner && cp .env.example .env && nano .env && docker compose up -d cd ci/act_runner && cp .env.example .env && nano .env && docker compose up -d
``` ```
В свежих версиях образ называется `gitea/runner` (раньше `gitea/act_runner`).
## 4. Запуск сборки ## 4. Запуск сборки
Workflow запускается **только вручную** — автоматических сборок по push и тегам нет Workflow запускается **только вручную** — автоматических сборок по push и тегам нет
@@ -149,9 +142,8 @@ $env:GITEA_TOKEN = '<токен с правом write:repository>'
.\ci\build-local.ps1 -Version v1.0.0 -SkipBuild -Publish -Public -Tag v1.0.0 .\ci\build-local.ps1 -Version v1.0.0 -SkipBuild -Publish -Public -Tag v1.0.0
``` ```
Скрипт находит Git Bash и PlatformIO, подставляет рабочий `python3` (в Git Bash это Скрипт находит Git Bash и PlatformIO, подставляет рабочий `python3`, чистит `out/`
заглушка Microsoft Store — из-за неё `build.sh` не может определить платформу env), перед сборкой, а с `-Publish` создаёт тег и релиз, прикрепляя к нему файлы
чистит `out/` перед сборкой, а с `-Publish` создаёт тег и релиз, прикрепляя к нему файлы
(`-Public` публикует сразу, без флага создаётся черновик). Токен нужен только для (`-Public` публикует сразу, без флага создаётся черновик). Токен нужен только для
публикации: сама сборка работает без него. публикации: сама сборка работает без него.
@@ -167,6 +159,26 @@ $env:GITEA_TOKEN = '<токен с правом write:repository>'
В CI шага публикации нет: сервер только собирает прошивки по ручному запуску и отдаёт их В CI шага публикации нет: сервер только собирает прошивки по ручному запуску и отдаёт их
артефактом. артефактом.
### Прошивальщик на сайте
Страница <https://meshinfo.ru/flasher/> берёт список прошивок из своего `index.json`
(статика в `/var/www/flasher`, а сами файлы браузер тянет через nginx напрямую из релизов
Gitea). Имена файлов в манифесте разобраны на плату, роль, чип и формат, поэтому после
каждой публикации релиза его нужно пересобрать — иначе на странице останутся старые ссылки.
```powershell
.\ci\update-flasher.ps1 # пересобрать и выложить на сервер
.\ci\update-flasher.ps1 -SkipUpload # только пересобрать, без заливки
```
Скрипт запускает генератор `build-flasher-index.py` из соседнего репозитория
(`..\MeshCore-posadmesh-meshcoretel\posadmesh-tools\`; путь задаётся через `-Generator`
или `FLASHER_GENERATOR`), делает на сервере бэкап старого манифеста, заливает новый и
проверяет, что страница отдаёт его, а файлы доступны через `/flasher/fw/...`.
При публикации релиза (`ci/build-local.ps1 -Publish`) обновление выполняется автоматически,
отключить можно флагом `-SkipFlasher`.
## 6. Набор прошивок по умолчанию ## 6. Набор прошивок по умолчанию
`ci/build-firmwares.sh`, массив `DEFAULT_TARGETS` — 26 env: `ci/build-firmwares.sh`, массив `DEFAULT_TARGETS` — 26 env:
@@ -180,26 +192,9 @@ $env:GITEA_TOKEN = '<токен с правом write:repository>'
## 7. Время и ресурсы ## 7. Время и ресурсы
- Сервер с **1 CPU / 2 ГБ RAM** полный набор собирает долго (первый прогон — часы), - Сервер с **1 CPU / 2 ГБ RAM** полный набор собирает часами, поэтому рабочий вариант —
и на время сборки Gitea с Postgres на той же машине заметно тормозят. Поэтому рабочий собирать полный набор локально (`ci/build-local.ps1`), а в Gitea запускать вручную
вариант — собирать полный набор локально (`ci/build-local.ps1`), а в Gitea запускать только те env, которые нужно проверить.
вручную только те env, которые нужно проверить.
- Первая сборка качает тулчейны (nRF52: framework + CMSIS + gcc-arm ≈ 660 МБ; - Первая сборка качает тулчейны (nRF52: framework + CMSIS + gcc-arm ≈ 660 МБ;
ESP32 — ещё столько же), дальше работает кэш `~/.platformio`. ESP32 — ещё столько же), дальше работает кэш `~/.platformio`.
- `capacity: 1` в `config.yaml` — одна сборка за раз. - `capacity: 1` в `config.yaml` — одна сборка за раз.
## 8. Грабли, на которые уже наступали
| Симптом | Причина и решение |
|---|---|
| `.../bin/pip: No such file or directory` | venv создан без pip — нет `python3-pip`; поставить пакет, venv пересоздать |
| `DEFAULT_ACTIONS_URL does not support "https://github.com"` | в Gitea 1.27 значение — `github`, не ссылка |
| раннер берёт задачи чужих репозиториев | токен выдан без `--scope owner/repo` |
| `registration file not found` в цикле рестартов | раннер запущен до регистрации: сначала `register`, потом сервис |
| `E: Could not get lock /var/lib/dpkg/lock-frontend` | работает unattended-upgrades; подождать либо остановить `apt-daily.timer` |
| на push запускается сборка сотен env | не выставлен `WORKFLOW_DIRS = .gitea/workflows` |
| сборка запускается на каждый push, хотя в workflow стоит `paths-ignore` | Gitea этот фильтр не применяет; автосборок нет — workflow запускается только вручную |
| `405 Method Not Allowed` при создании тега | в Gitea тег создаётся через `POST /repos/{owner}/{repo}/tags`, у `/git/refs` метода POST нет |
| `python3: command not found` или пустой вывод в Git Bash | это заглушка Microsoft Store; задайте `PYTHON3=/c/.../python.exe` (это делает `ci/build-local.ps1`) |
| в `out/` оказались файлы только последней платы | `build.sh` при каждом запуске делает `rm -rf out`; `ci/build-firmwares.sh` копит результат в `dist/` и в конце складывает всё в `out/` |
| нужно опубликовать уже собранное, без пересборки | `ci/build-local.ps1 -SkipBuild -Publish -Tag <тег>` |
+23 -1
View File
@@ -21,7 +21,11 @@
FIRMWARE_VERSION — попадает в имена файлов (по умолчанию local). FIRMWARE_VERSION — попадает в имена файлов (по умолчанию local).
.PARAMETER Publish .PARAMETER Publish
Создать релиз в Gitea и прикрепить к нему файлы из out/. Создать релиз в Gitea и прикрепить к нему файлы из out/. После публикации
автоматически обновляется манифест прошивальщика (ci/update-flasher.ps1).
.PARAMETER SkipFlasher
Не обновлять манифест прошивальщика после публикации релиза.
.PARAMETER Tag .PARAMETER Tag
Тег для релиза (обязателен с -Publish). Если тега в репозитории нет — он будет создан Тег для релиза (обязателен с -Publish). Если тега в репозитории нет — он будет создан
@@ -47,6 +51,7 @@ param(
[string]$Tag = '', [string]$Tag = '',
[switch]$Public, [switch]$Public,
[switch]$SkipBuild, [switch]$SkipBuild,
[switch]$SkipFlasher,
[string]$Token = $env:GITEA_TOKEN, [string]$Token = $env:GITEA_TOKEN,
[string]$GiteaUrl = 'https://git.meshinfo.ru', [string]$GiteaUrl = 'https://git.meshinfo.ru',
[string]$Repo = 'shade/MeshCore' [string]$Repo = 'shade/MeshCore'
@@ -170,6 +175,23 @@ try {
Write-Host '' Write-Host ''
Write-Host "Релиз: $GiteaUrl/$Repo/releases" -ForegroundColor Green Write-Host "Релиз: $GiteaUrl/$Repo/releases" -ForegroundColor Green
# Прошивальщик на сайте читает имена файлов из своего index.json: после публикации
# релиза манифест нужно пересобрать, иначе на странице останутся старые ссылки.
if (-not $SkipFlasher) {
$updater = Join-Path $root 'ci\update-flasher.ps1'
Write-Host ''
Write-Host '=== обновление прошивальщика ===' -ForegroundColor Cyan
if (Test-Path $updater) {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $updater -Token $Token
if ($LASTEXITCODE -ne 0) {
Write-Warning 'прошивальщик не обновился — запустите ci/update-flasher.ps1 вручную'
}
}
else {
Write-Warning "не найден $updater — прошивальщик не обновлён"
}
}
} }
else { else {
Write-Host '' Write-Host ''
+163
View File
@@ -0,0 +1,163 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Обновление страницы-прошивальщика (https://meshinfo.ru/flasher/) после публикации релиза.
.DESCRIPTION
Прошивальщик — статика в /var/www/flasher: страница читает index.json, где перечислены
релизы и файлы прошивок (имена разбираются на плату, роль, чип и формат). Файлы прошивок
на сервер не копируются: nginx проксирует их из релизов Gitea.
Скрипт пересобирает index.json генератором из posadmesh-tools и выкладывает его на сервер.
Запускать после того, как релиз в Gitea создан (иначе новых имён в манифесте не будет).
Из ci/build-local.ps1 вызывается автоматически при -Publish.
.PARAMETER Generator
Путь к build-flasher-index.py. По умолчанию берётся из FLASHER_GENERATOR или ищется
в соседнем репозитории ..\MeshCore-posadmesh-meshcoretel\posadmesh-tools\.
.PARAMETER Python
Интерпретатор Python. По умолчанию FLASHER_PYTHON, затем python из PATH, затем
из окружения PlatformIO.
.PARAMETER Token
Токен Gitea (нужен, если репозитории приватные). По умолчанию GITEA_TOKEN.
.PARAMETER SshKey
Ключ для доступа к серверу. По умолчанию ~/.ssh/meshcore_ci_ed25519.
.PARAMETER Server
Адрес сервера, по умолчанию root@meshinfo.ru.
.PARAMETER RemotePath
Куда положить манифест, по умолчанию /var/www/flasher/index.json.
.PARAMETER PublicUrl
Базовый адрес страницы для проверки, по умолчанию https://meshinfo.ru/flasher.
.PARAMETER SkipUpload
Только пересобрать index.json, ничего не заливать (для локальной проверки).
.EXAMPLE
.\ci\update-flasher.ps1
.EXAMPLE
.\ci\update-flasher.ps1 -SkipUpload
#>
param(
[string]$Generator = '',
[string]$Python = '',
[string]$Token = $env:GITEA_TOKEN,
[string]$SshKey = "$env:USERPROFILE\.ssh\meshcore_ci_ed25519",
[string]$Server = 'root@meshinfo.ru',
[string]$RemotePath = '/var/www/flasher/index.json',
[string]$PublicUrl = 'https://meshinfo.ru/flasher',
[switch]$SkipUpload
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
function Fail($msg) { Write-Host "ошибка: $msg" -ForegroundColor Red; exit 1 }
# --- генератор манифеста ----------------------------------------------------
if (-not $Generator) { $Generator = $env:FLASHER_GENERATOR }
if (-not $Generator) {
$candidates = @(
(Join-Path (Split-Path -Parent $repoRoot) 'MeshCore-posadmesh-meshcoretel\posadmesh-tools\build-flasher-index.py'),
(Join-Path $repoRoot 'posadmesh-tools\build-flasher-index.py')
)
$Generator = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
}
if (-not $Generator -or -not (Test-Path $Generator)) {
Fail "не найден build-flasher-index.py. Укажите путь: -Generator <путь> или переменную FLASHER_GENERATOR"
}
$Generator = (Resolve-Path $Generator).Path
Write-Host "генератор: $Generator"
# --- python -----------------------------------------------------------------
if (-not $Python) { $Python = $env:FLASHER_PYTHON }
if (-not $Python) {
$candidates = @(
'C:\Espressif\tools\python\python.exe',
(Join-Path $env:USERPROFILE '.platformio\penv\Scripts\python.exe')
)
$Python = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $Python) {
$cmd = Get-Command python -ErrorAction SilentlyContinue
if ($cmd) { $Python = $cmd.Source }
}
}
if (-not $Python) { Fail 'не найден python.exe (укажите -Python <путь>)' }
Write-Host "python: $Python"
# --- пересборка манифеста ---------------------------------------------------
$env:PYTHONIOENCODING = 'utf-8'
if ($Token) { $env:GITEA_TOKEN = $Token } else { Write-Warning 'GITEA_TOKEN не задан — приватные репозитории не прочитаются' }
Write-Host ''
Write-Host '=== пересборка index.json ===' -ForegroundColor Cyan
& $Python $Generator
if ($LASTEXITCODE -ne 0) { Fail "генератор завершился с кодом $LASTEXITCODE" }
$manifest = Join-Path (Split-Path -Parent $Generator) 'flasher\index.json'
if (-not (Test-Path $manifest)) { Fail "генератор не создал $manifest" }
$data = Get-Content -Raw -Encoding UTF8 $manifest | ConvertFrom-Json
$meshcore = $data.sections | Where-Object { $_.id -eq 'meshcore' } | Select-Object -First 1
$shaList = @($meshcore.releases | ForEach-Object { $_.files } | ForEach-Object { $_.sha } | Sort-Object -Unique)
Write-Host ''
Write-Host "манифест: $manifest"
Write-Host " собран: $($data.generated_at)"
Write-Host " всего файлов: $($data.totals.files), плат: $($data.totals.boards)"
Write-Host " MeshCore: плат $($meshcore.board_count), файлов $($meshcore.file_count)"
Write-Host " версии кода: $($shaList -join ', ')"
if ($meshcore.file_count -eq 0) { Fail 'в секции MeshCore нет файлов — релиз не найден или имена не разбираются' }
# --- выкладка на сервер -----------------------------------------------------
if ($SkipUpload) {
Write-Host ''
Write-Host 'заливка пропущена (-SkipUpload)' -ForegroundColor Yellow
exit 0
}
$sshOpts = @('-i', $SshKey, '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new')
Write-Host ''
Write-Host '=== выкладка на сервер ===' -ForegroundColor Cyan
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
& ssh @sshOpts $Server "cp $RemotePath $RemotePath.bak-$stamp" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { Fail "не удалось сделать бэкап на сервере ($Server)" }
Write-Host " бэкап: $RemotePath.bak-$stamp"
& scp @sshOpts $manifest "${Server}:$RemotePath" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { Fail 'scp не смог залить манифест' }
Write-Host " залито: $RemotePath"
# --- проверка ---------------------------------------------------------------
Write-Host ''
Write-Host '=== проверка ===' -ForegroundColor Cyan
$httpCode = & curl.exe -s -o NUL -w '%{http_code}' "$PublicUrl/index.json"
if ($httpCode -ne '200') { Fail "страница отдаёт index.json с кодом $httpCode" }
$remote = & curl.exe -s "$PublicUrl/index.json"
$remoteData = $remote | ConvertFrom-Json
Write-Host " index.json: HTTP 200, собран $($remoteData.generated_at)"
if ($remoteData.generated_at -ne $data.generated_at) {
Fail "на сервере старый манифест ($($remoteData.generated_at) вместо $($data.generated_at))"
}
# пара ссылок на файлы: первый по списку и первый merged, если есть
$sample = @($meshcore.releases[0].files | Select-Object -First 1)
$merged = @($meshcore.releases[0].files | Where-Object { $_.format -eq 'merged' } | Select-Object -First 1)
if ($merged) { $sample += $merged }
$bad = 0
foreach ($f in $sample) {
$code = & curl.exe -s -o NUL -w '%{http_code}' "$PublicUrl/fw/$($f.repo)/$($f.tag)/$($f.name)"
if ($code -eq '200') { Write-Host " $($f.name): HTTP 200" }
else { Write-Host " $($f.name): HTTP $code" -ForegroundColor Red; $bad++ }
}
if ($bad -gt 0) { Fail "файлы прошивок недоступны через $PublicUrl/fw/ — проверьте, что релиз в Gitea опубликован" }
Write-Host ''
Write-Host "готово: $PublicUrl/ — $($meshcore.file_count) файлов MeshCore, версия кода $($shaList -join ', ')" -ForegroundColor Green
exit 0
+9
View File
@@ -14,6 +14,11 @@
#define CH_MODE_SILENT 1 // в непрочитанные, но без звука и без всплытия #define CH_MODE_SILENT 1 // в непрочитанные, но без звука и без всплытия
#define CH_MODE_OFF 2 // на устройстве не уведомляем вообще #define CH_MODE_OFF 2 // на устройстве не уведомляем вообще
// Что показывать рядом с иконкой батареи в статус-баре
#define BATT_DISPLAY_VOLT 0 // напряжение, например 4.05V
#define BATT_DISPLAY_PERC 1 // процент заряда
#define BATT_DISPLAY_OFF 2 // индикатор батареи скрыт
// Сколько каналов покрываем настройками. Совпадает с MAX_GROUP_CHANNELS у companion-сборок, // Сколько каналов покрываем настройками. Совпадает с MAX_GROUP_CHANNELS у companion-сборок,
// но этот заголовок его не включает, поэтому задаём свой предел. // но этот заголовок его не включает, поэтому задаём свой предел.
#ifndef NOTIFY_MAX_CHANNELS #ifndef NOTIFY_MAX_CHANNELS
@@ -62,6 +67,8 @@ public:
uint8_t quiet_to = 7; // час окончания (локальный) uint8_t quiet_to = 7; // час окончания (локальный)
int8_t tz_offset = 0; // смещение от UTC в часах, -12..+14 int8_t tz_offset = 0; // смещение от UTC в часах, -12..+14
uint8_t ch_mode[CH_MODE_BYTES]; // режимы по каналам (CH_MODE_*), по 2 бита uint8_t ch_mode[CH_MODE_BYTES]; // режимы по каналам (CH_MODE_*), по 2 бита
uint8_t batt_display = BATT_DISPLAY_PERC; // что показывать рядом с батареей (BATT_DISPLAY_*)
float adc_multiplier = 0; // коррекция АЦП батареи (0 = как считает плата)
private: private:
class RadioPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) class RadioPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now)
@@ -83,6 +90,7 @@ private:
#endif #endif
def("tx", _parent->tx_power_dbm); def("tx", _parent->tx_power_dbm);
def("af", _parent->airtime_factor); def("af", _parent->airtime_factor);
def("adc_mult", _parent->adc_multiplier); // коррекция АЦП батареи (как в CommonCLI)
def("rxdelay", _parent->rx_delay_base); def("rxdelay", _parent->rx_delay_base);
//def("f_txdelay", _parent->tx_delay_factor); currently hard-coded //def("f_txdelay", _parent->tx_delay_factor); currently hard-coded
//def("d_txdelay", _parent->direct_tx_delay_factor); currently hard-coded //def("d_txdelay", _parent->direct_tx_delay_factor); currently hard-coded
@@ -143,6 +151,7 @@ private:
def("quiet_to", _parent->quiet_to); def("quiet_to", _parent->quiet_to);
def("tz", _parent->tz_offset); def("tz", _parent->tz_offset);
def("ch_mode", (void *)_parent->ch_mode, sizeof(_parent->ch_mode)); // hex-блоб def("ch_mode", (void *)_parent->ch_mode, sizeof(_parent->ch_mode)); // hex-блоб
def("batt_disp", _parent->batt_display); // что показывать у батареи
def("tel_base", _parent->telemetry_mode_base); def("tel_base", _parent->telemetry_mode_base);
def("tel_loc", _parent->telemetry_mode_loc); def("tel_loc", _parent->telemetry_mode_loc);
def("tel_env", _parent->telemetry_mode_env); def("tel_env", _parent->telemetry_mode_env);
+359 -51
View File
@@ -41,7 +41,9 @@
#ifndef UI_RADIO_FREQ_MAX #ifndef UI_RADIO_FREQ_MAX
#define UI_RADIO_FREQ_MAX 960.0f #define UI_RADIO_FREQ_MAX 960.0f
#endif #endif
#define UI_RADIO_FREQ_STEP_KHZ 25 // шаг перестройки частоты // Частота правится по разрядам, как в PosadMesh: целые МГц меняются шагом 1 МГц,
// а килогерцы — по символам (сотни, десятки, единицы). Разряд 0 — МГц, 1..3 — кГц.
#define UI_RADIO_FREQ_KHZ_DIGITS 3
#define UI_RADIO_MENU_TIMEOUT_MILLIS 300000UL // 5 минут простоя — выйти из меню без сохранения #define UI_RADIO_MENU_TIMEOUT_MILLIS 300000UL // 5 минут простоя — выйти из меню без сохранения
#define NOTIFY_MAIN_ROWS 7 // строк в меню уведомлений #define NOTIFY_MAIN_ROWS 7 // строк в меню уведомлений
@@ -146,7 +148,7 @@ class HomeScreen : public UIScreen {
SENSORS, SENSORS,
#endif #endif
THEME, // выбор темы дисплея (только если драйвер поддерживает темы) THEME, // выбор темы дисплея (только если драйвер поддерживает темы)
SHUTDOWN, POWER, // battery display, ADC correction, reboot/shutdown
Count // keep as last Count // keep as last
}; };
@@ -156,6 +158,16 @@ class HomeScreen : public UIScreen {
NodePrefs* _node_prefs; NodePrefs* _node_prefs;
uint8_t _page; uint8_t _page;
bool _shutdown_init; bool _shutdown_init;
bool _shutdown_restart; // что делать после отпускания кнопки: true — reboot, false — power off
// меню Power (страница POWER)
enum PowerScreen { PWR_OFF, PWR_MAIN, PWR_ADC, PWR_RS };
enum PowerRow { PWR_BATT, PWR_ADC_ROW, PWR_RST_ROW, PWR_BACK, PWR_ROW_COUNT };
uint8_t _power_screen;
uint8_t _power_sel;
uint8_t _rs_sel;
unsigned long _power_last_activity;
uint16_t _batt_mv; // сглаженное напряжение батареи (для индикатора и страницы POWER)
AdvertPath recent[UI_RECENT_LIST_SIZE]; AdvertPath recent[UI_RECENT_LIST_SIZE];
// режим ручной настройки радио (страница RADIO) // режим ручной настройки радио (страница RADIO)
@@ -163,6 +175,7 @@ class HomeScreen : public UIScreen {
bool _radio_edit; bool _radio_edit;
bool _radio_val_edit; bool _radio_val_edit;
uint8_t _radio_sel; uint8_t _radio_sel;
uint8_t _radio_fq_pos; // активный разряд частоты: 0 — МГц, 1..3 — сотни/десятки/единицы кГц
unsigned long _radio_last_activity; // для автовыхода из меню по простою unsigned long _radio_last_activity; // для автовыхода из меню по простою
void radioEditEnter() { void radioEditEnter() {
@@ -170,6 +183,7 @@ class HomeScreen : public UIScreen {
_radio_edit = true; _radio_edit = true;
_radio_val_edit = false; _radio_val_edit = false;
_radio_sel = ROW_FQ; _radio_sel = ROW_FQ;
_radio_fq_pos = 0;
_radio_last_activity = millis(); _radio_last_activity = millis();
} }
void radioEditLeave(bool save) { void radioEditLeave(bool save) {
@@ -187,6 +201,18 @@ class HomeScreen : public UIScreen {
const char* radioRowName(uint8_t row); const char* radioRowName(uint8_t row);
void renderRadioEditor(DisplayDriver& display); void renderRadioEditor(DisplayDriver& display);
// меню Power: отображение батареи, коррекция АЦП, reboot/shutdown
void powerEnter();
void powerLeave();
void powerStep(int8_t dir);
void powerAction();
void powerAdcStep(int8_t dir);
float adcBaseValue(); // текущий множитель АЦП: prefs → борд → ADC_MULTIPLIER варианта
uint16_t battMilliVolts(); // одно измерение на кадр + сглаживание
void renderPowerPage(DisplayDriver& display, uint16_t milliVolts);
void renderPowerScreen(DisplayDriver& display);
void renderPowerAdc(DisplayDriver& display);
// выбор темы дисплея (страница THEME) // выбор темы дисплея (страница THEME)
bool _theme_edit; bool _theme_edit;
uint8_t _theme_sel; uint8_t _theme_sel;
@@ -208,20 +234,24 @@ class HomeScreen : public UIScreen {
_node_prefs->ui_theme[sizeof(_node_prefs->ui_theme) - 1] = 0; _node_prefs->ui_theme[sizeof(_node_prefs->ui_theme) - 1] = 0;
the_mesh.savePrefs(); the_mesh.savePrefs();
} }
void themeEditLeave(bool save) { // вернуть тему, записанную в prefs (предпросмотр откатывается)
void themeRevert() {
DisplayDriver* d = _task->getDisplay();
if (d == NULL) return;
uint8_t idx = d->findTheme(_node_prefs->ui_theme);
d->setTheme(idx == 0xFF ? 0 : idx);
}
void themeEditLeave() {
themeRevert(); // тема сохраняется только длинным нажатием на строке темы, выход ничего не меняет
_theme_edit = false; _theme_edit = false;
if (save) {
themeStore();
_task->showAlert("Saved", 1000);
}
_task->showPendingMessages(); _task->showPendingMessages();
} }
void themeRowStep(int8_t dir) { void themeRowStep(int8_t dir) {
DisplayDriver* d = _task->getDisplay(); DisplayDriver* d = _task->getDisplay();
if (d == NULL) return; if (d == NULL) return;
uint8_t rows = d->getThemeCount() + 1; // темы + "Save & exit" uint8_t rows = d->getThemeCount() + 1; // темы + строка выхода
_theme_sel = (_theme_sel + rows + dir) % rows; _theme_sel = (_theme_sel + rows + dir) % rows;
if (_theme_sel < d->getThemeCount()) d->setTheme(_theme_sel); // живой предпросмотр if (_theme_sel < d->getThemeCount()) d->setTheme(_theme_sel); // только предпросмотр, в prefs не пишем
} }
void renderThemePage(DisplayDriver& display); void renderThemePage(DisplayDriver& display);
void renderThemeEditor(DisplayDriver& display); void renderThemeEditor(DisplayDriver& display);
@@ -284,27 +314,44 @@ class HomeScreen : public UIScreen {
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
// battery icon // battery icon
int iconWidth = 24; // что показывать рядом с батареей — напряжение, процент или ничего (страница POWER)
int iconHeight = 10; const uint8_t mode = _node_prefs->batt_display;
int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner int textWidth = 0;
int iconY = 0;
display.setColor(UIColor::title_txt);
// battery outline if (mode != BATT_DISPLAY_OFF) {
display.drawRect(iconX, iconY, iconWidth, iconHeight); // компактная иконка в правом верхнем углу
const int iconWidth = 18;
const int iconHeight = 9;
const int iconX = display.width() - iconWidth - 3;
const int iconY = 1;
display.setColor(UIColor::title_txt);
// battery "cap" // battery outline
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); display.drawRect(iconX, iconY, iconWidth, iconHeight);
// fill the battery based on the percentage // battery "cap"
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; display.fillRect(iconX + iconWidth, iconY + 2, 2, iconHeight - 4);
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
// show muted icon if buzzer is muted // fill the battery based on the percentage
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
char txt[10];
if (mode == BATT_DISPLAY_VOLT) {
snprintf(txt, sizeof(txt), "%.2fV", batteryMilliVolts / 1000.0f);
} else {
snprintf(txt, sizeof(txt), "%d%%", batteryPercentage);
}
textWidth = (int)display.getTextWidth(txt);
display.drawTextRightAlign(iconX - 3, iconY, txt);
}
// show muted icon if buzzer is muted (левее значения батареи)
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
if (_task->isBuzzerQuiet()) { if (_task->isBuzzerQuiet()) {
const int iconX = display.width() - 18 - 3;
display.setColor(UIColor::warning_txt); display.setColor(UIColor::warning_txt);
display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8); display.drawXbm(iconX - 3 - textWidth - 10, 1, muted_icon, 8, 8);
} }
#endif #endif
} }
@@ -339,7 +386,9 @@ class HomeScreen : public UIScreen {
public: public:
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs) HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
_shutdown_init(false), _radio_edit(false), _radio_val_edit(false), _radio_sel(0), _shutdown_init(false), _shutdown_restart(false),
_power_screen(PWR_OFF), _power_sel(0), _rs_sel(0), _power_last_activity(0), _batt_mv(0),
_radio_edit(false), _radio_val_edit(false), _radio_sel(0), _radio_fq_pos(0),
_radio_last_activity(0), _theme_edit(false), _theme_sel(0), _theme_last_activity(0), _radio_last_activity(0), _theme_edit(false), _theme_sel(0), _theme_last_activity(0),
_notify_screen(NOTIFY_OFF), _notify_sel(0), _notify_val_edit(false), _notify_last_activity(0), _notify_screen(NOTIFY_OFF), _notify_sel(0), _notify_val_edit(false), _notify_last_activity(0),
sensors_lpp(200) { } sensors_lpp(200) { }
@@ -358,7 +407,7 @@ public:
} }
if (_theme_edit) { if (_theme_edit) {
if (millis() - _theme_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) { if (millis() - _theme_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) {
themeEditLeave(false); themeEditLeave();
_task->blankDisplay(); _task->blankDisplay();
} else { } else {
_task->keepDisplayOn(); _task->keepDisplayOn();
@@ -372,8 +421,16 @@ public:
_task->keepDisplayOn(); _task->keepDisplayOn();
} }
} }
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released if (_power_screen != PWR_OFF) {
_task->shutdown(); if (millis() - _power_last_activity >= UI_RADIO_MENU_TIMEOUT_MILLIS) {
powerLeave(); // 5 минут простоя — закрыть меню
_task->blankDisplay();
} else {
_task->keepDisplayOn();
}
}
if (_shutdown_init && !_task->isButtonPressed()) { // дождаться отпускания кнопки
_task->shutdown(_shutdown_restart);
} }
} }
@@ -392,6 +449,10 @@ public:
renderNotifyMenu(display); renderNotifyMenu(display);
return 5000; return 5000;
} }
if (_power_screen != PWR_OFF) {
renderPowerScreen(display);
return 5000;
}
display.setColor(UIColor::title_bkg); display.setColor(UIColor::title_bkg);
display.fillRect(0, 0, display.width(), 12); display.fillRect(0, 0, display.width(), 12);
char tmp[80]; char tmp[80];
@@ -403,8 +464,10 @@ public:
display.setCursor(0, 2); display.setCursor(0, 2);
display.print(filtered_name); display.print(filtered_name);
// battery voltage // battery voltage: одно измерение на кадр, с мягким сглаживанием —
renderBatteryIndicator(display, _task->getBattMilliVolts()); // без него напряжение в статус-баре дрожало в последнем разряде
uint16_t batt_mv = battMilliVolts();
renderBatteryIndicator(display, batt_mv);
// curr page indicator: активная точка — акцентным цветом, остальные — вторичным. // curr page indicator: активная точка — акцентным цветом, остальные — вторичным.
// Раньше неактивные точки красились в title_bkg, и на тёмных палитрах // Раньше неактивные точки красились в title_bkg, и на тёмных палитрах
@@ -627,17 +690,8 @@ public:
#endif #endif
} else if (_page == HomePage::THEME) { } else if (_page == HomePage::THEME) {
renderThemePage(display); renderThemePage(display);
} else if (_page == HomePage::SHUTDOWN) { } else if (_page == HomePage::POWER) {
display.setColor(UIColor::corp_blue); renderPowerPage(display, batt_mv);
display.setTextSize(1);
if (_shutdown_init) {
display.setColor(UIColor::warning_txt);
display.drawTextCentered(display.width() / 2, 34, "hibernating...");
} else {
display.setColor(UIColor::secondary_txt);
display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
}
} }
return 5000; // next render after 5000 ms return 5000; // next render after 5000 ms
} }
@@ -655,10 +709,16 @@ public:
} }
if (c == KEY_ENTER) { if (c == KEY_ENTER) {
if (_radio_val_edit) { if (_radio_val_edit) {
_radio_val_edit = false; // назад к списку // у частоты долгое нажатие перебирает разряды, на последнем — возврат к списку
if (_radio_sel == ROW_FQ && _radio_fq_pos < UI_RADIO_FREQ_KHZ_DIGITS) {
_radio_fq_pos++;
} else {
_radio_val_edit = false; // назад к списку
}
} else if (_radio_sel == ROW_SAVE) { } else if (_radio_sel == ROW_SAVE) {
radioEditLeave(true); // сохранить в память и выйти radioEditLeave(true); // сохранить в память и выйти
} else { } else {
_radio_fq_pos = 0;
_radio_val_edit = true; // править значение _radio_val_edit = true; // править значение
} }
return true; return true;
@@ -679,9 +739,9 @@ public:
DisplayDriver* d = _task->getDisplay(); DisplayDriver* d = _task->getDisplay();
if (d != NULL) { if (d != NULL) {
if (_theme_sel >= d->getThemeCount()) { if (_theme_sel >= d->getThemeCount()) {
themeEditLeave(true); // строка "Save & exit" — сохранить и выйти themeEditLeave(); // строка "Exit" — выйти (сохранённая тема не меняется)
} else { } else {
themeStore(); // длинное нажатие на теме — запомнить её сейчас themeStore(); // длинное нажатие на теме — запомнить её в prefs
char msg[28]; char msg[28];
snprintf(msg, sizeof(msg), "theme %s OK", d->getThemeName(d->getTheme())); snprintf(msg, sizeof(msg), "theme %s OK", d->getThemeName(d->getTheme()));
_task->showAlert(msg, 1200); _task->showAlert(msg, 1200);
@@ -707,6 +767,22 @@ public:
} }
return true; return true;
} }
if (_power_screen != PWR_OFF) { // меню Power: страницы не листаются
_power_last_activity = millis();
if (c == KEY_NEXT || c == KEY_RIGHT) {
powerStep(+1);
return true;
}
if (c == KEY_PREV || c == KEY_LEFT) {
powerStep(-1);
return true;
}
if (c == KEY_ENTER) {
powerAction();
return true;
}
return true;
}
if (c == KEY_LEFT || c == KEY_PREV) { if (c == KEY_LEFT || c == KEY_PREV) {
_page = pageStep(-1); _page = pageStep(-1);
return true; return true;
@@ -762,8 +838,8 @@ public:
return true; return true;
} }
#endif #endif
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { if (c == KEY_ENTER && _page == HomePage::POWER) {
_shutdown_init = true; // need to wait for button to be released powerEnter(); // внутри меню Power: батарея, коррекция АЦП, reboot/shutdown
return true; return true;
} }
return false; return false;
@@ -773,7 +849,16 @@ public:
void HomeScreen::radioValueStep(int8_t dir) { void HomeScreen::radioValueStep(int8_t dir) {
switch (_radio_sel) { switch (_radio_sel) {
case ROW_FQ: { case ROW_FQ: {
int32_t khz = (int32_t)(_node_prefs->freq * 1000.0f + 0.5f) + dir * UI_RADIO_FREQ_STEP_KHZ; // разрядное редактирование: целые МГц (±1 МГц), килогерцы — по символам
int32_t khz = (int32_t)(_node_prefs->freq * 1000.0f + 0.5f);
if (_radio_fq_pos == 0) {
khz += dir * 1000;
} else {
int step = 1;
for (uint8_t i = _radio_fq_pos; i < UI_RADIO_FREQ_KHZ_DIGITS; i++) step *= 10; // 100, 10, 1
int digit = (khz / step) % 10;
khz += ((digit + dir + 10) % 10 - digit) * step; // по кругу: 9 + 1 -> 0, 0 - 1 -> 9
}
int32_t lo = (int32_t)(UI_RADIO_FREQ_MIN * 1000.0f), hi = (int32_t)(UI_RADIO_FREQ_MAX * 1000.0f); int32_t lo = (int32_t)(UI_RADIO_FREQ_MIN * 1000.0f), hi = (int32_t)(UI_RADIO_FREQ_MAX * 1000.0f);
if (khz < lo) khz = lo; if (khz < lo) khz = lo;
if (khz > hi) khz = hi; if (khz > hi) khz = hi;
@@ -841,13 +926,228 @@ const char* HomeScreen::radioRowName(uint8_t row) {
} }
} }
// ---------------------------------------------------------------- меню Power
static const char* battDisplayName(uint8_t mode) {
if (mode == BATT_DISPLAY_VOLT) return "Volt";
if (mode == BATT_DISPLAY_PERC) return "Perc";
return "Off";
}
void HomeScreen::powerEnter() {
_task->clearAlert();
_power_screen = PWR_MAIN;
_power_sel = PWR_BATT;
_rs_sel = 0;
_power_last_activity = millis();
}
void HomeScreen::powerLeave() {
_power_screen = PWR_OFF;
_task->showPendingMessages(); // сообщения, пришедшие пока было открыто меню
}
void HomeScreen::powerStep(int8_t dir) {
if (_power_screen == PWR_ADC) { powerAdcStep(dir); return; } // ±0.01
if (_power_screen == PWR_RS) {
_rs_sel = (uint8_t)((_rs_sel + 3 + dir) % 3);
return;
}
_power_sel = (uint8_t)((_power_sel + PWR_ROW_COUNT + dir) % PWR_ROW_COUNT);
}
// длинное нажатие: у Battery переключает значение, у остальных — вход/выход
void HomeScreen::powerAction() {
if (_power_screen == PWR_ADC) { // из правки АЦП — назад в основное меню
_power_screen = PWR_MAIN;
return;
}
if (_power_screen == PWR_RS) {
if (_rs_sel == 0) { _shutdown_restart = true; _shutdown_init = true; } // reboot
else if (_rs_sel == 1) { _shutdown_restart = false; _shutdown_init = true; } // shutdown
else { _power_screen = PWR_MAIN; } // back
return;
}
switch (_power_sel) {
case PWR_BATT:
_node_prefs->batt_display = (uint8_t)((_node_prefs->batt_display + 1) % 3);
the_mesh.savePrefs(); // применяется сразу и сохраняется
_task->showAlert(battDisplayName(_node_prefs->batt_display), 800);
break;
case PWR_ADC_ROW: _power_screen = PWR_ADC; break;
case PWR_RST_ROW: _power_screen = PWR_RS; _rs_sel = 0; break;
default: powerLeave(); break; // Back
}
}
void HomeScreen::powerAdcStep(int8_t dir) {
// считаем от того значения, которое реально работает сейчас, а не от нуля
float base = adcBaseValue();
if (base <= 0.0f) base = 1.0f; // плата без множителя — считаем от 1.00
float v = base + (float)dir * 0.01f;
if (v < 0.0f) v = 0.0f;
if (v > 10.0f) v = 10.0f;
v = roundf(v * 100.0f) / 100.0f; // без накопления погрешности float
if (!_task->setAdcMultiplier(v)) {
_task->showAlert("ADC not supported", 1200); // плата не умеет корректировать — не сохраняем
return;
}
_node_prefs->adc_multiplier = v;
the_mesh.savePrefs();
}
// Множитель АЦП, который действует сейчас: из prefs, иначе из платы,
// иначе — базовый множитель варианта (ADC_MULTIPLIER в variant.h)
float HomeScreen::adcBaseValue() {
if (_node_prefs->adc_multiplier > 0.0f) return _node_prefs->adc_multiplier;
float v = _task->getAdcMultiplier();
if (v > 0.0f) return v;
#ifdef ADC_MULTIPLIER
return (float)ADC_MULTIPLIER;
#else
return 0.0f;
#endif
}
// Напряжение батареи: измеряем один раз за кадр и сглаживаем, иначе последний
// разряд на экране дрожит (ADC шумит, а индикатор и страница POWER читали его отдельно)
uint16_t HomeScreen::battMilliVolts() {
uint16_t raw = _task->getBattMilliVolts();
if (raw == 0) return _batt_mv;
_batt_mv = (_batt_mv == 0) ? raw : (uint16_t)(((uint32_t)_batt_mv + raw) / 2);
return _batt_mv;
}
void HomeScreen::renderPowerPage(DisplayDriver& display, uint16_t mv) {
char tmp[24];
int pct = ((int)mv - BATT_MIN_MILLIVOLTS) * 100 / (BATT_MAX_MILLIVOLTS - BATT_MIN_MILLIVOLTS);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
// Крупная иконка батареи, справа — заряд и напряжение.
// Сверху 12 px занимает статус-бар, под ним (13..17) — точки страниц,
// поэтому группа начинается с y=19, а не вплотную к индикатору.
// Группу привязываем к центру экрана: на TFT 240x320 фиксированные края разъезжались.
const int half = display.width() / 2;
const int iconX = half - 60, iconY = 19, iconW = 44, iconH = 22;
display.setColor(UIColor::primary_txt);
display.drawRect(iconX, iconY, iconW, iconH);
display.fillRect(iconX + iconW, iconY + 6, 3, iconH - 12); // "пипка"
display.setColor(pct <= 15 ? UIColor::warning_txt : UIColor::primary_txt);
display.fillRect(iconX + 3, iconY + 3, (pct * (iconW - 6)) / 100, iconH - 6);
display.setColor(UIColor::warning_txt);
display.setTextSize(2);
snprintf(tmp, sizeof(tmp), "%d%%", pct);
display.drawTextRightAlign(half + 60, iconY + 2, tmp);
display.setColor(UIColor::secondary_txt);
display.setTextSize(1);
if (mv > 0) snprintf(tmp, sizeof(tmp), "%.2f V", mv / 1000.0f);
else strcpy(tmp, "-- V");
display.drawTextRightAlign(half + 60, iconY + iconH + 1, tmp);
display.setColor(UIColor::primary_txt);
display.drawTextCentered(display.width() / 2, 64 - 11, "power: " PRESS_LABEL);
}
void HomeScreen::renderPowerScreen(DisplayDriver& display) {
if (_power_screen == PWR_ADC) {
renderPowerAdc(display);
return;
}
display.setTextSize(1);
if (_power_screen == PWR_RS) {
drawMenuRow(display, 2, _rs_sel == 0, "Reboot", "");
drawMenuRow(display, 12, _rs_sel == 1, "Shutdown", "");
drawMenuRow(display, 22, _rs_sel == 2, "Back", "");
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=run clk=next");
return;
}
char val[16];
float adc = adcBaseValue();
if (adc > 0.0f) snprintf(val, sizeof(val), "%.2f", adc);
else strcpy(val, "auto");
drawMenuRow(display, 2, _power_sel == PWR_BATT, "Battery", battDisplayName(_node_prefs->batt_display));
drawMenuRow(display, 12, _power_sel == PWR_ADC_ROW, "ADC corr", val);
drawMenuRow(display, 22, _power_sel == PWR_RST_ROW, "Reboot/Shut", ">");
drawMenuRow(display, 32, _power_sel == PWR_BACK, "Back", "");
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=change clk=next");
}
void HomeScreen::renderPowerAdc(DisplayDriver& display) {
char buf[16];
display.setColor(UIColor::secondary_txt);
display.setTextSize(1);
display.drawTextLeftAlign(0, 2, "ADC +0.01/-0.01");
float adc = adcBaseValue();
if (adc > 0.0f) snprintf(buf, sizeof(buf), "%.2f", adc);
else strcpy(buf, "auto");
display.setColor(UIColor::warning_txt);
display.setTextSize(2);
display.drawTextCentered(display.width() / 2, 20, buf);
display.setColor(UIColor::secondary_txt);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=back");
}
void HomeScreen::renderRadioEditor(DisplayDriver& display) { void HomeScreen::renderRadioEditor(DisplayDriver& display) {
char buf[24]; char buf[24];
char head[32]; char head[32];
if (_radio_val_edit) { // фаза правки значения if (_radio_val_edit) { // фаза правки значения
if (_radio_sel == ROW_FQ) {
// частота правится по разрядам: активный разряд инвертируем, чтобы было видно,
// что именно меняется (целые МГц или один из разрядов кГц)
int32_t khz = (int32_t)(_node_prefs->freq * 1000.0f + 0.5f);
int mhz = khz / 1000, rem = khz % 1000;
snprintf(buf, sizeof(buf), "%d.%03d", mhz, rem);
snprintf(head, sizeof(head), "FQ %.0f-%.0f MHz", UI_RADIO_FREQ_MIN, UI_RADIO_FREQ_MAX);
display.setColor(UIColor::secondary_txt);
display.setTextSize(1);
display.drawTextLeftAlign(0, 2, head);
// Позиции символов считаем по реальным ширинам: у пропорциональных шрифтов
// (TFT_eSPI на T096) точка уже цифры, из-за фиксированного шага рамка уезжала.
char mhz_txt[8];
snprintf(mhz_txt, sizeof(mhz_txt), "%d", mhz);
int sel_len = _radio_fq_pos == 0 ? (int)strlen(mhz_txt) : 1;
int sel_start = _radio_fq_pos == 0 ? 0 : (int)strlen(mhz_txt) + _radio_fq_pos; // +1 — точка
const int buf_len = (int)strlen(buf);
display.setTextSize(2);
int total = 0;
for (int i = 0; i < buf_len; i++) {
char one[2] = { buf[i], 0 };
total += (int)display.getTextWidth(one);
}
int cx = (display.width() - total) / 2;
const int y = 20;
for (int i = 0; i < buf_len; i++) {
char one[2] = { buf[i], 0 };
int cw = (int)display.getTextWidth(one);
bool mark = (i >= sel_start && i < sel_start + sel_len);
if (mark) {
display.setColor(UIColor::primary_txt);
display.fillRect(cx - 1, y - 2, cw + 2, 20);
display.setColor(UIColor::window_bkg);
} else {
display.setColor(UIColor::warning_txt);
}
display.drawTextLeftAlign(cx, y, one);
cx += cw;
}
display.setColor(UIColor::secondary_txt);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 64 - 10, "clk+ dbl- hold=next");
return;
}
switch (_radio_sel) { switch (_radio_sel) {
case ROW_FQ: snprintf(head, sizeof(head), "FQ %.0f-%.0f MHz", UI_RADIO_FREQ_MIN, UI_RADIO_FREQ_MAX); break;
case ROW_BW: strcpy(head, "BW 7.8-500 kHz"); break; case ROW_BW: strcpy(head, "BW 7.8-500 kHz"); break;
case ROW_SF: strcpy(head, "SF 5-12"); break; case ROW_SF: strcpy(head, "SF 5-12"); break;
case ROW_CR: strcpy(head, "CR 5-8"); break; case ROW_CR: strcpy(head, "CR 5-8"); break;
@@ -913,14 +1213,14 @@ void HomeScreen::renderThemePage(DisplayDriver& display) {
void HomeScreen::renderThemeEditor(DisplayDriver& display) { void HomeScreen::renderThemeEditor(DisplayDriver& display) {
uint8_t n = display.getThemeCount(); uint8_t n = display.getThemeCount();
uint8_t rows = n + 1; // темы + "Save & exit" uint8_t rows = n + 1; // темы + строка выхода
const uint8_t VISIBLE = 6; // 6 строк по 10 px влезают в 64 const uint8_t VISIBLE = 5; // 5 строк по 10 px + подсказка внизу
uint8_t first = _theme_sel >= VISIBLE ? _theme_sel - VISIBLE + 1 : 0; uint8_t first = _theme_sel >= VISIBLE ? _theme_sel - VISIBLE + 1 : 0;
display.setTextSize(1); display.setTextSize(1);
int y = 2; int y = 2;
for (uint8_t r = first; r < rows && y <= 52; r++, y += 10) { for (uint8_t r = first; r < rows && y <= 42; r++, y += 10) {
const char* label = (r < n) ? display.getThemeName(r) : "Save & exit"; const char* label = (r < n) ? display.getThemeName(r) : "Exit";
if (r == _theme_sel) { if (r == _theme_sel) {
display.setColor(UIColor::primary_txt); // выбранная строка — инверсия display.setColor(UIColor::primary_txt); // выбранная строка — инверсия
display.fillRect(0, y - 1, display.width(), 10); display.fillRect(0, y - 1, display.width(), 10);
@@ -941,6 +1241,9 @@ void HomeScreen::renderThemeEditor(DisplayDriver& display) {
display.fillRect(sx + 22, y, 10, 8); display.fillRect(sx + 22, y, 10, 8);
} }
} }
// без длинного нажатия тема не сохраняется — выход вернёт прежнюю
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 64 - 11, "hold=save clk=next");
} }
uint8_t HomeScreen::channelCount() { uint8_t HomeScreen::channelCount() {
@@ -1215,6 +1518,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
_node_prefs = node_prefs; _node_prefs = node_prefs;
// применить сохранённую коррекцию АЦП батареи (0 = как считает плата)
if (_node_prefs->adc_multiplier > 0.0f) {
_board->setAdcMultiplier(_node_prefs->adc_multiplier);
}
if (_display != NULL) { if (_display != NULL) {
// применить сохранённую тему дисплея (если драйвер их поддерживает) // применить сохранённую тему дисплея (если драйвер их поддерживает)
if (_display->getThemeCount() > 0) { if (_display->getThemeCount() > 0) {
+5
View File
@@ -88,6 +88,11 @@ public:
bool displayHasThemes() const { return _display != NULL && _display->getThemeCount() > 0; } bool displayHasThemes() const { return _display != NULL && _display->getThemeCount() > 0; }
bool isButtonPressed() const; bool isButtonPressed() const;
// Коррекция АЦП батареи: реализация в board конкретного варианта,
// если плата так не умеет — set вернёт false, get вернёт 0
bool setAdcMultiplier(float multiplier) { return _board->setAdcMultiplier(multiplier); }
float getAdcMultiplier() const { return _board->getAdcMultiplier(); }
bool isBuzzerQuiet() { bool isBuzzerQuiet() {
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
return buzzer.isQuiet(); return buzzer.isQuiet();
+23 -8
View File
@@ -52,6 +52,14 @@ void T096Board::begin() {
periph_power.begin(); periph_power.begin();
loRaFEMControl.init(); loRaFEMControl.init();
delay(1); delay(1);
// ADC батареи настраиваем один раз: analogReference()/analogReadResolution() сбрасывают
// ADC, и первое чтение после них отдаёт мусор — из-за этого напряжение на экране скакало
analogReadResolution(12);
analogReference(AR_INTERNAL_3_0);
pinMode(PIN_VBAT_READ, INPUT);
pinMode(PIN_BAT_CTL, OUTPUT);
digitalWrite(PIN_BAT_CTL, 0); // делитель обесточен, пока не измеряем
} }
void T096Board::onBeforeTransmit() { void T096Board::onBeforeTransmit() {
@@ -65,18 +73,25 @@ void T096Board::onAfterTransmit() {
} }
uint16_t T096Board::getBattMilliVolts() { uint16_t T096Board::getBattMilliVolts() {
int adcvalue = 0;
analogReadResolution(12);
analogReference(AR_INTERNAL_3_0);
pinMode(PIN_VBAT_READ, INPUT);
pinMode(PIN_BAT_CTL, OUTPUT);
digitalWrite(PIN_BAT_CTL, 1); digitalWrite(PIN_BAT_CTL, 1);
delay(10); // делителю нужно устояться после включения
delay(10); // одного отсчёта мало: шум ADC давал скачки в последнем разряде
adcvalue = analogRead(PIN_VBAT_READ); uint32_t sum = 0;
const int N = 8;
for (int i = 0; i < N; i++) {
sum += analogRead(PIN_VBAT_READ);
}
digitalWrite(PIN_BAT_CTL, 0); digitalWrite(PIN_BAT_CTL, 0);
return (uint16_t)((float)adcvalue * MV_LSB * 4.9); return (uint16_t)((float)(sum / N) * MV_LSB * adc_mult);
}
// Коррекция АЦП батареи (страница POWER в ui-new): полный множитель делителя
bool T096Board::setAdcMultiplier(float multiplier) {
if (multiplier <= 0.0f) return false;
adc_mult = multiplier;
return true;
} }
void T096Board::variant_shutdown() { void T096Board::variant_shutdown() {
nrf_gpio_cfg_default(PIN_VEXT_EN); nrf_gpio_cfg_default(PIN_VEXT_EN);
+3
View File
@@ -8,6 +8,7 @@
class T096Board : public NRF52BoardDCDC { class T096Board : public NRF52BoardDCDC {
protected: protected:
float adc_mult = ADC_MULTIPLIER; // множитель делителя батареи, правится из UI (страница POWER)
#ifdef NRF52_POWER_MANAGEMENT #ifdef NRF52_POWER_MANAGEMENT
void initiateShutdown(uint8_t reason) override; void initiateShutdown(uint8_t reason) override;
#endif #endif
@@ -23,6 +24,8 @@ public:
void onBeforeTransmit(void) override; void onBeforeTransmit(void) override;
void onAfterTransmit(void) override; void onAfterTransmit(void) override;
uint16_t getBattMilliVolts() override; uint16_t getBattMilliVolts() override;
bool setAdcMultiplier(float multiplier) override;
float getAdcMultiplier() const override { return adc_mult; }
const char* getManufacturerName() const override ; const char* getManufacturerName() const override ;
void powerOff() override; void powerOff() override;
bool setLoRaFemLnaEnabled(bool enable) override; bool setLoRaFemLnaEnabled(bool enable) override;
+3
View File
@@ -6,6 +6,9 @@ build_flags =
${esp32_base.build_flags} ${esp32_base.build_flags}
-I variants/meshadventurer -I variants/meshadventurer
-D MESHADVENTURER -D MESHADVENTURER
-D ENV_INCLUDE_BME280=1
-D ENV_PIN_SDA=21
-D ENV_PIN_SCL=22
-D OLED_RU=1 -D OLED_RU=1
-D SX126X_CURRENT_LIMIT=140 -D SX126X_CURRENT_LIMIT=140
-D P_LORA_TX_LED=2 -D P_LORA_TX_LED=2