Compare commits

..
5 Commits
9 changed files with 614 additions and 59 deletions

No files matched your search

+39 -8
View File
@@ -41,9 +41,12 @@ git merge upstream/main # правки для upstream отправляю
- **Темы цветных TFT переключаются в рантайме, без пересборки.** Пять палитр - **Темы цветных TFT переключаются в рантайме, без пересборки.** Пять палитр
(`amber`, `teal`, `signal`, `neon`, `classic`) лежат в общей таблице (`amber`, `teal`, `signal`, `neon`, `classic`) лежат в общей таблице
[`src/helpers/ui/ColorThemes.h`](./src/helpers/ui/ColorThemes.h) и работают и на ST7735, [`src/helpers/ui/ColorThemes.h`](./src/helpers/ui/ColorThemes.h) и работают и на ST7735,
и на ST7789LCD. В `ui-new` есть страница `THEME`: живой предпросмотр, образцы цветов, и на ST7789LCD. В `ui-new` есть страница `THEME`: живой предпросмотр, образцы цветов.
сохранение выбора в prefs. По умолчанию — янтарь на Heltec T096 и официальная светлая **Тема сохраняется только длинным нажатием** на выбранной строке (попап `theme <имя> OK`);
схема MeshCore на остальных платах (как в upstream). короткое и двойное нажатие лишь показывают тему, а выход из меню (в том числе по таймауту)
возвращает ту, что записана в prefs — предпросмотр ничего не подменяет. По умолчанию —
янтарь на Heltec T096 и официальная светлая схема MeshCore на остальных платах
(как в upstream).
- **Индикатор страниц** в `ui-new` строится на `warning_txt` / `secondary_txt`: в upstream - **Индикатор страниц** в `ui-new` строится на `warning_txt` / `secondary_txt`: в upstream
цвета брались из `title_bkg`, из-за чего на тёмных темах активная точка сливалась с фоном. цвета брались из `title_bkg`, из-за чего на тёмных темах активная точка сливалась с фоном.
@@ -60,11 +63,14 @@ git merge upstream/main # правки для upstream отправляю
### Интерфейс (companion_radio / ui-new) ### Интерфейс (companion_radio / ui-new)
- **Настройка радио прямо с устройства** — страница RADIO, длинное нажатие: частота - **Настройка радио прямо с устройства** — страница RADIO, длинное нажатие: частота,
(шаг 25 кГц), ширина полосы, SF, CR и мощность передатчика. Значения применяются к радио ширина полосы, SF, CR и мощность передатчика. Частота правится **по разрядам** (как в
сразу, в память пишутся по пункту `Save & exit`; через 5 минут простоя меню закрывается PosadMesh): целые МГц меняются шагом 1 МГц, а килогерцы — по символам (сотни, десятки,
без сохранения и экран гаснет, а пока меню открыто — входящие сообщения не всплывают единицы). Короткое и двойное нажатие меняют активный разряд на ±1, длинное — переводит
поверх него. на следующий разряд, а после единиц кГц возвращает к списку; активный разряд показан
инверсией. Значения применяются к радио сразу, в память пишутся по пункту `Save & exit`;
через 5 минут простоя меню закрывается без сохранения и экран гаснет, а пока меню открыто —
входящие сообщения не всплывают поверх него.
- **Меню уведомлений** (длинное нажатие на первом экране): `Popups`, `Wake screen` - **Меню уведомлений** (длинное нажатие на первом экране): `Popups`, `Wake screen`
(запретить автовключение дисплея по входящему сообщению; по кнопке экран работает), (запретить автовключение дисплея по входящему сообщению; по кнопке экран работает),
`Alerts` (звук+вибрация / только звук / только вибрация / выключено), `Channels` `Alerts` (звук+вибрация / только звук / только вибрация / выключено), `Channels`
@@ -74,6 +80,25 @@ git merge upstream/main # правки для upstream отправляю
тихие часы — в `NodePrefs::isQuietNow()`, «тихая» доставка — через тихие часы — в `NodePrefs::isQuietNow()`, «тихая» доставка — через
`AbstractUITask::newMsgSilent()`. В тихие часы глушатся звук, вибрация и всплытия для `AbstractUITask::newMsgSilent()`. В тихие часы глушатся звук, вибрация и всплытия для
каналов, личных сообщений и уведомлений о новых контактах, но сообщения не теряются. каналов, личных сообщений и уведомлений о новых контактах, но сообщения не теряются.
- **Страница POWER** (последняя в списке страниц; длинное нажатие — вход в меню) — питание,
батарея и перезагрузка. Раньше этот пункт назывался `Hibernation` и сразу выключал устройство.
На самой странице крупно нарисованы батарея с заливкой по заряду, процент и напряжение.
- `Battery` — что показывать рядом с иконкой батареи в статус-баре: `Volt` (напряжение,
`4.05V`), `Perc` (процент) или `Off` (индикатор скрыт). Длинное нажатие переключает
значение; применяется и сохраняется сразу. Иконка батареи стала компактнее (18×9).
- `ADC corr` — коррекция АЦП батареи. Показывается **тот множитель, который действует
сейчас**: значение из настроек, иначе — из платы, иначе базовый `ADC_MULTIPLIER` варианта
(то есть правка начинается не с нуля, а с реального значения прошивки). Длинное нажатие
открывает правку, где короткое нажатие даёт `+0.01`, двойное `-0.01`, а длинное возвращает
в меню. Значение применяется к плате и сохраняется сразу, а при следующем включении
подхватывается из prefs.
- `Reboot/Shut` — подменю из трёх пунктов: `Reboot`, `Shutdown`, `Back`; длинное нажатие
выполняет выбранное.
- `Back` — выход из меню. Сохранять ничего не нужно: всё сохраняется в момент изменения,
через 5 минут простоя меню закрывается само.
- Коррекция работает на платах, где борд её поддерживает (`Heltec V4`, `V4 R8`, `Heltec
T096`, `promicro`, `Heltec RC32`, `Meshnology W12`); на остальных при попытке правки
выводится `ADC not supported`.
### Сборка и CI ### Сборка и CI
@@ -86,6 +111,12 @@ git merge upstream/main # правки для upstream отправляю
набор прошивок собирается на своей машине (тот же `build.sh` через Git Bash), а готовые набор прошивок собирается на своей машине (тот же `build.sh` через Git Bash), а готовые
файлы прикрепляются к релизу Gitea — сервер только принимает их и не тратит CPU на сборку. файлы прикрепляются к релизу Gitea — сервер только принимает их и не тратит CPU на сборку.
Это основной способ выпустить прошивки: на VPS всего одно ядро. Это основной способ выпустить прошивки: на VPS всего одно ядро.
- **Прошивальщик на сайте** (<https://meshinfo.ru/flasher/>): список прошивок страница берёт
из своего `index.json`, где имена файлов разобраны на плату, роль, чип и формат. После
публикации релиза манифест пересобирается скриптом
[`ci/update-flasher.ps1`](./ci/update-flasher.ps1): он выкладывает его на сервер, делает
бэкап предыдущего и проверяет, что страница и файлы доступны. При `-Publish` это делается
автоматически.
### Варианты плат ### Варианты плат
+20
View File
@@ -167,6 +167,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:
+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);
+344 -49
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,15 @@ 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;
AdvertPath recent[UI_RECENT_LIST_SIZE]; AdvertPath recent[UI_RECENT_LIST_SIZE];
// режим ручной настройки радио (страница RADIO) // режим ручной настройки радио (страница RADIO)
@@ -163,6 +174,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 +182,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 +200,17 @@ 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 варианта
void renderPowerPage(DisplayDriver& display);
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 +232,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 +312,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 +384,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),
_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 +405,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 +419,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 +447,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];
@@ -627,17 +686,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);
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 +705,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 +735,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 +763,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 +834,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 +845,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 +922,219 @@ 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
}
void HomeScreen::renderPowerPage(DisplayDriver& display) {
char tmp[24];
uint16_t mv = _task->getBattMilliVolts();
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 занимает статус-бар (имя ноды и индикатор батареи), поэтому начинаем ниже.
// Группу привязываем к центру экрана: на TFT 240x320 фиксированные края разъезжались.
const int half = display.width() / 2;
const int iconX = half - 60, iconY = 16, 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 + 4, 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 +1200,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 +1228,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 +1505,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();
+8 -1
View File
@@ -76,7 +76,14 @@ uint16_t T096Board::getBattMilliVolts() {
adcvalue = analogRead(PIN_VBAT_READ); adcvalue = 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)adcvalue * 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;