# QiPort example app A minimal, complete TypeScript client for the QiPort API, plus one program that uses it. - `qiport-client.ts` — the client. Copy this into your project. - `find-tuya-devices.ts` — lists devices, marks the likely Tuya ones, prints each device's capabilities and current values. No npm dependencies. Node 22 built-ins only (`fetch`, `URL`, `AbortSignal`, `TextDecoder`). --- ## Run it Node 22.18 or newer runs TypeScript directly — no build step, no `tsc`, no `ts-node`: ```bash export QIPORT_CLIENT_ID='your-client-id' export QIPORT_CLIENT_SECRET='your-client-secret' node find-tuya-devices.ts ``` On Node 22.6–22.17, enable type stripping explicitly: ```bash node --experimental-strip-types find-tuya-devices.ts ``` Both files are ES modules. If you copy them into a project that has a `package.json`, set `"type": "module"` in it — otherwise Node re-parses each file and prints a `MODULE_TYPELESS_PACKAGE_JSON` warning. The code still runs either way. Verified under `tsc --strict` with `noUncheckedIndexedAccess`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `verbatimModuleSyntax` and `isolatedModules`. No `any`. Options: | Variable / flag | Effect | |---|---| | `QIPORT_CLIENT_ID` | Required. OAuth2 client id. | | `QIPORT_CLIENT_SECRET` | Required. OAuth2 client secret. Server-side only — never ship it to a browser or a mobile binary. | | `QIPORT_BASE_URL` | Override the API base. Default `https://api.qiport.eu`. | | `QIPORT_SITE_ID` | Restrict the listing to one site (`site_id` filter). | | `--all` | List every device, not just the ones that look like Tuya. | Example output: ``` QiPort https://api.qiport.eu catalogue: 24 canonical capabilities registry: 4 device(s) visible to this token topology: 1 hub(s) / 3 child device(s) hub QP-HUB-00001 (SLZB-06P10) — 3 child device(s) likely Tuya devices: 2 of 4 ============================================================================== QP-SIM-00002 manufacturer _TZE200_xyz product_model TS0601 status active topology via hub QP-HUB-00001 note TS0601 is a generic Tuya model id. It says nothing about what this device does — read the capability list below. connectivity online, last seen 4s ago capabilities actuator.valve false [S2, writable, grant required] environment.soil_moisture 41.2 % [S0] environment.temperature 35.34 C [S0] system.battery_percent 88 % [S0] quality=stale ``` --- ## What it does 1. Requests an OAuth2 `client_credentials` token and caches it (refreshed 60 s before the 3600 s expiry, one in-flight request shared across callers). 2. Fetches `GET /v1/catalog/capabilities` once, for units, writability and safety class. 3. Walks `GET /v1/devices` through every cursor page. 4. Splits hubs from child devices using `parent_hub_id`. 5. Applies a heuristic Tuya filter on `manufacturer` (`_TZ*` / `_TY*`) and `product_model` (`TSxxxx`) — used for reporting only, never for behaviour. 6. Fetches `GET /v1/devices/{publicId}/state` for each selected device, four at a time so it stays far inside the 30 requests/second per-IP limit. 7. Prints each capability with its value, unit, safety class, and whether a write grant would be required. ## What it cannot do None of these are missing features of this program. They are absent from the API. | Not possible | Why | |---|---| | **Trigger a Zigbee join / permit-join on the SLZB-06P10** | There is no pairing API. NOT BUILT. Devices must already exist in the registry. | | Provision, claim or adopt new hardware | No provisioning API. NOT BUILT. | | Turn a relay or valve on or off | `POST /v1/devices/:publicId/commands` is BUILDING — written, not deployed. | | Receive live push updates | `GET /v1/live` (SSE) is BUILDING. Poll `/state` instead. | | Grant itself permission to control an S2/S3 actuator | `POST /v1/devices/:publicId/capability-grants` is BUILDING, and the database refuses a grant unless the device's catalogue profile is `verified`. | | Update firmware | No OTA API. NOT BUILT. | | Register a webhook | No webhook API. NOT BUILT. | | Connect to `mqtt.qiport.eu` | Applications never connect to the broker. There is no MQTT code in this client and there must not be. | If the registry is empty, the program says so and exits. That is the correct behaviour — retrying will not populate it. --- ## Using the client in your own code ```ts import { QiPortClient, looksLikeTuya } from './qiport-client.ts'; const client = new QiPortClient({ clientId: process.env.QIPORT_CLIENT_ID!, clientSecret: process.env.QIPORT_CLIENT_SECRET!, scope: 'devices:read telemetry:read catalog:read', }); for await (const device of client.iterateDevices({ site_id: 'site_apiary_north' })) { const state = await client.getDeviceState(device.publicId); const temp = state.state.find((s) => s.capability === 'environment.temperature'); if (temp !== undefined) { console.log(`${device.publicId}: ${temp.value} ${temp.unit ?? ''}`); } } ``` Key logic on `capability`, not on `product_model`. `looksLikeTuya()` is for reports and diagnostics, not for behaviour — a `TS0601` can be a soil probe, a radiator valve or an energy meter, and its capability list is the only thing that tells you which. ### Behaviour built into the client | Concern | Behaviour | |---|---| | Token | Cached with expiry, refreshed 60 s early, one in-flight request shared by concurrent callers. | | 401 | Token discarded, re-authenticated, request replayed **once**. A second 401 throws `QiPortAuthError`. | | 429 and 5xx | Retried up to `maxRetries` (default 3) with exponential backoff and **full jitter**; `Retry-After` wins when present. | | Other 4xx | Never retried. Thrown as `QiPortForbiddenError`, `QiPortNotFoundError`, `QiPortValidationError` or `QiPortError`. | | Cancellation | Every method takes an `AbortSignal`. A caller-initiated abort is never retried. | | Timeouts | Per-request timeout (default 20 s) combined with the caller's signal via `AbortSignal.any`. Not applied to the SSE stream. | | Unpinned envelopes | `extractList` / `extractCursor` read tolerantly instead of guessing a key. | | Non-JSON error bodies | Parsed defensively — an edge proxy returning HTML for a 502 does not become a parse crash. | | Scope validation | The constructor rejects scopes the token endpoint does not issue today, including `commands:*`. | ### BUILDING endpoints throw a named error, not a bare 404 ```ts import { QiPortFeatureUnavailableError } from './qiport-client.ts'; try { await client.sendCommand('QP-SIM-00002', { capabilityKey: 'actuator.valve', value: true, idempotencyKey: 'hive7-valve-open-20260805T0611Z', }); } catch (err) { if (err instanceof QiPortFeatureUnavailableError) { // QiPort feature "device.commands" (POST /v1/devices/:publicId/commands) is // BUILDING and is not available yet [not called]. Written, not deployed. // Request shape is provisional. Poll state instead. console.log(err.feature, err.featureStatus); } } ``` By default `sendCommand`, `setCapabilityGrant` and `streamLive` throw **without making a request**, so you find out at development time. Pass `{ attempt: true }` to genuinely feature-detect against a deployment; a resulting 404 or 501 is still mapped to `QiPortFeatureUnavailableError`, so it never reads like a wrong device id. `client.startPairing()` always throws. It exists only so that code reaching for "start pairing" gets a precise explanation instead of inventing a URL that does not exist. ### Error types | Class | Status | Retryable | Notes | |---|---|---|---| | `QiPortAuthError` | 401 | after one automatic re-auth | Bad or revoked credentials. | | `QiPortForbiddenError` | 403 | no | Missing scope, or a safety refusal. Read `.reasons`. | | `QiPortNotFoundError` | 404 | no | Absent **or** not visible to this token — deliberately indistinguishable. | | `QiPortValidationError` | 422 | no | Wrong type, out of range, missing value. Nothing is coerced server-side. | | `QiPortRateLimitError` | 429 | yes | Carries `.retryAfterMs`. | | `QiPortFeatureUnavailableError` | 501 / observed | no | Carries `.feature` and `.featureStatus` (`BUILDING` / `NOT_BUILT`). | | `QiPortError` | any | `.retryable` | Base class. | --- ## Testing without the real API Both files take `QIPORT_BASE_URL`, and `QiPortClient` accepts an injectable `fetchImpl` and `now`, so you can point them at a local stub or drive them from unit tests without a network. The client makes no assumptions about the host beyond the paths documented in `../ai-integration-guide.md`.