56eee3c563
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>
162 lines
6.5 KiB
PowerShell
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
|