# Haystack API

Baler serves the standard [Project Haystack HTTP API](https://project-haystack.org/doc/docHaystack/HttpApi),
so tools that already speak Haystack can use it without changes. SkySpark, Haxall's haystack
connector, nhaystack's client side, pyhaystack, phable and openhay's `hay` all work this way:
point the tool at Baler instead of a SkySpark host.

```
https://<your-host>/api/<project>/<op>     # SkySpark layout
https://<your-host>/api/<op>               # Haxall layout, same thing
```

`<project>` is accepted so SkySpark-shaped URLs work, but it doesn't scope anything yet.

## Logging in

Use your normal Baler email and password. Haystack clients run the standard HELLO →
SCRAM-SHA-256 handshake. Your password is never sent: it's checked against the same password hash
`POST /auth/login` uses, so there's nothing to reset and no second password to manage. The
handshake ends with a normal session JWT as the Haystack `authToken`.

You can also skip SCRAM and send any token the gateway accepts (a session JWT or an API key from
`POST /auth/api-keys`) as either:

```
Authorization: Bearer <token>                 # OAuth / modern API style
Authorization: BEARER authToken=<token>       # Haystack style
```

Read-type ops (`about`, `read`, `nav`, `hisRead`, `watch*`) need the `viewer` role.
`hisWrite` needs `operator`. Login attempts are rate-limited per IP at the gateway.

## Try it

With [openhay](https://github.com/jacobnash/openhay)'s `hay` CLI:

```bash
hay read http://localhost:8080/api/demo "point and temp" --user admin@acme.example --password change-me
hay his  http://localhost:8080/api/demo @urn:point:windrow~2Fmock-ahu-1~2FanalogValue~2C1 yesterday --to csv
```

From Python (`pip install -e ".[haystack]"` installs openhay):

```python
import openhay as oh

c = oh.Client("http://localhost:8080/api/demo", user="admin@acme.example", password="change-me")
for rec in c.read("ahu"):
    print(rec["id"], rec.get("dis"))
his = c.his_read(c.read("temp and sensor", limit=1)[0]["id"], "yesterday")
```

## What a record looks like

Records come from the same entity graph everything else reads:

| Haystack tag | where it comes from |
|---|---|
| `id` | the entity URI. Characters Haystack ids don't allow are escaped as `~XX`, e.g. `@urn:point:windrow~2Fahu-1~2FanalogInput~2C1` |
| markers (`temp`, `sensor`, …) | `haystack:hasTag` facts, as written by ingest and the Haystack puller |
| other tags (`dis`, `kind`, …) | `haystack:<tag>` values |
| `point` / `equip` | the kind of URI |
| `equipRef` | `brick:isPointOf` in the graph, not the original source's ref, so it always resolves here |
| `brickClass` | the Brick class mapping assigned |
| `spec` | the most specific `ph` Xeto spec the record fits (`@ph::Ahu`, `@ph.points::ZoneAirTempSensor`): from its own markers plus those its Brick class implies, so Brick-only records are typed for Xeto clients too. A stored `spec` (Haystack 4 import) is kept as-is. |
| `curVal`, `unit`, `his`, `mod` | the newest sample in the historian |

Filters work exactly like the [Query page](/his-query): a name matches as a Haystack marker,
a Brick class (`Air_Handling_Unit`) or a Brick tag word. So `read ahu` also finds AHUs that
never had a Haystack `ahu` tag. Tag paths (`point and equipRef->ahu`,
`equipRef->siteRef->dis == "HQ"`), units (`area > 1000ft²` matches only `ft²`, with no
conversion), date, time and Uri literals, and `^symbol` terms all work.

## Who sees what

Reads are scoped to the caller's sites once sites own data. See
[Multi-Site](/multi-site#who-sees-which-data). Anything out of scope reads as if it didn't exist.

## Formats

Requests can be `GET` with Zinc-encoded query params, or `POST` with a grid in Zinc, JSON (Haystack 3
or Haystack 4/Hayson) or Trio. Responses follow `Accept`: Zinc by default, plus `application/json`,
`text/trio` and `text/csv`. A JSON request gets JSON back in the same flavor, so older Haystack 3
clients keep working.

## Commanding points (pointWrite)

`pointWrite` with a `level` (1-17) commands a real BACnet point. A `val` of `null` relinquishes that
level. Level 17 is the default the point falls back to when every other level is empty (BACnet's
relinquish-default): it can be set but not relinquished. It works the way an operator's command at a controller does: a write at 8 overrides the
controller's own logic until it's released.

- **Writable points:** Windrow tags BACnet setpoints and commands (analog, binary and multi-state
  *output*/*value* objects, never sensors) as `writable`. Only those accept a write.
- **Routing:** the write goes to the Windrow instance that owns the point. That instance must run
  with `--site-id`, which is how it advertises its command channel. The command travels over that
  site's broker-ACL-protected MQTT channel.
- **Confirmation:** a successful response means the controller accepted the BACnet WriteProperty.
  Nothing is merely queued.
- **Offline sites:** if the site is offline, the call fails after 10 seconds. The command expires
  30 seconds after it was sent, and the site refuses it after that, so it can never apply a stale
  setpoint when it reconnects.
- **Timed overrides:** a level-8 write with a `duration` (`30min`, `2h`) is relinquished
  automatically when it runs out; without one it stays until released, as in Haxall. The array's
  `expires` column shows the time left.
- **Reading the array:** `pointWrite` without a `level` returns the 17-level array of what Baler
  has commanded (`level`, `levelDis`, `val`, `valDis`, `who`, `expires`, as Haxall returns it).
  The controller's own array can also hold other writers' levels.
- **Units:** a Number's unit is not converted. Send the value in the point's own unit.
- **Roles:** writing needs the `operator` role. Haystack clients POST pointWrite for reads too, so
  reading the array also needs `operator`.

## Deliberately unsupported

- **`invokeAction`** answers with a Haystack error grid (HTTP 200, `err` meta) that says it isn't
  supported, and `ops` doesn't list it. In SkySpark and Haxall an action is an Axon expression in a
  record's `actions` grid. Baler records define no actions and Baler has no Axon to run one. To
  command a point, use `pointWrite`.
- **A Symbol as a comparison value** (`kind == ^elec`) is a filter parse error. Haystack's filter
  grammar doesn't include it. A bare `^hot-water` term works and means `hot and water`.

## Ops, checked against Haxall

`hisRead` takes every range the spec defines (`today`, `yesterday`, a date, a date range, two
dateTimes, or one dateTime meaning "until now"), and the **batch** form (meta `range`, one `id` row
per point, returning `ts, v0..vN`). `hisWrite` takes the batch form too (a `ts` column plus one
column per point, each naming its point with an `id` in the column meta). `close` ends the session:
the token is refused by `/api` at once and by the gateway, on every API, within 30 seconds. `ops` returns the
Haystack 4 op defs. Each of these is a column in the CI conformance matrix, run against Haxall
as the reference ([Haystack conformance](/haystack-conformance)).
