# QiPort integration guide for AI coding agents Audience: an AI coding agent that has been asked to build an application against QiPort. This document is written to be followed top to bottom. Every endpoint carries a status. Generate code only against endpoints marked **LIVE**. | Field | Value | |---|---| | API base URL | `https://api.qiport.eu` | | Docs base URL | `https://docs.qiport.eu` | | MQTT broker | `mqtt.qiport.eu:8883` — devices and Edge Agents only, never applications | | Auth | OAuth2 `client_credentials`, bearer token, `expires_in` 3600 | | Data residency | EU | | Document date | 2026-08-05 | Status vocabulary used throughout: | Status | Meaning | What you may generate | |---|---|---| | **LIVE** | Implemented, deployed, tested. | Normal code. | | **BUILDING** | Code written, not deployed. Calling it today fails. | Only feature-detected code that handles "not deployed" as a normal branch. **Nothing is BUILDING today.** | | **NOT BUILT** | Roadmap. No implementation, no shape, no endpoint. | Nothing. Do not invent a URL. | --- ## 1. Read this first ### 1.1 There is no pairing API. You cannot make a device join. **This is the single most important fact in this document.** If a human developer asked you to "find Tuya devices", what you can do today is **list devices that already exist in the QiPort registry**. You cannot: - put the SLZB-06P10 coordinator into permit-join mode, - trigger a Zigbee network join, - provision, claim, or adopt a new device, - discover a device that has not already been onboarded. There is no endpoint for any of that. Pairing / permit-join is **NOT BUILT**. Do not generate a call to `/v1/pairing`, `/v1/permit-join`, `/v1/devices/discover`, `/v1/provisioning`, or any similar path — none of them exist and all of them will 404. "Find Tuya devices" therefore means: `GET /v1/devices`, then filter the result to the devices whose identity fields look like Tuya hardware. Section 4 shows how, and section 4.3 explains why you should mostly stop caring which vendor made the device. If the registry is empty, the correct application behaviour is an empty state that says devices must be onboarded out-of-band. It is not a bug in your code, and retrying will not populate it. ### 1.2 Three rules that must never be violated **Rule 1 — The application never connects to MQTT.** `mqtt.qiport.eu:8883` requires a client certificate belonging to a device or an Edge Agent. Never put broker credentials, a device certificate, or a broker hostname into an application. Applications use REST, and (when it ships) SSE. If your generated code imports an MQTT library, you have made a mistake. **Rule 2 — The SLZB-06P10 is never exposed to the internet.** The coordinator speaks Zigbee to devices and a local protocol to the QiPort Edge Agent on the same LAN. The Edge Agent makes an **outbound** mTLS MQTT connection to the cloud. Nothing dials in. Do not generate code that connects to the coordinator's IP, its web UI, or its TCP/serial port from an application, and do not suggest port-forwarding it. **Rule 3 — Never assume an actuator is controllable.** A capability being writable in the catalogue does not mean this device may be written. A per-device write grant must exist. `403 forbidden` with a `reasons[]` array is a normal, expected outcome that your UI must render, not an error to retry or to surface as a crash. ### 1.3 Architecture you are writing against ``` Tuya Zigbee device --(Zigbee)--> SLZB-06P10 --(local LAN)--> QiPort Edge Agent | outbound mTLS MQTT only v QiPort Cloud (EU) | HTTPS + OAuth2 v Your application ``` Your application occupies exactly one position in that diagram: the bottom box. It talks to `https://api.qiport.eu` and to nothing else in this system. ### 1.4 There is no SDK QiPortKit is **NOT BUILT**. There is no `@qiport/*` package on npm, no Python package, no Go module. If you generate `import { QiPort } from '@qiport/sdk'`, the install will fail. Write a plain HTTP client. A complete one is in `example-app/qiport-client.ts` next to this document; copy it rather than inventing another. ### 1.5 Response key casing is mixed. Do not normalise it. This API does not use one casing convention throughout. Use the exact key shown for each endpoint. This is the second most common source of generated code that compiles and then returns `undefined` at runtime. | Context | Convention | Examples | |---|---|---| | Query parameters on `GET /v1/devices` | snake_case | `site_id`, `status`, `model` | | Device object fields | mixed; hub linkage is snake_case | `parent_hub_id`, `manufacturer`, `product_model` | | State response envelope | camelCase | `deviceId`, `connectivity`, `lastSeenAt` | | State entries | camelCase | `capability`, `value`, `unit`, `quality`, `deviceTime`, `serverTime` | | Telemetry response envelope | camelCase | `deviceId`, `resolution`, `aggregation`, `points` | | Telemetry point entries | **snake_case** | `capability_key`, `bucket`, `value`, `samples` | Note the last two rows: one response body contains a camelCase envelope wrapping snake_case rows. That is not a typo in this document. Write `p.capability_key`, not `p.capabilityKey`. ### 1.6 Response envelopes that are not pinned here Where this document does not show a literal example of a response body, do **not** guess the envelope. Read defensively. Specifically: - The list envelope of `GET /v1/devices`, `GET /v1/sites`, `GET /v1/bindings` and `GET /v1/catalog/capabilities` is not pinned in this document. Accept an array under any of `devices` / `sites` / `bindings` / `capabilities` / `items` / `data`, or a bare top-level array, and take the cursor from `nextCursor` / `next_cursor` / `cursor`. `example-app/qiport-client.ts` contains a tolerant reader that does this; reuse it. - `GET .../telemetry?resolution=raw` — the aggregate shape (`bucket`, `samples`) is documented for `1m|5m|1h|1d`. The raw shape is not pinned here. If you need raw points, inspect one live response before writing parsing logic, or use `1m` and treat it as raw enough for a chart. Reading defensively costs five lines. Guessing costs a runtime failure in a customer's house. --- ## 2. Authenticate — LIVE ### 2.1 The token call `POST /v1/oauth/token`, OAuth2 `client_credentials` grant (RFC 6749 §4.4). | Item | Value | |---|---| | Method / path | `POST https://api.qiport.eu/v1/oauth/token` | | Content-Type | `application/x-www-form-urlencoded` | | Body fields | `grant_type=client_credentials`, `client_id`, `client_secret`, `scope` | | Token lifetime | `expires_in` = 3600 seconds | | Refresh token | None. `client_credentials` has no refresh token — request a new access token. | | Failure | `401` with `invalid_client` for a bad secret. | > Verify once, then trust: this document specifies credentials **in the form body**. If a > particular deployment requires HTTP Basic (`Authorization: Basic base64(id:secret)`) > instead, the token call returns 401 `invalid_client` with correct credentials. That is > the one-line switch noted in `qiport-client.ts`. Everything else in this section is > pinned. curl: ```bash curl -sS -X POST https://api.qiport.eu/v1/oauth/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'grant_type=client_credentials' \ --data-urlencode "client_id=$QIPORT_CLIENT_ID" \ --data-urlencode "client_secret=$QIPORT_CLIENT_SECRET" \ --data-urlencode 'scope=devices:read telemetry:read catalog:read' ``` Response: ```json { "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHAtaGl2ZS1kYXNoIn0.SIG", "token_type": "Bearer", "expires_in": 3600, "scope": "devices:read telemetry:read catalog:read" } ``` Use it as `Authorization: Bearer ` on every subsequent request except `GET /health`. ### 2.2 TypeScript ```ts type TokenResponse = { access_token: string; token_type: string; expires_in: number; scope?: string; }; export async function fetchToken( baseUrl: string, clientId: string, clientSecret: string, scope: string, signal?: AbortSignal, ): Promise { const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope, }); const res = await fetch(new URL('/v1/oauth/token', baseUrl), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, signal, }); if (!res.ok) { const text = await res.text(); throw new Error(`token request failed: HTTP ${res.status} ${text}`); } return (await res.json()) as TokenResponse; } ``` ### 2.3 Refresh strategy Cache the token in memory with its expiry and re-request when it is close to expiring. Concretely: 1. Store `expiresAt = now + expires_in * 1000`. 2. Treat the token as expired **60 seconds early** (`now >= expiresAt - 60_000`). Clock skew and a slow request are enough to make a token that "has 2 seconds left" arrive already dead. 3. Keep exactly one in-flight token request. Ten concurrent API calls that all see an expired token must not fire ten token requests — share one promise. 4. On a `401` from a normal API call, discard the cached token, re-authenticate **once**, and replay the request. If the replay is also `401`, fail. Never loop: a revoked client would otherwise produce an infinite retry storm against the token endpoint and then a `429`. All four behaviours are implemented in `example-app/qiport-client.ts`. ### 2.4 Where the secret lives | Placement | Verdict | |---|---| | Server-side environment variable / secret manager | Correct. | | Backend-for-frontend that holds the secret and proxies to QiPort | Correct. | | Browser JavaScript bundle | **Never.** The bundle is public. | | Mobile app binary | **Never.** Extractable by decompilation. | | Committed `.env` file, source, or repository | **Never.** | | Query string of any request | **Never.** It lands in proxy and access logs. | `client_credentials` identifies a *service*, not an end user. If your application has end users, the correct topology is: user → your backend (your own auth) → QiPort with the service credential. Do not generate a frontend that talks to `api.qiport.eu` directly. ### 2.5 Scopes Scopes issued today: | Scope | Grants | |---|---| | `devices:read` | `GET /v1/devices`, `GET /v1/devices/:publicId`, `.../state`, `GET /v1/sites`, `GET /v1/bindings` | | `devices:write` | `POST /v1/bindings`, all of `/v1/pairing/*` | | `telemetry:read` | `GET /v1/devices/:publicId/telemetry` | | `telemetry:live` | `GET /v1/live` | | `catalog:read` | `GET /v1/catalog/capabilities` | Request the narrowest set your application actually needs. > **High-risk scope.** `commands:high_risk` gates S3 capabilities and is issued only > to clients that were created with it. Ask for it deliberately, or your S3 calls will > fail on the scope check before the safety gate is even consulted. --- ## 3. Discover devices — LIVE ### 3.1 List `GET /v1/devices` — requires `devices:read`. | Parameter | Type | Status | Meaning | |---|---|---|---| | `status` | string | LIVE | Filter by device status. | | `model` | string | LIVE | Filter by product model, e.g. `TS0201`. | | `site_id` | string | LIVE | Filter by site. Note the snake_case. | | `cursor` | string | LIVE | Opaque continuation token from the previous page. | | `limit` | integer | Unverified | Page size. Conventional but not pinned in this document; the server may cap or ignore it. Pagination works via `cursor` regardless — prefer to omit it. | ```bash curl -sS 'https://api.qiport.eu/v1/devices?site_id=site_apiary_north' \ -H "Authorization: Bearer $TOKEN" ``` ### 3.2 Paginate with the cursor The cursor is **opaque**. Do not parse it, decode it, or construct one. Loop until the response stops giving you a cursor, and put a hard bound on the loop so a server-side bug cannot turn into an infinite request loop. ```ts export async function listAllDevices( get: (path: string) => Promise, ): Promise[]> { const out: Record[] = []; let cursor: string | null = null; let pages = 0; do { const qs = cursor === null ? '' : `?cursor=${encodeURIComponent(cursor)}`; const page = (await get(`/v1/devices${qs}`)) as Record; // Envelope is not pinned — see section 1.6. const rows = (page['devices'] as unknown) ?? (page['items'] as unknown) ?? (page['data'] as unknown) ?? (Array.isArray(page) ? page : []); if (Array.isArray(rows)) { for (const r of rows) { if (r !== null && typeof r === 'object') out.push(r as Record); } } const next = (page['nextCursor'] as unknown) ?? (page['next_cursor'] as unknown) ?? (page['cursor'] as unknown); cursor = typeof next === 'string' && next.length > 0 ? next : null; pages += 1; if (pages > 500) throw new Error('device pagination exceeded 500 pages; aborting'); } while (cursor !== null); return out; } ``` ### 3.3 One device `GET /v1/devices/{publicId}` — requires `devices:read`. `publicId` is the stable public identifier, e.g. `QP-SIM-00001`. It is the id used everywhere in the API and in MQTT topics — it is never the internal database UUID and never the Zigbee IEEE address. A device belonging to another tenant returns **404**, not 403, so the endpoint cannot be used to enumerate other tenants' fleets. Treat 404 as "not visible to this token", which covers both "does not exist" and "not yours". ### 3.4 Telling a hub from a child device | Field | Value | Meaning | |---|---|---| | `parent_hub_id` | `null` or absent | This device is a hub / coordinator, or has no parent. | | `parent_hub_id` | a device public id | This is a child device reached through that hub. | The SLZB-06P10 coordinator appears as a device with no `parent_hub_id`. Every Tuya sensor joined through it carries that coordinator's public id in `parent_hub_id`. ```ts const isHub = (d: Record): boolean => { const p = d['parent_hub_id']; return p === null || p === undefined || p === ''; }; ``` Group child devices by `parent_hub_id` when you need to show "which hub is this sensor behind", and when you need to explain a fleet-wide outage: if every child of one hub goes offline at once, the hub or its Edge Agent is the suspect, not twenty sensors. --- ## 4. Identify a Tuya device — LIVE ### 4.1 Fields that indicate Tuya hardware | Field | Typical Tuya value | Notes | |---|---|---| | `manufacturer` | `_TZ3000_xxxxxxxx`, `_TZE200_xxxxxxxx`, `_TZE204_xxxxxxxx`, `_TYZB01_xxxxxxxx` | Reported by the device as the Zigbee `manufacturerName`. The `_TZ`/`_TY` prefix followed by digits and an underscore is the strong signal. | | `product_model` | `TS0201`, `TS0203`, `TS011F`, `TS0601` | Reported as the Zigbee `modelID`. `TS0601` is the generic Tuya-datapoint model and covers dozens of physically different products. | | `manufacturer` | `Tuya`, `TuYa`, `_TZ...` variants | Some units report a plain vendor string. | Case is significant. `_TZ3000_abcd1234` and `TS0201` are case-sensitive vendor identifiers; QiPort preserves case exactly as the device reported it, because case-folding would collapse genuinely different models onto one profile. Do **not** lowercase before comparing, and if you must be lenient, be lenient explicitly. A practical, deliberately conservative predicate: ```ts const TUYA_MANUFACTURER_RE = /^_(TZ|TY)[A-Z0-9]*_/i; const TUYA_MODEL_RE = /^TS\d{4}[A-Z]?$/i; export function looksLikeTuya(device: Record): boolean { const manufacturer = typeof device['manufacturer'] === 'string' ? device['manufacturer'] : ''; const model = typeof device['product_model'] === 'string' ? device['product_model'] : ''; if (TUYA_MANUFACTURER_RE.test(manufacturer)) return true; if (/^tuya$/i.test(manufacturer.trim())) return true; if (TUYA_MODEL_RE.test(model)) return true; return false; } ``` This is a **heuristic**, and you should label it as one in the code you generate. It will occasionally miss a rebadged unit and occasionally match a non-Tuya device that happens to use a similar model string. That is acceptable, because — see the next section — almost no application logic should depend on the answer. ### 4.2 `TS0601` is not a product `TS0601` is Tuya's catch-all model for devices that speak Tuya's proprietary datapoint cluster instead of standard Zigbee clusters. A soil-moisture probe, a radiator valve, a smart curtain motor and an energy meter can all report `TS0601`. **Model string alone cannot tell you what a `TS0601` device does.** Its capability list can. ### 4.3 Key your logic on capabilities, not on model strings This is the reason the platform exists. QiPort's Edge Agent and device catalogue already did the work of turning Zigbee clusters and Tuya datapoints into canonical capability keys. An application that switches on `product_model` re-introduces exactly the coupling the platform removed, and breaks the first time a customer buys the same sensor from a different vendor. | Instead of | Do | |---|---| | `if (d.product_model === 'TS0201')` | `if (caps.has('environment.temperature'))` | | `if (d.manufacturer.startsWith('_TZ3000'))` | `if (caps.has('binary.contact'))` | | A per-model parsing table | Read `state[]` and switch on `capability` | | Hard-coded units per model | Read `unit` from the state entry or the catalogue | Legitimate uses of `manufacturer` / `product_model`: display in a device-detail screen, support diagnostics, inventory reports, and answering the literal question "which of these are Tuya". Everything else keys on capabilities. ### 4.4 The canonical capability catalogue `GET /v1/catalog/capabilities` — LIVE, requires `catalog:read`. Fetch it once at startup and cache it; it changes on platform releases, not per request. | Capability key | Unit | Type | Writable | Safety class | |---|---|---|---|---| | `environment.temperature` | C | number | no | S0 | | `environment.humidity` | %RH | number | no | S0 | | `environment.co2` | ppm | number | no | S0 | | `environment.pressure` | hPa | number | no | S0 | | `environment.illuminance` | lx | number | no | S0 | | `environment.soil_moisture` | % | number | no | S0 | | `mass.weight` | kg | number | no | S0 | | `motion.vibration` | m/s2 | number | no | S0 | | `water.leak` | — | boolean | no | S0 | | `water.flow` | L/min | number | no | S0 | | `electrical.voltage` | V | number | no | S0 | | `electrical.current` | A | number | no | S0 | | `electrical.power_active` | W | number | no | S0 | | `electrical.energy_import` | Wh | number | no | S0 | | `binary.contact` | — | boolean | no | S0 | | `binary.switch` | — | boolean | **yes** | S1 | | `actuator.relay` | — | boolean | **yes** | S2 | | `actuator.valve` | — | boolean | **yes** | S2 | | `actuator.contactor` | — | boolean | **yes** | S3 | | `system.battery_percent` | % | number | no | S0 | | `system.rssi` | dBm | number | no | S0 | | `system.lqi` | — | number | no | S0 | | `system.uptime` | s | number | no | S0 | | `system.device_temperature` | C | number | no | S0 | | `system.battery_voltage` | V | number | no | S0 | | `energy.pv_power` | W | number | no | S0 | | `energy.load_power` | W | number | no | S0 | | `energy.grid_power` | W | number | no | S0 | | `energy.battery_power` | W | number | no | S0 | | `energy.produced_today` | kWh | number | no | S0 | | `energy.consumed_today` | kWh | number | no | S0 | | `evse.power` | W | number | no | S0 | | `evse.current` | A | number | no | S0 | | `evse.session_energy` | kWh | number | no | S0 | | `evse.total_energy` | kWh | number | no | S0 | | `evse.state` | — | text | no | S0 | | `vehicle.battery_percent` | % | number | no | S0 | | `vehicle.range` | km | number | no | S0 | | `hvac.setpoint_temperature` | C | number | no | S0 | | `hvac.mode` | — | text | no | S0 | **Two of these keys are signed, and two families are counters.** `energy.grid_power` is positive when the house imports and negative when it exports; `energy.battery_power` is positive when the battery charges. Taking the absolute value of either one reverses what the reading means. And `energy.produced_today`, `energy.consumed_today` and `evse.total_energy` are counters that only rise, so they aggregate with `max` or `last` — a mean of a counter is not a quantity. The two daily counters reset at midnight in the site's own timezone; `evse.total_energy` never resets. Safety classes: | Class | Meaning | Controllable by default | |---|---|---| | S0 | Read-only measurement or diagnostic. | n/a — never writable | | S1 | Low-risk write, e.g. an indicator or a low-power switch. | Yes, subject to scope | | S2 | Relay or valve. Physical consequence. | **No.** Grant required. | | S3 | Mains contactor. High consequence. | **No.** Grant required, plus additional ceremony. | For S2 and S3 a grant row must exist, and a database trigger refuses to create one unless the device's catalogue profile is `verified`. A device whose profile is still a draft cannot be armed at all, by any API call, by anyone. Build your UI so that this is a normal state with an explanation, not an error dialog. Units come from the platform, but always render the `unit` you received rather than a constant you hard-coded — it is the only thing that stays correct when a capability gains a variant. --- ## 5. Read state and history — LIVE ### 5.1 Latest state `GET /v1/devices/{publicId}/state` — requires `devices:read`. Returns the latest value per capability. This response shape **is** pinned. ```bash curl -sS https://api.qiport.eu/v1/devices/QP-SIM-00001/state \ -H "Authorization: Bearer $TOKEN" ``` ```json { "deviceId": "QP-SIM-00001", "connectivity": "online", "lastSeenAt": "2026-08-05T06:11:33.789Z", "state": [ { "capability": "environment.temperature", "value": 35.34, "unit": "C", "quality": ["valid"], "deviceTime": "2026-08-05T06:11:33.001Z", "serverTime": "2026-08-05T06:11:33.789Z" } ] } ``` | Field | Notes | |---|---| | `deviceId` | The public id, same value you put in the path. | | `connectivity` | Connectivity of the device as the platform last observed it. | | `lastSeenAt` | ISO-8601 UTC. Use it to grey out stale readings. | | `state[].capability` | A canonical key from section 4.4. Switch on this. | | `state[].value` | `number` or `boolean` depending on the capability. Do not coerce. | | `state[].unit` | Render this; do not hard-code the unit. | | `state[].quality` | Array of markers, e.g. `["valid"]`. Treat anything that is not `["valid"]` as suspect and say so in the UI rather than silently charting it. | | `state[].deviceTime` | When the device says it measured. May be skewed or missing on battery devices. | | `state[].serverTime` | When QiPort stored it. **Use this for ordering and for "is this stale".** | Two rules for handling values: - **Do not coerce.** `binary.contact` is a JSON boolean. Do not accept `1`, `"1"`, `"true"` or truthiness. If you see a non-boolean on a boolean capability, that is a bug worth surfacing, not something to paper over. - **A capability absent from `state[]` means "no value yet"**, not zero and not false. A battery sensor that has not reported since onboarding has no `system.battery_percent` entry. Render "no data", never `0%`. ### 5.2 History `GET /v1/devices/{publicId}/telemetry` — requires `telemetry:read`. | Parameter | Type | Notes | |---|---|---| | `from` | ISO-8601 timestamp | Start of window, inclusive. | | `to` | ISO-8601 timestamp | End of window. | | `capabilities` | comma-separated capability keys | Restrict to what you will actually draw. | | `resolution` | `raw` \| `1m` \| `5m` \| `1h` \| `1d` | Bucket width. | | `aggregation` | e.g. `avg`, `min`, `max`, `sum` | How samples inside a bucket are combined. Ignored for `raw`. | Aggregate response (pinned): ```json { "deviceId": "QP-SIM-00001", "resolution": "1h", "aggregation": "avg", "points": [ { "capability_key": "environment.temperature", "bucket": "2026-08-04T18:00:00.000Z", "value": 34.9, "samples": 60 }, { "capability_key": "environment.temperature", "bucket": "2026-08-04T19:00:00.000Z", "value": 35.2, "samples": 60 } ] } ``` `points[]` entries are **snake_case** inside a camelCase envelope. See section 1.5. `samples` is the number of underlying readings in the bucket. A bucket with `samples: 1` next to buckets with `samples: 60` is a gap, not a spike — render it differently or you will show a customer a temperature cliff that never happened. ### 5.3 Worked example: choosing `resolution` for a chart Pick the resolution from the **window length and the pixel width of the chart**, not from the user's menu. The goal is roughly one point per pixel column: fewer and the chart is blocky, more and you transferred data the screen cannot show. | Window | Sensible resolution | Points per capability (approx.) | |---|---|---| | Last 1 hour | `1m` | 60 | | Last 6 hours | `1m` | 360 | | Last 24 hours | `5m` | 288 | | Last 7 days | `1h` | 168 | | Last 30 days | `1h` | 720 | | Last 90 days | `1d` | 90 | | Last 1 year | `1d` | 365 | ```ts export type Resolution = 'raw' | '1m' | '5m' | '1h' | '1d'; /** Choose the coarsest resolution that still fills `targetPoints` columns. */ export function chooseResolution( fromIso: string, toIso: string, targetPoints = 800, ): Resolution { const spanMs = Date.parse(toIso) - Date.parse(fromIso); if (!Number.isFinite(spanMs) || spanMs <= 0) { throw new Error(`invalid window: ${fromIso} .. ${toIso}`); } const idealBucketMs = spanMs / targetPoints; const ladder: Array<[Resolution, number]> = [ ['1m', 60_000], ['5m', 300_000], ['1h', 3_600_000], ['1d', 86_400_000], ]; for (const [name, ms] of ladder) { if (idealBucketMs <= ms) return name; } return '1d'; } ``` A 24-hour temperature chart for one device: ```bash FROM=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) TO=$(date -u +%Y-%m-%dT%H:%M:%SZ) curl -sS -G https://api.qiport.eu/v1/devices/QP-SIM-00001/telemetry \ -H "Authorization: Bearer $TOKEN" \ --data-urlencode "from=$FROM" \ --data-urlencode "to=$TO" \ --data-urlencode 'capabilities=environment.temperature,environment.humidity' \ --data-urlencode 'resolution=5m' \ --data-urlencode 'aggregation=avg' ``` Which `aggregation` to use: | Capability kind | Aggregation | Why | |---|---|---| | Temperature, humidity, pressure, CO2, illuminance, weight | `avg` | Continuous quantities. | | `electrical.power_active` on a "peak load" chart | `max` | An average hides the peak that trips a breaker. | | `electrical.energy_import` | `max` per bucket, then difference between buckets | It is a monotonically increasing meter total. Summing it multiplies your customer's energy bill by the number of samples. | | `water.leak`, `binary.contact`, `binary.switch` | `max` | Booleans aggregate to "did it happen in this bucket". | | `system.battery_percent` | `min` | You care about the worst point in the window. | --- ## 6. Bind a device to your domain object — LIVE ### 6.1 The model A binding is the join between a QiPort device and an object in *your* application's domain. It exists so your application never has to keep its own device-to-object mapping table in sync with the fleet. `POST /v1/bindings` — requires `devices:write`. ```json { "deviceId": "QP-SIM-00001", "resourceType": "hive", "resourceId": "hive_7", "channelMapping": { "environment.temperature": "brood_temp", "mass.weight": "gross_weight" } } ``` | Field | Required | Meaning | |---|---|---| | `deviceId` | yes | Device public id. | | `resourceType` | yes | Your domain type. Free-form string chosen by your application, e.g. `hive`, `room`, `pump`, `paddock`. | | `resourceId` | yes | Your identifier for that object, e.g. `hive_7`, `room_kitchen`. | | `channelMapping` | no | Maps canonical capability keys to names meaningful in your domain. | `GET /v1/bindings` — requires `devices:read`. Lists bindings. The list envelope is not pinned; read it defensively (section 1.6). ### 6.2 Two examples **A beehive scale.** One device on a hive, reporting weight, temperature and humidity: ```bash curl -sS -X POST https://api.qiport.eu/v1/bindings \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "deviceId": "QP-SIM-00001", "resourceType": "hive", "resourceId": "hive_7", "channelMapping": { "mass.weight": "gross_weight", "environment.temperature": "brood_temp", "environment.humidity": "brood_humidity" } }' ``` **A room.** Several devices bound to the same room — a Tuya temp/humidity sensor, a door contact and a CO2 monitor. `resourceType`/`resourceId` are identical across all three; only `deviceId` differs: ```bash for DEV in QP-SIM-00002 QP-SIM-00003 QP-SIM-00004; do curl -sS -X POST https://api.qiport.eu/v1/bindings \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"deviceId\":\"$DEV\",\"resourceType\":\"room\",\"resourceId\":\"room_kitchen\"}" done ``` The relationship is many devices to one resource. Your application reads `GET /v1/bindings`, groups by `resourceType` + `resourceId`, and renders one room card fed by three devices. Nothing in the application needs to know that one of them is a `TS0201`. `channelMapping` is optional and purely a labelling convenience. If you omit it, use the canonical capability keys directly — that is the more portable choice and the one to prefer when you are unsure. --- ## 7. Control a device — **LIVE** > **Status: LIVE.** `POST /v1/devices/{publicId}/commands` is deployed and actuates real > hardware. Everything in this section describes what the running system does. > > Expect refusal to be the normal case, not the error case. A command is refused unless > every condition holds at once, and the refusal arrives as HTTP 403 with a `reasons[]` > array meant to be shown to a human. ### 7.1 Request shape ```http POST /v1/devices/QP-SIM-00007/commands Authorization: Bearer Content-Type: application/json Idempotency-Key: hive7-valve-close-20260805T0611Z { "capabilityKey": "actuator.valve", "value": false, "mode": "desired", "ttlSeconds": 60, "confirm": true } ``` | Field | Provisional meaning | |---|---| | `capabilityKey` | Canonical capability key. Must be writable in the catalogue. | | `value` | Typed exactly as the capability declares. A boolean capability takes `true`/`false` — `1`, `"1"` and `"on"` are refused, deliberately, with no coercion anywhere. | | `mode` | `desired` (a setpoint that is reconciled — "the valve should be closed") or `imperative` (delivered once, never replayed — "pulse the door strike"). Replaying an imperative is how a gate opens twice. | | `ttlSeconds` | How long the command stays live. Clocked by the server. Out-of-range is rejected, not clamped. | | `confirm` | Required for S2 capabilities. | | `idempotencyKey` / `Idempotency-Key` header | Makes a retry safe. Reusing a key with a *different* capability or value is a conflict, not a silent success. | Send an idempotency key on every command. A double-click without one produces two actuations. ### 7.2 The safety gate A command is refused unless **every** one of these is affirmatively true: 1. The capability exists in the catalogue and is `writable`. 2. The device exists and belongs to your project. 3. The device's lifecycle is not quarantined, revoked, retired or suspended. 4. An unrevoked write grant exists for exactly that (device, capability) pair. 5. The value matches the capability's declared type, range, enum and step. 6. The class ceremony has been performed: S2 needs explicit confirmation; S3 needs an additional two-step challenge and a scope that is not issued today. Absence of information is refusal. There is no path that reaches "allowed" by falling through. Consequences for your code: - **403 is normal.** Render the `reasons[]` array. Do not retry, do not escalate to an error boundary, do not log it as a crash. - **403, not 404, for a device in another project.** The commands path answers 403 for a device it will not talk about, so it cannot be used to enumerate a fleet. Note this is the *opposite* of `GET /v1/devices/:publicId`, which answers 404. Do not write one shared "if 404 the device is gone" handler across both. - **Write grants are administrative.** `PUT /v1/devices/{publicId}/capability-grants` is LIVE. Enabling write on an S2/S3 capability additionally requires the device's catalogue profile to be `verified`; a database trigger refuses otherwise and it surfaces as 409. Revoking a grant deliberately requires *less* privilege than granting one. Do not generate a UI flow that silently grants itself permission before acting. ### 7.5 Pairing — how a device gets into the fleet at all **Status: LIVE.** Nothing joins by itself and nothing joins permanently by accident. ``` POST /v1/pairing/sessions open a window on one hub GET /v1/pairing/sessions/{id} window + everything that joined it POST /v1/pairing/sessions/{id}/adopt candidate -> device POST /v1/pairing/sessions/{id}/reject refuse it, keep the record DELETE /v1/pairing/sessions/{id} shut the window now ``` Four things a generated client must get right: 1. **A candidate is not a device.** It has no `public_id` and cannot be commanded, queried for telemetry, or bound to anything. Do not model it as a device with a missing field. 2. **The window closes itself.** You do not have to call DELETE. Do not build a retry loop that reopens a window because the first one "seems to have expired". 3. **One open window per hub.** A second open returns 409 with the existing session id. That is a normal outcome when two operators press the button; show the existing window rather than erroring. 4. **Adoption is not permission.** The response carries `write_blocked[]`. Every entry is a capability created with an explicit denial row. Control requires granting each one through `PUT /v1/devices/{publicId}/capability-grants`. A UI that hides this teaches operators that adopting an actuator means controlling it, which is exactly the belief the safety model exists to prevent. Subscribe to `GET /v1/live?types=pairing_update` for push instead of polling. The event carries `sessionId`, `hubId`, `ieeeAddr` and `state`; `deviceId` is null until adoption, because until adoption there is no device. ### 7.3 Feature-detect. Do not assume. Write the call so that "not deployed yet" is an ordinary branch: ```ts export type CommandOutcome = | { kind: 'accepted'; commandId: string } | { kind: 'unavailable'; feature: string; status: number } | { kind: 'refused'; reasons: string[] }; export async function sendCommand( apiFetch: (path: string, init: RequestInit) => Promise, publicId: string, capabilityKey: string, value: unknown, idempotencyKey: string, ): Promise { const res = await apiFetch(`/v1/devices/${encodeURIComponent(publicId)}/commands`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ capabilityKey, value, mode: 'desired', confirm: true }), }); // Not deployed yet: 404 (route absent) or 501 (explicitly unimplemented). if (res.status === 404 || res.status === 501) { return { kind: 'unavailable', feature: 'device.commands', status: res.status }; } if (res.status === 403) { const body = (await res.json().catch(() => ({}))) as { reasons?: unknown }; const reasons = Array.isArray(body.reasons) ? body.reasons.map(String) : ['forbidden']; return { kind: 'refused', reasons }; } if (!res.ok) throw new Error(`command failed: HTTP ${res.status}`); const body = (await res.json()) as { command?: { commandId?: string } }; const commandId = body.command?.commandId; if (typeof commandId !== 'string') throw new Error('command response missing commandId'); return { kind: 'accepted', commandId }; } ``` Note the 404 ambiguity this creates: on the commands path a 404 means "route not deployed", because a device that is missing or foreign answers 403. That is why the check above is safe here and would be wrong on `GET /v1/devices/:publicId`. **A command being accepted is not the device having acted.** `201` means the command was validated, recorded and queued. Confirmation that the hardware did anything arrives later. Never render "valve closed" on a 201 — render "closing…" and confirm from state. --- ## 8. Live data — **LIVE** > **Status: LIVE.** `GET /v1/live` (Server-Sent Events) is deployed. Polling > `GET /v1/devices/{publicId}/state` at 30–60 s remains a valid fallback for clients that > cannot hold a stream open — stay inside the 30 requests/second rate limit if you do. > > Event types on the stream: `telemetry`, `device_status`, `command_update`, > `pairing_update`. Filter with `?types=`. ### 8.1 Why a fetch-based reader is required The browser's native `EventSource` **cannot set an `Authorization` header**. There is no option for it in the API. Since `/v1/live` requires a bearer token and putting a token in the query string would write credentials into every proxy and access log, an application must read the stream with `fetch` and parse SSE frames itself. Two consequences you must not skip: 1. `EventSource` normally resends the last event id automatically as the `Last-Event-ID` header on reconnect. A fetch-based reader must track the last `id:` it saw and resend it itself — either as the `Last-Event-ID` header or as a `lastEventId` query parameter. Without it, every reconnect loses whatever arrived while disconnected. 2. `fetch` does not reconnect. You own the retry loop and its backoff. ### 8.2 Provisional shape | Item | Provisional value | |---|---| | Path | `GET /v1/live` | | Scope | `telemetry:live` | | Query | `deviceIds`, `capabilities`, `types` (all comma-separated), `heartbeat` (seconds, clamped 15–120), `lastEventId` | | Response | `text/event-stream` | | Frames | `id: `, `event: telemetry`, `data: ` | | Heartbeat | A comment line `: ping` at the heartbeat interval. Not an event; ignore it, but use it to detect a dead socket. | | Control events | `event: replay_gap` — you missed data; backfill from the telemetry REST endpoint. `event: stream_error` — the stream is ending. | | Reconnect hint | `retry: 5000` on open. | | Concurrency | Limited concurrent streams per client; over the limit is `429` with `Retry-After`. | The project scope always comes from the token. Query parameters can only ever **narrow** what the token permits, never widen it — do not generate a `projectId` query parameter. ### 8.3 Reader ```ts export type SseFrame = { id: string | null; event: string; data: string }; /** Reads an SSE stream with a bearer token. Yields frames; ignores comment lines. */ export async function* readSse( url: string, token: string, lastEventId: string | null, signal: AbortSignal, ): AsyncGenerator { const headers: Record = { Authorization: `Bearer ${token}`, Accept: 'text/event-stream', }; if (lastEventId !== null) headers['Last-Event-ID'] = lastEventId; const res = await fetch(url, { headers, signal }); if (res.status === 404 || res.status === 501) { throw new Error('QiPort live stream unavailable on this deployment'); } if (!res.ok || res.body === null) { throw new Error(`live stream failed: HTTP ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) return; buffer += decoder.decode(value, { stream: true }); // Frames are separated by a blank line. Handle both \n\n and \r\n\r\n. for (;;) { const sep = /\r?\n\r?\n/.exec(buffer); if (sep === null) break; const rawFrame = buffer.slice(0, sep.index); buffer = buffer.slice(sep.index + sep[0].length); let id: string | null = null; let event = 'message'; const dataLines: string[] = []; for (const line of rawFrame.split(/\r?\n/)) { if (line.startsWith(':')) continue; // comment / heartbeat const colon = line.indexOf(':'); const field = colon === -1 ? line : line.slice(0, colon); const rest = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, ''); if (field === 'id') id = rest; else if (field === 'event') event = rest; else if (field === 'data') dataLines.push(rest); } if (dataLines.length > 0) yield { id, event, data: dataLines.join('\n') }; } } } ``` Wrap it in a reconnect loop that keeps the last id and backs off exponentially with jitter, exactly as in section 9. Behind nginx, an SSE stream that arrives in minutes-late lumps means response buffering is on upstream — that is a server configuration issue, not a client bug. --- ## 9. Error handling ### 9.1 Status code table | Status | Body `error` | Meaning | Retry? | How | |---|---|---|---|---| | 200 / 201 | — | Success. 201 on create. | — | — | | 400 | `invalid_query`, `invalid_filter` | Malformed query string. | **No** | Fix the request. Retrying is a loop. | | 401 | `unauthorized`, `unauthenticated` | Missing, expired or invalid token. | **Once** | Discard cached token, re-authenticate, replay once. A second 401 is fatal. | | 403 | `forbidden` | Either a missing scope, or a safety refusal carrying `reasons[]`. | **No** | With `reasons[]`: show them; this is a policy decision. Without: your token lacks the scope — fix the token request, not the call. | | 404 | `not_found` | Resource absent **or** not visible to this token. | **No** | Do not distinguish "gone" from "not yours" — you cannot, deliberately. | | 409 | `conflict` | Idempotency key reused with different content, or a state conflict. | **No** | A retry with the same body is safe *only* if the body is byte-identical. | | 422 | `invalid_request`, `invalid_value` | The value or body failed validation — wrong type, out of range, off the step grid, missing. | **No** | Fix the value. No coercion will be applied server-side. | | 429 | rate limited | 30 req/s per IP, burst 60 at the edge. Also concurrent-stream limits on `/v1/live`. | **Yes** | Honour `Retry-After` if present. Otherwise exponential backoff with jitter. | | 500 | `internal_error` | Server fault. | **Yes, bounded** | Max ~3 attempts, exponential backoff. Do not hammer. | | 502 / 503 / 504 | `transport_unavailable` etc. | Upstream or dependency unavailable. | **Yes** | Exponential backoff with jitter; honour `Retry-After`. | | Network error / timeout | — | DNS, TLS, socket, abort. | **Yes** unless aborted | Backoff. If the request was aborted by your own `AbortSignal`, do not retry. | **Never retry a non-idempotent POST without an idempotency key.** `POST /v1/bindings` and `POST /v1/devices/{publicId}/commands` both fall in this category. With an idempotency key a retry is safe; without one, a retry after a timeout can create a second binding or a second actuation. ### 9.2 Backoff Exponential with full jitter, capped: ```ts export function backoffDelayMs(attempt: number, retryAfterHeader?: string | null): number { // Server instruction wins when it is present and parseable (seconds, or an HTTP date). if (retryAfterHeader !== undefined && retryAfterHeader !== null) { const asSeconds = Number(retryAfterHeader); if (Number.isFinite(asSeconds) && asSeconds >= 0) { return Math.min(asSeconds * 1000, 60_000); } const asDate = Date.parse(retryAfterHeader); if (Number.isFinite(asDate)) { return Math.min(Math.max(asDate - Date.now(), 0), 60_000); } } const base = Math.min(30_000, 500 * 2 ** attempt); // 500, 1000, 2000, 4000 ... cap 30 s return Math.random() * base; // full jitter: avoids a synchronised retry stampede } ``` Use full jitter, not a fixed delay. A fleet dashboard with 200 tiles that all retry at exactly 2000 ms reproduces the burst that caused the 429. ### 9.3 Error body shape Errors are JSON. Two shapes occur: ```json { "error": "not_found" } ``` ```json { "error": "forbidden", "reasons": ["capability_not_writable", "device_not_addressable"] } ``` `reasons[]` is a machine-readable list of refusal codes. Render it. Do not attempt to map unknown reason codes to friendlier text you invented — show the code and a generic sentence, so an operator can search for it. Not every error body is guaranteed to be JSON (an edge proxy can emit HTML for a 502). Parse defensively: `await res.json().catch(() => ({}))`. --- ## 10. What does not exist yet Complete list, so you do not invent any of it. ### 10.1 BUILDING — written, not deployed Nothing. Every endpoint in this guide is deployed and was exercised end to end before being written down. Commands, capability grants and the live stream moved from BUILDING to LIVE; pairing shipped straight to LIVE. ### 10.2 NOT BUILT — roadmap only | Feature | Status | What that means for your code | |---|---|---| | **Pairing / Zigbee permit-join** | No endpoint, no shape, no date. | You cannot make a device join. Devices must already exist in the registry. Do not generate a "Add device" flow that calls an API. | | **Provisioning and claiming** | No endpoint. | No way to register new hardware programmatically. | | **OTA firmware updates** | No endpoint. | Do not offer a firmware update button. | | **Webhooks** | No endpoint, no subscription model, no signing scheme. | Do not generate a webhook receiver "for QiPort". Poll, or wait for SSE. | | **Admin UI** | Does not exist. | Do not link to one or embed one. | | **QiPortKit SDK packages** | Not published. | No `@qiport/*` npm package, no PyPI package. Write a plain HTTP client. | ### 10.3 Things that exist but are commonly assumed wrongly | Assumption | Reality | |---|---| | "There is a WebSocket endpoint." | No. The live channel is SSE. | | "The app can subscribe to MQTT for live data." | No. Applications never touch the broker. That is rule 1. | | "`GET /v1/devices` returns everything in one response." | No. It is cursor-paginated. | | "A device that joined the radio is in the fleet." | No. It is a candidate until someone adopts it. | | "An adopted actuator can be commanded." | No. Writable capabilities are blocked at adoption. | | "A writable capability means this device is controllable." | No. A per-device grant must exist, and S2/S3 need a `verified` catalogue profile. | | "404 means the device was deleted." | It means "not visible to this token". That includes another tenant's device. | | "There is a refresh token." | No. `client_credentials` has none. Request a new access token. | | "`DELETE /v1/bindings/:id` exists." | Not documented here. Only `POST /v1/bindings` and `GET /v1/bindings` are LIVE. Do not generate other verbs against it. | | "Capability keys can be derived from the model." | No. `TS0601` covers dozens of unrelated products. Read the capability list. | --- ## 11. Checklist before you emit code - [ ] No MQTT client library imported. - [ ] No connection to the SLZB-06P10's address, port or web UI. - [ ] No call to a pairing, provisioning, OTA or webhook endpoint. - [ ] No `@qiport/*` package import. - [ ] Client secret read from the environment, never in client-side code, never in a query string. - [ ] Token cached with expiry, refreshed 60 s early, one in-flight request, re-auth on 401 exactly once. - [ ] Cursor pagination loop with a hard page bound. - [ ] Application logic switches on capability keys, not `product_model`. - [ ] `points[].capability_key` is read as snake_case; `state[].capability` as camelCase envelope. - [ ] Missing capability rendered as "no data", never as `0`. - [ ] 403 with `reasons[]` handled as a normal outcome and displayed. - [ ] 429 and 5xx retried with exponential backoff **and jitter**; 4xx not retried. - [ ] Adoption flows treat `write_blocked` as expected output, not as an error. - [ ] `AbortSignal` plumbed through so requests can be cancelled.