Files
wwtools/mbproxy/docs/Operations/Configuration.md
T
Joseph Doherty e719dd51c1 mbproxy: replace status page with a live SignalR web dashboard
The single auto-refreshing zero-JS status page gave operators a 25-column
wall and no way to drill into one connection. This adds a Bootstrap fleet
dashboard (filterable/sortable KPI table) and a per-PLC detail page with a
real-time debug view of raw PLC-side BCD vs. decoded client-side values,
streamed live over a SignalR feed. The debug view is fed by an on-demand
per-tag value capture, armed only while a detail page is open. All assets
(Bootstrap, SignalR client, fonts) are embedded so the UI works unchanged
on firewalled networks; GET /status.json is untouched for scrapers.

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

30 KiB
Raw Blame History

Configuration Reference

mbproxy binds its runtime configuration from appsettings.json under the Mbproxy section. This document is the full reference for every supported key, its type, default, range, and validation rules.

File Location

The configuration loader resolves appsettings.json relative to the executable.

  • Development run (dotnet run): src/Mbproxy/appsettings.json next to the build output.
  • Single-file publish (dotnet publish -c Release -r <rid>): appsettings.json next to the published binary. A win-x64 publish ships install/mbproxy.config.template.json; a linux-x64 publish ships install/mbproxy.linux.config.template.json (same keys, Unix log path) — each linked into the bundle as appsettings.json.
  • Installed as a Windows Service: %ProgramData%\mbproxy\appsettings.json, seeded by install.ps1 from mbproxy.config.template.json.
  • Installed as a systemd unit: /etc/mbproxy/appsettings.json (the unit's WorkingDirectory), seeded by install.sh from the Linux template.

In both installed cases the install script copies the template only when no config already exists — an existing file is preserved across reinstalls.

The file is loaded with reloadOnChange: true. All consumers read through IOptionsMonitor<MbproxyOptions>, so a save propagates without restarting the service. See ../Features/HotReload.md for per-key propagation semantics.

The .NET configuration provider accepts // and /* */ comments (JSONC) in appsettings.json when loaded through Host.CreateApplicationBuilder. The install template ships with comments.

Environment variables and command-line arguments are also accepted by the host. Either form can override any Mbproxy:* key; for example, Mbproxy__AdminPort=9090 (double-underscore segment separator) overrides the JSON. Environment overrides are useful for ephemeral diagnostic switches but should not replace the file as the source of truth — ReloadValidator runs against the merged configuration on every reload.

Top-Level Schema

Every supported key under Mbproxy:*, populated to a representative default:

{
  "Mbproxy": {

    // Global BCD tag list — applies to every PLC unless overridden per-PLC.
    "BcdTags": {
      "Global": [
        { "Address": 1024, "Width": 16 },                       // 16-bit BCD register
        { "Address": 1056, "Width": 32 },                       // 32-bit BCD pair (CDAB)
        { "Address": 1088, "Width": 16, "CacheTtlMs": 1000 }    // opt-in cache, 1 s TTL
      ]
    },

    // One entry per PLC. Each maps an upstream proxy port to a backend Modbus TCP endpoint.
    "Plcs": [
      {
        "Name": "Line1-Mixer",
        "ListenPort": 5020,
        "Host": "10.0.1.1",
        "Port": 502,
        "DefaultCacheTtlMs": 0,
        "BcdTags": {
          "Add":    [ { "Address": 1200, "Width": 32 } ],
          "Remove": [ 1056 ]
        }
      }
    ],

    // Read-only HTTP status page. Set to 0 to disable.
    "AdminPort": 8080,

    // Backend connection / request / shutdown timeouts and keepalive.
    "Connection": {
      "BackendConnectTimeoutMs":   3000,
      "BackendRequestTimeoutMs":   3000,
      "GracefulShutdownTimeoutMs": 10000,
      "Keepalive": {
        "Enabled":                      true,
        "TcpIdleTimeMs":                30000,
        "TcpProbeIntervalMs":           5000,
        "TcpProbeCount":                4,
        "BackendHeartbeatIdleMs":       30000,
        "BackendHeartbeatProbeAddress": 0
      }
    },

    // Polly resilience policies.
    "Resilience": {
      "BackendConnect": {
        "MaxAttempts": 3,
        "BackoffMs":   [ 100, 500, 2000 ]
      },
      "ListenerRecovery": {
        "InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ],
        "SteadyStateMs":    30000
      },
      "ReadCoalescing": {
        "Enabled":    true,
        "MaxParties": 32
      }
    },

    // Response-cache safety knobs. The cache is off by default per tag.
    "Cache": {
      "AllowLongTtl":       false,
      "MaxEntriesPerPlc":   1000,
      "EvictionIntervalMs": 5000
    }
  }
}

Serilog configuration is documented in ./Troubleshooting.md and lives outside the Mbproxy section.

The Windows Event Log sink is not the standard Serilog.Sinks.EventLog package. It is a custom EventLogBridge (src/Mbproxy/Diagnostics/EventLogBridge.cs) that writes Error+ events to the mbproxy source under Application only when the service runs under the SCM. Event Log source registration is intentionally NOT attempted at runtime (the service account may not be admin); install.ps1 registers the source at install time. Don't add Serilog.Sinks.EventLog — the bridge would duplicate every event. The bridge caches the source-exists check at construction (Phase 12 / W2.23), so a missing source produces no per-event registry traffic.

Mbproxy.AdminPort

Port for the read-only HTTP status server. Binds to all interfaces on startup.

Field Type Default Range
AdminPort int 8080 [1, 65535]

ReloadValidator rejects values outside [1, 65535] and rejects collisions with any Plcs[i].ListenPort. Source: MbproxyOptions.AdminPort.

The server exposes the SignalR-backed web dashboard (GET /, GET /plc/{name}, GET /assets/{path}, /hub/status) and the JSON twin GET /status.json. See ./StatusPage.md for the endpoint surface and schema.

Authentication is assumed at the network layer (trusted internal segment). The endpoint is read-only — no admin actions are exposed — so the risk surface is limited to status disclosure. Place the admin port behind a firewall rule that allows only operator workstations.

Mbproxy.AdminPushIntervalMs

Server-push cadence (milliseconds) for the admin dashboard's SignalR feed. Every interval StatusBroadcaster builds a status snapshot and pushes it to connected dashboard / detail-page clients.

Field Type Default Range
AdminPushIntervalMs int 1000 > 0

MbproxyOptionsValidator and ReloadValidator both reject values <= 0. The broadcaster additionally floors the effective interval at 100 ms. Source: MbproxyOptions.AdminPushIntervalMs.

Mbproxy.Plcs[]

One entry per PLC. The array drives the listener supervisor; on reload, entries added here cause new listeners to bind and entries removed here cause listeners to stop. Source: PlcOptions.cs.

Field Type Default Required Notes
Name string "" yes Non-empty, unique across the array. Shown on the status page and in structured logs as plc.
ListenPort int 0 yes Port the proxy listens on. [1, 65535]. Unique across the array. Cannot collide with AdminPort.
Host string "" yes PLC IP address or hostname.
Port int 502 no Backend Modbus TCP port on the PLC.
BcdTags object null no Per-PLC overrides on top of Mbproxy.BcdTags.Global. See below.
DefaultCacheTtlMs int 0 no Fallback TTL in milliseconds for any tag on this PLC whose explicit CacheTtlMs is unset (null). 0 disables caching by default.

Plcs[i].BcdTags

Per-PLC override block. Resolution: the effective tag list for a PLC is Global Add Remove, with Add winning on width when an address appears in both Global and Add. Source: BcdTagListOptions.PlcBcdOverrides.

Field Type Default Notes
Add BcdTagOptions[] [] Tags to append for this PLC. Can override a Global entry's Width by repeating the address. Each entry follows the BcdTagOptions shape (see next section).
Remove ushort[] [] Addresses to drop from this PLC's effective list. Matches by address.

The full tag-list resolution algorithm — Add width override semantics, overlap detection, and per-PLC tag-map flushing on reload — is documented in ../Features/BcdRewriting.md.

A subtle case worth pinning down: when an address appears in both Mbproxy.BcdTags.Global[] and Plcs[i].BcdTags.Add[], the per-PLC Add entry wins on Width and CacheTtlMs. This is how a per-PLC width override is expressed (for example, a 16-bit tag globally, promoted to 32-bit on the one PLC that uses the wider format). To strip a global tag from a PLC entirely, use Remove; do not add a same-address entry with Width = 0.

Mbproxy.BcdTags.Global[]

The fleet-wide BCD tag list. Every PLC starts with this set, then applies its per-PLC Add / Remove overrides. Source: BcdTagListOptions.Global, entries of type BcdTagOptions.

Field Type Default Range Notes
Address ushort 0 [0, 65535] Modbus PDU address (decimal). Address 0 is valid on DL205/DL260 — do not skip it. Octal V-memory addresses must be converted: V2000 octal = decimal 1024.
Width byte 0 { 16, 32 } Bit width. 16 is one register holding 4 BCD digits (09999). 32 is a CDAB-ordered register pair at Address (low word) and Address+1 (high word).
CacheTtlMs int? null >= 0, <= 60000 unless Cache.AllowLongTtl = true Optional per-tag opt-in to the response cache. null falls back to the PLC's DefaultCacheTtlMs. 0 explicitly disables caching for this tag even when the PLC default is non-zero.

MbproxyOptionsValidator rejects any entry whose Width is not 16 or 32. See ../Features/BcdRewriting.md for the wire encoding rules and the multi-tag-overlap validation that runs in BcdTagMapBuilder.

Address conversion examples for operators coming from DirectLOGIC ladder:

V-memory (octal) Modbus PDU (decimal)
V2000 1024
V2040 1056
V2100 1088
V2200 1152

The proxy expects PDU-decimal addresses. Do not use octal V-memory addresses and do not use 1-based 4xxxx Modbus references — both will resolve to the wrong register.

Mbproxy.Connection

Backend connection and shutdown timeouts. Source: ConnectionOptions.cs.

Field Type Default Notes
BackendConnectTimeoutMs int 3000 Max time in milliseconds to wait for one TCP connect to the backend PLC. Each Polly retry attempt is bounded by its own copy of this timeout — total worst-case connect time is MaxAttempts * BackendConnectTimeoutMs plus the configured backoffs.
BackendRequestTimeoutMs int 3000 Max time in milliseconds to wait for the PLC to respond to a forwarded PDU. On timeout the upstream client is disconnected. FC06 / FC16 writes are not retried because they are non-idempotent on BCD tags; FC03 / FC04 reads are also not retried mid-request (a fresh upstream request takes the full pipeline again).
GracefulShutdownTimeoutMs int 10000 Max time in milliseconds the shutdown coordinator waits for in-flight PDUs to drain after a stop signal (sc.exe stop or Windows Service stop). After this deadline, remaining work is cancelled. Keep at or below the Service Control Manager wait hint (30 s).

On hot reload, BackendConnectTimeoutMs and BackendRequestTimeoutMs apply to the next backend connect or request — in-flight operations keep their already-applied timeout. GracefulShutdownTimeoutMs is sampled only at shutdown.

Operational sizing notes:

  • The default 3 s connect timeout is appropriate for a local Ethernet segment to a healthy ECOM100. On WAN paths or for devices behind switches with slow MAC-table aging, raise to 510 s.
  • A 3 s request timeout is generous compared with typical DL205/DL260 scan times (a few ms to tens of ms for FC03 of 100 registers). The slack absorbs PLC scan-overlap jitter without faulting the upstream client.
  • GracefulShutdownTimeoutMs should be less than the Service Control Manager's stop deadline. The default 10 s suits a fleet of 54 PLCs; on a much larger fleet, raise both the SCM wait hint and this value in lockstep.

Mbproxy.Connection.Keepalive

TCP keepalive and backend heartbeat settings. Source: KeepaliveOptions.cs. Enabled by default — the DL205/DL260 ECOM never emits TCP keepalives, so an idle socket is otherwise dropped by middleboxes after 25 minutes. See ../Architecture/Keepalive.md for the full design.

Field Type Default Notes
Enabled bool true Master switch. When false, neither SO_KEEPALIVE nor the backend heartbeat is applied and the proxy behaves exactly as a pre-keepalive build.
TcpIdleTimeMs int 30000 SO_KEEPALIVE idle time before the OS sends its first probe. Applied to the backend socket and accepted upstream sockets.
TcpProbeIntervalMs int 5000 SO_KEEPALIVE interval between probes once idle.
TcpProbeCount int 4 SO_KEEPALIVE unanswered probes before the OS declares the socket dead.
BackendHeartbeatIdleMs int 30000 After this much backend idle, the proxy issues a synthetic FC03 qty=1 read to keep the path warm and prove the ECOM still answers Modbus. Must be greater than BackendRequestTimeoutMs.
BackendHeartbeatProbeAddress int 0 Modbus PDU address the heartbeat FC03 probe reads. Address 0 (V0) is valid on DL205/DL260 in factory absolute mode. Range [0, 65535].

On hot reload, the heartbeat interval and probe address are re-read on every loop tick. The Tcp* socket options are applied at connect/accept time, so a reload affects only sockets opened after the change. A reload where BackendHeartbeatIdleMs <= BackendRequestTimeoutMs is rejected — a heartbeat interval at or below the request timeout would fire continuously.

Mbproxy.Resilience

Polly retry pipelines for backend connect, listener bind, and the in-flight read coalescer. Source: ResilienceOptions.cs.

Mbproxy.Resilience.BackendConnect

Bounded retries on the backend TCP connect path. Mid-request failures (during a forwarded PDU) are never retried.

Field Type Default Notes
MaxAttempts int 3 Total connect tries, including the first. 1 disables retries.
BackoffMs int[] [100, 500, 2000] Delay in milliseconds between attempts. Must have MaxAttempts - 1 entries.

Mbproxy.Resilience.ListenerRecovery

Unbounded retries on the listener bind path. If a PLC's ListenPort cannot be bound (port in use, bad interface, transient OS error), the supervisor cycles through InitialBackoffMs once, then repeats SteadyStateMs forever. The same recovery code path also reacts to a listener that faults at runtime (for example, the underlying socket dies) and to listeners that come online from a hot-reload that adds a new PLC.

Field Type Default Notes
InitialBackoffMs int[] [1000, 2000, 5000, 15000, 30000] Backoff schedule for the first N retries after a fault.
SteadyStateMs int 30000 Backoff for every retry after the initial schedule is exhausted. Runs indefinitely.

Mbproxy.Resilience.ReadCoalescing

In-flight de-duplication of identical FC03 / FC04 reads. When multiple upstream clients issue the same (unitId, fc, startAddress, qty) tuple while a matching backend round-trip is already in flight, the late arrivals attach to the existing entry and the single response is fanned out to every party. See ../Architecture/ReadCoalescing.md.

Field Type Default Notes
Enabled bool true Master switch. Hot-reloadable; flipping to false lets already-coalesced entries drain naturally.
MaxParties int 32 Per-entry cap on attached parties. Past this cap, the next identical request opens a fresh in-flight entry.

Writes (FC06 / FC16) are never coalesced. FC03 and FC04 never share an entry. Different unitId bytes never share an entry.

Total FC03 + FC04 request accounting is preserved across the coalescing path: coalescedHitCount + coalescedMissCount equals the total reads observed by the multiplexer since startup. coalescedHitCount stays at 0 while Enabled = false, but every read still increments coalescedMissCount. See ./StatusPage.md for the full counter catalogue.

Mbproxy.Cache

Service-wide safety knobs for the opt-in response cache. The cache is off by default per tag — this section only governs the limits when an operator opts a tag in via CacheTtlMs or DefaultCacheTtlMs. Source: CacheOptions in MbproxyOptions.cs.

Field Type Default Notes
AllowLongTtl bool false Gate for any CacheTtlMs > 60_000. When false, ReloadValidator rejects any tag or PLC default that exceeds 60 s. Set to true to opt in explicitly.
MaxEntriesPerPlc int 1000 LRU cap on the number of entries per PLC. When full, the next insert evicts the least-recently-used entry. Must be >= 0. 0 is accepted but means "evict every insert immediately" — effectively the cache is disabled even for tags with non-zero TTL.
EvictionIntervalMs int 5000 Background eviction loop tick in milliseconds. Each tick scans the per-PLC caches and removes entries past their ExpiresAtUtc. Must be >= 0; values below 100 ms are clamped at 100 ms internally to avoid pathologically tight loops.

On hot reload, AllowLongTtl is enforced by the next reload validation. MaxEntriesPerPlc applies to subsequent inserts (existing entries are not pruned). EvictionIntervalMs is read by each fresh eviction loop iteration.

Any tag-list change for a given PLC drops that PLC's entire cache on reload — per-tag flush granularity is intentionally not implemented. New entries re-populate on demand under the new TTL. Process restart wipes every cache; there is no persistence and no last-known-good snapshot.

See ../Architecture/ResponseCache.md for the cache contract (lookup order, write-invalidation by address-range overlap, post-rewriter byte storage).

Per-Tag CacheTtlMs

Per-tag opt-in to the cache. The same field appears on every BcdTagOptions entry — both Mbproxy.BcdTags.Global[] and Mbproxy.Plcs[i].BcdTags.Add[].

Value Meaning
null (omitted) Unset. Falls back to Plcs[i].DefaultCacheTtlMs.
0 Caching explicitly disabled for this tag, even if the PLC default is non-zero.
1..60000 Cache enabled with this TTL in milliseconds.
> 60000 Rejected at reload unless Cache.AllowLongTtl = true.

TTL resolution order for any single tag: explicit per-tag value → per-PLC DefaultCacheTtlMs → 0 (off).

For multi-tag read ranges, the effective TTL is min(TTLs) across all configured tags inside the read range. If any tag in the range has CacheTtlMs = 0, the entire read is uncached.

The cache itself is described in detail in ../Architecture/ResponseCache.md. The properties most relevant to operators setting TTLs:

  • Lookup order is cache → coalesce → backend. A cache hit short-circuits the read coalescer entirely.
  • Writes invalidate by address-range overlap. A successful FC06 / FC16 response invalidates every cached FC03 / FC04 entry whose read range overlaps the write range — not just exact-key matches. Exception responses do not invalidate (the write did not take effect on the PLC).
  • Cache stores post-rewriter bytes. Hits never re-invoke the BCD rewriter. Tag-list reloads flush the affected PLC's whole cache so a rewriter-relevant change cannot serve stale post-rewriter bytes from before the change.
  • Different unitId bytes never invalidate each other. Invalidation is scoped to (unitId, FC ∈ {3, 4}).

Validation Rules

ReloadValidator.Validate runs on every config load (startup and hot reload) and rejects the entire snapshot if any rule fails. On rejection at startup, the service exits non-zero. On rejection at runtime, the current in-memory config stays in effect and mbproxy.config.reload.rejected is logged at Error.

Rules (in order):

  1. PLC names: every Plcs[i].Name is non-empty and unique (ordinal comparison).
  2. ListenPort: every Plcs[i].ListenPort is in [1, 65535] and unique across the array.
  3. AdminPort: in [1, 65535] and does not collide with any ListenPort.
  4. BCD tag map per PLC, delegated to BcdTagMapBuilder.Build:
    • duplicate addresses within a single PLC's resolved tag list
    • 32-bit entries whose high register (Address + 1) overlaps a separate 16-bit entry at that address
  5. Cache TTL bounds:
    • any CacheTtlMs or DefaultCacheTtlMs less than 0 is rejected
    • any CacheTtlMs or DefaultCacheTtlMs greater than 60_000 is rejected unless Cache.AllowLongTtl = true
  6. Cache size knobs: Cache.MaxEntriesPerPlc >= 0, Cache.EvictionIntervalMs >= 0.
  7. Width: every BcdTagOptions.Width is 16 or 32 (enforced by MbproxyOptionsValidator at schema time).

Sample rejection messages (logged at Error with the structured property errors carrying the full list):

Plc 'Line1-Mixer': Duplicate ListenPort 5020 (already used by 'Line1-Conveyor').
AdminPort 5020 collides with ListenPort of PLC 'Line1-Mixer'.
Plc 'Line1-Mixer': BCD tag map error (DuplicateAddress): address 1024 appears twice.
BcdTags.Global Address 1024: CacheTtlMs=120000 exceeds 60_000 ms without Cache.AllowLongTtl=true.
Plcs[2] (Line2-Press): DefaultCacheTtlMs must be >= 0.

Warning case (not a rejection):

  • Plcs[i].BcdTags.Remove[] entries that do not match any global tag address are logged as warnings — probably stale config, but the reload proceeds.

Two additional rejection categories handled earlier in the pipeline:

  • Type-mismatched / malformed JSON. The .NET configuration binder rejects values whose type does not match the bound property (for example, a string in BackendConnectTimeoutMs). At startup this aborts the host; on hot reload the binder retains the previous snapshot and the reload never reaches ReloadValidator.
  • Width invalid. MbproxyOptionsValidator rejects any BcdTagOptions.Width that is not 16 or 32. This runs as part of options validation before ReloadValidator and surfaces the same way as schema errors.

See ../Features/HotReload.md for the full reload-acceptance flow, including the log event names emitted on acceptance (mbproxy.config.reload.applied) and rejection (mbproxy.config.reload.rejected).

Two Concrete Examples

The minimal and production examples below are both complete appsettings.json snippets — paste either one and the service will start without further edits beyond the addresses and ports.

Minimal

One PLC, no BCD tags, no cache. The proxy is pure pass-through.

{
  "Mbproxy": {
    "BcdTags": { "Global": [] },
    "Plcs": [
      {
        "Name":       "Line1-Mixer",
        "ListenPort": 5020,
        "Host":       "10.0.1.1"
      }
    ],
    "AdminPort": 8080
  }
}

Everything else picks up defaults: Port = 502, Connection.BackendConnectTimeoutMs = 3000, Connection.BackendRequestTimeoutMs = 3000, Connection.GracefulShutdownTimeoutMs = 10000, Resilience.BackendConnect.MaxAttempts = 3, Resilience.ReadCoalescing.Enabled = true, Cache.AllowLongTtl = false, Cache.MaxEntriesPerPlc = 1000, Cache.EvictionIntervalMs = 5000, and so on.

Behaviour in this snapshot: every byte passes through unchanged in both directions, FC03 / FC04 reads are still subject to in-flight coalescing (the feature is on by default), and no responses are cached.

Production

Three PLCs, a global BCD tag list, one PLC with overrides, cache enabled on hot reads.

{
  "Mbproxy": {
    "BcdTags": {
      "Global": [
        { "Address": 1024, "Width": 16 },                        // V2000 — 16-bit BCD counter
        { "Address": 1056, "Width": 32 },                        // V2040 — 32-bit BCD total
        { "Address": 1088, "Width": 16, "CacheTtlMs": 1000 }     // V2100 — setpoint, 1 s cache
      ]
    },
    "Plcs": [
      {
        "Name":              "Line1-Mixer",
        "ListenPort":        5020,
        "Host":              "10.0.1.1",
        "Port":              502,
        "DefaultCacheTtlMs": 0,
        "BcdTags": {
          "Add":    [ { "Address": 1200, "Width": 32 } ],
          "Remove": [ 1056 ]
        }
      },
      {
        "Name":       "Line1-Conveyor",
        "ListenPort": 5021,
        "Host":       "10.0.1.2"
      },
      {
        "Name":              "Line2-Press",
        "ListenPort":        5022,
        "Host":              "10.0.2.1",
        "DefaultCacheTtlMs": 500
      }
    ],
    "AdminPort": 8080,
    "Connection": {
      "BackendConnectTimeoutMs":   3000,
      "BackendRequestTimeoutMs":   3000,
      "GracefulShutdownTimeoutMs": 10000
    },
    "Resilience": {
      "BackendConnect":   { "MaxAttempts": 3, "BackoffMs": [ 100, 500, 2000 ] },
      "ListenerRecovery": { "InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ], "SteadyStateMs": 30000 },
      "ReadCoalescing":   { "Enabled": true, "MaxParties": 32 }
    },
    "Cache": {
      "AllowLongTtl":       false,
      "MaxEntriesPerPlc":   1000,
      "EvictionIntervalMs": 5000
    }
  }
}

In this snapshot, Line1-Mixer adds a 32-bit tag at 1200 and removes the global 32-bit tag at 1056. Line2-Press opts every tag in (whose CacheTtlMs is null) into a 500 ms cache via its DefaultCacheTtlMs. The setpoint at 1088 already has an explicit per-tag TTL and that value wins.

The effective tag map per PLC after resolution:

PLC Effective tag list
Line1-Mixer 1024 (16-bit), 1088 (16-bit, CacheTtlMs = 1000), 1200 (32-bit). 1056 is removed.
Line1-Conveyor 1024 (16-bit), 1056 (32-bit), 1088 (16-bit, CacheTtlMs = 1000).
Line2-Press 1024 (16-bit, effective CacheTtlMs = 500 via PLC default), 1056 (32-bit, effective CacheTtlMs = 500), 1088 (16-bit, effective CacheTtlMs = 1000 from explicit per-tag value).

Any FC03 / FC04 read whose register range overlaps Line2-Press's tag 1088 resolves to the per-tag 1 s TTL. A read that spans tags with different TTLs takes min(TTLs) across the range; a read that includes a tag with CacheTtlMs = 0 is uncached even if every other tag in the range is opted in.

Hot-Reload Propagation Summary

A reduced view of ../Features/HotReload.md, restricted to the keys documented here. Every accepted reload emits mbproxy.config.reload.applied at Information with a summary of which PLCs were added or removed and the size of the tag-list delta.

Change Propagation
BcdTags.Global add / remove / width Rewriter dereferences IOptionsMonitor per PDU. Next PDU sees the new map; in-flight PDUs are not retroactively touched.
Plcs[i].BcdTags.{Add,Remove} Same per-PDU resolution as above, scoped to the affected PLC.
New Plcs[i] entry Listener supervisor binds the new port under ListenerRecovery.
Plcs[i] removed Supervisor stops the listener and closes all upstream connections for that PLC.
Plcs[i].ListenPort or Host changed Equivalent to remove + add.
Connection.Backend*TimeoutMs Next backend connect or request uses the new value.
Connection.Keepalive heartbeat fields Re-read on every heartbeat loop tick. Tcp* socket options apply to backend/upstream sockets opened after the change.
AdminPort Requires a service restart — the Kestrel admin host is built once at startup.
Resilience.ReadCoalescing.Enabled Hot-reloadable; in-flight coalesced entries drain naturally.
BcdTags.*.CacheTtlMs, Plcs[i].DefaultCacheTtlMs Tag-map reseat for the affected PLC drops that PLC's entire cache.
Cache.AllowLongTtl / MaxEntriesPerPlc / EvictionIntervalMs Enforced on next reload validation / next insert / next eviction tick respectively.

Where Options Live in Code

Section File Binding class
Root src/Mbproxy/Options/MbproxyOptions.cs MbproxyOptions
Plcs[] src/Mbproxy/Options/PlcOptions.cs PlcOptions
BcdTags.Global[] entry shape src/Mbproxy/Options/BcdTagOptions.cs BcdTagOptions
BcdTags.Global / Plcs[i].BcdTags src/Mbproxy/Options/BcdTagListOptions.cs BcdTagListOptions, PlcBcdOverrides
Connection src/Mbproxy/Options/ConnectionOptions.cs ConnectionOptions
Resilience src/Mbproxy/Options/ResilienceOptions.cs ResilienceOptions, RetryProfile, RecoveryProfile, ReadCoalescingOptions
Cache src/Mbproxy/Options/MbproxyOptions.cs CacheOptions (declared alongside MbproxyOptions in the same file)
Schema validation src/Mbproxy/Options/MbproxyOptions.cs MbproxyOptionsValidator
Reload validation src/Mbproxy/Configuration/ReloadValidator.cs ReloadValidator
Tag-map resolution src/Mbproxy/Bcd/BcdTagMapBuilder.cs BcdTagMapBuilder
Reload reconciliation src/Mbproxy/Configuration/ConfigReconciler.cs ConfigReconciler, ReloadPlan

All option classes are registered through services.Configure<T>(...) against the Mbproxy:* section in Program.cs. IOptionsMonitor<T> is the runtime read path; direct IOptions<T> injection is not used because it does not propagate reloads.