# Build the Windows executable (dist\opcda-diagnozer.exe) with PyInstaller. # # Usage (from the repo root): # powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build_exe.ps1 # .\scripts\build_exe.ps1 # onedir (default): dist\opcda-diagnozer\ # .\scripts\build_exe.ps1 -Onefile # single exe: dist\opcda-diagnozer.exe # .\scripts\build_exe.ps1 -Clean # remove build\ + dist\ first # # The exe is a console application hosting every CLI verb (check-config, # list-servers, list-items, read/probe, run) AND the Windows service host: # 'install' registers this same exe with the SCM (IMPLEMENTATION_PLAN 6); # when the SCM starts it, the fixed 'service' argv shape dispatches to # the ServiceFramework host. # # Hard rules enforced here: # 1. 32-bit interpreter (SPECIFICATION 11): the OPC DA Automation Wrapper # (OPCAutomation.dll) is a 32-bit in-proc COM DLL - a 64-bit exe cannot # load it and every COM call fails at runtime. The produced PE is # re-verified to be 32-bit (IMAGE_FILE_MACHINE_I386) after the build. # 2. PyInstaller must see the modules the code imports lazily inside # functions (COM, browse, service host) - declared via -HiddenImport. [CmdletBinding()] param( [switch]$Onefile, [switch]$Clean ) $ErrorActionPreference = 'Stop' $Repo = Split-Path -Parent $PSScriptRoot $VenvPython = Join-Path $Repo '.venv\Scripts\python.exe' $Entry = Join-Path $Repo 'src\opcda_diagnozer\__main__.py' $Dist = Join-Path $Repo 'dist' $Build = Join-Path $Repo 'build' $Name = 'opcda-diagnozer' $VersionFile = Join-Path $PSScriptRoot '.exe_version_info.txt' # Modules imported lazily inside functions (import guards, IMPLEMENTATION_PLAN 9); # PyInstaller's static analysis misses those, so they are declared explicitly. $HiddenImports = @( # pywin32 / COM - deferred into methods in opc/automation.py, opc/discovery.py 'pythoncom', 'pywintypes', 'win32com.client', # pywintypes.datetime(VARIANT DATE -> datetime conversion) lazily imports # win32timezone at CONVERSION time - missing from the bundle, every read of # item.TimeStamp raised SystemError('InvokeTypes ... exception set') frozen # (observed 2026-10-07: all tags failed in the exe while the venv worked). 'win32timezone', # opc backend modules imported lazily in main.py CLI verbs 'opcda_diagnozer.opc.automation', 'opcda_diagnozer.opc.browse', # the service host (Phase 3) - lazily imported by main (SCM dispatch) and # itself importing win32serviceutil/win32service/servicemanager/ # win32evtlogutil only inside functions 'opcda_diagnozer.service', 'win32service', 'win32serviceutil', 'servicemanager', 'win32evtlogutil' ) function Fail { param([string]$Message) Write-Host 'build_exe: FAILED' -ForegroundColor Red Write-Host " $Message" -ForegroundColor Red exit 1 } function Invoke-Captured { # Run a native command and capture stdout+stderr with its exit code. # $ErrorActionPreference must be relaxed around 2>&1 on PS 5.1: with 'Stop', # redirecting native stderr raises a NativeCommandError and kills the script. param([string]$FilePath, [string[]]$Arguments) $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $lines = & $FilePath @Arguments 2>&1 $text = ($lines | ForEach-Object { "$_" }) -join [Environment]::NewLine [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $text } } finally { $ErrorActionPreference = $prev } } # --- environment checks ------------------------------------------------------ if (-not (Test-Path $VenvPython)) { Fail ("the 32-bit venv was not found: $VenvPython - create it first " + "(README 'Development setup'): " + '& "C:\...\Python314-32\python.exe" -m venv .venv; ' + '& .\.venv\Scripts\python.exe -m pip install -e ".[dev]"') } $bitness = (Invoke-Captured -FilePath $VenvPython -Arguments @('-c', "import struct; print(struct.calcsize('P') * 8)")).Output.Trim() if ($bitness -ne '32') { Fail ("the venv interpreter is ${bitness}-bit, but the OPC DA wrappers are " + '32-bit in-proc DLLs (SPECIFICATION 11): a 64-bit exe cannot load ' + "them. Rebuild the venv with the 32-bit Python (README 'Development setup').") } Write-Host 'build_exe: OK - 32-bit interpreter (SPECIFICATION 11 bitness rule)' $pyi = Invoke-Captured -FilePath $VenvPython -Arguments @('-m', 'PyInstaller', '--version') if ($pyi.ExitCode -ne 0) { Write-Host 'build_exe: pyinstaller missing - installing into the venv ...' & $VenvPython -m pip install pyinstaller if ($LASTEXITCODE -ne 0) { Fail 'pip install pyinstaller failed' } } if ($Clean) { foreach ($folder in @($Build, $Dist)) { if (Test-Path $folder) { Remove-Item -Recurse -Force $folder } } Write-Host 'build_exe: cleaned build/ and dist/' } # --- version metadata (exe properties -> Details tab) ------------------------- $initText = Get-Content (Join-Path $Repo 'src\opcda_diagnozer\__init__.py') -Raw if ($initText -notmatch '__version__\s*=\s*"([^"]+)"') { Fail 'could not read __version__ from src\opcda_diagnozer\__init__.py' } $Version = $Matches[1] $parts = $Version.Split('.') $major = $parts[0] $minor = $parts[1] # Keyword names must match PyInstaller's FixedFileInfo.__init__ exactly: # filevers, prodvers, mask, flags, OS, fileType, subtype, date. # [IO.File]::WriteAllText writes UTF-8 WITHOUT a BOM - Set-Content -Encoding # UTF8 on PS 5.1 prepends one, and the BOM breaks PyInstaller's eval(). $VersionInfo = @" # UTF-8 VSVersionInfo( ffi=FixedFileInfo( filevers=($major, $minor, 0, 0), prodvers=($major, $minor, 0, 0), mask=0x3F, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0) ), kids=[ StringFileInfo( [ StringTable( '040904B0', [ StringStruct('CompanyName', 'Vivab'), StringStruct('FileDescription', 'OPC DA Diagnozer - read-only OPC DA reference diagnostic client'), StringStruct('FileVersion', '$Version.0'), StringStruct('InternalName', 'opcda-diagnozer'), StringStruct('OriginalFilename', 'opcda-diagnozer.exe'), StringStruct('ProductName', 'OPC DA Diagnozer'), StringStruct('ProductVersion', '$Version') ] ) ] ), VarFileInfo([VarStruct('Translation', [1033, 1200])]) ] ) "@ [IO.File]::WriteAllText($VersionFile, $VersionInfo) # --- the build itself --------------------------------------------------------- $pyArgs = @( '-m', 'PyInstaller', '--noconfirm', '--clean', '--name', $Name, '--distpath', $Dist, '--workpath', $Build, '--specpath', $Build, '--console', '--version-file', $VersionFile ) foreach ($hidden in $HiddenImports) { $pyArgs += @('--hidden-import', $hidden) } $pyArgs += $(if ($Onefile) { '--onefile' } else { '--onedir' }) $pyArgs += $Entry Write-Host 'build_exe: running PyInstaller...' & $VenvPython @pyArgs if ($LASTEXITCODE -ne 0) { Fail "PyInstaller exited $LASTEXITCODE" } $Exe = if ($Onefile) { Join-Path $Dist "$Name.exe" } else { Join-Path $Dist "$Name\$Name.exe" } if (-not (Test-Path $Exe)) { Fail "expected exe not found: $Exe" } Write-Host "build_exe: OK - $Exe" # --- final bitness guard: the produced PE must be 32-bit (SPECIFICATION 11) -- $bytes = [System.IO.File]::ReadAllBytes($Exe) $peOffset = [System.BitConverter]::ToInt32($bytes, 0x3C) $machine = [System.BitConverter]::ToUInt16($bytes, $peOffset + 4) if ($machine -ne 0x14C) { Fail ("the built exe is not 32-bit (PE machine 0x{0:X4}) - build from the 32-bit venv" -f $machine) } Write-Host 'build_exe: OK - exe is 32-bit (PE machine 0x014C, IMAGE_FILE_MACHINE_I386)' # --- smoke test --------------------------------------------------------------- Write-Host "build_exe: smoke-testing $Name.exe ..." $ExampleConfig = Join-Path $Repo 'config.example.yaml' $checks = @( @{ Label = '--version'; Arguments = @('--version') }, @{ Label = 'check-config'; Arguments = @('check-config', '--config', $ExampleConfig) }, @{ Label = 'list-servers (registry)'; Arguments = @('list-servers', '--source', 'registry') } ) foreach ($check in $checks) { $result = Invoke-Captured -FilePath $Exe -Arguments $check.Arguments $first = ($result.Output -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1) Write-Host (" {0} -> exit {1}: {2}" -f $check.Label, $result.ExitCode, $first) if ($result.ExitCode -ne 0) { Fail ("smoke test failed: " + $check.Label) } } # Frozen-read canary: the COM timestamp conversion (item.TimeStamp) lazily # imports win32timezone - if it is missing from the bundle, EVERY read fails # with SystemError('InvokeTypes ... exception set'). The registry/discovery # smoke tests cannot catch this; only a real read does. Skipped gracefully # when no COM server is reachable on this build host. $canary = Invoke-Captured -FilePath $Exe -Arguments @( 'read', '--server', 'Matrikon.OPC.Simulation.1', 'Bucket Brigade.Int1' ) $canaryFirst = ($canary.Output -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 2) -join ' | ' if ($canary.ExitCode -eq 0) { Write-Host " read canary (Matrikon sim) -> exit 0: $canaryFirst" } elseif ($canary.Output -match 'SystemError.*InvokeTypes') { Fail ("frozen read canary failed with the missing-win32timezone signature: $canaryFirst") } else { Write-Host " read canary skipped (no Matrikon sim reachable): exit $($canary.ExitCode)" } Write-Host 'build_exe: smoke test OK' exit 0