#Requires -Version 7 <# .SYNOPSIS Provision a Python venv and launch the pymodbus DL205 simulator. .DESCRIPTION Idempotent: re-runs skip venv provisioning when tests/sim/.venv is fully provisioned. Spawns 'pymodbus.simulator' with the DL205/DL260 register profile on a configurable port; the server process stays attached so Ctrl-C (or parent exit) kills it cleanly. pymodbus version pin: 3.13.0 (Matches the profile comment in dl205.json. Record the version here AND in tests/sim/README.md so it is never lost across re-provisioning.) API note: pymodbus 3.13.0 uses 'pymodbus.simulator' (not the legacy 'pymodbus.server run' command). The Modbus TCP port is set in the JSON config; this script writes a temp config that overrides the port so the free-port-picker pattern works. aiohttp is required by the pymodbus simulator HTTP console and is installed alongside pymodbus. .PARAMETER Profile Path to the pymodbus JSON profile. Defaults to dl205.json in this script's directory (i.e. the checked-in DL205 quirk profile). .PARAMETER Port TCP port for the Modbus server to listen on. Defaults to 5020. .EXIT CODES 0 Clean exit (Ctrl-C or natural termination). 1 Python not found, or venv provisioning failed. 2 pymodbus.simulator launch failed. 3 Profile file not found. #> [CmdletBinding()] param( [string]$Profile = (Join-Path $PSScriptRoot 'dl205.json'), [int]$Port = 5020 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── 1. Resolve and validate the profile path ───────────────────────────────── $ProfileResolved = (Resolve-Path -Path $Profile -ErrorAction SilentlyContinue)?.Path if (-not $ProfileResolved) { Write-Error "Profile not found: $Profile" exit 3 } # ── 2. Locate Python ───────────────────────────────────────────────────────── # Windows: 'python' (standard PATH install), then the 'py' launcher. # Linux/macOS: 'python3' (the canonical name), then 'python'. # The candidate order is platform-specific so Windows never matches the Microsoft # Store 'python3' stub. $pythonExe = $null $pythonCandidates = $IsWindows ? @('python', 'py') : @('python3', 'python') foreach ($candidate in $pythonCandidates) { try { $ver = & $candidate --version 2>&1 if ($LASTEXITCODE -eq 0) { $pythonExe = $candidate Write-Host "[sim] Python found via '$candidate': $ver" break } } catch { # not on PATH — continue } } if (-not $pythonExe) { Write-Error @" Python 3.10+ is required to run the DL205 simulator but was not found on PATH. Install Python from https://www.python.org/downloads/ and ensure it is on your PATH, or use the Windows Store launcher ('py'). "@ exit 1 } # ── 3. Provision the venv (idempotent) ─────────────────────────────────────── # pymodbus version pin: 3.13.0 # Update this constant AND tests/sim/README.md together if you re-pin. $PYMODBUS_VERSION = '3.13.0' $venvDir = Join-Path $PSScriptRoot '.venv' # venv executable layout differs by OS: Windows puts them in Scripts\ with a .exe # extension; Linux/macOS put them in bin/ with no extension. $venvBin = $IsWindows ? 'Scripts' : 'bin' $exeExt = $IsWindows ? '.exe' : '' $venvPython = Join-Path $venvDir $venvBin "python$exeExt" $pipExe = Join-Path $venvDir $venvBin "pip$exeExt" $simulatorExe = Join-Path $venvDir $venvBin "pymodbus.simulator$exeExt" # sentinel for complete install # Provisioning is idempotent: we only skip it when pymodbus.simulator.exe exists. # Checking only the .venv directory is not enough — a previous run killed mid-install # leaves the directory but without pymodbus installed. $needsProvision = (-not (Test-Path $simulatorExe)) if ($needsProvision) { if (-not (Test-Path $venvDir)) { Write-Host "[sim] Creating venv at $venvDir ..." & $pythonExe -m venv $venvDir if ($LASTEXITCODE -ne 0) { Write-Error "Failed to create Python venv (exit $LASTEXITCODE)." exit 1 } } else { Write-Host "[sim] Venv exists but pymodbus is not fully installed — installing now." } # pymodbus 3.13.0 does not provide a [server] extra; the simulator module is # included in the base package. aiohttp is required by the simulator's HTTP # console and is not a declared dependency of pymodbus, so we install it # explicitly here. Write-Host "[sim] Installing pymodbus==$PYMODBUS_VERSION + aiohttp ..." & $pipExe install "pymodbus==$PYMODBUS_VERSION" aiohttp if ($LASTEXITCODE -ne 0) { Write-Error "Failed to install pymodbus / aiohttp (exit $LASTEXITCODE). Check network or proxy settings." exit 1 } Write-Host "[sim] Venv provisioned." } else { Write-Host "[sim] Venv and pymodbus already provisioned — skipping." } # ── 4. Prepare a port-specific config file ─────────────────────────────────── # pymodbus.simulator 3.13.0 reads the Modbus TCP port from the JSON config, not # from a command-line --port flag. To allow the fixture's free-port-picker pattern, # we write a temp config that is a copy of the base profile but with srv.port # overridden to $Port. $tempConfig = [System.IO.Path]::GetTempFileName() + '.json' try { $json = Get-Content -Raw $ProfileResolved | ConvertFrom-Json -Depth 20 $json.server_list.srv.port = $Port $json | ConvertTo-Json -Depth 20 | Set-Content -Encoding UTF8 $tempConfig Write-Host "[sim] Wrote temp config with port=$Port to: $tempConfig" } catch { Write-Error "Failed to prepare port-specific config: $_" exit 2 } # ── 5. Launch pymodbus simulator ───────────────────────────────────────────── # pymodbus 3.13.0 API: pymodbus.simulator --json_file --modbus_server # --modbus_device # We don't pass --http_port because we don't need the REST API in tests. # The process is kept alive in the foreground; Ctrl-C (or parent-exit Kill) stops it. Write-Host "[sim] Starting pymodbus DL205 simulator on Modbus TCP port $Port ..." try { & $simulatorExe ` --json_file $tempConfig ` --modbus_server srv ` --modbus_device dev $exitCode = $LASTEXITCODE } catch { Write-Error "Failed to launch pymodbus.simulator: $_" Remove-Item -Force $tempConfig -ErrorAction SilentlyContinue exit 2 } finally { Remove-Item -Force $tempConfig -ErrorAction SilentlyContinue } # A non-zero exit from pymodbus is unexpected (0 = clean shutdown). if ($exitCode -ne 0) { Write-Error "pymodbus.simulator exited with code $exitCode." exit 2 } exit 0