#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 DL260/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 ../../DL260/dl205.json relative to 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 '..\..\DL260\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 ───────────────────────────────────────────────────────── # Try 'python' first (standard PATH install), then the Windows-store launcher 'py'. $pythonExe = $null foreach ($candidate in 'python', 'py') { 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' $venvPython = Join-Path $venvDir 'Scripts\python.exe' $pipExe = Join-Path $venvDir 'Scripts\pip.exe' $simulatorExe = Join-Path $venvDir 'Scripts\pymodbus.simulator.exe' # 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