Note
What a Niagara station's oBIX server actually exposes
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.
- Integration
- Agents
- Station security
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
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 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 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 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 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.
Sources
Where this is written down
- oBIX Version 1.1, OASIS Committee Specification 01 — 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 — The binding that maps the object model onto HTTP verbs and URIs, which is what a station's servlet is an implementation of
Related
Where this comes up in the work
Protocols & data
Field protocol integration and the data behind it: BACnet, Modbus, M-Bus and MQTT, into a building system, a database or a dashboard.
Integration audit
A fixed-scope paid audit of an existing Niagara integration: what talks to what, where it breaks, and what fixing it would take.
More notes
Other things worth writing down
Letting an agent write to a control system
A write to a station is a plant movement at a priority level, not a variable assignment. What has to be gated before a model gets the verb.
- Agents
- Permissions
- Station engineering
Authentication schemes: each Niagara user picks one
A station can run several login mechanisms at once, and the choice is made per user, not per station. What each scheme is actually for.
- Station security
- Permissions
- Station engineering
Why a BACnet write relinquishes on its own
Fallback is level 17, which is not a BACnet priority. What the driver sends when a point falls to it, and why the in slot sometimes changes nothing.
- BACnet
- Integration
- Station engineering
Next step
Tell us the version, the hardware, and what it has to do.
You will get a written scope and a fixed price against it. If the honest answer is that you do not need us, you will get that instead.