Merge origin/feature/N7Web into feature/N7Web

Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
This commit is contained in:
Miayui
2026-09-17 12:46:51 +08:00
135 changed files with 30180 additions and 513 deletions
+76 -9
View File
@@ -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
View File
@@ -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
@@ -15,6 +15,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]
@@ -52,9 +54,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",
@@ -148,7 +161,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",
@@ -175,7 +188,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:
@@ -263,6 +285,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],
@@ -339,10 +383,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 npm is not None:
for command in _web_build_commands(npm):
@@ -369,6 +414,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,
@@ -376,7 +435,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)
+30
View File
@@ -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
}