/** * QiPort HTTP client. * * Node 22 built-ins only: fetch, URL, URLSearchParams, AbortSignal, TextDecoder. * No npm dependencies. No MQTT. No SDK. * * Design rules this file enforces, in order of how badly they break things: * * 1. Applications never connect to mqtt.qiport.eu. This file has no broker code. * 2. Endpoints that are BUILDING throw a typed QiPortFeatureUnavailableError that * names the feature and its status, instead of a generic 404 that reads like a * bad device id. * 3. Response envelopes that this platform has not pinned are read tolerantly * rather than guessed. * 4. 4xx is never retried (except one re-auth on 401). 429 and 5xx are retried * with exponential backoff and full jitter. * * Written in erasable TypeScript so it runs directly on Node 22: * node find-tuya-devices.ts */ /* ------------------------------------------------------------------ * * Feature status registry * ------------------------------------------------------------------ */ export type FeatureStatus = 'LIVE' | 'BUILDING' | 'NOT_BUILT'; /** * Every endpoint this client knows about, and whether it is deployed. * Kept as data so a caller can inspect it and render an honest UI. */ export const QIPORT_FEATURES: Record = { 'oauth.token': { status: 'LIVE', path: 'POST /v1/oauth/token', note: '' }, 'devices.list': { status: 'LIVE', path: 'GET /v1/devices', note: '' }, 'devices.get': { status: 'LIVE', path: 'GET /v1/devices/:publicId', note: '' }, 'devices.state': { status: 'LIVE', path: 'GET /v1/devices/:publicId/state', note: '' }, 'devices.telemetry': { status: 'LIVE', path: 'GET /v1/devices/:publicId/telemetry', note: '' }, 'catalog.capabilities': { status: 'LIVE', path: 'GET /v1/catalog/capabilities', note: '' }, 'sites.list': { status: 'LIVE', path: 'GET /v1/sites', note: '' }, 'bindings.create': { status: 'LIVE', path: 'POST /v1/bindings', note: '' }, 'bindings.list': { status: 'LIVE', path: 'GET /v1/bindings', note: '' }, 'health': { status: 'LIVE', path: 'GET /health', note: 'unauthenticated' }, 'device.commands': { status: 'BUILDING', path: 'POST /v1/devices/:publicId/commands', note: 'Written, not deployed. Request shape is provisional. Poll state instead.', }, 'live.sse': { status: 'BUILDING', path: 'GET /v1/live', note: 'Written, not deployed. Poll GET /v1/devices/:publicId/state until it ships.', }, 'device.capabilityGrants': { status: 'BUILDING', path: 'POST /v1/devices/:publicId/capability-grants', note: 'Written, not deployed. Gates S2/S3 control.', }, 'pairing.permitJoin': { status: 'NOT_BUILT', path: '(none)', note: 'There is no pairing API. A Zigbee join cannot be triggered over HTTP today.', }, 'provisioning.claim': { status: 'NOT_BUILT', path: '(none)', note: 'No provisioning or claiming API.' }, 'ota.update': { status: 'NOT_BUILT', path: '(none)', note: 'No OTA API.' }, 'webhooks': { status: 'NOT_BUILT', path: '(none)', note: 'No webhook subscriptions, no signing scheme.' }, }; /* ------------------------------------------------------------------ * * Errors * ------------------------------------------------------------------ */ export class QiPortError extends Error { readonly status: number; readonly code: string; readonly reasons: string[]; readonly requestPath: string; readonly retryable: boolean; constructor( message: string, opts: { status: number; code: string; reasons?: string[]; requestPath: string; retryable?: boolean }, ) { super(message); this.name = 'QiPortError'; this.status = opts.status; this.code = opts.code; this.reasons = opts.reasons ?? []; this.requestPath = opts.requestPath; this.retryable = opts.retryable ?? false; } } /** 401. The token was missing, expired or rejected. */ export class QiPortAuthError extends QiPortError { constructor(requestPath: string, code: string) { super(`QiPort refused the credentials for ${requestPath} (${code})`, { status: 401, code, requestPath, }); this.name = 'QiPortAuthError'; } } /** * 403. Either a missing OAuth scope, or a safety refusal carrying `reasons[]`. * This is an expected outcome for actuators, not a crash. */ export class QiPortForbiddenError extends QiPortError { constructor(requestPath: string, code: string, reasons: string[]) { const detail = reasons.length > 0 ? ` reasons: ${reasons.join(', ')}` : ' (likely a missing OAuth scope)'; super(`QiPort refused ${requestPath}.${detail}`, { status: 403, code, reasons, requestPath }); this.name = 'QiPortForbiddenError'; } } /** 404. Absent, or not visible to this token. The API deliberately does not distinguish. */ export class QiPortNotFoundError extends QiPortError { constructor(requestPath: string) { super(`QiPort has no resource visible at ${requestPath} for this token`, { status: 404, code: 'not_found', requestPath, }); this.name = 'QiPortNotFoundError'; } } /** 422. The body or a value failed validation. Nothing is coerced server-side. */ export class QiPortValidationError extends QiPortError { constructor(requestPath: string, code: string, reasons: string[]) { super(`QiPort rejected the request body for ${requestPath}: ${reasons.join(', ') || code}`, { status: 422, code, reasons, requestPath, }); this.name = 'QiPortValidationError'; } } /** 429. Edge limit is 30 req/s per IP with burst 60. */ export class QiPortRateLimitError extends QiPortError { readonly retryAfterMs: number | null; constructor(requestPath: string, retryAfterMs: number | null) { super(`QiPort rate limited ${requestPath}`, { status: 429, code: 'rate_limited', requestPath, retryable: true, }); this.name = 'QiPortRateLimitError'; this.retryAfterMs = retryAfterMs; } } /** * The endpoint is not deployed. Thrown instead of a bare 404 so a caller can tell * "this feature does not exist yet" from "that device id is wrong". */ export class QiPortFeatureUnavailableError extends QiPortError { readonly feature: string; readonly featureStatus: FeatureStatus; constructor(feature: string, observedStatus: number | null) { const meta = QIPORT_FEATURES[feature]; const status: FeatureStatus = meta?.status ?? 'NOT_BUILT'; const path = meta?.path ?? '(unknown)'; const note = meta?.note ?? ''; const observed = observedStatus === null ? 'not called' : `HTTP ${observedStatus}`; super( `QiPort feature "${feature}" (${path}) is ${status} and is not available yet [${observed}]. ${note}`.trim(), { status: observedStatus ?? 501, code: 'feature_unavailable', requestPath: path }, ); this.name = 'QiPortFeatureUnavailableError'; this.feature = feature; this.featureStatus = status; } } /* ------------------------------------------------------------------ * * Types * ------------------------------------------------------------------ */ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; export type QiPortDevice = { /** Stable public id, e.g. "QP-SIM-00001". Never the internal UUID, never the IEEE address. */ publicId: string; status: string | null; manufacturer: string | null; /** Zigbee modelID, e.g. "TS0201". snake_case on the wire. */ product_model: string | null; /** null / absent means this device is a hub. snake_case on the wire. */ parent_hub_id: string | null; site_id: string | null; /** Every field the API returned, unmodified. */ raw: Record; }; export type QiPortStateEntry = { capability: string; value: number | boolean | string | null; unit: string | null; quality: string[]; deviceTime: string | null; serverTime: string | null; }; export type QiPortDeviceState = { deviceId: string; connectivity: string | null; lastSeenAt: string | null; state: QiPortStateEntry[]; }; export type Resolution = 'raw' | '1m' | '5m' | '1h' | '1d'; /** NOTE the snake_case keys — they are snake_case inside a camelCase envelope. */ export type QiPortTelemetryPoint = { capability_key: string; bucket: string; value: number | boolean | null; samples: number | null; }; export type QiPortTelemetry = { deviceId: string; resolution: string; aggregation: string | null; points: QiPortTelemetryPoint[]; }; export type QiPortCapability = { key: string; unit: string | null; valueType: string | null; writable: boolean; safetyClass: string | null; raw: Record; }; export type QiPortBinding = { deviceId: string; resourceType: string; resourceId: string; channelMapping?: Record; }; export type QiPortClientOptions = { baseUrl?: string; clientId: string; clientSecret: string; /** Space-separated. Only scopes the token endpoint issues today. */ scope?: string; /** Per-request timeout in ms. Default 20000. */ timeoutMs?: number; /** Retry attempts for 429/5xx/network. Default 3 (so up to 4 total sends). */ maxRetries?: number; /** * Send client credentials as HTTP Basic instead of in the form body. * Flip this if the token endpoint answers 401 invalid_client with correct * credentials — that is the documented one-line switch. */ useBasicAuthForToken?: boolean; /** Injectable for tests. Defaults to global fetch. */ fetchImpl?: typeof fetch; /** Injectable for tests. Defaults to Date.now. */ now?: () => number; }; export const QIPORT_DEFAULT_BASE_URL = 'https://api.qiport.eu'; /** Scopes the token endpoint issues today. commands:* are NOT among them. */ export const QIPORT_LIVE_SCOPES = [ 'devices:read', 'devices:write', 'telemetry:read', 'telemetry:live', 'catalog:read', ] as const; /* ------------------------------------------------------------------ * * Envelope readers (see guide section 1.6) * ------------------------------------------------------------------ */ function asRecord(v: unknown): Record | null { return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; } function str(rec: Record, ...keys: string[]): string | null { for (const k of keys) { const v = rec[k]; if (typeof v === 'string' && v.length > 0) return v; } return null; } function bool(rec: Record, ...keys: string[]): boolean | null { for (const k of keys) { const v = rec[k]; if (typeof v === 'boolean') return v; } return null; } function num(rec: Record, ...keys: string[]): number | null { for (const k of keys) { const v = rec[k]; if (typeof v === 'number' && Number.isFinite(v)) return v; // pg returns NUMERIC/BIGINT as strings; accept that without coercing garbage. if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v); } return null; } /** * Pull a list out of a response whose envelope key this platform has not pinned. * Accepts a bare array, or an object holding the array under any of `keys`, * `items` or `data`. */ export function extractList(payload: unknown, ...keys: string[]): Record[] { const candidates: unknown[] = []; if (Array.isArray(payload)) { candidates.push(payload); } else { const rec = asRecord(payload); if (rec !== null) { for (const k of [...keys, 'items', 'data', 'results']) { if (Array.isArray(rec[k])) candidates.push(rec[k]); } } } const first = candidates[0]; if (!Array.isArray(first)) return []; const out: Record[] = []; for (const row of first) { const rec = asRecord(row); if (rec !== null) out.push(rec); } return out; } /** Pull the opaque continuation token out of a page. Never parse or construct one. */ export function extractCursor(payload: unknown): string | null { const rec = asRecord(payload); if (rec === null) return null; const direct = str(rec, 'nextCursor', 'next_cursor', 'cursor'); if (direct !== null) return direct; const page = asRecord(rec['page']) ?? asRecord(rec['pagination']) ?? asRecord(rec['meta']); if (page !== null) return str(page, 'nextCursor', 'next_cursor', 'cursor'); return null; } /* ------------------------------------------------------------------ * * Backoff * ------------------------------------------------------------------ */ /** Exponential backoff with full jitter. `Retry-After` wins when present. */ export function backoffDelayMs(attempt: number, retryAfterHeader?: string | null): number { if (retryAfterHeader !== undefined && retryAfterHeader !== null && retryAfterHeader !== '') { 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); return Math.round(Math.random() * base); } function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted === true) { reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')); return; } const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); const onAbort = (): void => { clearTimeout(t); reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted')); }; signal?.addEventListener('abort', onAbort, { once: true }); }); } /* ------------------------------------------------------------------ * * Client * ------------------------------------------------------------------ */ type RequestOptions = { method?: string; query?: Record; body?: unknown; headers?: Record; signal?: AbortSignal; /** Skip the Authorization header (only GET /health). */ anonymous?: boolean; }; export class QiPortClient { readonly baseUrl: string; private readonly clientId: string; private readonly clientSecret: string; private readonly scope: string; private readonly timeoutMs: number; private readonly maxRetries: number; private readonly useBasicAuthForToken: boolean; private readonly fetchImpl: typeof fetch; private readonly now: () => number; private accessToken: string | null = null; private tokenExpiresAtMs = 0; /** One in-flight token request, shared by every concurrent caller. */ private tokenInFlight: Promise | null = null; constructor(options: QiPortClientOptions) { if (options.clientId === '' || options.clientSecret === '') { throw new Error('QiPortClient requires clientId and clientSecret'); } this.baseUrl = options.baseUrl ?? QIPORT_DEFAULT_BASE_URL; this.clientId = options.clientId; this.clientSecret = options.clientSecret; this.scope = options.scope ?? 'devices:read telemetry:read catalog:read'; this.timeoutMs = options.timeoutMs ?? 20_000; this.maxRetries = options.maxRetries ?? 3; this.useBasicAuthForToken = options.useBasicAuthForToken ?? false; this.fetchImpl = options.fetchImpl ?? globalThis.fetch; this.now = options.now ?? Date.now; for (const s of this.scope.split(/\s+/).filter((x) => x !== '')) { if (!(QIPORT_LIVE_SCOPES as readonly string[]).includes(s)) { throw new Error( `scope "${s}" is not issued by the QiPort token endpoint today. ` + `Issued scopes: ${QIPORT_LIVE_SCOPES.join(' ')}`, ); } } } /* ---------------- auth ---------------- */ /** Cached bearer token. Refreshed 60 s before expiry. One request in flight at a time. */ async getAccessToken(signal?: AbortSignal): Promise { const skewMs = 60_000; if (this.accessToken !== null && this.now() < this.tokenExpiresAtMs - skewMs) { return this.accessToken; } if (this.tokenInFlight !== null) return this.tokenInFlight; this.tokenInFlight = this.requestToken(signal).finally(() => { this.tokenInFlight = null; }); return this.tokenInFlight; } private async requestToken(signal?: AbortSignal): Promise { const url = new URL('/v1/oauth/token', this.baseUrl); const params = new URLSearchParams({ grant_type: 'client_credentials', scope: this.scope }); const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (this.useBasicAuthForToken) { const basic = Buffer.from(`${this.clientId}:${this.clientSecret}`, 'utf8').toString('base64'); headers['Authorization'] = `Basic ${basic}`; } else { params.set('client_id', this.clientId); params.set('client_secret', this.clientSecret); } const res = await this.fetchWithTimeout(url, { method: 'POST', headers, body: params.toString() }, signal); if (res.status === 401) { const body = await safeJson(res); const code = typeof body['error'] === 'string' ? body['error'] : 'invalid_client'; throw new QiPortAuthError('POST /v1/oauth/token', code); } if (!res.ok) { const text = await res.text().catch(() => ''); throw new QiPortError(`token request failed: HTTP ${res.status} ${text}`.trim(), { status: res.status, code: 'token_request_failed', requestPath: 'POST /v1/oauth/token', retryable: res.status >= 500 || res.status === 429, }); } const body = await safeJson(res); const token = typeof body['access_token'] === 'string' ? body['access_token'] : null; const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 3600; if (token === null) { throw new QiPortError('token response contained no access_token', { status: res.status, code: 'token_response_malformed', requestPath: 'POST /v1/oauth/token', }); } this.accessToken = token; this.tokenExpiresAtMs = this.now() + expiresIn * 1000; return token; } /** Drop the cached token. Called automatically on a 401 from a normal request. */ invalidateToken(): void { this.accessToken = null; this.tokenExpiresAtMs = 0; } /* ---------------- transport ---------------- */ private async fetchWithTimeout(url: URL, init: RequestInit, signal?: AbortSignal): Promise { const timeout = AbortSignal.timeout(this.timeoutMs); const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]); return this.fetchImpl(url, { ...init, signal: combined }); } /** * One authenticated request, with: * - retry on 429/5xx/network using exponential backoff with full jitter, * - exactly one re-auth-and-replay on 401, * - no retry on any other 4xx. */ async request(path: string, options: RequestOptions = {}): Promise { const method = options.method ?? 'GET'; const label = `${method} ${path}`; const url = new URL(path, this.baseUrl); if (options.query !== undefined) { for (const [k, v] of Object.entries(options.query)) { if (v !== undefined) url.searchParams.set(k, String(v)); } } // Serialise once so a retry resends byte-identical content. const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body); let reauthed = false; let attempt = 0; for (;;) { const headers: Record = { Accept: 'application/json', ...options.headers }; if (bodyText !== undefined) headers['Content-Type'] = 'application/json'; if (options.anonymous !== true) { headers['Authorization'] = `Bearer ${await this.getAccessToken(options.signal)}`; } let res: Response; try { res = await this.fetchWithTimeout(url, { method, headers, body: bodyText }, options.signal); } catch (err) { // A caller-initiated abort is final; a timeout or socket error is retryable. if (options.signal?.aborted === true) throw err; if (attempt >= this.maxRetries) { throw new QiPortError(`${label} failed: ${err instanceof Error ? err.message : String(err)}`, { status: 0, code: 'network_error', requestPath: label, retryable: true, }); } await sleep(backoffDelayMs(attempt), options.signal); attempt += 1; continue; } if (res.status === 401 && !reauthed && options.anonymous !== true) { reauthed = true; this.invalidateToken(); continue; // replay exactly once, then fail } if (res.status === 429 || res.status >= 500) { if (attempt >= this.maxRetries) { if (res.status === 429) { throw new QiPortRateLimitError(label, backoffDelayMs(attempt, res.headers.get('retry-after'))); } const body = await safeJson(res); throw new QiPortError(`${label} failed with HTTP ${res.status}`, { status: res.status, code: typeof body['error'] === 'string' ? body['error'] : 'server_error', reasons: readReasons(body), requestPath: label, retryable: true, }); } await sleep(backoffDelayMs(attempt, res.headers.get('retry-after')), options.signal); attempt += 1; continue; } if (!res.ok) throw await toError(res, label); if (res.status === 204) return undefined as T; return (await safeJson(res)) as T; } } /* ---------------- LIVE endpoints ---------------- */ /** GET /health — unauthenticated. */ async health(signal?: AbortSignal): Promise> { return this.request>('/health', { anonymous: true, signal }); } /** GET /v1/devices — one page. Prefer iterateDevices/listAllDevices. */ async listDevicesPage( filters: { status?: string; model?: string; site_id?: string; cursor?: string } = {}, signal?: AbortSignal, ): Promise<{ devices: QiPortDevice[]; nextCursor: string | null }> { const payload = await this.request('/v1/devices', { query: filters, signal }); return { devices: extractList(payload, 'devices').map(toDevice), nextCursor: extractCursor(payload), }; } /** GET /v1/devices — every page. Bounded so a server-side cursor bug cannot loop forever. */ async *iterateDevices( filters: { status?: string; model?: string; site_id?: string } = {}, signal?: AbortSignal, ): AsyncGenerator { let cursor: string | undefined = undefined; let pages = 0; for (;;) { const page = await this.listDevicesPage({ ...filters, cursor }, signal); for (const d of page.devices) yield d; if (page.nextCursor === null) return; if (page.nextCursor === cursor) { throw new Error('QiPort device pagination returned the same cursor twice; aborting'); } cursor = page.nextCursor; pages += 1; if (pages > 500) throw new Error('QiPort device pagination exceeded 500 pages; aborting'); } } async listAllDevices( filters: { status?: string; model?: string; site_id?: string } = {}, signal?: AbortSignal, ): Promise { const out: QiPortDevice[] = []; for await (const d of this.iterateDevices(filters, signal)) out.push(d); return out; } /** GET /v1/devices/:publicId */ async getDevice(publicId: string, signal?: AbortSignal): Promise { const payload = await this.request(`/v1/devices/${encodeURIComponent(publicId)}`, { signal }); const rec = asRecord(payload); if (rec === null) throw new Error(`unexpected device payload for ${publicId}`); const inner = asRecord(rec['device']) ?? rec; return toDevice(inner); } /** GET /v1/devices/:publicId/state — latest value per capability. Shape is pinned. */ async getDeviceState(publicId: string, signal?: AbortSignal): Promise { const payload = await this.request(`/v1/devices/${encodeURIComponent(publicId)}/state`, { signal }); const rec = asRecord(payload) ?? {}; const entries: QiPortStateEntry[] = []; for (const e of extractList(rec['state'] ?? [], 'state')) { const capability = str(e, 'capability', 'capability_key'); if (capability === null) continue; const raw = e['value']; entries.push({ capability, value: typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'string' ? raw : null, unit: str(e, 'unit'), quality: Array.isArray(e['quality']) ? (e['quality'] as unknown[]).map(String) : [], deviceTime: str(e, 'deviceTime', 'device_time'), serverTime: str(e, 'serverTime', 'server_time'), }); } return { deviceId: str(rec, 'deviceId', 'device_id') ?? publicId, connectivity: str(rec, 'connectivity'), lastSeenAt: str(rec, 'lastSeenAt', 'last_seen_at'), state: entries, }; } /** * GET /v1/devices/:publicId/telemetry * Aggregate points are snake_case inside a camelCase envelope. That is not a typo. */ async getTelemetry( publicId: string, query: { from: string; to: string; capabilities?: string[]; resolution?: Resolution; aggregation?: string }, signal?: AbortSignal, ): Promise { const payload = await this.request( `/v1/devices/${encodeURIComponent(publicId)}/telemetry`, { query: { from: query.from, to: query.to, capabilities: query.capabilities === undefined ? undefined : query.capabilities.join(','), resolution: query.resolution, aggregation: query.aggregation, }, signal, }, ); const rec = asRecord(payload) ?? {}; const points: QiPortTelemetryPoint[] = []; for (const p of extractList(rec['points'] ?? [], 'points')) { const key = str(p, 'capability_key', 'capabilityKey', 'capability'); if (key === null) continue; const value = p['value']; points.push({ capability_key: key, bucket: str(p, 'bucket', 'ts', 'time') ?? '', value: typeof value === 'number' || typeof value === 'boolean' ? value : num(p, 'value'), samples: num(p, 'samples'), }); } return { deviceId: str(rec, 'deviceId', 'device_id') ?? publicId, resolution: str(rec, 'resolution') ?? query.resolution ?? 'raw', aggregation: str(rec, 'aggregation'), points, }; } /** GET /v1/catalog/capabilities */ async listCapabilities(signal?: AbortSignal): Promise { const payload = await this.request('/v1/catalog/capabilities', { signal }); return extractList(payload, 'capabilities').map((c) => ({ key: str(c, 'key', 'capability_key', 'capability') ?? '', unit: str(c, 'unit'), valueType: str(c, 'valueType', 'value_type'), writable: bool(c, 'writable') ?? false, safetyClass: str(c, 'safetyClass', 'safety_class'), raw: c, })); } /** GET /v1/sites */ async listSites(signal?: AbortSignal): Promise[]> { return extractList(await this.request('/v1/sites', { signal }), 'sites'); } /** GET /v1/bindings */ async listBindings(signal?: AbortSignal): Promise[]> { return extractList(await this.request('/v1/bindings', { signal }), 'bindings'); } /** POST /v1/bindings — requires devices:write. Not idempotent; do not blind-retry. */ async createBinding(binding: QiPortBinding, signal?: AbortSignal): Promise> { return this.request>('/v1/bindings', { method: 'POST', body: binding, // A retried POST could create a second binding, so no automatic retry here. signal, }); } /* ---------------- BUILDING endpoints ---------------- */ /** * POST /v1/devices/:publicId/commands — **BUILDING**. * * Throws QiPortFeatureUnavailableError immediately unless `attempt: true`. * With `attempt: true` the call is made and 404/501 is still mapped to the same * typed error, so the caller never sees a bare 404 that reads like a bad id. */ async sendCommand( publicId: string, command: { capabilityKey: string; value: unknown; mode?: 'desired' | 'imperative'; ttlSeconds?: number; confirm?: boolean; idempotencyKey: string; }, options: { attempt?: boolean; signal?: AbortSignal } = {}, ): Promise> { if (options.attempt !== true) throw new QiPortFeatureUnavailableError('device.commands', null); try { return await this.request>( `/v1/devices/${encodeURIComponent(publicId)}/commands`, { method: 'POST', headers: { 'Idempotency-Key': command.idempotencyKey }, body: { capabilityKey: command.capabilityKey, value: command.value, mode: command.mode ?? 'desired', ttlSeconds: command.ttlSeconds, confirm: command.confirm, }, signal: options.signal, }, ); } catch (err) { // On this path a 404 means "route not deployed": a missing or foreign device // answers 403 by design, so it cannot be a device-id problem. if (err instanceof QiPortError && (err.status === 404 || err.status === 501)) { throw new QiPortFeatureUnavailableError('device.commands', err.status); } throw err; } } /** POST /v1/devices/:publicId/capability-grants — **BUILDING**. */ async setCapabilityGrant( publicId: string, grant: { capabilityKey: string; writeEnabled: boolean; reason: string }, options: { attempt?: boolean; signal?: AbortSignal } = {}, ): Promise> { if (options.attempt !== true) throw new QiPortFeatureUnavailableError('device.capabilityGrants', null); if (grant.reason.trim().length < 8) { throw new Error('capability grant reason must be at least 8 characters — it is an audit record'); } try { return await this.request>( `/v1/devices/${encodeURIComponent(publicId)}/capability-grants`, { method: 'POST', body: grant, signal: options.signal }, ); } catch (err) { if (err instanceof QiPortError && (err.status === 404 || err.status === 501)) { throw new QiPortFeatureUnavailableError('device.capabilityGrants', err.status); } throw err; } } /** * GET /v1/live (Server-Sent Events) — **BUILDING**. * * Native EventSource cannot send an Authorization header, so this is a * fetch-based reader. It does NOT reconnect; the caller owns the retry loop and * must resend `lastEventId` or data is lost across a reconnect. */ async *streamLive( filters: { deviceIds?: string[]; capabilities?: string[]; types?: string[]; lastEventId?: string } = {}, options: { attempt?: boolean; signal?: AbortSignal } = {}, ): AsyncGenerator<{ id: string | null; event: string; data: string }> { if (options.attempt !== true) throw new QiPortFeatureUnavailableError('live.sse', null); const url = new URL('/v1/live', this.baseUrl); if (filters.deviceIds !== undefined) url.searchParams.set('deviceIds', filters.deviceIds.join(',')); if (filters.capabilities !== undefined) url.searchParams.set('capabilities', filters.capabilities.join(',')); if (filters.types !== undefined) url.searchParams.set('types', filters.types.join(',')); const headers: Record = { Authorization: `Bearer ${await this.getAccessToken(options.signal)}`, Accept: 'text/event-stream', }; if (filters.lastEventId !== undefined) headers['Last-Event-ID'] = filters.lastEventId; // No AbortSignal.timeout here: the whole point of the stream is that it stays open. const res = await this.fetchImpl(url, { headers, signal: options.signal }); if (res.status === 404 || res.status === 501) { throw new QiPortFeatureUnavailableError('live.sse', res.status); } if (!res.ok || res.body === null) { throw await toError(res, 'GET /v1/live'); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; for (;;) { const chunk = await reader.read(); if (chunk.done) return; buffer += decoder.decode(chunk.value, { stream: true }); for (;;) { const sep = /\r?\n\r?\n/.exec(buffer); if (sep === null || sep.index === undefined) 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 / ": ping" 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') }; } } } /* ---------------- NOT BUILT ---------------- */ /** * There is no pairing API. This method exists only so an agent that reaches for * "start pairing" gets a precise explanation instead of inventing a URL. */ startPairing(): never { throw new QiPortFeatureUnavailableError('pairing.permitJoin', null); } } /* ------------------------------------------------------------------ * * Helpers * ------------------------------------------------------------------ */ function toDevice(rec: Record): QiPortDevice { return { publicId: str(rec, 'publicId', 'public_id', 'deviceId', 'device_id', 'id') ?? '', status: str(rec, 'status', 'lifecycle_status', 'connectivity'), manufacturer: str(rec, 'manufacturer', 'manufacturer_name', 'manufacturerName'), product_model: str(rec, 'product_model', 'productModel', 'model', 'modelID'), parent_hub_id: str(rec, 'parent_hub_id', 'parentHubId'), site_id: str(rec, 'site_id', 'siteId'), raw: rec, }; } async function safeJson(res: Response): Promise> { // An edge proxy can answer 502 with HTML. Never let that become a parse crash. try { const parsed: unknown = await res.json(); return asRecord(parsed) ?? (Array.isArray(parsed) ? { items: parsed } : {}); } catch { return {}; } } function readReasons(body: Record): string[] { return Array.isArray(body['reasons']) ? (body['reasons'] as unknown[]).map(String) : []; } async function toError(res: Response, label: string): Promise { const body = await safeJson(res); const code = typeof body['error'] === 'string' ? body['error'] : `http_${res.status}`; const reasons = readReasons(body); if (res.status === 401) return new QiPortAuthError(label, code); if (res.status === 403) return new QiPortForbiddenError(label, code, reasons); if (res.status === 404) return new QiPortNotFoundError(label); if (res.status === 422) return new QiPortValidationError(label, code, reasons); return new QiPortError(`${label} failed with HTTP ${res.status} (${code})`, { status: res.status, code, reasons, requestPath: label, }); } /* ------------------------------------------------------------------ * * Tuya identification + chart helpers * ------------------------------------------------------------------ */ const TUYA_MANUFACTURER_RE = /^_(TZ|TY)[A-Z0-9]*_/i; const TUYA_MODEL_RE = /^TS\d{4}[A-Z]?$/i; /** * Heuristic. Tuya hardware reports a `_TZxxxx_`/`_TYxxxx_` manufacturer string * and/or a `TSxxxx` model id. It will miss rebadged units and can match a * lookalike. Use it for reporting and diagnostics — never for behaviour. * Behaviour keys on capabilities. */ export function looksLikeTuya(device: QiPortDevice): boolean { const manufacturer = device.manufacturer ?? ''; const model = 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; } /** A device with no parent hub is a hub / coordinator. */ export function isHub(device: QiPortDevice): boolean { return device.parent_hub_id === null || device.parent_hub_id === ''; } /** * Coarsest resolution that still fills roughly `targetPoints` chart columns. * The default of 800 matches the ladder table in the integration guide * (1h->1m, 24h->5m, 7d->1h, 30d->1h, 90d->1d, 1y->1d). */ 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 ideal = 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 (ideal <= ms) return name; return '1d'; }