feat: add seat firmware workbench and OTA delivery
This commit is contained in:
@@ -4,7 +4,14 @@ param(
|
||||
[string]$Profile = 'combined',
|
||||
[string]$Configuration = 'rev_d',
|
||||
[string]$RepositoryRoot = '',
|
||||
[string]$IarBuild = ''
|
||||
[string]$IarBuild = '',
|
||||
[string]$OutputDirectory = '',
|
||||
[ValidateSet(0, 1)]
|
||||
[int]$PositionControl = 1,
|
||||
[ValidateSet(0, 1)]
|
||||
[int]$Ventilation = 1,
|
||||
[ValidateSet(0, 1)]
|
||||
[int]$Heating = 1
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -69,9 +76,46 @@ function Write-ImageSummary {
|
||||
if (-not (Test-Path -LiteralPath $ImagePath -PathType Leaf)) {
|
||||
throw "Build completed but HEX image was not found: $ImagePath"
|
||||
}
|
||||
$hash = Get-FileHash -LiteralPath $ImagePath -Algorithm SHA256
|
||||
# Use the .NET primitive directly. Some locked-down PowerShell hosts do not
|
||||
# expose Get-FileHash even though the Utility module is otherwise present.
|
||||
$stream = [System.IO.File]::OpenRead($ImagePath)
|
||||
try {
|
||||
$algorithm = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$hashBytes = $algorithm.ComputeHash($stream)
|
||||
}
|
||||
finally {
|
||||
$algorithm.Dispose()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
$hashValue = [System.BitConverter]::ToString($hashBytes).Replace('-', '').ToLowerInvariant()
|
||||
Write-Host "Seat_Controller_demo HEX: $ImagePath"
|
||||
Write-Host "Seat_Controller_demo SHA256: $($hash.Hash.ToLowerInvariant())"
|
||||
Write-Host "Seat_Controller_demo SHA256: $hashValue"
|
||||
}
|
||||
|
||||
function Copy-BuildArtifact {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination
|
||||
)
|
||||
|
||||
$lastError = $null
|
||||
for ($attempt = 1; $attempt -le 6; $attempt++) {
|
||||
try {
|
||||
Copy-Item -LiteralPath $Source -Destination $Destination -Force
|
||||
return
|
||||
}
|
||||
catch [System.IO.IOException] {
|
||||
$lastError = $_
|
||||
if ($attempt -lt 6) {
|
||||
Start-Sleep -Milliseconds (150 * $attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw $lastError
|
||||
}
|
||||
|
||||
# Preserve the IAR project's original ../../common and ../../tviibh8m layout
|
||||
@@ -97,23 +141,46 @@ try {
|
||||
New-Item -ItemType Junction -Path (Join-Path $stagingRootFull 'common') -Target $vendorCommon | Out-Null
|
||||
New-Item -ItemType Junction -Path (Join-Path $stagingRootFull 'tviibh8m') -Target $vendorDevice | Out-Null
|
||||
|
||||
$featureProfilePath = Join-Path $stagingProjectRoot 'Config\seat_feature_profile.h'
|
||||
$featureProfile = @"
|
||||
#ifndef SEAT_FEATURE_PROFILE_H
|
||||
#define SEAT_FEATURE_PROFILE_H
|
||||
|
||||
/* Generated by tools/build_seat_firmware.ps1 for this build only. */
|
||||
#define SEAT_FEATURE_POSITION_CONTROL ($PositionControl)
|
||||
#define SEAT_FEATURE_VENTILATION ($Ventilation)
|
||||
#define SEAT_FEATURE_HEATING ($Heating)
|
||||
|
||||
#endif /* SEAT_FEATURE_PROFILE_H */
|
||||
"@
|
||||
[System.IO.File]::WriteAllText(
|
||||
$featureProfilePath,
|
||||
$featureProfile,
|
||||
[System.Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
Write-Host "Feature profile: position=$PositionControl ventilation=$Ventilation heating=$Heating"
|
||||
|
||||
$stagedProject = Join-Path $stagingProjectRoot 'IAR\Seat_Controller_demo.ewp'
|
||||
Invoke-IarBuild -ProjectPath $stagedProject
|
||||
|
||||
$stagedExe = Join-Path $stagingProjectRoot "IAR\$Configuration\Exe"
|
||||
$repositoryExe = Join-Path $repositoryFirmware "IAR\$Configuration\Exe"
|
||||
New-Item -ItemType Directory -Path $repositoryExe -Force | Out-Null
|
||||
$artifactOutput = if ($OutputDirectory) {
|
||||
[System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
} else {
|
||||
Join-Path $repositoryFirmware "IAR\$Configuration\Exe"
|
||||
}
|
||||
New-Item -ItemType Directory -Path $artifactOutput -Force | Out-Null
|
||||
foreach ($artifact in @('Seat_Controller_demo.hex', 'Seat_Controller_demo.out')) {
|
||||
$sourceArtifact = Join-Path $stagedExe $artifact
|
||||
if (-not (Test-Path -LiteralPath $sourceArtifact -PathType Leaf)) {
|
||||
throw "IAR build did not produce $sourceArtifact"
|
||||
}
|
||||
Copy-Item -LiteralPath $sourceArtifact -Destination (
|
||||
Join-Path $repositoryExe $artifact
|
||||
) -Force
|
||||
Copy-BuildArtifact -Source $sourceArtifact -Destination (
|
||||
Join-Path $artifactOutput $artifact
|
||||
)
|
||||
}
|
||||
Write-ImageSummary -ImagePath (
|
||||
Join-Path $repositoryExe 'Seat_Controller_demo.hex'
|
||||
Join-Path $artifactOutput 'Seat_Controller_demo.hex'
|
||||
)
|
||||
}
|
||||
finally {
|
||||
|
||||
+65
-6
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Start N7Web and its read-only LAN mode-card service together."""
|
||||
"""Start N7Web, the read-only OTA endpoint, and the LAN mode-card service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,6 +13,8 @@ import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib import error as urlerror
|
||||
from urllib import request as urlrequest
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -49,9 +51,20 @@ def _parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="main N7Web bind address (use 0.0.0.0 for trusted-LAN access)",
|
||||
help="main N7Web bind address (defaults to local management only)",
|
||||
)
|
||||
parser.add_argument("--port", type=_port, default=8000, help="main N7Web port")
|
||||
parser.add_argument(
|
||||
"--ota-host",
|
||||
default="127.0.0.1",
|
||||
help="read-only firmware OTA bind address (designed for an HTTPS tunnel)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ota-port",
|
||||
type=_port,
|
||||
default=8012,
|
||||
help="read-only firmware OTA port",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scene-bind",
|
||||
default="0.0.0.0",
|
||||
@@ -140,7 +153,7 @@ def _commands(
|
||||
args: argparse.Namespace,
|
||||
python: Path,
|
||||
catalog: Path,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
main_command = [
|
||||
str(python),
|
||||
"-m",
|
||||
@@ -167,7 +180,16 @@ def _commands(
|
||||
"--server-id",
|
||||
args.server_id,
|
||||
]
|
||||
return main_command, scene_command
|
||||
ota_command = [
|
||||
str(python),
|
||||
"-m",
|
||||
"cognitive_agent.firmware_ota_server",
|
||||
"--host",
|
||||
args.ota_host,
|
||||
"--port",
|
||||
str(args.ota_port),
|
||||
]
|
||||
return main_command, scene_command, ota_command
|
||||
|
||||
|
||||
def _display_command(command: Sequence[str]) -> str:
|
||||
@@ -189,6 +211,28 @@ def _catalog_is_ready(path: Path) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _ota_service_is_ready(host: str, port: int) -> bool:
|
||||
probe_host = "127.0.0.1" if host in {"0.0.0.0", "::", "[::]"} else host
|
||||
if ":" in probe_host and not probe_host.startswith("["):
|
||||
probe_host = f"[{probe_host}]"
|
||||
request = urlrequest.Request(
|
||||
f"http://{probe_host}:{port}/healthz",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
opener = urlrequest.build_opener(urlrequest.ProxyHandler({}))
|
||||
try:
|
||||
with opener.open(request, timeout=1.0) as response:
|
||||
document = json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, ValueError, urlerror.URLError):
|
||||
return False
|
||||
return bool(
|
||||
response.status == 200
|
||||
and isinstance(document, dict)
|
||||
and document.get("status") == "ok"
|
||||
and document.get("service") == "n7-firmware-ota"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_catalog(
|
||||
path: Path,
|
||||
main_process: subprocess.Popen[bytes],
|
||||
@@ -264,10 +308,11 @@ def run(argv: Sequence[str] | None = None) -> int:
|
||||
return 2
|
||||
|
||||
environment = _child_environment()
|
||||
main_command, scene_command = _commands(args, python, catalog)
|
||||
main_command, scene_command, ota_command = _commands(args, python, catalog)
|
||||
print(f"Repository: {REPOSITORY_ROOT}", flush=True)
|
||||
print(f"Catalog: {catalog}", flush=True)
|
||||
print(f"Main: {_display_command(main_command)}", flush=True)
|
||||
print(f"OTA: {_display_command(ota_command)}", flush=True)
|
||||
print(f"Cards: {_display_command(scene_command)}", flush=True)
|
||||
if args.dry_run:
|
||||
return 0
|
||||
@@ -283,6 +328,20 @@ def run(argv: Sequence[str] | None = None) -> int:
|
||||
children.append(ChildProcess("main N7Web service", main_process))
|
||||
_wait_for_catalog(catalog, main_process, args.catalog_ready_timeout)
|
||||
|
||||
if _ota_service_is_ready(args.ota_host, args.ota_port):
|
||||
print(
|
||||
f"OTA service already available at "
|
||||
f"http://{args.ota_host}:{args.ota_port}; reusing it.",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
ota_process = subprocess.Popen(
|
||||
ota_command,
|
||||
cwd=REPOSITORY_ROOT,
|
||||
env=environment,
|
||||
)
|
||||
children.append(ChildProcess("firmware OTA service", ota_process))
|
||||
|
||||
scene_process = subprocess.Popen(
|
||||
scene_command,
|
||||
cwd=REPOSITORY_ROOT,
|
||||
@@ -290,7 +349,7 @@ def run(argv: Sequence[str] | None = None) -> int:
|
||||
)
|
||||
children.append(ChildProcess("LAN mode-card service", scene_process))
|
||||
print(
|
||||
"Both services are running; press Ctrl+C once to stop both.",
|
||||
"All services are running; press Ctrl+C once to stop all of them.",
|
||||
flush=True,
|
||||
)
|
||||
name, exit_code = _wait_for_exit(children)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||
$venvPython = Join-Path $repositoryRoot ".venv\Scripts\python.exe"
|
||||
$logRoot = Join-Path $repositoryRoot "artifacts\logs"
|
||||
$logPath = Join-Path $logRoot "n7web-autostart.log"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $venvPython -PathType Leaf)) {
|
||||
throw "N7Web Python executable not found: $venvPython"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
|
||||
$python = (& $venvPython -c "import sys; print(sys._base_executable)").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $python -PathType Leaf)) {
|
||||
throw "N7Web base Python executable could not be resolved."
|
||||
}
|
||||
$env:PYTHONPATH = @(
|
||||
(Join-Path $repositoryRoot "src")
|
||||
(Join-Path $repositoryRoot "domains\seat\src")
|
||||
) -join [System.IO.Path]::PathSeparator
|
||||
|
||||
while ($true) {
|
||||
& $python -m cognitive_agent.firmware_ota_server --host 127.0.0.1 --port 8012 *>> $logPath
|
||||
$exitCode = $LASTEXITCODE
|
||||
$timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"
|
||||
Add-Content -LiteralPath $logPath -Value (
|
||||
"[$timestamp] OTA service exited with code $exitCode; restarting in 2 seconds."
|
||||
)
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
Reference in New Issue
Block a user