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>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# DL205 Modbus Simulator
|
||||
|
||||
Wraps the `DL260/dl205.json` pymodbus profile as a standalone launcher and as an xUnit managed lifecycle.
|
||||
|
||||
## Manual launch
|
||||
|
||||
```powershell
|
||||
pwsh tests/sim/run-dl205-sim.ps1 -Port 5020
|
||||
```
|
||||
|
||||
On first run the script creates a Python venv at `tests/sim/.venv` and installs:
|
||||
|
||||
```
|
||||
pymodbus==3.13.0
|
||||
aiohttp
|
||||
```
|
||||
|
||||
(`pymodbus 3.13.0` does not provide a `[server]` extra; the simulator is included in
|
||||
the base package. `aiohttp` is required by the simulator's HTTP console.)
|
||||
|
||||
Re-runs detect the existing venv and skip provisioning (fast path, < 2 s to first packet).
|
||||
|
||||
Ctrl-C exits cleanly. The venv directory is gitignored.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+ on `PATH` (tested with 3.13). The script also tries the Windows `py` launcher.
|
||||
- Network access for first-run venv provisioning. Subsequent runs are fully offline.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|------------|--------------------------------|------------------------------------|
|
||||
| `-Profile` | `../../DL260/dl205.json` | pymodbus JSON device profile |
|
||||
| `-Port` | `5020` | TCP port the Modbus server binds |
|
||||
|
||||
## xUnit integration
|
||||
|
||||
Test classes that need a live simulator declare:
|
||||
|
||||
```csharp
|
||||
[Collection(nameof(DL205SimulatorCollection))]
|
||||
```
|
||||
|
||||
The `DL205SimulatorFixture` (in `tests/Mbproxy.Tests/Sim/`) spawns `run-dl205-sim.ps1` via `pwsh -NoProfile -File`, polls for a TCP connection within 10 s, and exposes `Host`, `Port`, and `LogTail`. If Python is unavailable, `SkipReason` is populated and every test in the collection skips cleanly rather than failing.
|
||||
|
||||
## Version pin
|
||||
|
||||
`pymodbus[server]==3.13.0` — update this README and `run-dl205-sim.ps1` together when re-pinning.
|
||||
@@ -0,0 +1,161 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user