Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d832915f7 | ||
|
|
e5ddf1700f | ||
|
|
e58059c24b | ||
|
|
9a89c11a1b | ||
|
|
759a1bf340 | ||
|
|
440aa8a512 | ||
|
|
c58782aeeb | ||
|
|
e59a5f1759 | ||
|
|
24e2e48e1d |
No files matched your search
@@ -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
@@ -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
@@ -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 ''
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user