Files
ScadaBridge/docs/former-api-specs/mes/Alarm-API.md
T
Joseph Doherty fad833e8d1 docs(former-api-specs): legacy MES alarm API — code-verified behaviour notes
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
2026-09-28 09:25:10 -04:00

312 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.