Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
759a1bf340 | ||
|
|
440aa8a512 | ||
|
|
c58782aeeb | ||
|
|
e59a5f1759 | ||
|
|
24e2e48e1d |
No files matched your search
@@ -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` это делается
|
||||||
|
автоматически.
|
||||||
|
|
||||||
### Варианты плат
|
### Варианты плат
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user