Files
Joseph Doherty b330faff03 mbproxy: cross-platform support — Linux/systemd alongside Windows
Make the service build, run, and install on Linux as a first-class
target while keeping the Windows Service + Event Log behaviour intact.

- Build: drop the hardcoded win-x64 RID — single-file publish now works
  for any RID. publish.ps1 gains -Rid; new publish.sh for Linux hosts.
- Diagnostics: DiagnosticSinkSelector picks the Error+ sink per host —
  Windows Event Log under the SCM, local syslog under systemd
  (Serilog.Sinks.SyslogMessages), none for interactive runs. The
  EventLog truncation helper is extracted so it is testable cross-OS.
- Host: Program.cs registers AddSystemd() alongside AddWindowsService().
- Config: a RID-conditioned appsettings template ships Windows or Unix
  paths; both templates are schema-validated by a test.
- Install: systemd unit (Type=exec) plus install.sh / uninstall.sh.
  Also fixes two cross-platform bugs found while testing: install.ps1
  and uninstall.ps1 used New-EventLog / Remove-EventLog (absent in
  PowerShell 7), and the E2E sim launcher hardcoded Windows venv paths.
- Docs updated across README, CLAUDE.md, and docs/ for dual-platform.

413 tests pass on Windows; 374 (all non-simulator) on Linux.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:41:59 -04:00

170 lines
6.9 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 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 <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