Compare commits
4 Commits
focas-tier
...
modbus-exc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96940aeb24 | ||
| 340f580be0 | |||
|
|
8d88ffa14d | ||
| 446a5c022c |
@@ -69,14 +69,32 @@ covers the common address shapes; per-model quirks are not stressed.
|
||||
- Parameter range enforcement (CNC rejects out-of-range writes)
|
||||
- MTB (machine tool builder) custom screens that expose non-standard data
|
||||
|
||||
### 5. Tier-C process isolation behavior
|
||||
### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated
|
||||
|
||||
Per driver-stability.md, FOCAS should run process-isolated because
|
||||
`Fwlib32.dll` has documented crash modes. The test suite runs in-process +
|
||||
only exercises the happy path + mapped error codes — a native access
|
||||
violation from the DLL would take the test host down. The process-isolation
|
||||
path (similar to Galaxy's out-of-process Host) has been scoped but not
|
||||
implemented.
|
||||
The Tier-C architecture is now in place as of PRs #169–#173 (FOCAS
|
||||
PR A–E, task #220):
|
||||
|
||||
- `Driver.FOCAS.Shared` carries MessagePack IPC contracts
|
||||
- `Driver.FOCAS.Host` (.NET 4.8 x86 Windows service via NSSM) accepts
|
||||
a connection on a strictly-ACL'd named pipe + dispatches frames to
|
||||
an `IFocasBackend`
|
||||
- `Driver.FOCAS.Ipc.IpcFocasClient` implements the `IFocasClient` DI
|
||||
seam by forwarding over IPC — swap the DI registration and the
|
||||
driver runs Tier-C with zero other changes
|
||||
- `Driver.FOCAS.Supervisor.FocasHostSupervisor` owns the spawn +
|
||||
heartbeat + respawn + 3-in-5min crash-loop breaker + sticky alert
|
||||
- `Driver.FOCAS.Host.Stability.PostMortemMmf` ↔
|
||||
`Driver.FOCAS.Supervisor.PostMortemReader` — ring-buffer of the
|
||||
last ~1000 IPC operations survives a Host crash
|
||||
|
||||
The one remaining gap is the production `FwlibHostedBackend`: an
|
||||
`IFocasBackend` implementation that wraps the licensed
|
||||
`Fwlib32.dll` P/Invoke. That's hardware-gated on task #222 — we
|
||||
need a CNC on the bench (or the licensed FANUC developer kit DLL
|
||||
with a test harness) to validate it. Until then, the Host ships
|
||||
`FakeFocasBackend` + `UnconfiguredFocasBackend`. Setting
|
||||
`OTOPCUA_FOCAS_BACKEND=fake` lets operators smoke-test the whole
|
||||
Tier-C pipeline end-to-end without any CNC.
|
||||
|
||||
## When to trust FOCAS tests, when to reach for a rig
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ shaped (neither is a Modbus-side concept).
|
||||
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
|
||||
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
|
||||
(octal → Modbus offset)
|
||||
- `DL205ExceptionCodeTests` — Modbus exception → OPC UA StatusCode mapping
|
||||
- `DL205ExceptionCodeTests` — Modbus exception 0x02 → OPC UA `BadOutOfRange` against the dl205 profile (natural out-of-range path)
|
||||
- `ExceptionInjectionTests` — every other exception code in the mapping table (0x01 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) against the `exception_injection` profile on both read + write paths
|
||||
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
|
||||
- `DL205StringQuirkTests` — packed-string V-memory layout
|
||||
- `DL205VMemoryQuirkTests` — V-memory octal addressing
|
||||
@@ -103,8 +104,13 @@ Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
|
||||
|
||||
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
|
||||
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
|
||||
2. Extend `pymodbus` profiles to inject exception responses — a JSON flag per
|
||||
register saying "next read returns exception 0x04."
|
||||
2. ~~Extend `pymodbus` profiles to inject exception responses~~ — **shipped**
|
||||
via the `exception_injection` compose profile + standalone
|
||||
`exception_injector.py` server. Rules in
|
||||
`Docker/profiles/exception_injection.json` map `(fc, address)` to an
|
||||
exception code; `ExceptionInjectionTests` exercises every code in
|
||||
`MapModbusExceptionToStatus` (0x01 / 0x02 / 0x03 / 0x04 / 0x05 / 0x06 /
|
||||
0x0A / 0x0B) end-to-end on both read (FC03) and write (FC06) paths.
|
||||
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# FOCAS Tier-C isolation — plan for task #220
|
||||
|
||||
> **Status**: DRAFT — not yet started. Tracks the multi-PR work to
|
||||
> move `Fwlib32.dll` behind an out-of-process host, mirroring the
|
||||
> Galaxy Tier-C split in [`phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md).
|
||||
> **Status**: PRs A–E shipped. Architecture is in place; the only
|
||||
> remaining FOCAS work is the hardware-dependent production
|
||||
> integration of `Fwlib32.dll` into a real `IFocasBackend`
|
||||
> (`FwlibHostedBackend`), which needs an actual CNC on the bench
|
||||
> and is tracked as a follow-up on #220.
|
||||
>
|
||||
> **Pre-reqs shipped** (this PR): version matrix + pre-flight
|
||||
> validation + unit tests. Those close the cheap half of the
|
||||
> hardware-free stability gap. Tier-C closes the expensive half.
|
||||
> **Pre-reqs shipped**: version matrix + pre-flight validation
|
||||
> (PR #168 — the cheap half of the hardware-free stability gap).
|
||||
|
||||
## Why isolate
|
||||
|
||||
@@ -79,32 +80,41 @@ its own timer + pushes change notifications so the Proxy doesn't
|
||||
round-trip per poll. Matches `Driver.Galaxy.Host` subscription
|
||||
forwarding.
|
||||
|
||||
## PR sequence (proposed)
|
||||
## PR sequence — shipped
|
||||
|
||||
1. **PR A — shared contracts**
|
||||
Create `Driver.FOCAS.Shared` with the MessagePack DTOs. No
|
||||
behaviour change. ~200 LOC + round-trip tests for each DTO.
|
||||
2. **PR B — Host project skeleton**
|
||||
Create `Driver.FOCAS.Host` .NET 4.8 x86 project, NSSM wrapper,
|
||||
pipe server scaffold with the same ACL + caller-SID + shared
|
||||
secret plumbing as Galaxy.Host. No Fwlib32 wiring yet — returns
|
||||
`NotImplemented` for everything. ~400 LOC.
|
||||
3. **PR C — Move Fwlib32 calls into Host**
|
||||
Move `FocasNativeSession`, `FocasTagReader`, `FocasTagWriter`,
|
||||
`FocasPmcBitRmw` + the STA thread into the Host. Proxy forwards
|
||||
over IPC. This is the biggest PR — probably 800-1500 LOC of
|
||||
move-with-translation. Existing unit tests keep passing because
|
||||
`IFocasTagFactory` is the DI seam the tests inject against.
|
||||
4. **PR D — Supervisor + respawn**
|
||||
Proxy-side heartbeat + respawn + crash-loop circuit breaker +
|
||||
BackPressure fan-out on Host death. ~500 LOC + chaos tests.
|
||||
5. **PR E — Post-mortem MMF + operational glue**
|
||||
MMF writer in Host, reader in Proxy. Install scripts for the
|
||||
new `OtOpcUaFocasHost` Windows service. Docs. ~300 LOC.
|
||||
1. **PR A (#169) — shared contracts** ✅
|
||||
`Driver.FOCAS.Shared` netstandard2.0 with MessagePack DTOs for every
|
||||
IPC surface (Hello/Heartbeat/OpenSession/Read/Write/PmcBitWrite/
|
||||
Subscribe/Probe/RuntimeStatus/Recycle/ErrorResponse) + FrameReader/
|
||||
FrameWriter + 24 round-trip tests.
|
||||
2. **PR B (#170) — Host project skeleton** ✅
|
||||
`Driver.FOCAS.Host` net48 x86 Windows Service entry point,
|
||||
`PipeAcl` + `PipeServer` + `IFrameHandler` + `StubFrameHandler`.
|
||||
ACL denies LocalSystem/Administrators; Hello verifies
|
||||
shared-secret + protocol major. 3 handshake tests.
|
||||
3. **PR C (#171) — IPC path end-to-end** ✅
|
||||
Proxy `Ipc/FocasIpcClient` + `Ipc/IpcFocasClient` (implements
|
||||
IFocasClient via IPC). Host `Backend/IFocasBackend` +
|
||||
`FakeFocasBackend` + `UnconfiguredFocasBackend` +
|
||||
`Ipc/FwlibFrameHandler` replacing the stub. 13 new round-trip
|
||||
tests via in-memory loopback.
|
||||
4. **PR D (#172) — Supervisor + respawn** ✅
|
||||
`Supervisor/Backoff` (5s→15s→60s) + `CircuitBreaker` (3-in-5min →
|
||||
1h→4h→manual) + `HeartbeatMonitor` + `IHostProcessLauncher` +
|
||||
`FocasHostSupervisor`. 14 tests.
|
||||
5. **PR E — Ops glue** ✅ (this PR)
|
||||
`ProcessHostLauncher` (real Process.Start + FocasIpcClient
|
||||
connect), `Host/Stability/PostMortemMmf` (magic 'OFPC') +
|
||||
Proxy `Supervisor/PostMortemReader`, `scripts/install/
|
||||
Install-FocasHost.ps1` + `Uninstall-FocasHost.ps1` NSSM wrappers.
|
||||
7 tests (4 MMF round-trip + 3 reader format compatibility).
|
||||
|
||||
Total estimate: 2200-3200 LOC across 5 PRs. Consistent with Galaxy
|
||||
Tier-C but narrower since FOCAS has no Historian + no alarm
|
||||
history.
|
||||
**Post-shipment totals: 189 FOCAS driver tests + 24 Shared tests + 13 Host tests = 226 FOCAS-family tests green.**
|
||||
|
||||
What remains is hardware-dependent: wiring `Fwlib32.dll` P/Invoke
|
||||
into a real `FwlibHostedBackend` implementation of `IFocasBackend`
|
||||
+ validating against a live CNC. The architecture is all the
|
||||
plumbing that work needs.
|
||||
|
||||
## Testing without hardware
|
||||
|
||||
|
||||
108
scripts/install/Install-FocasHost.ps1
Normal file
108
scripts/install/Install-FocasHost.ps1
Normal file
@@ -0,0 +1,108 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Registers the OtOpcUaFocasHost Windows service. Optional companion to
|
||||
Install-Services.ps1 — only run this on nodes where FOCAS driver instances will run
|
||||
with Tier-C process isolation enabled.
|
||||
|
||||
.DESCRIPTION
|
||||
FOCAS PR #220 / Tier-C isolation plan. Wraps OtOpcUa.Driver.FOCAS.Host.exe (net48 x86)
|
||||
as a Windows service using NSSM, running under the same service account as the main
|
||||
OtOpcUa service so the named-pipe ACL works. Passes the per-process shared secret via
|
||||
environment variable at service-start time so it never hits disk.
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
Where the FOCAS Host binaries live (typically
|
||||
C:\Program Files\OtOpcUa\Driver.FOCAS.Host).
|
||||
|
||||
.PARAMETER ServiceAccount
|
||||
Service account SID or DOMAIN\name. Must match the main OtOpcUa server account so the
|
||||
PipeAcl match succeeds.
|
||||
|
||||
.PARAMETER FocasSharedSecret
|
||||
Per-process secret passed via env var. Generated freshly per install if not supplied.
|
||||
|
||||
.PARAMETER FocasBackend
|
||||
Backend selector for the Host process. One of:
|
||||
fwlib32 (default — real Fanuc Fwlib32.dll integration; requires licensed DLL on PATH)
|
||||
fake (in-memory; smoke-test mode)
|
||||
unconfigured (safe default returning structured errors; use until hardware is wired)
|
||||
|
||||
.PARAMETER FocasPipeName
|
||||
Pipe name the Host listens on. Default: OtOpcUaFocas.
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-FocasHost.ps1 -InstallRoot 'C:\Program Files\OtOpcUa\Driver.FOCAS.Host' `
|
||||
-ServiceAccount 'OTOPCUA\svc-otopcua' -FocasBackend fwlib32
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$InstallRoot,
|
||||
[Parameter(Mandatory)] [string]$ServiceAccount,
|
||||
[string]$FocasSharedSecret,
|
||||
[ValidateSet('fwlib32','fake','unconfigured')] [string]$FocasBackend = 'unconfigured',
|
||||
[string]$FocasPipeName = 'OtOpcUaFocas',
|
||||
[string]$ServiceName = 'OtOpcUaFocasHost',
|
||||
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Resolve-Sid {
|
||||
param([string]$Account)
|
||||
if ($Account -match '^S-\d-\d+') { return $Account }
|
||||
try {
|
||||
$nt = New-Object System.Security.Principal.NTAccount($Account)
|
||||
return $nt.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
} catch {
|
||||
throw "Could not resolve '$Account' to a SID. Pass an explicit SID or check the account name."
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $NssmPath)) {
|
||||
throw "nssm.exe not found at '$NssmPath'. Install NSSM or pass -NssmPath."
|
||||
}
|
||||
|
||||
$hostExe = Join-Path $InstallRoot 'OtOpcUa.Driver.FOCAS.Host.exe'
|
||||
if (-not (Test-Path $hostExe)) {
|
||||
throw "FOCAS Host binary not found at '$hostExe'. Publish the Driver.FOCAS.Host project first."
|
||||
}
|
||||
|
||||
if (-not $FocasSharedSecret) {
|
||||
$FocasSharedSecret = [System.Guid]::NewGuid().ToString('N')
|
||||
Write-Host "Generated FocasSharedSecret — store it alongside the OtOpcUa service config."
|
||||
}
|
||||
|
||||
$allowedSid = Resolve-Sid $ServiceAccount
|
||||
|
||||
# Idempotent install — remove + re-create if present.
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host "Removing existing '$ServiceName' service..."
|
||||
& $NssmPath stop $ServiceName confirm | Out-Null
|
||||
& $NssmPath remove $ServiceName confirm | Out-Null
|
||||
}
|
||||
|
||||
& $NssmPath install $ServiceName $hostExe | Out-Null
|
||||
& $NssmPath set $ServiceName DisplayName 'OT-OPC-UA FOCAS Host (Tier-C isolated Fwlib32)' | Out-Null
|
||||
& $NssmPath set $ServiceName Description 'Out-of-process Fwlib32.dll host for OtOpcUa FOCAS driver. Crash-isolated from the main OPC UA server.' | Out-Null
|
||||
& $NssmPath set $ServiceName ObjectName $ServiceAccount | Out-Null
|
||||
& $NssmPath set $ServiceName Start SERVICE_AUTO_START | Out-Null
|
||||
& $NssmPath set $ServiceName AppStdout (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stdout.log') | Out-Null
|
||||
& $NssmPath set $ServiceName AppStderr (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stderr.log') | Out-Null
|
||||
& $NssmPath set $ServiceName AppRotateFiles 1 | Out-Null
|
||||
& $NssmPath set $ServiceName AppRotateBytes 10485760 | Out-Null
|
||||
|
||||
& $NssmPath set $ServiceName AppEnvironmentExtra `
|
||||
"OTOPCUA_FOCAS_PIPE=$FocasPipeName" `
|
||||
"OTOPCUA_ALLOWED_SID=$allowedSid" `
|
||||
"OTOPCUA_FOCAS_SECRET=$FocasSharedSecret" `
|
||||
"OTOPCUA_FOCAS_BACKEND=$FocasBackend" | Out-Null
|
||||
|
||||
& $NssmPath set $ServiceName DependOnService OtOpcUa | Out-Null
|
||||
|
||||
Write-Host "Installed '$ServiceName' under '$ServiceAccount' (SID=$allowedSid)."
|
||||
Write-Host "Pipe: \\.\pipe\$FocasPipeName Backend: $FocasBackend"
|
||||
Write-Host "Start the service with: Start-Service $ServiceName"
|
||||
Write-Host ""
|
||||
Write-Host "NOTE: the Fwlib32 backend requires the licensed Fwlib32.dll on PATH"
|
||||
Write-Host "alongside the Host exe. See docs/v2/focas-deployment.md."
|
||||
27
scripts/install/Uninstall-FocasHost.ps1
Normal file
27
scripts/install/Uninstall-FocasHost.ps1
Normal file
@@ -0,0 +1,27 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes the OtOpcUaFocasHost Windows service.
|
||||
|
||||
.DESCRIPTION
|
||||
Companion to Install-FocasHost.ps1. Stops + unregisters the service via NSSM.
|
||||
Idempotent — succeeds silently if the service doesn't exist.
|
||||
|
||||
.EXAMPLE
|
||||
.\Uninstall-FocasHost.ps1
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServiceName = 'OtOpcUaFocasHost',
|
||||
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if (-not $svc) { Write-Host "Service '$ServiceName' not present — nothing to do."; return }
|
||||
|
||||
if (-not (Test-Path $NssmPath)) { throw "nssm.exe not found at '$NssmPath'." }
|
||||
|
||||
& $NssmPath stop $ServiceName confirm | Out-Null
|
||||
& $NssmPath remove $ServiceName confirm | Out-Null
|
||||
Write-Host "Removed '$ServiceName'."
|
||||
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Ring-buffer of the last N IPC operations, written into a memory-mapped file. On a
|
||||
/// hard crash the Proxy-side supervisor reads the MMF after the corpse is gone to see
|
||||
/// what was in flight at the moment the Host died. Single-writer (the Host), multi-reader
|
||||
/// (the supervisor) — the file format is identical to the Galaxy Tier-C
|
||||
/// <c>PostMortemMmf</c> so a single reader tool can work both.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File layout:
|
||||
/// <code>
|
||||
/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)]
|
||||
/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]]
|
||||
/// </code>
|
||||
/// Magic is 'OFPC' (0x4F46_5043) to distinguish a FOCAS file from the Galaxy MMF.
|
||||
/// </remarks>
|
||||
public sealed class PostMortemMmf : IDisposable
|
||||
{
|
||||
private const int Magic = 0x4F465043; // 'OFPC'
|
||||
private const int Version = 1;
|
||||
private const int HeaderBytes = 16;
|
||||
public const int EntryBytes = 256;
|
||||
private const int MessageOffset = 16;
|
||||
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||
|
||||
public int Capacity { get; }
|
||||
public string Path { get; }
|
||||
|
||||
private readonly MemoryMappedFile _mmf;
|
||||
private readonly MemoryMappedViewAccessor _accessor;
|
||||
private readonly object _writeGate = new();
|
||||
|
||||
public PostMortemMmf(string path, int capacity = 1000)
|
||||
{
|
||||
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
Capacity = capacity;
|
||||
Path = path;
|
||||
|
||||
var fileBytes = HeaderBytes + capacity * EntryBytes;
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
|
||||
|
||||
var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
|
||||
fs.SetLength(fileBytes);
|
||||
_mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||
_accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||
|
||||
if (_accessor.ReadInt32(0) != Magic)
|
||||
{
|
||||
_accessor.Write(0, Magic);
|
||||
_accessor.Write(4, Version);
|
||||
_accessor.Write(8, capacity);
|
||||
_accessor.Write(12, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(long opKind, string message)
|
||||
{
|
||||
lock (_writeGate)
|
||||
{
|
||||
var idx = _accessor.ReadInt32(12);
|
||||
var offset = HeaderBytes + idx * EntryBytes;
|
||||
|
||||
_accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
_accessor.Write(offset + 8, opKind);
|
||||
|
||||
var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty);
|
||||
var copy = Math.Min(msgBytes.Length, MessageCapacity - 1);
|
||||
_accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy);
|
||||
_accessor.Write(offset + MessageOffset + copy, (byte)0);
|
||||
|
||||
var next = (idx + 1) % Capacity;
|
||||
_accessor.Write(12, next);
|
||||
}
|
||||
}
|
||||
|
||||
public PostMortemEntry[] ReadAll()
|
||||
{
|
||||
var magic = _accessor.ReadInt32(0);
|
||||
if (magic != Magic) return new PostMortemEntry[0];
|
||||
|
||||
var capacity = _accessor.ReadInt32(8);
|
||||
var writeIndex = _accessor.ReadInt32(12);
|
||||
|
||||
var entries = new PostMortemEntry[capacity];
|
||||
var count = 0;
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
var slot = (writeIndex + i) % capacity;
|
||||
var offset = HeaderBytes + slot * EntryBytes;
|
||||
|
||||
var ts = _accessor.ReadInt64(offset + 0);
|
||||
if (ts == 0) continue;
|
||||
|
||||
var op = _accessor.ReadInt64(offset + 8);
|
||||
var msgBuf = new byte[MessageCapacity];
|
||||
_accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||
|
||||
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||
}
|
||||
|
||||
Array.Resize(ref entries, count);
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor.Dispose();
|
||||
_mmf.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct PostMortemEntry
|
||||
{
|
||||
public long UtcUnixMs { get; }
|
||||
public long OpKind { get; }
|
||||
public string Message { get; }
|
||||
|
||||
public PostMortemEntry(long utcUnixMs, long opKind, string message)
|
||||
{
|
||||
UtcUnixMs = utcUnixMs;
|
||||
OpKind = opKind;
|
||||
Message = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy-side reader for the Host's post-mortem MMF. After a Host crash the supervisor
|
||||
/// opens the file (which persists beyond the process lifetime) and enumerates the last
|
||||
/// few thousand IPC operations that were in flight. Format matches
|
||||
/// <c>Driver.FOCAS.Host.Stability.PostMortemMmf</c> — magic 'OFPC' / 256-byte entries.
|
||||
/// </summary>
|
||||
public sealed class PostMortemReader
|
||||
{
|
||||
private const int Magic = 0x4F465043; // 'OFPC'
|
||||
private const int HeaderBytes = 16;
|
||||
private const int EntryBytes = 256;
|
||||
private const int MessageOffset = 16;
|
||||
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public PostMortemReader(string path) => Path = path;
|
||||
|
||||
public PostMortemEntry[] ReadAll()
|
||||
{
|
||||
if (!File.Exists(Path)) return [];
|
||||
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(Path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
|
||||
using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
|
||||
|
||||
if (accessor.ReadInt32(0) != Magic) return [];
|
||||
|
||||
var capacity = accessor.ReadInt32(8);
|
||||
var writeIndex = accessor.ReadInt32(12);
|
||||
var entries = new PostMortemEntry[capacity];
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
var slot = (writeIndex + i) % capacity;
|
||||
var offset = HeaderBytes + slot * EntryBytes;
|
||||
var ts = accessor.ReadInt64(offset + 0);
|
||||
if (ts == 0) continue;
|
||||
var op = accessor.ReadInt64(offset + 8);
|
||||
var msgBuf = new byte[MessageCapacity];
|
||||
accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||
}
|
||||
|
||||
Array.Resize(ref entries, count);
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IHostProcessLauncher"/>. Spawns <c>OtOpcUa.Driver.FOCAS.Host.exe</c>
|
||||
/// with the pipe name / allowed-SID / per-spawn shared secret in the environment, waits for
|
||||
/// the pipe to come up, then connects a <see cref="FocasIpcClient"/> and wraps it in an
|
||||
/// <see cref="IpcFocasClient"/>. On <see cref="TerminateAsync"/> best-effort kills the
|
||||
/// process and closes the IPC stream.
|
||||
/// </summary>
|
||||
public sealed class ProcessHostLauncher : IHostProcessLauncher
|
||||
{
|
||||
private readonly ProcessHostLauncherOptions _options;
|
||||
private Process? _process;
|
||||
private FocasIpcClient? _ipc;
|
||||
|
||||
public ProcessHostLauncher(ProcessHostLauncherOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool IsProcessAlive => _process is { HasExited: false };
|
||||
|
||||
public async Task<IFocasClient> LaunchAsync(CancellationToken ct)
|
||||
{
|
||||
await TerminateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var secret = _options.SharedSecret ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _options.HostExePath,
|
||||
Arguments = _options.Arguments ?? string.Empty,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.Environment["OTOPCUA_FOCAS_PIPE"] = _options.PipeName;
|
||||
psi.Environment["OTOPCUA_ALLOWED_SID"] = _options.AllowedSid;
|
||||
psi.Environment["OTOPCUA_FOCAS_SECRET"] = secret;
|
||||
psi.Environment["OTOPCUA_FOCAS_BACKEND"] = _options.Backend;
|
||||
|
||||
_process = Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"Failed to start {_options.HostExePath}");
|
||||
|
||||
// Poll for pipe readiness up to the configured connect timeout.
|
||||
var deadline = DateTime.UtcNow + _options.ConnectTimeout;
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_process.HasExited)
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS Host exited before pipe was ready (ExitCode={_process.ExitCode}).");
|
||||
|
||||
try
|
||||
{
|
||||
_ipc = await FocasIpcClient.ConnectAsync(
|
||||
_options.PipeName, secret, TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
throw new TimeoutException(
|
||||
$"FOCAS Host pipe {_options.PipeName} did not come up within {_options.ConnectTimeout:g}.");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new IpcFocasClient(_ipc, _options.Series);
|
||||
}
|
||||
|
||||
public async Task TerminateAsync(CancellationToken ct)
|
||||
{
|
||||
if (_ipc is not null)
|
||||
{
|
||||
try { await _ipc.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best effort */ }
|
||||
_ipc = null;
|
||||
}
|
||||
|
||||
if (_process is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
await _process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
finally
|
||||
{
|
||||
_process.Dispose();
|
||||
_process = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ProcessHostLauncherOptions(
|
||||
string HostExePath,
|
||||
string PipeName,
|
||||
string AllowedSid)
|
||||
{
|
||||
public string? SharedSecret { get; init; }
|
||||
public string? Arguments { get; init; }
|
||||
public string Backend { get; init; } = "fwlib32";
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PostMortemMmfTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath;
|
||||
|
||||
public PostMortemMmfTests()
|
||||
{
|
||||
_tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-{Guid.NewGuid():N}.bin");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_tempPath)) File.Delete(_tempPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_and_read_preserve_order_and_content()
|
||||
{
|
||||
using (var mmf = new PostMortemMmf(_tempPath, capacity: 10))
|
||||
{
|
||||
mmf.Write(opKind: 1, "read R100");
|
||||
mmf.Write(opKind: 2, "write MACRO:500 = 3.14");
|
||||
mmf.Write(opKind: 3, "probe ok");
|
||||
}
|
||||
|
||||
// Reopen (simulating a reader after the writer crashed).
|
||||
using var reader = new PostMortemMmf(_tempPath, capacity: 10);
|
||||
var entries = reader.ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
entries[0].OpKind.ShouldBe(1L);
|
||||
entries[0].Message.ShouldBe("read R100");
|
||||
entries[1].OpKind.ShouldBe(2L);
|
||||
entries[2].Message.ShouldBe("probe ok");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ring_buffer_wraps_at_capacity()
|
||||
{
|
||||
using var mmf = new PostMortemMmf(_tempPath, capacity: 3);
|
||||
for (var i = 0; i < 10; i++) mmf.Write(i, $"op-{i}");
|
||||
|
||||
var entries = mmf.ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
// Oldest surviving entry is op-7 (entries 7,8,9 survive in FIFO order).
|
||||
entries[0].Message.ShouldBe("op-7");
|
||||
entries[1].Message.ShouldBe("op-8");
|
||||
entries[2].Message.ShouldBe("op-9");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Truncated_message_is_null_terminated_and_does_not_overflow()
|
||||
{
|
||||
using var mmf = new PostMortemMmf(_tempPath, capacity: 4);
|
||||
var big = new string('x', 500); // longer than the 240-byte message capacity
|
||||
mmf.Write(42, big);
|
||||
|
||||
var entries = mmf.ReadAll();
|
||||
entries.Length.ShouldBe(1);
|
||||
entries[0].Message.Length.ShouldBeLessThanOrEqualTo(240);
|
||||
entries[0].OpKind.ShouldBe(42L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reopening_with_existing_data_preserves_entries()
|
||||
{
|
||||
using (var first = new PostMortemMmf(_tempPath, capacity: 5))
|
||||
{
|
||||
first.Write(1, "first-run-1");
|
||||
first.Write(2, "first-run-2");
|
||||
}
|
||||
|
||||
using var second = new PostMortemMmf(_tempPath, capacity: 5);
|
||||
var entries = second.ReadAll();
|
||||
entries.Length.ShouldBe(2);
|
||||
entries[0].Message.ShouldBe("first-run-1");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Proxy-side <see cref="PostMortemReader"/> must read the Host's MMF format
|
||||
/// (magic 'OFPC', 256-byte entries). This test writes a hand-crafted file that mimics
|
||||
/// the Host's layout exactly + asserts the reader decodes it correctly. Keeps the two
|
||||
/// codebases in lockstep on the wire format without needing to reference the net48
|
||||
/// Host assembly from the net10 test project.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PostMortemReaderCompatibilityTests : IDisposable
|
||||
{
|
||||
private readonly string _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-compat-{Guid.NewGuid():N}.bin");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_tempPath)) File.Delete(_tempPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reader_parses_host_format_and_returns_entries_in_oldest_first_order()
|
||||
{
|
||||
const int magic = 0x4F465043;
|
||||
const int capacity = 5;
|
||||
const int headerBytes = 16;
|
||||
const int entryBytes = 256;
|
||||
const int messageOffset = 16;
|
||||
var fileBytes = headerBytes + capacity * entryBytes;
|
||||
|
||||
using (var fs = new FileStream(_tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read))
|
||||
{
|
||||
fs.SetLength(fileBytes);
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||
using var acc = mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||
acc.Write(0, magic);
|
||||
acc.Write(4, 1);
|
||||
acc.Write(8, capacity);
|
||||
acc.Write(12, 2); // writeIndex — next write would land at slot 2
|
||||
|
||||
void WriteEntry(int slot, long ts, long op, string msg)
|
||||
{
|
||||
var offset = headerBytes + slot * entryBytes;
|
||||
acc.Write(offset + 0, ts);
|
||||
acc.Write(offset + 8, op);
|
||||
var bytes = Encoding.UTF8.GetBytes(msg);
|
||||
acc.WriteArray(offset + messageOffset, bytes, 0, bytes.Length);
|
||||
acc.Write(offset + messageOffset + bytes.Length, (byte)0);
|
||||
}
|
||||
|
||||
WriteEntry(0, 100, 1, "op-a");
|
||||
WriteEntry(1, 200, 2, "op-b");
|
||||
// Slots 2,3 unwritten (ts=0) — reader must skip.
|
||||
WriteEntry(4, 50, 9, "old-wrapped");
|
||||
}
|
||||
|
||||
var entries = new PostMortemReader(_tempPath).ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
// writeIndex=2 means the ring walk starts at slot 2, so iteration order is 2→3→4→0→1.
|
||||
// Slots 2 and 3 are empty; 4 yields "old-wrapped"; then 0="op-a", 1="op-b".
|
||||
entries[0].Message.ShouldBe("old-wrapped");
|
||||
entries[1].Message.ShouldBe("op-a");
|
||||
entries[2].Message.ShouldBe("op-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reader_returns_empty_when_file_missing()
|
||||
{
|
||||
new PostMortemReader(_tempPath + "-does-not-exist").ReadAll().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reader_returns_empty_when_magic_mismatches()
|
||||
{
|
||||
File.WriteAllBytes(_tempPath, new byte[1024]);
|
||||
new PostMortemReader(_tempPath).ReadAll().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ RUN pip install --no-cache-dir "pymodbus[simulator]==3.13.0"
|
||||
WORKDIR /fixtures
|
||||
COPY profiles/ /fixtures/
|
||||
|
||||
# Standalone exception-injection server (pure Python stdlib — no pymodbus
|
||||
# dependency). Speaks raw Modbus/TCP and emits arbitrary exception codes
|
||||
# per rules in exception_injection.json. Drives the `exception_injection`
|
||||
# compose profile. See Docker/README.md §exception injection.
|
||||
COPY exception_injector.py /fixtures/
|
||||
|
||||
EXPOSE 5020
|
||||
|
||||
# Default to the standard profile; docker-compose.yml overrides per service.
|
||||
|
||||
@@ -9,9 +9,10 @@ nothing else.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + the four profile JSONs |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500`); all bind `:5020` so only one runs at a time |
|
||||
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
|
||||
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
|
||||
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
|
||||
|
||||
## Run
|
||||
|
||||
@@ -29,6 +30,10 @@ docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\
|
||||
|
||||
# Siemens S7-1500 MB_SERVER quirks
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
|
||||
|
||||
# Exception-injection — end-to-end coverage of every Modbus exception code
|
||||
# (01/02/03/04/05/06/0A/0B), not just the 02 + 03 pymodbus emits naturally
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile exception_injection up
|
||||
```
|
||||
|
||||
Detached + stop:
|
||||
@@ -61,6 +66,36 @@ dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
|
||||
records a `SkipReason` when unreachable, so tests stay green on a fresh
|
||||
clone without Docker running.
|
||||
|
||||
## Exception injection
|
||||
|
||||
pymodbus's simulator naturally emits only Modbus exception codes `0x02`
|
||||
(Illegal Data Address, on reads outside its configured ranges) and
|
||||
`0x03` (Illegal Data Value, on over-length requests). The driver's
|
||||
`MapModbusExceptionToStatus` table translates eight codes: `0x01`,
|
||||
`0x02`, `0x03`, `0x04`, `0x05`, `0x06`, `0x0A`, `0x0B`. Unit tests
|
||||
lock the translation function; the integration side previously only
|
||||
proved the wire-to-status path for `0x02`.
|
||||
|
||||
The `exception_injection` profile runs
|
||||
[`exception_injector.py`](exception_injector.py) — a tiny standalone
|
||||
Modbus/TCP server written against the Python stdlib (zero
|
||||
dependencies outside what's in the base image). It speaks the wire
|
||||
protocol directly (FC 01/02/03/04/05/06/15/16) and looks up each
|
||||
incoming `(fc, address)` against the rules in
|
||||
[`profiles/exception_injection.json`](profiles/exception_injection.json);
|
||||
a matching rule makes the server reply with
|
||||
`[fc | 0x80, exception_code]` instead of the normal response.
|
||||
|
||||
Current rules (see the JSON file for the canonical list):
|
||||
|
||||
- `FC03 @1000..1007` — one per exception code (`0x01`/`0x02`/`0x03`/`0x04`/`0x05`/`0x06`/`0x0A`/`0x0B`)
|
||||
- `FC06 @2000..2001` — `0x04` Server Failure, `0x06` Server Busy (write-path coverage)
|
||||
- `FC16 @3000` — `0x04` Server Failure (multi-register write path)
|
||||
|
||||
Adding more coverage is append-only: drop a new `{fc, address,
|
||||
exception, description}` entry into the JSON, restart the service,
|
||||
add an `[InlineData]` row in `ExceptionInjectionTests`.
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/drivers/Modbus-Test-Fixture.md`](../../../docs/drivers/Modbus-Test-Fixture.md) — coverage map + gap inventory
|
||||
|
||||
@@ -77,3 +77,24 @@ services:
|
||||
"--modbus_device", "dev",
|
||||
"--json_file", "/fixtures/s7_1500.json"
|
||||
]
|
||||
|
||||
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
|
||||
# server shipped as exception_injector.py instead of the pymodbus
|
||||
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
|
||||
# this profile extends integration coverage to the other codes the
|
||||
# driver's MapModbusExceptionToStatus table handles (01, 04, 05, 06,
|
||||
# 0A, 0B). Rules are driven by exception_injection.json.
|
||||
exception_injection:
|
||||
profiles: ["exception_injection"]
|
||||
image: otopcua-pymodbus:3.13.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-modbus-exception-injector
|
||||
restart: "no"
|
||||
ports:
|
||||
- "5020:5020"
|
||||
command: [
|
||||
"python", "/fixtures/exception_injector.py",
|
||||
"--config", "/fixtures/exception_injection.json"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal Modbus/TCP server that supports per-address + per-function-code
|
||||
exception injection — the missing piece of the pymodbus simulator, which
|
||||
only naturally emits exception code 02 (Illegal Data Address) via its
|
||||
"invalid" list and 03 (Illegal Data Value) via spec-enforced length caps.
|
||||
|
||||
Integration tests against this fixture drive the driver's
|
||||
`MapModbusExceptionToStatus` end-to-end over the wire for codes 01, 04,
|
||||
05, 06, 0A, 0B — the ones the pymodbus simulator can't be configured to
|
||||
return.
|
||||
|
||||
Wire protocol — straight Modbus/TCP (spec chapter 7.1):
|
||||
|
||||
MBAP header (7 bytes): [tx_id:u16 BE][proto=0:u16][length:u16][unit_id:u8]
|
||||
then length-1 bytes of PDU. Length covers unit_id + PDU.
|
||||
|
||||
Supported function codes (enough for the driver's RMW + read paths):
|
||||
01 Read Coils, 02 Read Discrete Inputs,
|
||||
03 Read Holding Registers, 04 Read Input Registers,
|
||||
05 Write Single Coil, 06 Write Single Register,
|
||||
15 Write Multiple Coils, 16 Write Multiple Registers.
|
||||
|
||||
Config JSON schema (see exception_injection.json):
|
||||
|
||||
{
|
||||
"listen": { "host": "0.0.0.0", "port": 5020 },
|
||||
"seeds": { "hr": { "<addr>": <uint16>, ... },
|
||||
"ir": { "<addr>": <uint16>, ... },
|
||||
"co": { "<addr>": <0|1>, ... },
|
||||
"di": { "<addr>": <0|1>, ... } },
|
||||
"rules": [ { "fc": <int>, "address": <int>, "exception": <int>,
|
||||
"description": "..." }, ... ]
|
||||
}
|
||||
|
||||
Rules match on (fc, starting address). A matching rule wins and the server
|
||||
responds with the PDU `[fc | 0x80, exception_code]`.
|
||||
|
||||
Zero runtime dependencies outside the Python stdlib so the Docker image
|
||||
stays tiny.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
log = logging.getLogger("exception_injector")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
fc: int
|
||||
address: int
|
||||
exception: int
|
||||
description: str = ""
|
||||
|
||||
|
||||
class Store:
|
||||
"""In-memory data store backing non-injected reads + writes."""
|
||||
|
||||
def __init__(self, seeds: dict[str, dict[str, int]]) -> None:
|
||||
self.hr: dict[int, int] = {int(k): int(v) for k, v in seeds.get("hr", {}).items()}
|
||||
self.ir: dict[int, int] = {int(k): int(v) for k, v in seeds.get("ir", {}).items()}
|
||||
self.co: dict[int, int] = {int(k): int(v) for k, v in seeds.get("co", {}).items()}
|
||||
self.di: dict[int, int] = {int(k): int(v) for k, v in seeds.get("di", {}).items()}
|
||||
|
||||
def read_bits(self, table: dict[int, int], addr: int, count: int) -> bytes:
|
||||
"""Pack `count` bits LSB-first into the Modbus bit response body."""
|
||||
bits = [table.get(addr + i, 0) & 1 for i in range(count)]
|
||||
out = bytearray((count + 7) // 8)
|
||||
for i, b in enumerate(bits):
|
||||
if b:
|
||||
out[i // 8] |= 1 << (i % 8)
|
||||
return bytes(out)
|
||||
|
||||
def read_regs(self, table: dict[int, int], addr: int, count: int) -> bytes:
|
||||
"""Pack `count` uint16 BE into the Modbus register response body."""
|
||||
return b"".join(struct.pack(">H", table.get(addr + i, 0) & 0xFFFF) for i in range(count))
|
||||
|
||||
|
||||
class Server:
|
||||
EXC_ILLEGAL_FUNCTION = 0x01
|
||||
EXC_ILLEGAL_DATA_ADDRESS = 0x02
|
||||
EXC_ILLEGAL_DATA_VALUE = 0x03
|
||||
|
||||
def __init__(self, store: Store, rules: list[Rule]) -> None:
|
||||
self._store = store
|
||||
# Index rules by (fc, address) for O(1) lookup.
|
||||
self._rules: dict[tuple[int, int], Rule] = {(r.fc, r.address): r for r in rules}
|
||||
|
||||
def lookup_rule(self, fc: int, address: int) -> Rule | None:
|
||||
return self._rules.get((fc, address))
|
||||
|
||||
def exception_pdu(self, fc: int, code: int) -> bytes:
|
||||
return bytes([fc | 0x80, code & 0xFF])
|
||||
|
||||
def handle_pdu(self, pdu: bytes) -> bytes:
|
||||
if not pdu:
|
||||
return self.exception_pdu(0, self.EXC_ILLEGAL_FUNCTION)
|
||||
|
||||
fc = pdu[0]
|
||||
|
||||
# Reads: FC 01/02/03/04 — [fc u8][addr u16][quantity u16]
|
||||
if fc in (0x01, 0x02, 0x03, 0x04):
|
||||
if len(pdu) != 5:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, count = struct.unpack(">HH", pdu[1:5])
|
||||
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
log.info("inject fc=%d addr=%d -> exception 0x%02X (%s)",
|
||||
fc, addr, rule.exception, rule.description)
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
|
||||
# Spec caps — FC01/02 allow 1..2000 bits; FC03/04 allow 1..125 regs.
|
||||
if fc in (0x01, 0x02):
|
||||
if not 1 <= count <= 2000:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
body = self._store.read_bits(
|
||||
self._store.co if fc == 0x01 else self._store.di, addr, count)
|
||||
return bytes([fc, len(body)]) + body
|
||||
|
||||
if not 1 <= count <= 125:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
body = self._store.read_regs(
|
||||
self._store.hr if fc == 0x03 else self._store.ir, addr, count)
|
||||
return bytes([fc, len(body)]) + body
|
||||
|
||||
# FC05 — [fc u8][addr u16][value u16] where value is 0xFF00=ON or 0x0000=OFF.
|
||||
if fc == 0x05:
|
||||
if len(pdu) != 5:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, value = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
if value == 0xFF00:
|
||||
self._store.co[addr] = 1
|
||||
elif value == 0x0000:
|
||||
self._store.co[addr] = 0
|
||||
else:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
return pdu # FC05 echoes the request on success.
|
||||
|
||||
# FC06 — [fc u8][addr u16][value u16].
|
||||
if fc == 0x06:
|
||||
if len(pdu) != 5:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, value = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
self._store.hr[addr] = value
|
||||
return pdu # FC06 echoes on success.
|
||||
|
||||
# FC15 — [fc u8][addr u16][count u16][byte_count u8][values...]
|
||||
if fc == 0x0F:
|
||||
if len(pdu) < 6:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, count = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
# Happy-path ignore-the-data, ack with standard response.
|
||||
return struct.pack(">BHH", fc, addr, count)
|
||||
|
||||
# FC16 — [fc u8][addr u16][count u16][byte_count u8][u16 values...]
|
||||
if fc == 0x10:
|
||||
if len(pdu) < 6:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, count = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
byte_count = pdu[5]
|
||||
data = pdu[6:6 + byte_count]
|
||||
for i in range(count):
|
||||
self._store.hr[addr + i] = struct.unpack(">H", data[i * 2:i * 2 + 2])[0]
|
||||
return struct.pack(">BHH", fc, addr, count)
|
||||
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_FUNCTION)
|
||||
|
||||
async def handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
peer = writer.get_extra_info("peername")
|
||||
log.info("client connected from %s", peer)
|
||||
try:
|
||||
while True:
|
||||
hdr = await reader.readexactly(7)
|
||||
tx_id, proto, length, unit_id = struct.unpack(">HHHB", hdr)
|
||||
if length < 1:
|
||||
return
|
||||
pdu = await reader.readexactly(length - 1)
|
||||
|
||||
resp = self.handle_pdu(pdu)
|
||||
out = struct.pack(">HHHB", tx_id, proto, len(resp) + 1, unit_id) + resp
|
||||
writer.write(out)
|
||||
await writer.drain()
|
||||
except asyncio.IncompleteReadError:
|
||||
log.info("client %s disconnected", peer)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("unexpected error serving %s", peer)
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: str) -> tuple[Store, list[Rule], str, int]:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = json.load(fh)
|
||||
listen = raw.get("listen", {})
|
||||
host = listen.get("host", "0.0.0.0")
|
||||
port = int(listen.get("port", 5020))
|
||||
store = Store(raw.get("seeds", {}))
|
||||
rules = [
|
||||
Rule(
|
||||
fc=int(r["fc"]),
|
||||
address=int(r["address"]),
|
||||
exception=int(r["exception"]),
|
||||
description=str(r.get("description", "")),
|
||||
)
|
||||
for r in raw.get("rules", [])
|
||||
]
|
||||
return store, rules, host, port
|
||||
|
||||
|
||||
async def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", required=True, help="Path to exception-injection JSON config.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s - %(message)s")
|
||||
|
||||
store, rules, host, port = load_config(args.config)
|
||||
server = Server(store, rules)
|
||||
listener = await asyncio.start_server(server.handle_connection, host, port)
|
||||
|
||||
log.info("exception-injector listening on %s:%d with %d rule(s)", host, port, len(rules))
|
||||
for r in rules:
|
||||
log.info(" rule: fc=%d addr=%d -> exception 0x%02X (%s)",
|
||||
r.fc, r.address, r.exception, r.description)
|
||||
|
||||
async with listener:
|
||||
await listener.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(asyncio.run(main(sys.argv[1:])))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": "Modbus exception-injection profile — feeds exception_injector.py (not pymodbus). Rules match by (fc, address). HR[0-31] are address-as-value for the happy-path reads; HR[1000..1010] + coils[2000..2010] carry per-exception-code rules. Every code in the driver's MapModbusExceptionToStatus table that pymodbus can't naturally emit has a dedicated slot. See Docker/README.md §exception injection.",
|
||||
|
||||
"listen": { "host": "0.0.0.0", "port": 5020 },
|
||||
|
||||
"seeds": {
|
||||
"hr": {
|
||||
"0": 0, "1": 1, "2": 2, "3": 3,
|
||||
"4": 4, "5": 5, "6": 6, "7": 7,
|
||||
"8": 8, "9": 9, "10": 10, "11": 11,
|
||||
"12": 12, "13": 13, "14": 14, "15": 15,
|
||||
"16": 16, "17": 17, "18": 18, "19": 19,
|
||||
"20": 20, "21": 21, "22": 22, "23": 23,
|
||||
"24": 24, "25": 25, "26": 26, "27": 27,
|
||||
"28": 28, "29": 29, "30": 30, "31": 31
|
||||
}
|
||||
},
|
||||
|
||||
"rules": [
|
||||
{ "fc": 3, "address": 1000, "exception": 1, "description": "FC03 @1000 -> Illegal Function (0x01)" },
|
||||
{ "fc": 3, "address": 1001, "exception": 2, "description": "FC03 @1001 -> Illegal Data Address (0x02)" },
|
||||
{ "fc": 3, "address": 1002, "exception": 3, "description": "FC03 @1002 -> Illegal Data Value (0x03)" },
|
||||
{ "fc": 3, "address": 1003, "exception": 4, "description": "FC03 @1003 -> Server Failure (0x04)" },
|
||||
{ "fc": 3, "address": 1004, "exception": 5, "description": "FC03 @1004 -> Acknowledge (0x05)" },
|
||||
{ "fc": 3, "address": 1005, "exception": 6, "description": "FC03 @1005 -> Server Busy (0x06)" },
|
||||
{ "fc": 3, "address": 1006, "exception": 10, "description": "FC03 @1006 -> Gateway Path Unavailable (0x0A)" },
|
||||
{ "fc": 3, "address": 1007, "exception": 11, "description": "FC03 @1007 -> Gateway Target No Response (0x0B)" },
|
||||
|
||||
{ "fc": 6, "address": 2000, "exception": 4, "description": "FC06 @2000 -> Server Failure (0x04, e.g. CPU in PROGRAM mode)" },
|
||||
{ "fc": 6, "address": 2001, "exception": 6, "description": "FC06 @2001 -> Server Busy (0x06)" },
|
||||
|
||||
{ "fc": 16, "address": 3000, "exception": 4, "description": "FC16 @3000 -> Server Failure (0x04)" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end verification that the driver's <c>MapModbusExceptionToStatus</c>
|
||||
/// translation is wire-correct for every exception code in the mapping table —
|
||||
/// not just 0x02, which is the only code the pymodbus simulator naturally emits.
|
||||
/// Drives the standalone <c>exception_injector.py</c> server (<c>exception_injection</c>
|
||||
/// compose profile) at each of the rule addresses in
|
||||
/// <c>Docker/profiles/exception_injection.json</c> and asserts the driver surfaces
|
||||
/// the expected OPC UA StatusCode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Why integration coverage on top of the unit tests: the unit tests prove the
|
||||
/// translation function is correct; these prove the driver wires it through on
|
||||
/// the read + write paths unchanged, after the MBAP header + PDU round-trip
|
||||
/// (where a subtle framing bug could swallow or misclassify the exception).
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "ExceptionInjection")]
|
||||
public sealed class ExceptionInjectionTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
private const uint StatusGood = 0u;
|
||||
private const uint StatusBadOutOfRange = 0x803C0000u;
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
|
||||
private void SkipUnlessInjectorLive()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
|
||||
if (!string.Equals(profile, "exception_injection", StringComparison.OrdinalIgnoreCase))
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != exception_injection — skipping. " +
|
||||
"Start the fixture with --profile exception_injection.");
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DataValueSnapshot>> ReadSingleAsync(int address, string tagName)
|
||||
{
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(tagName,
|
||||
ModbusRegion.HoldingRegisters, Address: (ushort)address,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return await driver.ReadAsync([tagName], TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, StatusBadNotSupported, "exc 0x01 (Illegal Function) -> BadNotSupported")]
|
||||
[InlineData(1001, StatusBadOutOfRange, "exc 0x02 (Illegal Data Address) -> BadOutOfRange")]
|
||||
[InlineData(1002, StatusBadOutOfRange, "exc 0x03 (Illegal Data Value) -> BadOutOfRange")]
|
||||
[InlineData(1003, StatusBadDeviceFailure, "exc 0x04 (Server Failure) -> BadDeviceFailure")]
|
||||
[InlineData(1004, StatusBadDeviceFailure, "exc 0x05 (Acknowledge / long op) -> BadDeviceFailure")]
|
||||
[InlineData(1005, StatusBadDeviceFailure, "exc 0x06 (Server Busy) -> BadDeviceFailure")]
|
||||
[InlineData(1006, StatusBadCommunicationError, "exc 0x0A (Gateway Path Unavailable) -> BadCommunicationError")]
|
||||
[InlineData(1007, StatusBadCommunicationError, "exc 0x0B (Gateway Target No Response) -> BadCommunicationError")]
|
||||
public async Task FC03_read_at_injection_address_surfaces_expected_status(
|
||||
int address, uint expectedStatus, string scenario)
|
||||
{
|
||||
SkipUnlessInjectorLive();
|
||||
var results = await ReadSingleAsync(address, $"Injected_{address}");
|
||||
results[0].StatusCode.ShouldBe(expectedStatus, scenario);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FC03_read_at_non_injected_address_returns_Good()
|
||||
{
|
||||
// Sanity: HR[0..31] are seeded with address-as-value in the profile. A read at
|
||||
// one of those addresses must come back Good (0) — otherwise the injector is
|
||||
// misbehaving and every other assertion in this class is uninformative.
|
||||
SkipUnlessInjectorLive();
|
||||
var results = await ReadSingleAsync(address: 5, tagName: "Healthy_5");
|
||||
results[0].StatusCode.ShouldBe(StatusGood);
|
||||
results[0].Value.ShouldBe((ushort)5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2000, StatusBadDeviceFailure, "exc 0x04 on FC06 -> BadDeviceFailure (CPU in PROGRAM mode)")]
|
||||
[InlineData(2001, StatusBadDeviceFailure, "exc 0x06 on FC06 -> BadDeviceFailure (Server Busy)")]
|
||||
public async Task FC06_write_at_injection_address_surfaces_expected_status(
|
||||
int address, uint expectedStatus, string scenario)
|
||||
{
|
||||
SkipUnlessInjectorLive();
|
||||
var tag = $"InjectedWrite_{address}";
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(tag,
|
||||
ModbusRegion.HoldingRegisters, Address: (ushort)address,
|
||||
DataType: ModbusDataType.UInt16, Writable: true),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc-write");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writes = await driver.WriteAsync(
|
||||
[new WriteRequest(tag, (ushort)42)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writes[0].StatusCode.ShouldBe(expectedStatus, scenario);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user