diff --git a/README.md b/README.md
index 8bee8407..2b5927f7 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,12 @@ git merge upstream/main # правки для upstream отправляю
набор прошивок собирается на своей машине (тот же `build.sh` через Git Bash), а готовые
файлы прикрепляются к релизу Gitea — сервер только принимает их и не тратит CPU на сборку.
Это основной способ выпустить прошивки: на VPS всего одно ядро.
+- **Прошивальщик на сайте** (): список прошивок страница берёт
+ из своего `index.json`, где имена файлов разобраны на плату, роль, чип и формат. После
+ публикации релиза манифест пересобирается скриптом
+ [`ci/update-flasher.ps1`](./ci/update-flasher.ps1): он выкладывает его на сервер, делает
+ бэкап предыдущего и проверяет, что страница и файлы доступны. При `-Publish` это делается
+ автоматически.
### Варианты плат
diff --git a/ci/README.md b/ci/README.md
index 01f58ce4..f607287f 100644
--- a/ci/README.md
+++ b/ci/README.md
@@ -167,6 +167,26 @@ $env:GITEA_TOKEN = '<токен с правом write:repository>'
В CI шага публикации нет: сервер только собирает прошивки по ручному запуску и отдаёт их
артефактом.
+### Прошивальщик на сайте
+
+Страница берёт список прошивок из своего `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. Набор прошивок по умолчанию
`ci/build-firmwares.sh`, массив `DEFAULT_TARGETS` — 26 env:
diff --git a/ci/build-local.ps1 b/ci/build-local.ps1
index 25c500cd..f5e9985a 100644
--- a/ci/build-local.ps1
+++ b/ci/build-local.ps1
@@ -21,7 +21,11 @@
FIRMWARE_VERSION — попадает в имена файлов (по умолчанию local).
.PARAMETER Publish
- Создать релиз в Gitea и прикрепить к нему файлы из out/.
+ Создать релиз в Gitea и прикрепить к нему файлы из out/. После публикации
+ автоматически обновляется манифест прошивальщика (ci/update-flasher.ps1).
+
+.PARAMETER SkipFlasher
+ Не обновлять манифест прошивальщика после публикации релиза.
.PARAMETER Tag
Тег для релиза (обязателен с -Publish). Если тега в репозитории нет — он будет создан
@@ -47,6 +51,7 @@ param(
[string]$Tag = '',
[switch]$Public,
[switch]$SkipBuild,
+ [switch]$SkipFlasher,
[string]$Token = $env:GITEA_TOKEN,
[string]$GiteaUrl = 'https://git.meshinfo.ru',
[string]$Repo = 'shade/MeshCore'
@@ -170,6 +175,23 @@ try {
Write-Host ''
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 {
Write-Host ''
diff --git a/ci/update-flasher.ps1 b/ci/update-flasher.ps1
new file mode 100644
index 00000000..b58b7da4
--- /dev/null
+++ b/ci/update-flasher.ps1
@@ -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