Configuration
All configuration lives in appsettings.json (or environment-specific overrides like appsettings.Development.json).
AMS Router
The embedded ADS router provides cross-platform ADS connectivity without requiring a local TwinCAT installation.
{
"AmsRouter": {
"Name": "Adsify",
"NetId": "192.168.1.78.1.1",
"TcpPort": 48898,
"LoopbackIP": "127.0.0.1",
"LoopbackPort": 48898,
"ChannelPortType": "Loopback",
"Routes": [
{
"Name": "Line1-PLC",
"Address": "192.168.10.143",
"NetId": "5.80.201.1.1.1"
}
]
}
}| Field | Description |
|---|---|
Name | Name of this ADS router instance |
NetId | AMS Net ID of this machine (typically <your-ip>.1.1) |
TcpPort | ADS TCP port (default: 48898) |
Routes | Array of PLC routes — each needs Name, Address (IP), and NetId |
Routes entries are what let the embedded router reach a remote PLC from a host with no TwinCAT installation — on Windows the OS router already holds the routes, so this is only required off-Windows. The library validates each entry at startup: NetId must be six octets in 0–255, Name and Address must be non-empty, and duplicate names fail the host.
PLC Targets
Maps aliases to PLC connection details. For a Real target (the default), the AmsNetId must match a route the AMS router already knows about — a Routes entry off-Windows, or a route already registered with the OS-level AMS router on Windows. A Simulated target ignores AmsNetId entirely — it never connects over the network, so there’s nothing for it to match.
{
"PlcTargets": {
"Line1": {
"AmsNetId": "5.80.201.1.1.1",
"Port": 851,
"DisplayName": "Assembly Line 1",
"TimeoutMs": 5000,
"SymbolBrowseTimeoutMs": 30000
}
}
}| Field | Description | Default |
|---|---|---|
AmsNetId | AMS Net ID of the PLC. Required for Real targets; ignored for Simulated targets. | (required for Real) |
Port | ADS port (851 = PLC runtime 1) | 851 |
DisplayName | Human-readable name shown in API responses | "" |
TimeoutMs | Timeout for ADS operations | 5000 |
SymbolBrowseTimeoutMs | Timeout for symbol browsing | 30000 |
Mode | Real (connect over ADS/AMS) or Simulated (in-memory value store, no PLC or router needed) | Real |
InitialValues | Seed values for a Simulated target, keyed by symbol path. Ignored for Real targets. See the simulated quick start for the caveat that JSON-bound seed values read back as strings. Not replaced by Scenario below — the two are independent seed sources layered on top of each other; see Scenario. | {} |
EtherCat | EtherCAT diagnostics config (see below) | null (disabled) |
Scenario | Path to a GVL_HMI v1 scenario directory (containing machine.yaml), resolved relative to the application content root; absolute paths work too. Only valid on a Simulated target — startup fails if declared on a Real one. See Simulation. | null (no scenario) |
ScenarioAutoStart | Whether the scenario’s real-time pump starts automatically once loaded. Set false for deterministic testing: the target seeds but does not tick, so a test drives it entirely through POST sim/step. | true |
EtherCAT Diagnostics (per PLC)
Optional. When present, the EtherCAT polling service reads diagnostics for this PLC.
{
"PlcTargets": {
"Line1": {
"AmsNetId": "192.168.1.136.1.1",
"Port": 851,
"EtherCat": {
"PollingIntervalMs": 1000,
"CrcErrorThreshold": 100,
"EnableNotifications": true,
"PollCycleBudgetMs": 5000
}
}
}
}| Field | Description | Default |
|---|---|---|
PollingIntervalMs | Background polling interval for diagnostics | 1000 |
CrcErrorThreshold | Per-port CRC count that triggers CrcErrorThresholdExceededEvent notification | 100 |
EnableNotifications | Enable SignalR events for this PLC’s EtherCAT bus | true |
PollCycleBudgetMs | Wall-clock bound on one master’s poll cycle — a cycle running longer than this is abandoned and the master marked degraded. See the EtherCAT Diagnostics docs | 5000 |
Feature Flags
Enable or disable entire feature slices. A disabled feature is excluded from the OpenAPI spec, and answers 404. The exceptions are Alarms, Rpc and TypeMetadata, which answer 501 instead, so a gateway aggregating a fleet can tell “this feature is genuinely absent” from “this one is failing right now”.
{
"Features": {
"Variables": { "Enabled": true },
"Symbols": { "Enabled": true },
"DeviceInfo": { "Enabled": true },
"Lifecycle": { "Enabled": false },
"Notifications": { "Enabled": false },
"Files": { "Enabled": false },
"EtherCatDiagnostics": { "Enabled": false },
"Mcp": { "Enabled": false },
"Simulation": { "Enabled": false }
}
}Simulation gates the sim/* scenario control endpoints — sim/step,
sim/run, sim/pause, sim/reset, sim/state. Off by default: these endpoints drive machine
state and are for development and CI, not a shared network. Unlike the other flags above, a
disabled Simulation flag does not affect whether a Scenario loads at startup — only whether
its control endpoints are reachable.
Authentication
See Authentication for full details.
{
"Authentication": {
"Authority": "https://keycloak.example.com/realms/adsify",
"Audience": "adsify-api",
"RequireHttpsMetadata": true,
"RoleClaimType": "realm_access.roles",
"ValidIssuers": ["https://keycloak.example.com/realms/adsify"]
}
}Notifications
{
"Notifications": {
"DefaultCycleTimeMs": 100,
"MaxSymbolsPerClient": 50,
"HeartbeatIntervalSeconds": 30
}
}CORS
{
"Cors": {
"AllowedOrigins": ["https://hmi.example.com"],
"AllowCredentials": true
}
}| Key | Default | Effect |
|---|---|---|
AllowedOrigins | [] | The origins a browser-based client may call the API from. |
AllowCredentials | false | Permits credentialed cross-origin requests. Ignored when AllowAnyOrigin is set — the CORS protocol forbids that combination. |
AllowAnyOrigin | false | Serves cross-origin requests from any origin. |
An unconfigured deployment denies every cross-origin request. With AllowedOrigins empty
and AllowAnyOrigin unset, no Access-Control-Allow-Origin header is sent and a browser will
refuse to hand the response to the calling script. Both states log a warning at startup, so
check the log if a browser client is being blocked unexpectedly.
Serving any origin is a deliberate opt-in:
{
"Cors": { "AllowAnyOrigin": true }
}Avoid it outside local development. In the Development and Simulation profiles the API also
grants full admin permissions to anonymous callers (see
Authorization), so any page a browser visits could otherwise drive a
PLC on localhost.
CORS is not an authentication control — it governs what a browser lets a script read, and has
no effect on a request from curl, a service, or any non-browser client.
Rate Limiting
Per-user, per-endpoint rate limiting protects the API from excessive requests. A global per-user limiter applies to all endpoints, and additional per-endpoint policies provide finer control.
{
"RateLimiting": {
"Enabled": true,
"GlobalPerUser": { "PermitLimit": 200, "WindowSeconds": 1 },
"VariableReads": { "PermitLimit": 100, "WindowSeconds": 1 },
"VariableWrites": { "PermitLimit": 20, "WindowSeconds": 1 },
"BatchReads": { "PermitLimit": 100, "WindowSeconds": 1 },
"BatchWrites": { "PermitLimit": 20, "WindowSeconds": 1 },
"SymbolBrowsing": { "PermitLimit": 10, "WindowSeconds": 1 },
"FileOperations": { "PermitLimit": 5, "WindowSeconds": 1 },
"NotificationConnections": { "PermitLimit": 5, "WindowSeconds": 60 }
}
}The partition key is composed of the authenticated user ID (or client IP as fallback) combined with the plcId. When a limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header indicating when the client may retry.
Set Enabled to false to serve every request unlimited — appropriate when a gateway in front of Adsify already rate limits. The remaining keys are then ignored. One limit is not configurable and stays in force either way: PLC start/stop/reset is capped at one request per five seconds per PLC, guarding the hardware against rapid state changes.
Effective throughput
A batch carries up to 100 symbols, so these defaults produce the following ceilings per user, per PLC:
| Path | Endpoint | Symbols/second |
|---|---|---|
| Single read | GET /variables/{symbolPath} | 100 |
| Batch read | GET /variables?paths=… | 10,000 (100 requests × 100 symbols) |
| Single write | PUT /variables/{symbolPath} | 20 |
| Batch write | PUT /variables | 2,000 (20 requests × 100 symbols) |
Each batch limit deliberately matches its single-operation counterpart, so batching is never the slower choice. Reading N symbols one at a time costs N requests; batching them costs one. Equal request rates therefore make a batch no slower than a single read even at a batch of one, and N times faster at a batch of N. If you tune these values, keep BatchReads at or above VariableReads and BatchWrites at or above VariableWrites — dropping either below its counterpart penalises the cheaper path.
Note that GlobalPerUser applies on top of every policy, so no single user exceeds 200 requests/second in total regardless of the per-endpoint limits. Batching consumes that global budget far more slowly than individual reads.
These ceilings are a configuration choice, not a transport limit. A full round trip through the single-read endpoint measures roughly 175 µs on loopback, under 2% of the 10 ms budget that 100 reads/second allows; against a simulated PLC the server sustained about 6,500 reads/second on one connection and 13,000 across eight. Raise the limits if your deployment needs more.
BatchReads and BatchWrites replace the former BatchOperations key, which is no longer read. A deployment that set RateLimiting:BatchOperations silently falls back to the new defaults above — a relaxation from 5 requests/second to 100 (reads) and 20 (writes). Re-apply your tuning under the new keys if you had lowered it deliberately.Security Headers
Response headers applied to all API responses for defense-in-depth:
{
"SecurityHeaders": {
"StrictTransportSecurity": "max-age=31536000; includeSubDomains",
"XContentTypeOptions": "nosniff",
"XFrameOptions": "DENY",
"ContentSecurityPolicy": "default-src 'none'; frame-ancestors 'none'",
"CacheControl": "no-store",
"ReferrerPolicy": "no-referrer",
"PermissionsPolicy": "()"
}
}Connection Limits
Controls the maximum number of real-time connections (SSE, WebSocket) per user:
{
"ConnectionLimits": {
"MaxSseConnectionsPerUser": 5,
"MaxWebSocketConnectionsPerUser": 5,
"MaxTotalSubscriptionsPerUser": 100,
"MaxConnectionLifetimeMinutes": 480,
"IdleTimeoutMinutes": 30
}
}| Field | Description | Default |
|---|---|---|
MaxSseConnectionsPerUser | Maximum concurrent SSE connections per user | 5 |
MaxWebSocketConnectionsPerUser | Maximum concurrent WebSocket connections per user | 5 |
MaxTotalSubscriptionsPerUser | Maximum variable subscriptions across all connections | 100 |
MaxConnectionLifetimeMinutes | Maximum lifetime of a single connection | 480 |
IdleTimeoutMinutes | Disconnect after this many minutes of inactivity | 30 |
Symbol Access Control
Controls which symbols can be written per PLC. Configured under each PLC target:
{
"PlcTargets": {
"plc1": {
"SymbolAccess": {
"Mode": "denylist",
"Writable": [],
"Denied": []
}
}
}
}| Field | Description |
|---|---|
Mode | allowlist — only symbols matching Writable patterns can be written. denylist — all symbols can be written except those matching Denied patterns. |
Writable | Glob patterns for writable symbols (used in allowlist mode) |
Denied | Glob patterns for denied symbols (used in denylist mode) |
Glob patterns: * matches one path segment, ** matches any depth. For example, MAIN.* matches MAIN.nCounter but not MAIN.stMotor.bEnabled, while MAIN.** matches both.
Value Constraints
Per-symbol validation rules that are enforced on writes. Configured under each PLC target:
{
"PlcTargets": {
"plc1": {
"ValueConstraints": {
"MAIN.nSpeed": {
"Min": 0,
"Max": 100
},
"MAIN.sMode": {
"Enum": [1, 2, 3]
},
"MAIN.sName": {
"Pattern": "^[A-Z].*"
}
}
}
}
}| Constraint | Description |
|---|---|
Min / Max | Numeric range validation. The write is rejected if the value falls outside the range. |
Enum | Discrete allowed values. The write is rejected if the value is not in the list. |
Pattern | Regular expression for string validation. The write is rejected if the value does not match. |
ReadOnly | When true, all writes to this symbol are rejected. |
Writes that violate constraints return 400 with error code VALUE_OUT_OF_RANGE.