fad833e8d1
Checked against the legacy mesapi source (MesNotifier/MesServices/Tag) plus ServiceStack/OrmLite 6.0.2 in a scratch harness: - unhandled exceptions are an HTTP 500 ServiceStack ErrorResponse, not WasSuccessful=false - no matching alarms = success with an empty list; failures don't short-circuit - AckDT is null only for a null MXAccess value, not 'when unacked' - tag type-conversion failures are silent (InAlarm false -> alarm drops out) - simplealarmstatus SAPID edge cases: null never matches, "" matches, duplicates -> arbitrary TOP 1 - 'test' API keys hit the production BatchDB (TestDb routing never reaches MesNotifier) - latent SetResult-after-timeout race in the MXAccess data-change callback
312 lines
14 KiB
Markdown
312 lines
14 KiB
Markdown
# WWSupport MES API — Alarm Status API
|
||
|
||
Reference for the alarm-status endpoints exposed by the **WWSupport / APIServer** ServiceStack
|
||
service. The API reads live machine-alarm state out of AVEVA Wonderware (System Platform /
|
||
Galaxy) via MXAccess and returns it to MES callers.
|
||
|
||
- **Service host:** `AppHost` (`AppSelfHostBase`, name `"APIServer"`) — `APIServer/APIServer/AppHost.cs`
|
||
- **Service implementation:** `MesServices` — `APIServer/APIServer.ServiceInterface/MesServices.cs`
|
||
- **Business logic:** `MesNotifier` — `APIServer/APIServer.ServiceInterface/MesNotifier.cs`
|
||
- **Framework:** ServiceStack 6.0.2, self-hosted; SQL Server (OrmLite, `SqlServer2016Dialect`)
|
||
|
||
> Serialization note: `JsConfig.IncludeNullValues = true`, so null fields **are** emitted in JSON
|
||
> responses (e.g. `"AckDT": null`).
|
||
|
||
---
|
||
|
||
## Endpoints
|
||
|
||
| Verb | Route | Request DTO | Response DTO |
|
||
|------|-------|-------------|--------------|
|
||
| `POST` | `/mes/alarmstatus` | `AlarmStatusRequest` | `AlarmStatusResponse` |
|
||
| `POST` | `/mes/simplealarmstatus` | `SimpleAlarmStatusRequest` | `AlarmStatusResponse` |
|
||
|
||
Both endpoints return the **same** `AlarmStatusResponse` shape. Both are dispatched through
|
||
ServiceStack `Any(...)` handlers in `MesServices`, which resolve the singleton `MesNotifier` and
|
||
call `AlarmStatus(...)` / `SimpleAlarmStatus(...)`.
|
||
|
||
The service also enables `PostmanFeature` and `OpenApiFeature` (Swagger), so a running instance
|
||
exposes a browsable contract and a Postman collection.
|
||
|
||
---
|
||
|
||
## Authentication & authorization
|
||
|
||
All MES services are decorated with:
|
||
|
||
```csharp
|
||
[Authenticate]
|
||
[RequiredRole("MESAPI")]
|
||
public class MesServices : Service { ... }
|
||
```
|
||
|
||
The caller must be authenticated **and** hold the `MESAPI` role. Auth is configured in
|
||
`AppHost.Configure`:
|
||
|
||
- **API key** — `ApiKeyAuthProvider`
|
||
- `SessionCacheDuration = 30 minutes`
|
||
- `RequireSecureConnection = false` (HTTP is accepted; TLS not enforced)
|
||
- `AppHost.GetDbConnection` routes an API key whose `Environment == "test"` to a `TestDb`
|
||
connection — **but this does NOT apply to these endpoints** (see *Behavior notes*: test keys
|
||
query the production `BatchDB`).
|
||
- **LDAP** — `LdapAuthProvider` (directory credentials).
|
||
- `AllowGetAuthenticateRequests = true`.
|
||
|
||
User/role data is persisted with `OrmLiteAuthRepository` (`UseDistinctRoleTables = true`) in the
|
||
same SQL Server database.
|
||
|
||
---
|
||
|
||
## `POST /mes/simplealarmstatus`
|
||
|
||
The convenience endpoint: identify a machine by SAPID, get back its MES-relevant alarms.
|
||
|
||
### Request — `SimpleAlarmStatusRequest`
|
||
|
||
| Field | Type | Notes |
|
||
|-------|------|-------|
|
||
| `SAPID` | string | SAP identifier of the machine. Required. |
|
||
|
||
```json
|
||
{ "SAPID": "100012345" }
|
||
```
|
||
|
||
### Behavior
|
||
|
||
1. Look up the `Machine` by `SAPID`. If not found → `WasSuccessful = false`,
|
||
`ErrorText = "Failed to find machine with SAPID '<id>'"`.
|
||
2. Select that machine's alarms **filtered to `FlaggedForMES = true` only**.
|
||
3. Read live tag state and return every alarm that is **currently triggered** (`InAlarm == true`).
|
||
|
||
Notes specific to this endpoint:
|
||
- Always **flagged-only** (cannot return non-MES alarms).
|
||
- Does **not** honor an "include acked" toggle — acked alarms are always included (with
|
||
`StatusCode = "Triggered.Acked"`).
|
||
|
||
---
|
||
|
||
## `POST /mes/alarmstatus`
|
||
|
||
The full endpoint: pick the machine by any one of several keys, and filter the alarm set.
|
||
|
||
### Request — `AlarmStatusRequest`
|
||
|
||
| Field | Type | Notes |
|
||
|-------|------|-------|
|
||
| `MachineFilter` | `MachineFilter` | Selects the machine (see below). Defaults to empty. |
|
||
| `AlarmFilter` | `AlarmFilter` | Filters the alarms (see below). Defaults to "all flagged + unflagged, triggered + acked". |
|
||
|
||
#### `MachineFilter`
|
||
|
||
| Field | Type | Notes |
|
||
|-------|------|-------|
|
||
| `MachineID` | int? | DB primary key. |
|
||
| `SAPID` | string | SAP identifier. |
|
||
| `ZTag` | string | Z-tag identifier. |
|
||
| `Code` | string | Machine code (also the MXAccess tag prefix). |
|
||
|
||
**Resolution precedence** (first non-empty wins): `SAPID` → `Code` → `ZTag` → `MachineID`.
|
||
At least one selector must be supplied; if all are empty/unmatched the call fails with
|
||
`"Failed to find machine with given machine filter '<dump>'"`. A supplied-but-unmatched selector
|
||
fails with a selector-specific message, e.g. `"Failed to find machine with Code '<code>'"`.
|
||
|
||
#### `AlarmFilter`
|
||
|
||
| Field | Type | Default | Effect |
|
||
|-------|------|---------|--------|
|
||
| `NameFilter` | string | `null` | Case-insensitive **substring** match on alarm `Name`. |
|
||
| `MinSeverity` | int? | `null` | Keep alarms with `Severity >= MinSeverity`. |
|
||
| `MaxSeverity` | int? | `null` | Keep alarms with `Severity <= MaxSeverity`. |
|
||
| `IncludeTriggered` | bool | `true` | **Currently unused** — see Behavior notes. |
|
||
| `IncludeAcked` | bool | `true` | When `false`, acked alarms are excluded from the result. |
|
||
| `FlaggedOnly` | bool | `false` | When `true`, restrict to `FlaggedForMES = true` alarms. |
|
||
|
||
```json
|
||
{
|
||
"MachineFilter": { "SAPID": "100012345" },
|
||
"AlarmFilter": {
|
||
"MinSeverity": 500,
|
||
"FlaggedOnly": true,
|
||
"IncludeAcked": false
|
||
}
|
||
}
|
||
```
|
||
|
||
### Behavior
|
||
|
||
1. Resolve the machine via `MachineFilter` precedence (above).
|
||
2. Load all `MachineAlarm` rows for that machine, then apply the in-process `AlarmFilter` in this
|
||
order: `FlaggedOnly` → `MinSeverity` → `MaxSeverity` → `NameFilter`.
|
||
3. Read live tag state; return every remaining alarm that is **triggered** (`InAlarm == true`),
|
||
skipping acked alarms when `IncludeAcked == false`.
|
||
|
||
---
|
||
|
||
## Response — `AlarmStatusResponse`
|
||
|
||
| Field | Type | Notes |
|
||
|-------|------|-------|
|
||
| `WasSuccessful` | bool | `false` on any lookup or tag-read failure. |
|
||
| `ErrorText` | string | Populated when `WasSuccessful == false`. |
|
||
| `Alarms` | `AlarmInfo[]` | Triggered alarms matching the request. **Cleared if `WasSuccessful == false`.** |
|
||
|
||
### `AlarmInfo`
|
||
|
||
| Field | Type | Source |
|
||
|-------|------|--------|
|
||
| `Name` | string | `MachineAlarm.Name`. |
|
||
| `HierarchicalName` | string | `"{Machine.Code}.{Name}"`. |
|
||
| `Description` | string | Live `….DescAttrName` tag value. |
|
||
| `IsFlaggedForMES` | bool | `MachineAlarm.FlaggedForMES`. |
|
||
| `Severity` | int | `MachineAlarm.Severity` (0–999). |
|
||
| `StatusCode` | string | `"Triggered"`, or `"Triggered.Acked"` when acked. |
|
||
| `TriggeredDT` | DateTime | Live `….TimeAlarmOn` tag value. |
|
||
| `AckDT` | DateTime? | Live `….TimeAlarmAcked` tag value — read for every triggered alarm, acked or not; null only if MXAccess delivers a null value (see *Behavior notes*). |
|
||
| `AckComment` | string | Live `….AckMsg` tag value. |
|
||
|
||
### Example response
|
||
|
||
```json
|
||
{
|
||
"WasSuccessful": true,
|
||
"ErrorText": null,
|
||
"Alarms": [
|
||
{
|
||
"Name": "HighVacuumFault",
|
||
"HierarchicalName": "Z28061.HighVacuumFault",
|
||
"Description": "High vacuum sensor out of range",
|
||
"IsFlaggedForMES": true,
|
||
"Severity": 800,
|
||
"StatusCode": "Triggered.Acked",
|
||
"TriggeredDT": "2026-06-25T01:14:22",
|
||
"AckDT": "2026-06-25T01:16:05",
|
||
"AckComment": "Investigating - day shift"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## How alarm state is read (MXAccess)
|
||
|
||
Alarm configuration (name, severity, MES flag) lives in SQL; **live state** is read from
|
||
Wonderware at request time:
|
||
|
||
- For each `MachineAlarm`, an `AlarmTagset` (`AlarmTagset.cs`) builds MXAccess tag paths of the
|
||
form `{Machine.Code}.{Alarm.Name}.<suffix>`, where `<suffix>` is one of:
|
||
`Quality`, `InAlarm`, `TimeAlarmOn`, `DescAttrName`, `Acked`, `TimeAlarmAcked`, `AckMsg`.
|
||
- `MesNotifier` subscribes via `LMXProxyServerClass.AdviseSupervisory` (handle registered as
|
||
`"MesNotifier"`), waits for the first data change per tag, then unsubscribes.
|
||
- **Quality gate:** every alarm's `Quality` must be OPC-Good (`192`); otherwise the whole request
|
||
fails with `ErrorText = "Failed to read machine alarm status"` and `Alarms` is cleared.
|
||
- Detail tags (`TimeAlarmOn`, `DescAttrName`, `Acked`, `TimeAlarmAcked`, `AckMsg`) are only read
|
||
for alarms whose `InAlarm` is true.
|
||
- **Per-request timeout:** 30 seconds (`CancellationTokenSource(30000)`); on timeout the pending
|
||
reads resolve as failed.
|
||
|
||
---
|
||
|
||
## Underlying data model — `MachineAlarm`
|
||
|
||
SQL table backing alarm configuration (`APIServer.ServiceModel/DTO/MachineAlarm.cs`):
|
||
|
||
| Column | Type | Constraints |
|
||
|--------|------|-------------|
|
||
| `MachineAlarmID` | int | PK, auto-increment |
|
||
| `MachineID` | int | FK → `Machine` |
|
||
| `Machine` | `Machine` | OrmLite `[Reference]` |
|
||
| `Name` | string | Required, ≤ 256 |
|
||
| `Severity` | int | 0–999 |
|
||
| `FlaggedForMES` | bool | Marks an alarm as MES-relevant |
|
||
| `LastUpdate` | DateTime | |
|
||
| `LastUpdateBy` | string | Required, ≤ 128 |
|
||
| `OtherData` | string | Max-length text |
|
||
|
||
---
|
||
|
||
## Behavior notes & gotchas
|
||
|
||
- **`AlarmFilter.IncludeTriggered` is declared but never used.** Both endpoints only ever return
|
||
alarms whose `InAlarm` tag is `true`; setting `IncludeTriggered = false` has no effect.
|
||
- **Only triggered alarms are returned.** There is no "all configured alarms" mode — an alarm not
|
||
currently in alarm state never appears, regardless of filters.
|
||
- **`/simplealarmstatus` is flagged-only and ignores ack filtering**; use `/mes/alarmstatus` with
|
||
`FlaggedOnly` / `IncludeAcked` for control over those.
|
||
- **`MachineFilter` precedence matters** — supplying both `SAPID` and `Code` uses `SAPID`; the
|
||
others are ignored.
|
||
- **All-or-nothing on read errors.** A single bad-quality tag fails the whole response (success
|
||
flag flips, alarm list is emptied) rather than returning a partial set.
|
||
- **Severity bounds are inclusive** on both `MinSeverity` and `MaxSeverity`.
|
||
- **Unhandled exceptions are an HTTP 500, not `WasSuccessful = false`.** Neither `MesServices`
|
||
handler catches anything (they block on `.Result`) and `AppHost` registers no exception handlers.
|
||
A SQL error, an exception from the MXAccess COM calls (`AddItem` / `AdviseSupervisory`) or
|
||
cancellation while opening the DB connection returns ServiceStack's error body (response DTO has
|
||
no `ResponseStatus`, so the generic `ErrorResponse` is used; `ErrorCode` = the *inner* exception
|
||
type name, `Message` = its raw text, `StackTrace` null outside Debug builds):
|
||
```
|
||
HTTP 500
|
||
{"ResponseStatus":{"ErrorCode":"SqlException","Message":"<raw exception text>","StackTrace":null,"Errors":[]}}
|
||
```
|
||
A caller without `Accept: application/json` gets ServiceStack's HTML error page instead. The
|
||
error is also written to `requestlogs/{year}-{month}/{…}-errors.csv`. (Behaviour reproduced on
|
||
ServiceStack 6.0.2 with the same handler shape, 2026-09-28.) The 30-second read timeout does
|
||
**not** throw — pending reads resolve as failed and yield the normal `WasSuccessful = false` body.
|
||
- **No matching alarms = success with an empty list.** A machine with no `MachineAlarm` rows (or,
|
||
on `/simplealarmstatus`, no `FlaggedForMES` rows) returns `WasSuccessful = true`, `Alarms: []` —
|
||
indistinguishable from "configured alarms, none in alarm".
|
||
- **Failures don't short-circuit.** After a quality/read failure the handler still reads the detail
|
||
tags and builds the list, then clears it before returning — cost only (time + MXAccess load).
|
||
- **`AckDT` is not guaranteed null for an unacked alarm.** `Tag<T>.ParseUpdate` yields null only
|
||
for a null MXAccess value; otherwise it is whatever `TimeAlarmAcked` holds. A Galaxy time
|
||
attribute normally holds a date, so expect a stale/default timestamp rather than null
|
||
(unverified against a live Galaxy). Use `StatusCode` to decide acked vs. unacked.
|
||
- **Tag type-conversion failures are silent.** If a value fails `Convert.ChangeType`,
|
||
`ParseUpdate` sets the tag to `default(T)` and `IsGood = false`, but the read still reports success
|
||
(it keys on MXAccess quality only) and only the `Quality` tag's `IsGood` is checked. So a bad
|
||
`InAlarm` reads as `false` and the alarm silently drops out; a bad `TimeAlarmOn` becomes
|
||
`0001-01-01T00:00:00`; a bad `DescAttrName`/`AckMsg` becomes null.
|
||
- **`/simplealarmstatus` machine lookup edge cases** (SQL is `SELECT TOP 1 … WHERE SAPID = @0`,
|
||
confirmed on OrmLite 6.0.2; the column is nullable and not unique):
|
||
- missing / null `SAPID` → parameter is `NULL`, `= NULL` never matches, so machines with a NULL
|
||
SAPID are **not** returned → `"Failed to find machine with SAPID ''"`;
|
||
- `"SAPID": ""` **does** match a machine whose SAPID is the empty string (the full endpoint
|
||
guards with `IsNullOrEmpty` and never issues this query);
|
||
- duplicate SAPIDs → `TOP 1` with no `ORDER BY`, so SQL Server picks an arbitrary machine. The
|
||
same applies to `SAPID`/`Code`/`ZTag` lookups on `/mes/alarmstatus`.
|
||
- **"test" API keys hit the production database.** `AppHost.GetDbConnection`'s `TestDb` routing
|
||
only affects a service's `Db` property; `MesNotifier` opens its own connection from the
|
||
registered `OrmLiteConnectionFactory` (`MesNotifier.cs` lines 71/168/347/525), which is always
|
||
`BatchDB`. No `TestDb` named connection is registered either. Applies to MoveIn/MoveOut too.
|
||
- **Late-arriving value after timeout (latent).** `LmxProxyOnOnDataChange` completes a pending read
|
||
with `SetResult`, not `TrySetResult`; a value that lands after the 30 s timeout already resolved
|
||
the read (before its `ContinueWith` removes it) would throw inside the MXAccess COM callback.
|
||
Not reproduced.
|
||
- The route attributes carry placeholder Swagger text (`Summary = "POST Summary"`,
|
||
`Notes = "Notes"`); these are cosmetic.
|
||
|
||
---
|
||
|
||
## Quick reference (curl)
|
||
|
||
```bash
|
||
# Simple — flagged alarms for one machine by SAPID
|
||
curl -X POST http://<host>:<port>/mes/simplealarmstatus \
|
||
-H "Content-Type: application/json" \
|
||
-H "Authorization: Bearer <api-key>" \
|
||
-d '{"SAPID":"100012345"}'
|
||
|
||
# Full — filtered alarms, machine chosen by Code
|
||
curl -X POST http://<host>:<port>/mes/alarmstatus \
|
||
-H "Content-Type: application/json" \
|
||
-H "Authorization: Bearer <api-key>" \
|
||
-d '{
|
||
"MachineFilter": { "Code": "Z28061" },
|
||
"AlarmFilter": { "MinSeverity": 500, "IncludeAcked": false }
|
||
}'
|
||
```
|
||
|
||
> Exact auth header format depends on how `ApiKeyAuthProvider` is configured for the deployment
|
||
> (bearer token vs. HTTP Basic with the key as username). Confirm against the live Swagger/Postman
|
||
> metadata for the target server.
|