Files
wwtools/mbproxy/tests/sim/run-dl205-sim.ps1
T
Joseph Doherty 56eee3c563 mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the
production-ready single-listener / 1:1-backend transparent Modbus TCP
proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260
fleet. Phase 9 replaces the connection layer with a single backend
socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's
4-concurrent-client cap as an operational ceiling.

Phase 9 additions of note:
- PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap
- InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing
  for Phase 10 read coalescing — do not collapse to a single field)
- Per-request watchdog: surfaces Modbus exception 0x0B to upstream
  on BackendRequestTimeoutMs, defending against lost responses,
  dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed-
  request bug (its ServerRequestHandler.last_pdu state race)
- Status DTO + HTML gain inFlight / maxInFlight / txIdWraps /
  disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md)

Tests: 263 unit + 38 E2E. Multiplexer correctness under truly
concurrent backend traffic is proved against a stub backend in
PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus
3.13's single-PDU framer stays in known-good mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:49:35 -04:00

162 lines
6.5 KiB
PowerShell

#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 <path> --modbus_server <key>
# --modbus_device <key>
# 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