Update CCS12 peripheral firmware and documentation

This commit is contained in:
2026-09-04 19:34:10 +03:00
parent 55bee25b4f
commit 8cd81736cf
56 changed files with 3808 additions and 504 deletions

View File

@@ -0,0 +1,263 @@
<#
.SYNOPSIS
Проверяет либо публикует бинарный образ Balsam в каталоге релизов Gitea.
.DESCRIPTION
Проверяет входные параметры и образ, получает учётные данные из переменных
окружения или Windows Credential Manager, затем обращается к API репозитория.
Режим CheckOnly проверяет доступ без изменения удалённого каталога. Секреты не
выводятся в журнал; все ошибки завершают скрипт ненулевым кодом через режим Stop.
#>
param(
[Parameter(Mandatory = $true)][string]$ImagePath,
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$Notes,
[Parameter(Mandatory = $true)]
[ValidateSet("tms", "rs485", "can", "stm32")][string]$Transport,
[string]$Product = "Balsam 167 peripheral",
[string]$GiteaBase = "https://git.rd12.ru",
[string]$Owner = "setcorp",
[string]$Repository = "SETRD12-Releases",
[string]$Branch = "main",
[switch]$CheckOnly
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
Add-Type -AssemblyName System.Net.Http
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class SetCredentialReader {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL {
public UInt32 Flags;
public UInt32 Type;
public IntPtr TargetName;
public IntPtr Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public UInt32 CredentialBlobSize;
public IntPtr CredentialBlob;
public UInt32 Persist;
public UInt32 AttributeCount;
public IntPtr Attributes;
public IntPtr TargetAlias;
public IntPtr UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern void CredFree(IntPtr credential);
public static string[] Read(string target) {
IntPtr pointer;
if (!CredRead(target, 1, 0, out pointer)) return null;
try {
CREDENTIAL value = (CREDENTIAL)Marshal.PtrToStructure(pointer, typeof(CREDENTIAL));
string user = Marshal.PtrToStringUni(value.UserName) ?? "";
string password = value.CredentialBlobSize == 0 ? "" :
Marshal.PtrToStringUni(value.CredentialBlob, (int)value.CredentialBlobSize / 2);
return new string[] { user, password };
} finally {
CredFree(pointer);
}
}
}
'@
function Get-AuthHeaders {
if ($env:GITEA_TOKEN) {
return @{ Authorization = "token $($env:GITEA_TOKEN)" }
}
if ($env:GITEA_USER -and $env:GITEA_PASSWORD) {
$plain = "$($env:GITEA_USER):$($env:GITEA_PASSWORD)"
$basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain))
return @{ Authorization = "Basic $basic" }
}
$saved = [SetCredentialReader]::Read("SET/SETGUI/Gitea")
if ($null -ne $saved -and $saved.Count -eq 2 -and $saved[0] -and $saved[1]) {
$plain = "$($saved[0]):$($saved[1])"
$basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain))
Write-Host "Authentication: Windows Credential Manager (SETGUI)"
return @{ Authorization = "Basic $basic" }
}
throw "Gitea credentials not found. Save them in SETGUI, set GITEA_TOKEN, or set GITEA_USER and GITEA_PASSWORD."
}
function Invoke-GiteaJson {
param(
[Parameter(Mandatory = $true)][string]$Method,
[Parameter(Mandatory = $true)][string]$Path,
[object]$Body,
[switch]$AllowNotFound
)
$parameters = @{
Method = $Method
Uri = "$GiteaBase/api/v1$Path"
Headers = $script:Headers
UseBasicParsing = $true
}
if ($null -ne $Body) {
$parameters.ContentType = "application/json; charset=utf-8"
$parameters.Body = $Body | ConvertTo-Json -Depth 20 -Compress
}
try {
return Invoke-RestMethod @parameters
} catch {
$response = $_.Exception.Response
if ($AllowNotFound -and $null -ne $response -and [int]$response.StatusCode -eq 404) {
return $null
}
throw
}
}
function Send-ReleaseAsset {
param([long]$ReleaseId, [string]$FilePath, [string]$AssetName)
$client = [Net.Http.HttpClient]::new()
try {
foreach ($entry in $script:Headers.GetEnumerator()) {
[void]$client.DefaultRequestHeaders.TryAddWithoutValidation($entry.Key, $entry.Value)
}
$encodedName = [Uri]::EscapeDataString($AssetName)
$uri = "$GiteaBase/api/v1/repos/$Owner/$Repository/releases/$ReleaseId/assets?name=$encodedName"
$form = [Net.Http.MultipartFormDataContent]::new()
try {
$stream = [IO.File]::OpenRead($FilePath)
$content = [Net.Http.StreamContent]::new($stream)
$content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream")
$form.Add($content, "attachment", $AssetName)
$response = $client.PostAsync($uri, $form).GetAwaiter().GetResult()
$text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if (-not $response.IsSuccessStatusCode) {
throw "Asset upload failed: HTTP $([int]$response.StatusCode): $text"
}
} finally {
if ($null -ne $form) { $form.Dispose() }
}
} finally {
$client.Dispose()
}
}
$resolvedImage = (Resolve-Path -LiteralPath $ImagePath).Path
if ([IO.Path]::GetExtension($resolvedImage) -notin @(".bin", ".hex")) {
throw "Firmware image must have .bin or .hex extension."
}
$imageInfo = Get-Item -LiteralPath $resolvedImage
if ($imageInfo.Length -le 0 -or $imageInfo.Length -gt 134217728) {
throw "Firmware image is empty or exceeds 128 MiB."
}
$match = [regex]::Match($Version, '^(\d+)\.(\d+)\.(\d+)$')
if (-not $match.Success) {
throw "Version must use MAJOR.MINOR.PATCH format, for example 1.0.0."
}
$major = [long]$match.Groups[1].Value
$minor = [long]$match.Groups[2].Value
$patch = [long]$match.Groups[3].Value
if ($minor -gt 999 -or $patch -gt 999) {
throw "MINOR and PATCH must be between 0 and 999."
}
$versionCode = $major * 1000000 + $minor * 1000 + $patch
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$hashStream = [IO.File]::OpenRead($resolvedImage)
try {
$hashBytes = $sha256.ComputeHash($hashStream)
} finally {
$hashStream.Dispose()
}
} finally {
$sha256.Dispose()
}
$hash = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
$safeProduct = ($Product.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-')
$tag = "$safeProduct-v$Version"
$extension = [IO.Path]::GetExtension($resolvedImage).ToLowerInvariant()
$assetName = "$safeProduct-$Version$extension"
$encodedTag = [Uri]::EscapeDataString($tag)
$script:Headers = Get-AuthHeaders
$script:Headers["User-Agent"] = "Balsam-firmware-publisher/1.0"
Write-Host "Image: $resolvedImage"
Write-Host "Size: $($imageInfo.Length) bytes"
Write-Host "SHA: $hash"
Write-Host "Tag: $tag"
if ($CheckOnly) {
$repositoryInfo = Invoke-GiteaJson GET "/repos/$Owner/$Repository"
$contentInfo = Invoke-GiteaJson GET "/repos/$Owner/$Repository/contents/update.json`?ref=$Branch"
if (-not $repositoryInfo.full_name -or -not $contentInfo.sha) {
throw "Gitea access check returned incomplete repository data."
}
Write-Host "CHECK OK: access to $($repositoryInfo.full_name), update.json and local image is valid."
exit 0
}
$release = Invoke-GiteaJson GET "/repos/$Owner/$Repository/releases/tags/$encodedTag" -AllowNotFound
if ($null -eq $release) {
$release = Invoke-GiteaJson POST "/repos/$Owner/$Repository/releases" ([ordered]@{
tag_name = $tag
target_commitish = $Branch
name = "$Product $Version"
body = $Notes
draft = $false
prerelease = $false
})
} else {
$release = Invoke-GiteaJson PATCH "/repos/$Owner/$Repository/releases/$($release.id)" ([ordered]@{
name = "$Product $Version"
body = $Notes
draft = $false
prerelease = $false
})
}
$assets = Invoke-GiteaJson GET "/repos/$Owner/$Repository/releases/$($release.id)/assets"
$oldAsset = @($assets) | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if ($null -ne $oldAsset) {
Invoke-GiteaJson DELETE "/repos/$Owner/$Repository/releases/$($release.id)/assets/$($oldAsset.id)" | Out-Null
}
Send-ReleaseAsset $release.id $resolvedImage $assetName
$manifestPath = "update.json"
$contentInfo = Invoke-GiteaJson GET "/repos/$Owner/$Repository/contents/$manifestPath`?ref=$Branch"
$manifestText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(($contentInfo.content -replace '\s', '')))
$manifest = $manifestText | ConvertFrom-Json
if ($null -eq $manifest.firmware) {
$manifest | Add-Member NoteProperty firmware ([pscustomobject]@{ catalogVersion = 1; releases = @() })
}
if ($null -eq $manifest.firmware.releases) {
$manifest.firmware | Add-Member NoteProperty releases @()
}
$releases = @($manifest.firmware.releases) | Where-Object {
-not ($_.product -eq $Product -and [long]$_.versionCode -eq $versionCode)
}
$downloadUrl = "$GiteaBase/$Owner/$Repository/releases/download/$tag/$assetName"
$entry = [pscustomobject][ordered]@{
product = $Product
versionCode = $versionCode
versionName = $Version
imageUrl = $downloadUrl
fileName = $assetName
sha256 = $hash
transport = $Transport
notes = $Notes
}
$manifest.firmware.releases = @($entry) + $releases
$manifest.firmware.catalogVersion = [long]$manifest.firmware.catalogVersion + 1
$updatedJson = $manifest | ConvertTo-Json -Depth 20
$encodedManifest = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($updatedJson + "`n"))
Invoke-GiteaJson PUT "/repos/$Owner/$Repository/contents/$manifestPath" ([ordered]@{
branch = $Branch
sha = $contentInfo.sha
message = "Publish firmware $Product $Version"
content = $encodedManifest
}) | Out-Null
Write-Host "Published: $downloadUrl"
Write-Host "Catalog: $GiteaBase/$Owner/$Repository/raw/branch/$Branch/update.json"