# What a Niagara station's oBIX server actually exposes

> The mount point, the three verbs, the twelve lobby branches, the five that appear in no listing, and where the open alarms actually are.

Source: https://plantroomlabs.com/notes/what-an-obix-server-actually-exposes/  
Published: 2026-09-30 (30 September 2026) · Plantroom Labs  
Topics: Integration, Agents, Station security

oBIX is how an outside program reads a Niagara station without a vendor library and without a driver on either side. What the station actually answers is not quite what the specification describes, and the differences are the ones that cost a morning.

## Why read the jars and not the specification

oBIX is an OASIS specification, and a Niagara station ships a server for it. Those are two different documents. The specification says what an oBIX server may look like; the shipped code says what this one does. Where they disagree, the code wins, and a client written from the specification alone fails in ways that look like a network problem.

Everything below was read out of one stock installation — the module jars and the documentation jars, with `javap` — rather than out of the standard or out of recollection. This note names a class or method where that is what makes a claim checkable; the full list, one citation per decision with the jar hashes to go with it, is [EVIDENCE.md](https://github.com/UsamaIqbal0304/obix-mcp/blob/main/EVIDENCE.md) in the bridge this came out of. Where the installation does not answer a question, the gap is said to be a gap.

## The mount point is a literal

The server component carries a `servletName` property whose default is `obix`, which reads like a setting. None of the five methods that return a path reads it: each returns a fixed string.

| Method | What it returns |
|---|---|
| `getServletPath()` | `/obix` |
| `getSoapPath()` | `/obix/soap` |
| `getWsdlPath()` | `/obix/wsdl` |
| `getXsdPath()` | `/obix/xsd` |
| `getXslPath()` | `/obix/xsl` |

So the lobby is at `http://host/obix`, and the guide gives the name as read-only. The property is not inert, though, and it took reading a second jar to see why: the servlet is registered with the web server under its *name*, and the encoder that answers every request is built with a lobby path of `/` plus that same property — which is the prefix a URI inside a request body has to carry. Two live code paths read the property; five hard-coded getters ignore it. On a stock station they all agree on `/obix`, so a client that offers a configurable path is offering a setting the station honours in some places and not in others.

## Three verbs and one servlet

The servlet's `service` method branches on the HTTP method and nothing else. GET encodes whatever the URI resolves to. PUT writes. POST invokes an operation. The station's own WSDL declares exactly the same three — a read, a write and an invoke, each returning an oBIX object — so this is not an accident of the HTTP binding.

Responses come back as `text/xml`. There is no JSON representation to ask for, and no content negotiation to get one.

## The lobby is the discoverable surface

A GET of the lobby returns a list of branches, each contributed by a lobby agent component. Twelve agents are registered in this installation and only *seven* of them write an element into that list: an *about* object, an alarms branch, a batch operation, the station itself, histories, a watch service, and the export branch. The other five — a singular *alarm* branch, a BQL query branch, a contract branch, an ORD branch, and a units branch — have an `encodeLobbyChild` that is a single `return`, so each resolves but appears in no listing (below).

Eight of the twelve give their name as a literal from `getLobbyName()`. Four return a static field set in the class initialiser, which is why a grep for the name in the source of the class finds nothing and the name has to be read out of `<clinit>`.

> **The one that wastes an afternoon.** The documentation calls the exported-points branch *exports*. The export lobby agent mounts it at `continuousControl`. A client that walks to `/obix/exports` because the guide said so gets nothing, and nothing in the reply explains why.

> **Five branches are in no listing.** The *alarm*, *bql*, *def*, *ord* and *units* lobby agents each have an `encodeLobbyChild` that is a single `return`: they write no element. Each resolves anyway, and the singular *alarm* branch is the one worth reaching — `/obix/alarm/<uuid>` looks a Niagara UUID up in the alarm database and answers with that one record — so a client that walks what it is shown cannot discover an address that works. When the lookup fails, the whole method body sits inside one `catch` that throws a no-argument `BadUriErr`, discarding even the *UUID not found* text the same method had just built. An unparseable UUID, a UUID that is simply absent and a database error all arrive as one empty fault, whose `display` is a Java class name standing where its message should be.

## A relative href is relative to the document it came in

Every href a client follows after the lobby is written by one method, `getChildHref(parentHref, name)`, reached through the `configChild` path that *every* component child goes through. It has three cases. When the parent element is the document element and its href ends in a slash, the child gets a bare `name + "/"`. When the parent carries no href at all, the same. Otherwise the child's href is the parent's with the name concatenated onto it.

The lobby is the first case, so a lobby child's bare `histories/` means `/obix/histories/`, and a client that joins bare hrefs onto the servlet root is right. One document deeper it is wrong. A GET of the alarm service — at `/obix/config/Services/AlarmService/`, the href the alarms ref advertises — answers with its query operation at `~alarmQuery/`, which means `/obix/config/Services/AlarmService/~alarmQuery/`; joined onto the servlet root instead it asks for `/obix/~alarmQuery/`, and the station answers 404. The lobby is the one document where the two readings agree, which is why this survives testing: a client can walk the whole lobby, resolve every branch correctly, and still fail on the first operation it tries to invoke.

So resolve each href against the href of the document it arrived in, the way a browser resolves a link, rather than against the root. One form is exempt: an href beginning `|` is an ORD the encoder fell back to for a child it could not name, and it is not a path to join onto anything.

## A value's type is its element name

The station's encoder writes a small, fixed vocabulary of element names — `obj`, `list`, `op`, `ref`, `err`, `feed`, `bool`, `int`, `real`, `str`, `enum`, `abstime`, `reltime`, `uri` — and types a value *by that element name alone*. The value itself lives in a `val` attribute, and the attribute does not say what it is.

The practical consequence is on the way in rather than the way out: a numeric set point sent as `<str val="21.5"/>` is a string write, not a number write, and the station is entitled to take it or refuse it on that basis. A client that infers the element from the look of the value will be right until the first time it is not.

## A watch is a lease, and it belongs to a user

Subscribing is not a socket. You ask the watch service to make a watch, then poll it, and the watch expires unless you keep renewing the lease. The operations are declared as in/out contract pairs in each class's static initialiser.

| Operation | What it does |
|---|---|
| `make` | On the watch service. Returns a new watch object, with its own hrefs for everything below. |
| `add` | Adds hrefs and returns their current readings, so the first poll is not a flood. |
| `pollChanges` | What has changed since the last poll. |
| `pollRefresh` | Everything in the watch, whether it changed or not. |
| `remove` / `delete` | Drop hrefs, or drop the watch. Leaving a watch to expire works and costs the station a lease's worth of bookkeeping. |

Two defaults in this installation disagree: the watch service's default lease is thirty seconds, and the watch component's own lease property defaults to fifteen. A client that assumes either number will eventually renew too late on a station configured the other way. Read the lease out of the watch the station actually returned, and take each operation's href from that reply rather than assembling it from a template.

Two more properties of the implementation are worth knowing before designing around it. A failure to resolve one href in an `add` is caught per item, so an unknown point comes back as a fault row among the good readings rather than failing the whole call. And a watch belongs to the user who made it: another user touching it gets a permission fault, by an explicit check, not by accident.

## The batch op reads many points in one request

One of those branches is an operation rather than a folder. POST a list of URIs to it and the station answers with one object per URI, which on a controller is the difference between one request per point and one request. It is the cheapest improvement available to anything walking a station, and the details that decide whether a client gets it working today or tomorrow are all in the request.

The request's document element has to be a `list`. A watch's input wraps its list inside an `obj`; a batch's must not, and the station rejects the whole document rather than the one item. Each item has to be named `uri`. And each item's `val` has to be a *full* path including the lobby path, because the resolver looks for that prefix inside the value and refuses anything without it — so the relative hrefs that work in a watch are rejected here, which is the difference that costs the afternoon. The verb is an attribute: `is="obix:Read"`, matched by substring, defaulting to empty, so an item that omits it is answered with an unknown-contract fault rather than with a reading.

The good part is the failure mode. Each item is attempted inside its own catch, so a bad URI becomes a fault row in place while every other reading comes back normally — the same per-item forgiveness a watch's `add` has. The reply is a list marked `of="obix:BatchOut"` and carrying no `is` attribute at all, so a client that matches on the contract it was promised finds nothing.

It is also the one operation that can write. The same list may carry write and invoke items, and they travel as a POST to the batch operation rather than as a PUT to a point — so any permission model a client enforces at its own edge has to cover this path as well, or it covers nothing. The [bridge this was read out of](https://github.com/UsamaIqbal0304/obix-mcp) answers that by having no way to express a write item: the request builder takes no verb, and a test asserts that no code in the module names another contract. The bytecode citations are in [EVIDENCE.md](https://github.com/UsamaIqbal0304/obix-mcp/blob/main/EVIDENCE.md) under section M.

## Where the open alarms are

The *alarms* lobby branch is a ref, not a folder you reach by that name. Its agent is a shortcut whose advertised href is `/obix/config/Services/AlarmService/` — the alarm *service* component, reached under the station's `config` branch. A bare GET of `/obix/alarms/` by name does not go there: the shortcut resolves an empty remainder to the station root. Follow the advertised href instead. The service's own children are the station's alarm classes — configuration, not records. What the encoder adds beside them is where the records are: an `<int name="count">` of the open alarms, a feed, and an operation named `query` that takes an `obix:AlarmFilter` and returns an `obix:AlarmQueryOut`. Each alarm class carries the same three, so one class can be asked instead of the whole service.

The filter's children are read by name — `limit`, `start`, `end` — and a missing one is tolerated, so an empty filter means every open alarm. They are turned into a BQL query over the alarm database, which is worth knowing because it settles what the bounds mean: they constrain each alarm's last update, not when it started.

Two details of the reply decide whether a client reads it correctly. The records arrive first, as `<list name="data" of="obix:Alarm">`, and the `count`, `start` and `end` are written *after* that list, because the station streams a cursor and only then knows them — so nothing may depend on child order. And each record names several contracts at once, `obix:Alarm obix:AckAlarm` plus a point or stateful flavour, alongside a `niagara-uuid` that is the only handle by which that alarm can be named again.

The second of those contracts is the one to notice before building on this. An acknowledgement is an operation on the record, so it travels as a POST to the alarm rather than as a PUT to a point — and with a `forceCleared` child it also drives the source state to normal and fires the alarm service's audit action. It is the same shape of hole as the batch op, and it wants the same answer: a read-only client has to be unable to express it, not merely uninclined to.

## The canned history queries are refs, and one neighbour writes

Tridium documents that a history is exposed through a predefined set of query options, available as lobby URIs with a GET and no operation, and names three of them. The encoder writes eleven: `today`, `last24Hours`, `yesterday`, `weekToDate`, `lastWeek`, `last7Days`, `monthToDate`, `lastMonth`, `unboundedQuery`, and two whose name carries a bracket, `yearToDate (limit=1000)` and `lastYear (limit=1000)`. Each is a `ref` whose href already carries its bounds as query parameters, so a GET is the whole mechanism and there is no URL to assemble.

In the same document, beside them, are four operations, which a GET cannot use at all: `query`, `rollup`, `feed` and `append`. The last of those writes records into the history. Anything that reaches for a history query by name, out of a listing that mixes refs and operations together, is one spelling away from appending to a trend log — so read the two apart and refuse the operations by construction rather than by intent.

## 403 means two different things

Before anything else, the servlet checks the licence, and an unlicensed server sends 403 with the string *Unlicensed oBIX Server*. A station whose oBIX server is disabled — either the component is off or the network's status says disabled — sends 410 with *oBIX Server Disabled*. The driver documentation says the same about the second.

But 403 is also what a permission refusal looks like, and that one arrives with an oBIX fault in the body naming the object. So the status code alone cannot tell "this station is not licensed for oBIX" from "this user may not read that point", and a client that guesses will send somebody to check a licence file when the real answer is a role.

Read the body. If it decodes as a fault, the fault is the answer and any generic advice about the status code is noise. Which permissions apply is a station question rather than an oBIX one — the same roles and permissions that govern [any other user on the station](https://plantroomlabs.com/notes/niagara-roles-and-permissions/) govern this one.

## What the installation does not answer

Four things worth stating as unknowns rather than filling in from the standard.

There is no literal oBIX request or response document anywhere in the shipped documentation — checked by exhaustive search across the extracted HTML, not assumed. The HTTP challenge is not in either oBIX jar: no `WWW-Authenticate`, no Base64 decode, no credential comparison. The servlet reads the `Authorization` header only to decide whether to reuse a session, so whether a given station demands [one scheme or another](https://plantroomlabs.com/notes/niagara-authentication-schemes/) is answered by the web service, not here. The specification's watch sign-up operation appears nowhere in either jar. And there is no compiled-in ceiling on points, devices or watches — the numeric limits come from the licence file.

None of which is a reason not to use oBIX. It is a reason to write the client against the station rather than the standard, and to know which of the two a given line of code is trusting.

## Why this matters more now than it did

Until recently the audience for this was a person writing an integration once. Increasingly it is a program deciding what to call at run time, which changes the stakes: a human who walks to `/obix/exports` and finds nothing asks somebody, and a program writes a plausible-looking failure into a log. That is a separate design problem, and it is the subject of [letting an agent write to a control system](https://plantroomlabs.com/notes/letting-an-agent-write-to-a-control-system/).

## Where this is written down

- [oBIX Version 1.1, OASIS Committee Specification 01](https://docs.oasis-open.org/obix/obix/v1.1/obix-v1.1.html) — The object model, the lobby, the watch contract and the fault types this station implements a version of — read it to see what the code departs from
- [Bindings for oBIX: REST Bindings Version 1.0, OASIS Committee Specification 01](https://docs.oasis-open.org/obix/obix-rest/v1.0/obix-rest-v1.0.html) — The binding that maps the object model onto HTTP verbs and URIs, which is what a station's servlet is an implementation of
