# 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 `): `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`, so a save propagates without restarting the service. See [`../Features/HotReload.md`](../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: ```jsonc { "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`](./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 `GET /` (auto-refreshing HTML) and `GET /status.json`. See [`./StatusPage.md`](./StatusPage.md) for the schema. Authentication is assumed at the network layer (trusted internal segment). The endpoint is read-only — there are no `POST` / `PUT` / `DELETE` routes — so the risk surface is limited to status disclosure. Place the admin port behind a firewall rule that allows only operator workstations. ## `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`](../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 (`0–9999`). `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`](../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 5–10 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 2–5 minutes. See [`../Architecture/Keepalive.md`](../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`](../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`](./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`](../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`](../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): ```text 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`](../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. ```jsonc { "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. ```jsonc { "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`](../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(...)` against the `Mbproxy:*` section in `Program.cs`. `IOptionsMonitor` is the runtime read path; direct `IOptions` injection is not used because it does not propagate reloads. ## Related Documentation - [`../Features/HotReload.md`](../Features/HotReload.md) — reload acceptance flow and per-key propagation semantics - [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md) — tag-list resolution, wire encoding, multi-tag overlap rules - [`../Architecture/ResponseCache.md`](../Architecture/ResponseCache.md) — cache contract, lookup order, write invalidation - [`./StatusPage.md`](./StatusPage.md) — schema served by `AdminPort` - [`./Troubleshooting.md`](./Troubleshooting.md) — Serilog block and common config rejection diagnostics - [`../../README.md`](../../README.md) — install and operational entry point