# Reading a live QiTune home through QiPort This page is a worked example against a real installation. Every identifier, key and response below was taken from the running system, not invented for documentation. If you are a coding agent: read [ai-integration-guide.md](./ai-integration-guide.md) first for the rules, then use this page for a concrete target to write code against. Base URL: `https://api.qiport.eu` · Auth: OAuth2 client credentials · Format: JSON --- ## 1. The gateway model A QiTune hub is a **gateway**. It holds a client certificate, connects to the MQTT broker over mTLS, and reports on behalf of the Zigbee devices behind it. Applications never touch MQTT — they read the REST API below. ``` Zigbee device ──radio──> hub (QP-GW-…) ──mTLS MQTT──> QiPort ingest ──> ts.telemetry │ your application <──HTTPS───────┘ ``` Two consequences that matter when you write a client: - **Every Zigbee device has its own identity.** It is not a field inside the hub. `QP-Z-` is a device in `/v1/devices` with its own state and its own history, and it carries `parent_hub_id` so the platform knows which gateway to route a command through. - **Devices appear on their own.** When a hub reports for a child that is not registered yet, QiPort registers it and the child inherits tenant, project and site from the gateway. The gateway has already proven its identity with a certificate and the broker ACL confines it to `q1/hub//#`, so it cannot register a device that is not behind it. Gateway telemetry topic (devices only — applications must not connect to MQTT): ``` q1/hub//child//telemetry ``` --- ## 2. A real installation One hub, five Zigbee devices, a photovoltaic system with a car, and an air conditioner. Values below are a genuine sample. | Device | Public ID | Reports | |---|---|---| | Hallway thermometer | `QP-Z-A4C13802BE8F9D3C` | `environment.temperature`, `environment.humidity`, `system.lqi` | | Living-room thermometer | `QP-Z-A4C1383D4E6D5DE0` | the same, plus `system.battery_percent` | | Smart plug (water heater) | `QP-Z-A4C1385627AC1B88` | `electrical.power_active`, `binary.switch` | | Philips Hue bulb | `QP-Z-00178801027B1F19` | `binary.switch`, `system.lqi` | | Thermometer, joined 30 August | `QP-Z-A4C1380015E8D3B5` | `environment.temperature`, `environment.humidity`, `system.battery_percent`, `system.lqi` | | Hub | `QP-GW-QT03` | `system.rssi`, `system.free_heap`, `system.uptime` | | House battery | `QP-V-BATT1` | `system.battery_voltage`, `system.battery_percent`, `energy.battery_power` | | Inverter | `QP-V-INV1` | `energy.pv_power`, `energy.load_power`, `energy.grid_power`, `energy.battery_power`, `energy.produced_today`, `energy.consumed_today`, `system.battery_voltage`, `system.battery_percent` | | Wallbox | `QP-V-EVSE1` | `evse.power`, `evse.current`, `evse.session_energy`, `evse.total_energy` | | Vehicle | `QP-V-CAR-MUSTANG` | `vehicle.battery_percent`, `vehicle.range` | | Air conditioner (indoor unit) | `QP-V-DAIKIN1` | `environment.temperature`, `hvac.setpoint_temperature`, `hvac.mode`, `binary.switch` | | Air conditioner (outdoor unit) | `QP-V-DAIKIN-OUT` | `environment.temperature` | A live sample, all read through the same API. Every line carries the time that reading was taken, not the time it was fetched — they are not the same, and the API never pretends otherwise: ``` 17:42 QP-V-INV1 pv 307 W · load 220 W · grid 0 W · battery +87 W 53.62 V · 100 % · today 16.26 kWh made, 13.20 kWh used 17:42 QP-V-EVSE1 0 W · 0 A · session 9.20 kWh · lifetime 7494.90 kWh 17:42 QP-V-CAR-MUSTANG 100 % · 443 km 17:38 QP-V-DAIKIN1 22.0 °C · target 21.0 °C · cooling · on 17:38 QP-V-DAIKIN-OUT 24.5 °C 16:40 QP-Z-A4C1383D4E… 25.5 °C · 51 %RH · battery 100 % · LQI 25 16:40 QP-Z-A4C138001… 23.0 °C · 55 %RH · battery 100 % · LQI 142 15:28 QP-Z-A4C1385627… 0 W · switch on ``` Read that sample as a state of the house, and it says something specific: the sun is going down (307 W left), the car finished charging and is at 100 % after taking 9.2 kWh, the wallbox has fallen to zero, and the last of the solar is topping the house battery off at 87 W. None of that was computed — every number came from the device that measures it. That is enough to draw a whole energy dashboard from one API: generation, the household's own consumption, what goes to or from the grid, what the battery is doing, what the car is taking, and how far that car can then drive. Note the spread of timestamps. The connectors publish every 30 s, the air conditioner every 20–60 minutes, and the Zigbee devices whenever the hub last reported. A client that renders all of this as one instant is lying by a small margin most of the time and by a large one occasionally. Render each value with its own age. ### Two kinds of device, one API The thermometers and the plug are **Zigbee devices behind a gateway**. The inverter, the wallbox and the car are **cloud-connected devices**: QiPort does not talk to Victron, go-e or Ford directly — a connector polls the vendor cloud, translates the readings into this vocabulary, and publishes them under the device's own identity. A client cannot tell the difference, and should not need to. Both kinds appear in `/v1/devices`, both have `/state` and `/telemetry`, both use the same capability keys. Where a reading came from is an operational detail of the platform, not something an application should encode. ### One sensor, one device The air conditioner is two devices, not one device with two temperatures. The room sensor and the sensor on the outdoor unit sit in different places and measure different air, so each is its own device and both report plain `environment.temperature`. The alternative — a second key such as `environment.temperature_outdoor` — would force every client to learn which key means which place, and would break the moment a third sensor appeared. Group by device, and the same key keeps working however many sensors a site grows. ### Freshness One rule the connectors follow, which a client can rely on: **a reading is not republished once it is stale.** A vendor cloud that stops answering makes a device go `offline` with its last values intact and their original timestamps — it does not produce a flat line of repeated numbers that would look like fresh measurement. How stale is too stale depends on the source, and that is deliberate. The inverter and the wallbox are read every half minute, so anything older than five minutes is dropped. The Daikin cloud enforces a hard daily call quota, so it is polled every 20–60 minutes and not at all between 23:00 and 05:00; its readings are accepted for 90 minutes and carry the time they were actually measured, not the time they were forwarded. The air-conditioning devices therefore go offline overnight. That is the real state of the data, and the API says so rather than smoothing it over. --- ## 3. Token ```bash curl -s -X POST https://api.qiport.eu/v1/oauth/token \ -H "Content-Type: application/json" \ -d '{"grant_type":"client_credentials", "client_id":"YOUR_CLIENT_ID", "client_secret":"YOUR_CLIENT_SECRET"}' ``` `expires_in` is 3600. Cache the token; do not request one per call. Read-only presentation clients should be issued exactly these scopes: `devices:read telemetry:read telemetry:live catalog:read`. A client that only draws charts must not hold `devices:write`. --- ## 4. Current values ```bash curl -s https://api.qiport.eu/v1/devices/QP-Z-A4C13802BE8F9D3C/state \ -H "Authorization: Bearer $TOKEN" ``` ```json { "deviceId": "QP-Z-A4C13802BE8F9D3C", "connectivity": "online", "lastSeenAt": "2026-08-30T13:01:44.448Z", "state": [ { "capability": "environment.temperature", "value": 25.1, "unit": "C", "quality": ["valid"], "deviceTime": "2026-08-30T13:01:44.000Z" }, { "capability": "environment.humidity", "value": 50, "unit": "%RH", "quality": ["valid"] }, { "capability": "system.lqi", "value": 132, "quality": ["valid"] } ] } ``` The same call against the inverter returns the energy keys instead — same envelope, same `quality` and `deviceTime` on every item: ```bash curl -s https://api.qiport.eu/v1/devices/QP-V-INV1/state \ -H "Authorization: Bearer $TOKEN" ``` ```json { "deviceId": "QP-V-INV1", "connectivity": "online", "state": [ { "capability": "energy.pv_power", "value": 4280, "unit": "W", "quality": ["valid"] }, { "capability": "energy.load_power", "value": 1235, "unit": "W", "quality": ["valid"] }, { "capability": "energy.grid_power", "value": 0, "unit": "W", "quality": ["valid"] }, { "capability": "energy.battery_power", "value": 3045, "unit": "W", "quality": ["valid"] }, { "capability": "system.battery_percent", "value": 99, "unit": "%", "quality": ["valid"] }, { "capability": "energy.produced_today", "value": 15.63, "unit": "kWh", "quality": ["valid"] } ] } ``` **Read `quality` before you display a value.** A reading outside the capability's declared range is stored and flagged `out_of_range`; a late one is flagged `delayed`. QiPort neither drops nor silently corrects a measurement — it labels it and leaves the decision to you. --- ## 5. History and aggregation ```bash # raw points, one capability curl -s "https://api.qiport.eu/v1/devices/QP-Z-A4C13802BE8F9D3C/telemetry\ ?from=2026-08-30T12:00:00Z&to=2026-08-30T13:00:00Z\ &capabilities=environment.temperature&limit=500" \ -H "Authorization: Bearer $TOKEN" # hourly means for a week — aggregated in the database, not in your client curl -s "https://api.qiport.eu/v1/devices/QP-Z-A4C13802BE8F9D3C/telemetry\ ?from=2026-08-23T00:00:00Z&to=2026-08-30T00:00:00Z\ &resolution=1h&aggregation=mean" \ -H "Authorization: Bearer $TOKEN" # a day of solar production and household load, five-minute buckets curl -s "https://api.qiport.eu/v1/devices/QP-V-INV1/telemetry\ ?from=2026-08-30T00:00:00Z&to=2026-08-31T00:00:00Z\ &capabilities=energy.pv_power,energy.load_power\ &resolution=5m&aggregation=mean" \ -H "Authorization: Bearer $TOKEN" ``` | Parameter | Values | Notes | |---|---|---| | `from`, `to` | ISO 8601 with offset | required | | `capabilities` | comma separated | omit for all | | `resolution` | `raw` `1m` `5m` `1h` `1d` | bucket width | | `aggregation` | `mean` `min` `max` `first` `last` `count` | ignored when `raw` | | `limit` | 1–10000 | default 1000 | A `raw` response returns `points[]` with `capability_key`, `device_ts`, `value_num`, `value_bool`, `value_text`, `quality_flags`. An aggregated response returns `capability_key`, `bucket`, `value`, `samples`. Note `samples`: a bucket built from two readings is not the same evidence as one built from sixty. **Pick the aggregation to match the quantity.** `mean` is right for power in watts — the mean of the buckets is the day's average power. It is wrong for `energy.produced_today` and `evse.total_energy`, which are counters that only rise: there, `max` gives the value at the end of the bucket and `last` the most recent reading, while a mean of a counter means nothing at all. --- ## 6. Capability keys in this installation Keys come from `catalog.capabilities` and are the contract. A key outside the catalog is rejected at ingest — which is why a client can rely on them. Full list: `GET /v1/catalog/capabilities`. | Key | Unit | Meaning | |---|---|---| | `environment.temperature` | C | air temperature | | `environment.humidity` | %RH | relative humidity | | `electrical.power_active` | W | active power of a socket or meter | | `binary.switch` | — | on / off, as reported by the device | | `system.battery_voltage` | V | battery terminal voltage | | `system.battery_percent` | % | state of charge | | `system.lqi` | 0–255 | Zigbee link quality | | `system.rssi` | dBm | WiFi signal of a gateway | | `energy.pv_power` | W | photovoltaic generation | | `energy.load_power` | W | household consumption | | `energy.grid_power` | W | grid exchange; **positive = import, negative = export** | | `energy.battery_power` | W | **positive = charging**, negative = discharging | | `energy.produced_today` | kWh | produced since midnight, resets daily | | `energy.consumed_today` | kWh | consumed since midnight, resets daily | | `evse.power` | W | charging power delivered to the vehicle | | `evse.current` | A | charging current per phase | | `evse.session_energy` | kWh | delivered in the current charging session | | `evse.total_energy` | kWh | lifetime total of the charger, never resets | | `evse.state` | — | text: `idle`, `connected`, `charging`, `finished` | | `vehicle.battery_percent` | % | traction battery state of charge | | `vehicle.range` | km | remaining range as the vehicle estimates it | | `hvac.setpoint_temperature` | C | target temperature set on the unit | | `hvac.mode` | — | text: `heating`, `cooling`, `auto`, `dry`, `fanOnly` | **Signs are part of the contract.** `energy.grid_power` and `energy.battery_power` are signed; a client that takes the absolute value will show a house exporting power as if it were importing it. **Two different batteries.** `system.battery_percent` on a thermometer is a coin cell that will need replacing; on `QP-V-BATT1` it is a house battery in kilowatt hours; `vehicle.battery_percent` is a traction battery. The unit is the same and the meaning is not — label them by their device, never pool them into one number. --- ## 7. Minimal client Node 22, no dependencies. ```js const BASE = 'https://api.qiport.eu'; let cached = null; async function token() { if (cached && cached.exp > Date.now() + 60_000) return cached.value; const r = await fetch(`${BASE}/v1/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: process.env.QIPORT_CLIENT_ID, client_secret: process.env.QIPORT_CLIENT_SECRET, }), }); if (!r.ok) throw new Error(`token ${r.status}`); const j = await r.json(); cached = { value: j.access_token, exp: Date.now() + j.expires_in * 1000 }; return cached.value; } async function reading(deviceId, capability) { const t = await token(); const r = await fetch(`${BASE}/v1/devices/${deviceId}/state`, { headers: { Authorization: `Bearer ${t}` }, }); if (r.status === 404) return null; // device not in this project if (!r.ok) throw new Error(`state ${r.status}`); const { state, connectivity } = await r.json(); const item = state.find((s) => s.capability === capability); if (!item) return null; // device does not report it // A stale value from an offline device is still a value — say so, do not hide it. return { value: item.value, unit: item.unit, stale: connectivity !== 'online', quality: item.quality, at: item.deviceTime }; } console.log(await reading('QP-Z-A4C13802BE8F9D3C', 'environment.temperature')); console.log(await reading('QP-V-INV1', 'energy.pv_power')); console.log(await reading('QP-V-CAR-MUSTANG', 'vehicle.range')); console.log(await reading('QP-V-DAIKIN-OUT', 'environment.temperature')); ``` --- ## 8. What a client must not assume - **A missing capability is not zero.** If `state` has no entry for a key, the device does not report it. Render nothing, not `0`. - **`connectivity: "offline"` does not empty the state.** The last known values remain, with their original timestamps. Show them with their age. - **An actuator is not writable by default.** Writing requires a grant; `403` with a `reasons[]` array is a normal outcome, not an error to retry. - **Device IDs are stable, names are not.** Names live in the end-user application and can change at any time. Key your storage on the public ID. - **Do not derive one reading from another.** `pv − load` is not the grid flow, and the battery does not make up the difference on demand; read `energy.grid_power` and `energy.battery_power`, which are measured. A computed number that disagrees with the meter is worse than no number.