f49e27e316
Adds 11 topic-focused docs under docs/{Architecture,Features,Operations,Reference,Testing}/
and links them from README.md's new "Detailed documentation" section. Existing
top-level docs (design.md, kpi.md, operations.md) remain as canonical landings.
Architecture/
- Overview.md (150 lines) — listener topology, request flow, per-PLC isolation
- ConnectionModel.md (247 lines) — TxId multiplexer, watchdog, disconnect cascade
- ReadCoalescing.md (243 lines) — in-flight FC03/04 dedup via InFlightByKeyMap
- ResponseCache.md (398 lines) — opt-in per-tag TTL cache + range-overlap invalidation
Features/
- BcdRewriting.md (252 lines) — codec, CDAB, FC scope, partial-overlap policy
- HotReload.md (189 lines) — IOptionsMonitor + per-change-kind reconcile rules
Operations/
- Configuration.md (422 lines) — every Mbproxy:* option + validation rules
- StatusPage.md (334 lines) — admin endpoint surface, every JSON field
- Troubleshooting.md (364 lines) — diagnosis playbook keyed to log events
Reference/
- LogEvents.md (499 lines) — 28 events across 7 categories, grep-verified
Testing/
- Simulator.md (235 lines) — pymodbus fixture, skip policy, 3.13 framer quirk
Each doc was written by a dedicated agent against the StyleGuide.md rules with
a per-doc phase gate (PascalCase filename, H1 Title Case, code-fence language
tags, Related Documentation section with >=3 relative links, real type names
verified against src/). Cross-references between docs use relative paths;
all 18 README->docs links and all sibling links resolve.
Known follow-up: docs/design.md lines 215-251 are stale on two log-event
property templates (config.reload.applied and config.reload.rejected) and
mention LogContext.PushProperty scoping that isn't actually used. Reference/
LogEvents.md is now the authoritative event catalog and source-of-truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
423 lines
26 KiB
Markdown
423 lines
26 KiB
Markdown
# 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 win-x64`): `appsettings.json` next to `Mbproxy.exe` in the publish folder.
|
||
- **Installed as a Windows Service**: `%ProgramData%\mbproxy\appsettings.json`. The install script copies the template at `install/mbproxy.config.template.json` to this path the first time only — 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`](../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.
|
||
"Connection": {
|
||
"BackendConnectTimeoutMs": 3000,
|
||
"BackendRequestTimeoutMs": 3000,
|
||
"GracefulShutdownTimeoutMs": 10000
|
||
},
|
||
|
||
// 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.
|
||
|
||
## `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.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. |
|
||
| `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.
|
||
|
||
## 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
|