// mbproxy configuration template — copy to %ProgramData%\mbproxy\appsettings.json // and edit before starting the service. // // The .NET configuration loader accepts // and /* */ comments in JSON files // (JSONC semantics) when using the default Host.CreateApplicationBuilder path. // // IMPORTANT: This file is overwritten on each install ONLY if no appsettings.json // already exists at the destination. An existing file is always preserved. { "Mbproxy": { // ── Global BCD tag list ───────────────────────────────────────────────────────────── // These tags apply to EVERY PLC by default. // Each entry: Address (Modbus PDU address, decimal), Width (16 or 32 bits). // // Width 16 — one register holds 4 BCD digits (0–9999). // Wire value 0x1234 decodes to decimal 1234. // // Width 32 — a CDAB-ordered register pair (Address = low word, Address+1 = high word). // Decoded decimal = high * 10000 + low (DirectLOGIC CDAB word order). // // Per-PLC overrides (see Plcs[].BcdTags below): // Add — appends extra tags beyond what Global defines, or overrides a // Global entry's Width when the same Address appears in both. // Remove — removes specific addresses from the effective set for that PLC. // Effective set = (Global ∪ Add) − Remove, resolved per PDU. "BcdTags": { "Global": [ // V2000 (octal) = decimal address 1024. 16-bit BCD counter. { "Address": 1024, "Width": 16 }, // V2040 (octal) = decimal address 1056. 32-bit BCD total at 1056/1057. { "Address": 1056, "Width": 32 }, // V2100 (octal) = decimal address 1088. 16-bit BCD setpoint. // // Phase 11: CacheTtlMs (optional) opts this tag into the response cache. With // CacheTtlMs > 0 set, upstream clients reading this register will see values up // to CacheTtlMs MILLISECONDS OLD — explicit acknowledgement of the staleness // window is required by enabling it. Default (omitted or 0) = cache disabled // for this tag. The cache is OFF by default for every tag. { "Address": 1088, "Width": 16 /* , "CacheTtlMs": 1000 */ } ] }, // ── PLC list ──────────────────────────────────────────────────────────────────────── // Each entry maps one upstream proxy port → one backend PLC. // Upstream clients connect to ListenPort; the proxy forwards to Host:Port. // // IMPORTANT: H2-ECOM100 modules accept at most 4 simultaneous TCP connections. // With the 1:1 upstream↔backend model, a fifth upstream client to the same proxy // port will cause a backend connect failure and an immediate upstream disconnect. "Plcs": [ { "Name": "Line1-Mixer", // Human-readable name (shown on status page and in logs) "ListenPort": 5020, // Port the proxy listens on (upstream clients connect here) "Host": "10.0.1.1", // PLC IP address or hostname "Port": 502, // PLC Modbus TCP port (almost always 502) "BcdTags": { // Additional 32-bit tag specific to this PLC only. "Add": [ { "Address": 1200, "Width": 32 } ], // Remove address 1056 from the Global list for this PLC // (this mixer doesn't use the 32-bit BCD total). "Remove": [ 1056 ] } }, { "Name": "Line1-Conveyor", "ListenPort": 5021, "Host": "10.0.1.2", "Port": 502 // No BcdTags override — uses the Global set as-is. } // Add one entry per PLC. Ports must be unique per host. Typical fleet: 54 PLCs. ], // ── Admin port ────────────────────────────────────────────────────────────────────── // Read-only HTTP status page. // GET / → self-contained HTML (auto-refreshes every 5 s) // GET /status.json → same data as JSON for monitoring scrapers // // Authentication is assumed at the network layer (trusted internal segment). // Set to 0 to disable the admin endpoint. "AdminPort": 8080, // ── Connection timeouts ───────────────────────────────────────────────────────────── "Connection": { // Max time (ms) to wait for a TCP connect to the PLC backend. // Each Polly retry attempt gets its own copy of this timeout. "BackendConnectTimeoutMs": 3000, // Max time (ms) to wait for the PLC to respond to a forwarded PDU. // Non-idempotent FC06/FC16 writes are one-shot — the upstream client // is disconnected immediately on timeout (no retry). "BackendRequestTimeoutMs": 3000, // Max time (ms) to wait for in-flight PDUs to complete during graceful shutdown // (sc.exe stop / Windows Service stop signal). After this deadline the coordinator // cancels remaining work and proceeds. Keep at or below the SCM wait-hint (30 s). "GracefulShutdownTimeoutMs": 10000, // ── Keepalive / connection monitoring ─────────────────────────────────── // The DL205/DL260 ECOM does not emit TCP keepalives, so an idle backend // socket can be silently dropped by a middlebox (switch, firewall, NAT) // after 2-5 minutes. This section enables OS-level SO_KEEPALIVE on both // backend and upstream sockets, and drives a periodic Modbus FC03 heartbeat // on each idle backend socket so a dead path is detected before a real // client request hits it. See docs/Architecture/Keepalive.md. "Keepalive": { // Master switch. false → no SO_KEEPALIVE and no heartbeat; the proxy // behaves exactly as a pre-keepalive build. "Enabled": true, // SO_KEEPALIVE: idle time (ms) before the OS sends its first probe. "TcpIdleTimeMs": 30000, // SO_KEEPALIVE: interval (ms) between probes once the idle time elapses. "TcpProbeIntervalMs": 5000, // SO_KEEPALIVE: unanswered probes before the OS declares the socket dead. "TcpProbeCount": 4, // Backend heartbeat: after this much backend idle (ms) the proxy issues a // synthetic FC03 qty=1 read to keep the path warm and prove the ECOM is // still answering Modbus. Must be greater than BackendRequestTimeoutMs. "BackendHeartbeatIdleMs": 30000, // FC03 PDU address the heartbeat reads. 0 = V0, valid on DL205/DL260. "BackendHeartbeatProbeAddress": 0 } }, // ── Resilience policies ───────────────────────────────────────────────────────────── "Resilience": { // Polly retry policy for backend TCP connect attempts. // MaxAttempts: total connect tries (including the first). // BackoffMs: delay between each attempt (must have MaxAttempts−1 entries). "BackendConnect": { "MaxAttempts": 3, "BackoffMs": [ 100, 500, 2000 ] }, // Polly recovery policy for listener bind failures. // If a PLC's listen port can't be bound (in-use, bad IP, transient OS error), // the supervisor retries according to this schedule. // InitialBackoffMs: backoff per step (first N retries). // SteadyStateMs: backoff for all subsequent retries (runs indefinitely). "ListenerRecovery": { "InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ], "SteadyStateMs": 30000 }, // Phase 10 — in-flight read coalescing. // // When two or more upstream clients (HMI / historian / engineering workstation / // gateway) issue the SAME FC03 or FC04 read while a matching backend round-trip is // already in flight, the proxy attaches the late arrivals to the existing in-flight // entry and fans the single PLC response out to every attached client — saving the // ECOM's per-scan PDU budget on duplicated reads. // // Zero post-response staleness: coalescing operates ONLY between "first request // sent to PLC" and "response received from PLC" (microseconds to ~10 ms typical). // Each upstream client still sees its own MBAP transaction ID echoed correctly; // the proxy is transparent. // // FC06 / FC16 writes are NEVER coalesced (non-idempotent). FC03 vs FC04 are // separate Modbus tables and never share a coalescing key. Different unit IDs // (multi-drop / gateway-backed setups) never coalesce. // // Enabled — master switch. Hot-reloadable; flipping to false leaves running // coalesced entries to drain naturally. // MaxParties — per-entry cap on attached parties. Past the cap, the next // identical request opens a fresh backend round-trip (load-shedding // safety valve for very fan-out-heavy fleets). "ReadCoalescing": { "Enabled": true, "MaxParties": 32 } }, // ── Response cache (Phase 11) — opt-in bounded-staleness cache ────────────────── // // ⚠ DESIGN-CONTRACT PIVOT: with caching enabled the proxy is no longer purely // transparent. Upstream FC03/FC04 reads for cache-enabled tags may return values // up to CacheTtlMs MILLISECONDS OLD. Operators opt tags in by setting a non-zero // CacheTtlMs on a BcdTagOptions entry (or DefaultCacheTtlMs on a PlcOptions entry). // // The cache is OFF BY DEFAULT for every tag. A deployment with NO TTL config (this // section entirely absent and no BcdTags.*.CacheTtlMs / Plcs[i].DefaultCacheTtlMs) // behaves IDENTICALLY to a pre-Phase-11 deployment — no behaviour change. // // AllowLongTtl — gate for any CacheTtlMs > 60_000. Reload validation // rejects configs that exceed 60 s without this opt-in, // to prevent accidentally-stale-for-an-hour deployments. // MaxEntriesPerPlc — LRU cap per-PLC. Past this cap, the next insert evicts // the least-recently-used entry. Defaults to 1000. // EvictionIntervalMs — background eviction tick. Scans each PLC's cache and // removes entries past their TTL. Defaults to 5000. // // Properties (full text in docs/Architecture/ResponseCache.md): // * Cache hits SHORT-CIRCUIT coalescing entirely (cache → coalesce → backend). // * Successful FC06/FC16 write responses invalidate every cached FC03/FC04 entry // whose address range OVERLAPS the write — not just exact-key match. // * Multi-tag read range: effective TTL = min(TTLs). Any tag with TTL=0 in the // range disables caching for the whole read. // * Cache stores POST-rewriter bytes; hits never re-invoke the BCD rewriter. // * Tag-list hot-reload flushes the affected PLC's whole cache. // * No persistence — process restart wipes the cache. "Cache": { "AllowLongTtl": false, "MaxEntriesPerPlc": 1000, "EvictionIntervalMs": 5000 } }, // ── Serilog ───────────────────────────────────────────────────────────────────────────── // Structured log output. Default: Information level, rolling-file under ProgramData. // The EventLogBridge writes Error+ events to the Windows Application Event Log // automatically when the service runs under the SCM (not under dotnet run). "Serilog": { "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } }, "WriteTo": [ { "Name": "Console", "Args": { "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" } }, { "Name": "File", "Args": { // Rolling log: one file per day, kept for 30 days. // Survives uninstall — logs are archived to %ProgramData%\mbproxy.archived-\. "path": "C:\\ProgramData\\mbproxy\\logs\\mbproxy-.log", "rollingInterval": "Day", "retainedFileCountLimit": 30, "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" } } ] } }