# Sensmos attachments (EXT) — the integration API

> **Internal draft — not official documentation.** This page is a working note, not
> a public commitment: endpoints, fields and limits may change without notice.

An **attachment** is any device or program that measures something useful but is not,
and will not be, a Sensmos node: a LoRaWAN concentrator, an SDR, a GNSS receiver, a power
meter. It has no wallet and no attestation of its own. Instead, **a Sensmos node on the
same LAN vouches for it**, and the backend issues it a narrow, revocable token.

This page documents the integration contract end to end: pairing, tokens and scopes,
and the data formats the backend accepts.

---

## The trust model

Two barriers, each closing a different door:

| barrier | proves | checked by |
|---|---|---|
| node **PIN** | you are physically on the owner's network | the node |
| backend **grant** | the node exists, has an owner, has a free slot | the backend |

Three questions are kept separate on every request:

| question | carried by |
|---|---|
| who are you | token (`sma_…`) |
| what are you allowed to do | scopes (rows in the backend, not bytes in the token) |
| what format are you sending | the URL |

A token is 24 random bytes with an `sma_` prefix — it encodes nothing. Permissions live
server-side and can be edited or revoked without reissuing anything.

---

## Pairing (how you get a token)

All of this happens **on the LAN**, against the node's local HTTP server (port 80).
You need the node's IP and its PIN (the owner has both).

### 1 — check the node

```
GET http://<node-ip>/info
→ 200 { "device_id": "…", "firmware": "0.88", "ws_connected": true }
```

`ws_connected` must be `true` — the node relays your request to the backend over its
WebSocket link; without it there is nothing to ask for approval.

(To discover nodes, probe `/info` across the subnet — whatever answers is a node.)

### 2 — request authorization

```
POST http://<node-ip>/ext/authorize
Authorization: Bearer <PIN>
{ "kind": "lora-gw", "name": "rooftop RAK", "scopes": "radio.frames,radio.spectrum" }
```

Note: `scopes` is a **comma-separated string**, not a JSON array.

| response | meaning |
|---|---|
| `202 { "req": "<id>" }` | request relayed to the backend, poll for the answer |
| `403` | wrong PIN |
| `400` | missing `kind`/`scopes` |

### 3 — poll for the grant

```
GET http://<node-ip>/ext/authorize?req=<id>
Authorization: Bearer <PIN>
→ { "status": "pending" }
→ { "status": "granted", "token": "sma_…", "id": "<attachment-id>", "exp": "<iso>" }
→ { "status": "denied",  "reason": "…" }
```

Poll once a second; the node keeps the answer for about 5 minutes. Store the token with
mode `0600` — from now on you talk to the backend directly and the node is out of the loop.

A granted-but-unused token expires after **30 minutes**. Its full lifetime (30 days,
renewable) starts at first use. One node can vouch for at most **4 attachments**; the
owner can revoke any of them from the app, which makes the token inert immediately.

---

## Talking to the backend

Base URL: `https://sensmos.com/v1/ext`. Every request carries:

```
Authorization: Bearer sma_…
```

### Introspection

```
GET /v1/ext/me
→ { "id": "…", "node": "209f5ca1", "kind": "lora-gw", "name": "…",
    "scopes": ["radio.frames"], "expires_at": "…", "catalog": { … } }
```

Call it on startup: it tells you whether you are still alive, what you may send, and
what integrations/formats the server currently accepts (`catalog`).

### Renewal

```
POST /v1/ext/renew        → { "ok": true, … }
```

Extends the token's expiry. Call it once a day; a token left alone expires 30 days
after first use.

### Sending data

```
POST /v1/ext/<integration>/<format>
→ 200 { "ok": true, "accepted": <n> }
```

The integration and format come from the catalog. Today:

| URL | scope | body |
|---|---|---|
| `/v1/ext/radio/rxpk` | `radio.frames` | Semtech packet-forwarder `PUSH_DATA` JSON, as-is |
| `/v1/ext/radio/frames` | `radio.frames` | our native frame format (below) |
| `/v1/ext/radio/channel` | `radio.spectrum` | one spectrum reading (below) |

---

## Data formats

### `rxpk` — Semtech packet forwarder, one to one

Send the JSON object exactly as the forwarder produced it in `PUSH_DATA`:

```json
{ "rxpk": [ {
    "time": "2026-08-21T20:48:11Z",   // optional, only GPS gateways have it
    "freq": 867.3, "datr": "SF9BW125", "modu": "LORA",
    "rssi": -44, "lsnr": 12.0, "size": 38, "stat": 1,
    "data": "4FNNT1MgY2Nk…"           // base64 PHY payload
} ] }
```

Notes from the parser: `datr` is only parsed for LoRa (`SF9BW125`); modulation is read
from `modu`, not guessed from `datr`. `stat` follows Semtech: `1` CRC OK, `-1` CRC fail,
`0` no CRC. `tmst` is ignored (concentrator tick counter, not wall time) — without
`time` the server stamps arrival time.

### `frames` — native format

For attachments written for us (own receiver, SDR, Meshtastic bridge) that prefer to
normalise on their side:

```json
{ "frames": [ {
    "ts":   1755808091,      // unix seconds, optional
    "freq": 867.3,           // MHz
    "sf":   9,               // 0 for FSK
    "mode": 0,               // 0 LoRa, 1 FSK
    "rssi": -44, "snr": 12.0,
    "len":  38,              // bytes on air
    "crc":  true,
    "hex":  "e0534d4f53…",   // PHY payload, hex, first 128 bytes kept
    "dir":  "rx"             // "rx" heard | "tx" transmitted by your own gateway
} ] }
```

`dir: "tx"` is for transmissions your gateway makes **that others can hear** (normal
inverted-IQ downlinks prove nothing — skip them). For `tx`, `rssi` carries TX power.

### `channel` — one spectrum reading

```json
{ "freq": 869.618, "bw": 62.5, "sf": 8, "sync": 18, "noise": -118, "peak": -97 }
```

`freq` is required, the rest defaults to 0. `noise`/`peak` in dBm.

---

## Limits and errors

| code | body | meaning |
|---|---|---|
| 401 | `invalid_token` | token unknown, expired or revoked |
| 403 | `missing_scope` | token valid, but not for this endpoint |
| 400 | parser message | body does not match the format |
| 404 | `unknown_endpoint` + catalog | no such integration/format |
| 413 | `too_many` | over the batch cap — **200 frames** per request |

Batch on your side — for example once per second, or when 50 frames accumulate,
whichever comes first. Application payloads stay encrypted end to end —
the server works with radio metadata and recognises Sensmos beacons among the traffic;
it has no keys for anyone's LoRaWAN payloads and wants none.

---

## What the data does

Frames land in the same tables and on the same map as frames heard by a node's own
radio — an attachment is not a separate being, it is a better antenna for the node
that vouched for it. Verified Sensmos beacons build radio-coverage edges between
nodes; the vouching node's data profile gains the RF category.

---

## Future integrations — drafts

**Nothing below is live.** These are drafts of what the next integrations are expected
to look like, published so hardware owners can see the direction and argue about field
names before anything ships. The backend does not accept these endpoints yet.

### GNSS — sky view & interference

Draft: `POST /v1/ext/gnss/sky`, scope `gnss.sky`. What a cheap GNSS receiver sees of
the sky: satellite counts per constellation, signal quality, and the interference
indicators most receivers already report. Deliberately **no position** — position
affects coverage rewards and needs its own decision before any attachment may report it.

```json
{ "sky": {
    "ts": 1755808091,
    "sats_visible": 17, "sats_used": 9,
    "constellations": { "gps": 8, "galileo": 5, "glonass": 3, "beidou": 1 },
    "hdop": 0.9,
    "snr_avg": 38, "snr_max": 47,
    "agc": 0.62,
    "jam_ind": 12
} }
```

### ADS-B — aircraft reception

Draft: `POST /v1/ext/adsb/summary`, scope `air.traffic`. A per-window summary of what
the antenna hears, not a feed of individual aircraft — range and message rate say
everything about the receiver, which is what the network cares about.

```json
{ "window_s": 60,
  "aircraft_seen": 14, "msgs": 4210,
  "max_range_km": 187.4,
  "min_alt_ft": 2100, "max_alt_ft": 41000,
  "farthest": { "hex": "4d2228", "range_km": 187.4, "alt_ft": 38000 } }
```

### AIS — ship reception

Draft: `POST /v1/ext/ais/summary`, scope `sea.traffic`. Same idea on the water:
a reception summary per window.

```json
{ "window_s": 300,
  "vessels_seen": 9, "msgs": 512,
  "max_range_km": 42.7,
  "classes": { "a": 6, "b": 3 } }
```

### Adding a new integration type

The server side of every integration is one declarative entry in a registry (scopes,
formats, parser, handler) — the drafts above, `rtl_433` and power-meter inputs are all
candidates on this exact pattern. If you have hardware and want a format accepted,
get in touch — a format entry is a small, reviewable change.
