/** * find-tuya-devices.ts * * Lists every device visible to the token, marks the ones that look like Tuya * hardware, and prints each one's capabilities with its current value. * * What this program CANNOT do, because the API does not offer it: * - trigger a Zigbee join / permit-join on the SLZB-06P10 * - provision, claim or adopt a new device * - control an actuator (the commands endpoint is BUILDING, not deployed) * * It only reports on devices that are already in the QiPort registry. * * Run: * export QIPORT_CLIENT_ID=... * export QIPORT_CLIENT_SECRET=... * node find-tuya-devices.ts */ import { QiPortClient, QiPortError, QiPortForbiddenError, looksLikeTuya, isHub, } from './qiport-client.ts'; import type { QiPortCapability, QiPortDevice, QiPortDeviceState } from './qiport-client.ts'; /* ------------------------------------------------------------------ * * Configuration * ------------------------------------------------------------------ */ function requireEnv(name: string): string { const v = process.env[name]; if (v === undefined || v === '') { console.error(`Missing environment variable ${name}.`); console.error('The client secret is a service credential. Never commit it, never ship it to a browser.'); process.exit(2); } return v; } const BASE_URL = process.env['QIPORT_BASE_URL'] ?? 'https://api.qiport.eu'; const SITE_ID = process.env['QIPORT_SITE_ID']; const SHOW_ALL = process.argv.includes('--all'); /* ------------------------------------------------------------------ * * Small concurrency limiter — stay well inside 30 req/s per IP * ------------------------------------------------------------------ */ async function mapWithConcurrency( items: T[], limit: number, fn: (item: T) => Promise, ): Promise { const results = new Array(items.length); let next = 0; const worker = async (): Promise => { for (;;) { const index = next; next += 1; if (index >= items.length) return; const item = items[index]; if (item === undefined) return; results[index] = await fn(item); } }; const workers: Promise[] = []; for (let i = 0; i < Math.min(limit, items.length); i += 1) workers.push(worker()); await Promise.all(workers); return results; } /* ------------------------------------------------------------------ * * Formatting * ------------------------------------------------------------------ */ function formatValue(value: number | boolean | string | null, unit: string | null): string { if (value === null) return 'no data'; if (typeof value === 'boolean') return value ? 'true' : 'false'; const text = typeof value === 'number' ? String(Number(value.toFixed(3))) : value; return unit === null || unit === '' ? text : `${text} ${unit}`; } function ageLabel(iso: string | null): string { if (iso === null) return 'never seen'; const ms = Date.now() - Date.parse(iso); if (!Number.isFinite(ms)) return iso; if (ms < 90_000) return `${Math.round(ms / 1000)}s ago`; if (ms < 5_400_000) return `${Math.round(ms / 60_000)}m ago`; if (ms < 172_800_000) return `${Math.round(ms / 3_600_000)}h ago`; return `${Math.round(ms / 86_400_000)}d ago`; } function pad(text: string, width: number): string { return text.length >= width ? text : text + ' '.repeat(width - text.length); } /* ------------------------------------------------------------------ * * Main * ------------------------------------------------------------------ */ async function main(): Promise { const client = new QiPortClient({ baseUrl: BASE_URL, clientId: requireEnv('QIPORT_CLIENT_ID'), clientSecret: requireEnv('QIPORT_CLIENT_SECRET'), scope: 'devices:read catalog:read', }); // Ctrl-C cancels in-flight requests instead of leaving sockets open. const abort = new AbortController(); process.on('SIGINT', () => { console.error('\ninterrupted'); abort.abort(new Error('SIGINT')); }); const signal = abort.signal; console.log(`QiPort ${BASE_URL}`); // 1. Capability catalogue: units, writability, safety class. Cache it; it is // a platform-release-scale document, not a per-request one. let catalogue = new Map(); try { for (const cap of await client.listCapabilities(signal)) catalogue.set(cap.key, cap); console.log(`catalogue: ${catalogue.size} canonical capabilities`); } catch (err) { if (err instanceof QiPortForbiddenError) { console.warn('catalog:read scope not granted — continuing without safety-class annotations'); catalogue = new Map(); } else { throw err; } } // 2. Devices. Cursor-paginated; the client walks every page. const filters = SITE_ID === undefined ? {} : { site_id: SITE_ID }; const devices = await client.listAllDevices(filters, signal); console.log(`registry: ${devices.length} device(s) visible to this token\n`); if (devices.length === 0) { console.log('No devices in the registry.'); console.log('This is not an error and retrying will not change it.'); console.log('QiPort has no pairing / permit-join API today, so a device cannot be made to'); console.log('join the SLZB-06P10 from here. Devices must be onboarded out-of-band first.'); return; } const hubs = devices.filter(isHub); const children = devices.filter((d) => !isHub(d)); console.log(`topology: ${hubs.length} hub(s) / ${children.length} child device(s)`); for (const hub of hubs) { const behind = children.filter((c) => c.parent_hub_id === hub.publicId).length; console.log(` hub ${hub.publicId} (${hub.product_model ?? 'unknown model'}) — ${behind} child device(s)`); } console.log(''); // 3. Filter to likely Tuya units. This is a heuristic on identity fields only; // no application behaviour below depends on the answer. const selected = SHOW_ALL ? devices : devices.filter(looksLikeTuya); const label = SHOW_ALL ? 'all devices' : 'likely Tuya devices'; console.log(`${label}: ${selected.length} of ${devices.length}`); if (!SHOW_ALL && selected.length === 0) { console.log('No device reported a _TZ*/_TY* manufacturer or a TSxxxx model id.'); console.log('Re-run with --all to list every device regardless of vendor.'); return; } console.log(''); // 4. Current state per device. Concurrency 4 keeps us far below 30 req/s. type Row = { device: QiPortDevice; state: QiPortDeviceState | null; error: string | null }; const rows = await mapWithConcurrency(selected, 4, async (device) => { try { return { device, state: await client.getDeviceState(device.publicId, signal), error: null }; } catch (err) { const message = err instanceof QiPortError ? `${err.name}: ${err.message}` : String(err); return { device, state: null, error: message }; } }); for (const { device, state, error } of rows) { const parent = device.parent_hub_id === null ? 'no parent (hub)' : `via hub ${device.parent_hub_id}`; console.log('='.repeat(78)); console.log(`${device.publicId}`); console.log(` manufacturer ${device.manufacturer ?? '(not reported)'}`); console.log(` product_model ${device.product_model ?? '(not reported)'}`); console.log(` status ${device.status ?? '(unknown)'}`); console.log(` topology ${parent}`); if (device.product_model !== null && /^TS0601$/i.test(device.product_model)) { console.log(' note TS0601 is a generic Tuya model id. It says nothing about'); console.log(' what this device does — read the capability list below.'); } if (error !== null) { console.log(` state unavailable — ${error}`); console.log(''); continue; } if (state === null) { console.log(' state unavailable'); console.log(''); continue; } console.log(` connectivity ${state.connectivity ?? '(unknown)'}, last seen ${ageLabel(state.lastSeenAt)}`); if (state.state.length === 0) { console.log(' capabilities none reported yet (device has sent no telemetry)'); console.log(''); continue; } console.log(' capabilities'); for (const entry of [...state.state].sort((a, b) => a.capability.localeCompare(b.capability))) { const cap = catalogue.get(entry.capability); const safety = cap?.safetyClass ?? ''; const writable = cap?.writable === true; const flags: string[] = []; if (safety !== '' && safety !== null) flags.push(safety); if (writable) flags.push('writable'); // A writable capability is not a controllable one: a per-device grant must // exist, and S2/S3 additionally need a verified catalogue profile. if (writable && (safety === 'S2' || safety === 'S3')) flags.push('grant required'); const suffix = flags.length > 0 ? ` [${flags.join(', ')}]` : ''; const quality = entry.quality.length > 0 && !(entry.quality.length === 1 && entry.quality[0] === 'valid') ? ` quality=${entry.quality.join('|')}` : ''; console.log( ` ${pad(entry.capability, 30)} ${pad(formatValue(entry.value, entry.unit), 16)}${suffix}${quality}`, ); } console.log(''); } console.log('='.repeat(78)); console.log('Reminder: control is not available. POST /v1/devices/:publicId/commands is'); console.log('BUILDING (written, not deployed), and even once deployed an S2/S3 actuator'); console.log('needs an explicit write grant on a verified catalogue profile.'); } main().catch((err: unknown) => { if (err instanceof QiPortError) { console.error(`\n${err.name} (HTTP ${err.status}, ${err.code}) on ${err.requestPath}`); console.error(err.message); if (err.reasons.length > 0) console.error(`reasons: ${err.reasons.join(', ')}`); process.exit(1); } console.error('\nUnexpected failure:'); console.error(err); process.exit(1); });