Client API
The Client API is how an app β a guest app on the phone, an app on the TV, a partner's server β talks to the hotel:
- the room's devices β read every device of a room, read one, control it, rename it, and follow changes live;
- the stay β who is checked in and in which language (
/stay); - the hotel's TV channels with everything needed to tune them (
/channels); - the hotel's smart apps β Netflix, YouTube β¦ (
/apps).
It is plain HTTPS and JSON, so it works from any language; a Flutter example is at the end.
Base URL
https://pcr-api.pages.dev/v1
Send one header, X-Api-Key (a partner) or X-Guest-Token (a guest's phone) β nothing else is needed. The same API is also
reachable at the long address shown in the console's Settings β Integrations; that address additionally needs the
platform's public key in an Authorization header (the short address adds it for you).
Try every request here. Each request below has a Try it panel: paste your key in the bar at the top of the page,
fill in the room and press Send. The key stays in this browser tab only. The requests are real β a command to a Live
site moves real devices, so try it on a simulated site first ("mode": "mock" in the answer says which).
The contract is kept stable on purpose: apps built by other companies rely on it. It began as the Direct API of the
IoT Simulator and was renamed when it grew beyond devices. Nothing changed for existing apps: every request of the original
is here (the device requests below), and the old address /api/direct/v1 still works. Anything not described as new
is unchanged from the original.
An app that runs inside the TV as a partner TV app does not need a key at all: the TV answers the same questions over messages.
Who may call it
Every request carries one credential, in a header:
| Header | Who | Reaches |
|---|---|---|
X-Api-Key: pcr_β¦ |
A partner's app or server | Every room of one site. Rooms are addressed by room number |
X-Guest-Token: β¦ |
A guest's phone | Only the room the phone is paired with, until check-out |
A request without a valid credential gets 401 Unauthorized.
API keys are created by a site administrator in Settings β Integrations, one per partner (name them after the partner β each key is logged by name, never in full β so one can be revoked without touching the others). A key is shown once, when it is created; revoking it stops the apps that use it at once. Keep it out of app binaries that guests can unpack: use a key from your own server, and give phones the guest token instead.
Guest tokens come from pairing. The TV shows a QR code and a 6-digit code; the phone redeems either one and receives a token that is valid for the stay and is revoked at check-out:
POST https://<project>.supabase.co/rest/v1/rpc/redeem_pairing
apikey: <public key>
Authorization: Bearer <public key>
Content-Type: application/json
{ "p_code": "123456" } // or { "p_token": "<from the QR code>" }
The QR code holds a link <app address>/?t=<token>; take the value of t as p_token. The answer lists room_number and
guest_token. A wrong or expired code returns an empty list; after 30 wrong attempts from one address in ten minutes,
redeeming is refused for a while.
Request and response format
Request bodies are Content-Type: application/json; every answer is JSON.
Success
{ "status": "success", "data": { β¦ } }
Error
{ "status": "error", "error": { "statusCode": 404, "message": "Room 999 not found" } }
Errors and what to do
| Code | Meaning |
|---|---|
200 |
The request succeeded β for a command, the device changed |
400 |
Body fields missing, or a value the device does not accept |
401 |
No credential, or an invalid, revoked or expired one. A guest token also stops working at check-out |
403 |
The component is read-only, or the call is not allowed (a guest token cannot rename a thing) |
404 |
No such room, device or component β a guest asking for another room gets this too |
405 |
The address exists but not with that method (for example POST /channels) |
429 |
Too many commands: 60 a minute per room for guests, 240 for a partner key. Wait Retry-After seconds |
502 |
The building refused the command; the device did not change |
504 |
The building did not answer in time and the device did not hold the new value when it was read back; the device did not change |
A command that did not happen is reported as a failure. A 200 means the device changed. If the building refused the
command or did not answer, the answer is 502 or 504, the stored value is left exactly as it was, and no real-time
event is sent. A 504 carries what you need to decide what to do β and nothing about the building itself:
{ "status": "error", "error": {
"statusCode": 504, "message": "The room did not respond in time. Try again.",
"reason": "timeout", "durationMs": 8012, "checked": "not-applied"
} }
reason is timeout or refused; durationMs is how long the server waited; checked is not-applied when the point
was read back and did not hold the value, or unverified when it could not be read back to be sure.
What to do with a 502 or 504: treat it as "not applied yet", not as "the room is broken" β these are usually
transient. Retry once, and show the control in its previous state rather than the requested one. A retry is safe: every
settable component takes an absolute value, so sending it twice is the same as sending it once. To confirm a command took
effect independently of the answer, read the device with ?live=true after a short delay.
Commands to one component are carried out one at a time. A component has one write in flight and one value waiting
behind it. Send a new value for the same component while one is outstanding and it takes that seat; send another and it
replaces the one waiting, which is answered 200 without having been sent β the caller replaced their own value before it
left. Every settable component takes an absolute value, so this is not a dropped instruction: the room ends at the newest
value the caller asked for, which is what a guest holding a dimmer means.
A slow building is not a failed command. The building applies a write and answers afterwards, so a command can land
at five seconds while the answer is still outstanding. A command is given about 8 seconds, and when even that runs out
the point is read back before anything is called a failure. A write that arrived late is answered 200, because the
guest's lamp is on. durationMs in a successful command answer is how long it took end to end: around 250 ms is ordinary,
over a second means the building is running behind β showing or logging the slow ones is how a client notices a site
getting slower before it starts failing.
A guest may set the thermostat only between the limits the hotel chose (normally 20β25 Β°C); a partner key may use the whole range the equipment accepts. Error messages never contain the building's own details.
How a room and a thing are identified
A room has one address, its room number: roomId in every path (632). Room numbers are unique inside one site, and a
key reaches one site, so the number is enough. The answer also carries roomType (Suite, Deluxe) and displayName
(null β the number is what everybody reads).
A thing has four fields, never interchangeable:
| Field | Example | What it is |
|---|---|---|
id |
632_desk-lamp |
The address. Derived from the room and the current alias, unique across the site. Put this in a URL β but it is not permanent (below) |
type |
DIMMABLE_LIGHT |
What it is, and therefore what it can do. Branch on this |
alias |
Desk Lamp |
What a person calls it. Free text chosen by the hotel. Changing it also changes id |
description |
BR30 RGBW |
A note about this particular one. Informational, often empty |
An id moves when the alias does. It is {room}_{slug(alias)}, made unique. Renaming a thing
(below) gives it a new id; the old one stops resolving. If your integration stores ids, refresh them from
GET /rooms/{roomId}/things before relying on them, or treat a 404 on a previously-working id as a sign it was renamed
and read the list again β a rename you make yourself hands you the new id back in the answer.
Grouping and presentation
Four things the hotel sets about a device that do not change how it is controlled. They are what lets one app render a room it has never seen:
| Field | Where | What to do with it |
|---|---|---|
tags |
thing | Ids from the tag dictionary (GET /tags) saying what a fixture is for and which things belong with it. Group controls by them and label the group with the tag's alias, never its id. Any tag may appear on any type |
presets |
thing (room list only) | Named component values the thing reads under a name (bright, dimmed, cool β¦). A preset holds every component the state depends on β a thermostat's carries power, setpoint and fan speed. Apply one by sending its values as ordinary commands |
hidden |
thing | Served in full, with every component and value, and simply not listed among the controls a guest browses β a light on an automation the guest does not operate by hand. Honour it in the list; keep reading its state |
| disabled | β | Not a field. A device the hotel has disabled is absent from every answer and 404 at its own address β what the API serves is what a guest may use |
Ids are constants; names are not. A tag id, a preset name and a thing type never change and are safe to branch on.
alias β of a thing or of a tag β is wording the hotel edits, and a client that switches on it breaks the first time
somebody renames a fixture.
Endpoints
List the things in a room
GET /rooms/{roomId}/things[?live=true]
Every enabled thing of the room with its last-known values. Add ?live=true to read the building again before answering β
slower, but the freshest possible value; without it the answer may be up to a few seconds old (it is shared between everyone
looking at the room). A room with eleven lights returns eleven entries, distinguished by id and named by alias β do not
assume a fixed set, and do not assume they are all one type: a lamp that dims is DIMMABLE_LIGHT and one on an on/off
circuit is ONOFF_LIGHT.
{
"status": "success",
"data": {
"roomId": "632",
"roomType": "Suite",
"iotGroupName": "Suite",
"displayName": null,
"vendorSeenAt": "2026-09-29T14:37:32Z",
"vendorCheck": null,
"mode": "live",
"things": [
{
"id": "632_desk-lamp",
"type": "DIMMABLE_LIGHT",
"wireType": "Dimming",
"alias": "Desk Lamp",
"description": "BR30 RGBW",
"tags": ["BED_LIGHT"],
"hidden": false,
"presets": { "bright": { "Dimming": "100" }, "dimmed": { "Dimming": "30" } },
"components": [
{ "componentId": "OnOff", "value": "1", "valueType": "Boolean", "settable": true },
{ "componentId": "Dimming", "value": "40", "valueType": "Int8", "settable": true }
]
}
]
}
}
modeis new:"mock"means the site is simulated and nothing reaches the building β useful while you develop.vendorSeenAtis when the building was last read for this answer.- Read-only sensors (
CurrentTemperature,Presence) change only when the room is read again β follow the real-time stream or read with?live=true.
/rooms/{roomId}/thingsTry itpress Send to run
Read one thing
GET /rooms/{roomId}/things/{thingId}[?live=true]
One thing's current state β the read a client repeats, so it carries state and not configuration: presets is on the room
list instead, while tags and hidden are here as well. For a scent diffuser ?live=true also asks the scent service for
the real power state and the occasions its capsules can produce.
{ "status": "success", "data": {
"id": "632_desk-lamp", "type": "DIMMABLE_LIGHT", "wireType": "Dimming", "alias": "Desk Lamp",
"description": "BR30 RGBW", "tags": ["BED_LIGHT"], "hidden": false,
"components": [
{ "componentId": "OnOff", "value": "1", "valueType": "Boolean", "settable": true },
{ "componentId": "Dimming", "value": "50", "valueType": "Int8", "settable": true }
] } }
/rooms/{roomId}/things/{thingId}Try itpress Send to run
Control a thing
PUT /rooms/{roomId}/things/{thingId}
Content-Type: application/json
{ "componentId": "OnOff", "value": "1" }
Sends a command for one component of one thing. The service forwards it to the building and answers 200 only once the
building has accepted it. componentId and value are required, and value is a string; the component's own definition
supplies the type, so a valueType hint (accepted by the original) is not needed.
The answer is the device as it now stands, not an echo β switching a dimmable light on also moves its brightness:
{ "status": "success", "data": {
"roomId": "632", "thingId": "632_desk-lamp", "componentId": "OnOff", "value": "1",
"durationMs": 248,
"components": [ { "componentId": "OnOff", "value": "1", β¦ }, { "componentId": "Dimming", "value": "100", β¦ } ]
} }
A read-only component answers 403:
{ "status": "error", "error": { "statusCode": 403, "message": "Component Presence is read-only" } }
/rooms/{roomId}/things/{thingId}Try itpress Send to run
Rename a thing
PATCH /things/{thingId}
Content-Type: application/json
{ "alias": "Table Lamp", "description": "BR30 RGBW", "everywhere": false }
As in the original. Changes what a thing is called, or the note on it β send alias, description, or both (at least one).
Only an API key may do this; a guest token is answered 403.
{ "status": "success", "data": { "id": "632_table-lamp", "alias": "Table Lamp", "description": "BR30 RGBW", "things": ["632_table-lamp"] } }
- The id moves with the name. Use the
idin the answer from now on; the old one answers404. Sending the same name again changes nothing, so a retry never breaks an id you hold. everywhere: truerenames every thing of the site that carries the same current name β one fixture set is often mapped into several rooms, and renaming it in only one of them leaves a fleet with three names for one thing.thingslists all the new ids;idis the new id of the thing you addressed.- Two things in one room cannot share an address: a name that would collide gets a number appended.
- Errors:
400(neither field, an empty or over-long name β 80 characters at most β or a note over 500),404(no such thing at this site),403(guest token).
/things/{thingId}Try it β API key onlypress Send to run
Thing types
GET /types
Every kind of thing this service supports, the components each exposes, the tag ids a thing may carry and the preset values
a thing of the type starts with. What a client sees here is what it can actually reach β read it instead of copying the
reference below into your app. tags is the whole dictionary and is the same on every type; range and enum
tell you what a component accepts (limits is the narrower range a guest is offered).
{ "status": "success", "data": [
{ "type": "DIMMABLE_LIGHT", "wireType": "Dimming", "vendor": "se", "tags": ["ACCENT_LIGHT", "BED_LIGHT"],
"defaultPresets": { "bright": { "Dimming": "100" }, "dimmed": { "Dimming": "50" } },
"components": [
{ "componentId": "Dimming", "valueType": "Int8", "settable": true, "range": { "min": "0", "max": "100" } },
{ "componentId": "OnOff", "valueType": "Boolean", "settable": true, "enum": [{ "value": "0", "label": "Off" }, { "value": "1", "label": "On" }] }
] }
] }
/typesTry itpress Send to run
Tags
GET /tags
Every tag id of the site, with the wording to put on a control grouped by it. One dictionary for the whole property β the
same ids are offered on every type. id is the constant to branch on; alias is the hotel's wording and is what a guest
must read, because a hotel that calls reading lights something else changes the alias and not the id.
{ "status": "success", "data": [
{ "id": "BED_LIGHT", "alias": "Reading lights", "description": "Bedside and headboard reading lights" },
{ "id": "BEDSIDE", "alias": "Bedside Lamps", "description": "The pair of bedside lamps, presented as one control" }
] }
/tagsTry itpress Send to run
The stay
GET /rooms/{roomId}/stay
New. Who is staying in the room, so your app can greet them and use their language. Check-in is done in the console (or, later, sent by the hotel's property management system) β see Check-in.
{ "status": "success", "data": {
"hotel": { "id": "β¦", "name": "Pullman Singapore Orchard" },
"room": { "number": "632", "status": "occupied" },
"guest": { "name": "Kim Minjun", "language": "ko", "checkinAt": "2026-09-30T10:00:00Z" }
} }
guest is null when the room is empty (room.status is vacant). language is a code such as en, ko, zh β use
English when your app has no translation for it. A partner key can ask for any room of its site; a guest token only for the
room it is paired with (another room answers 404).
/rooms/{roomId}/stayTry itpress Send to run
TV channels
GET /channels
New. The channels the hotel shows guests, in channel-number order. Switched-off channels are not listed.
{ "status": "success", "data": [
{ "id": "5c1eβ¦", "number": 1, "name": "BBC One", "kind": "tv",
"icon": "https://upload.wikimedia.org/β¦/BBC_One_logo_2021.svg.png", "encrypted": false,
"tune": { "channelType": "rf", "rfBroadcastType": "terrestrial", "frequency": 618000000, "programNumber": 4 } },
{ "id": "9ab2β¦", "number": 7, "name": "City TV", "kind": "tv", "icon": null, "encrypted": false,
"tune": { "channelType": "ip", "ip": "239.1.1.7", "port": 5000, "ipBroadcastType": "udp" } }
] }
kindistvorradio.iconis an https address, ornull.encryptedmarks a pay channel.tuneis exactly what the TV needs to open the channel: the input of the LG webOS IDCAP requestidcap://tv/channel/change/request(channel class RF or IP, orlg_channels). A phone cannot tune a TV by itself β show the list, and let the TV do the tuning (a partner TV app asks the TV with atunemessage).- Frequencies are in Hz for terrestrial and cable, in MHz for satellite (as in IDCAP).
/channelsTry itpress Send to run
Smart apps
GET /apps
New. The apps the hotel shows guests, in the order the hotel chose.
{ "status": "success", "data": [
{ "id": "youtube.leanback.v4", "title": "YouTube", "description": "Videos and music", "icon": "https://upload.wikimedia.org/β¦/YouTube_logo.png" },
{ "id": "netflix", "title": "Netflix", "description": null, "icon": null }
] }
id is the webOS app id the TV launches (idcap://application/launch). description is the hotel's line under the
name, or null; icon may be null too β show the title.
/appsTry itpress Send to run
Real-time events
GET /rooms/{roomId}/events
A Server-Sent Events stream. The service reads the room every few seconds and sends only what changed:
data: {"type":"values","method":"GET","source":"poll","roomId":"632",
"data":[{"id":"632_desk-lamp","type":"DIMMABLE_LIGHT",
"components":[{"componentId":"Dimming","value":"50","valueType":"Int8"}]}]}
Comment lines (: keepalive) keep the connection open. The service closes it after about two minutes β reconnect when it
ends (most SSE clients do it themselves), and read the room once after reconnecting. This is polling on the server, not a
push from the building: a change made at a wall panel shows up within a few seconds, not instantly (the original pushed
within 3β15 seconds from the building's own event stream; the difference is in the same range).
The browser EventSource cannot send headers, so for browsers that use it the key may go in the query string instead
(?key=β¦) β a key in a URL ends up in logs, so use the header wherever you can (fetch with a stream reader can).
/rooms/{roomId}/eventsTry itpress Connect to run
Recommended integration pattern
GET /rooms/{roomId}/thingsβ fetch the current snapshot.- Render the devices from it.
- Open
/rooms/{roomId}/events. - On each event, patch the matching component in the UI β no need to read the whole room again.
- On a command, show the answer's
components(the device as it now stands); on502/504put the control back. - When a path starts answering
404, or the stream ends, read the list again.
This avoids polling from your side and keeps the UI in step with other clients and with changes made at the room's panels.
Thing types and components
A room holds as many of each type as were provisioned into it β eleven lights across two light types is normal β so read
the room's list rather than assuming a fixed set. GET /types returns the same information at runtime.
DIMMABLE_LIGHT β a light that dims (wireType Dimming)
| componentId | valueType | Settable | Accepted values |
|---|---|---|---|
OnOff |
Boolean | yes | "0" off, "1" on |
Dimming |
Int8 | yes | "0"β"100" (percent) |
A dimmable light takes one number, so OnOff is translated into a level: OnOff = "1" sends the light's last brightness (a
light stored at 0 goes to 100, since it has no level to return to); OnOff = "0" sends 0. To turn on at a given brightness,
set Dimming directly.
ONOFF_LIGHT β a light that does not dim (wireType Switch)
| componentId | valueType | Settable | Accepted values |
|---|---|---|---|
OnOff |
Boolean | yes | "0" off, "1" on |
Branch on type, not on the room: the lamps over the bed, the reading lights, the floor lamp are wired as on/off circuits
while the cove and the downlights in the same room dim.
THERMOSTAT β a fan-coil unit (wireType Thermostat)
| componentId | valueType | Settable | Accepted values |
|---|---|---|---|
OnOff |
Boolean | yes | "0" / "1" |
TargetTemperature |
Double | yes | "16"β"32" Β°C accepted; offer "20"β"25" (the guest limit) |
CurrentTemperature |
Double | no | read-only sensor value |
Speed |
String | yes | "1" High, "2" Mid, "3" Low, "4" Auto |
Speed is passed through unchanged, so a client showing the raw number is always right.
CURTAIN β a motorised blind (wireType Curtain)
| componentId | valueType | Settable | Accepted values |
|---|---|---|---|
OpenClose |
Boolean | yes | "0" close, "1" open |
There is no stop component. A curtain has one motor but two points behind it β one that is written and one that is read β which is why its reported position can lag a command briefly.
COURTESY_PANEL β the door panel (wireType MultiLevelSensor)
| componentId | valueType | Settable | Accepted values |
|---|---|---|---|
Level |
String | yes | "off", "MUR" (Make Up Room), "DND" (Do Not Disturb), "STC" (Skip the Clean) β one at a time |
SCENT_DIFFUSER β a scent diffuser (wireType MultiLevelSensor)
Two physical diffusers per room, each addressable on its own β read the room's list, an id follows the name.
| componentId | valueType | Settable | Accepted values |
|---|---|---|---|
OnOff |
Boolean | yes | "0" off, "1" on |
Occasion |
String | yes | relax, refresh, deepsleep, focus, exercise, romantic, normal β see below |
The thing also carries availableOccasions β the scents its capsules allow; choosing another is refused (502 when the scent
service refuses, for example because the diffuser is off β it did not change). Choosing a scent switches the diffuser on.
The current scent is the last one chosen.
OCCUPANCY_SENSOR β room occupancy (wireType BinarySensor)
| componentId | valueType | Settable | Description |
|---|---|---|---|
Presence |
Boolean | no | "1" occupied, "0" vacant |
curl examples
Replace {API_KEY} with your key. Add | jq . to read the answers more easily.
# List the things in room 632
curl -s "https://pcr-api.pages.dev/v1/rooms/632/things" -H "X-Api-Key: {API_KEY}"
# Turn on a light
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_desk-lamp" \
-H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
-d '{"componentId":"OnOff","value":"1"}'
# Set the thermostat to 22 Β°C
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_thermostat" \
-H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
-d '{"componentId":"TargetTemperature","value":"22"}'
# Open a curtain
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_main-curtain" \
-H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
-d '{"componentId":"OpenClose","value":"1"}'
# Choose a scent
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_scent-diffuser" \
-H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
-d '{"componentId":"Occasion","value":"romantic"}'
# Rename a thing everywhere it appears β the answer lists every id that changed
curl -s -X PATCH "https://pcr-api.pages.dev/v1/things/632_desk-lamp" \
-H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
-d '{"alias":"Reading Lamp","everywhere":true}'
# Who is staying, the channels and the apps
curl -s "https://pcr-api.pages.dev/v1/rooms/632/stay" -H "X-Api-Key: {API_KEY}"
curl -s "https://pcr-api.pages.dev/v1/channels" -H "X-Api-Key: {API_KEY}"
curl -s "https://pcr-api.pages.dev/v1/apps" -H "X-Api-Key: {API_KEY}"
# Follow changes live (-N turns off buffering; Ctrl+C to stop)
curl -N "https://pcr-api.pages.dev/v1/rooms/632/events" -H "X-Api-Key: {API_KEY}"
Subscribe from a browser or Node 18+ with a stream reader (so the key stays in a header):
const res = await fetch('https://pcr-api.pages.dev/v1/rooms/632/events', { headers: { 'X-Api-Key': key } })
const reader = res.body.getReader(), decoder = new TextDecoder()
let rest = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break // the service closes after ~2 minutes β read the room, then reconnect
const lines = (rest + decoder.decode(value, { stream: true })).split('\n')
rest = lines.pop()
for (const line of lines) if (line.startsWith('data: ')) {
const event = JSON.parse(line.slice(6))
for (const thing of event.data) console.log('thing update:', thing.id, thing.components)
}
}
Limits
Commands are limited to 60 a minute per room for guest tokens and 240 a minute per site for a partner key; above
that the answer is 429 with Retry-After. A command returns when the building has answered, usually within a second; a
slow one can take several. A guest token works until the guest checks out; a partner key until it is revoked.
Example: Flutter
import 'dart:convert';
import 'package:http/http.dart' as http;
const base = 'https://pcr-api.pages.dev/v1';
Map<String, String> headers(String guestToken) => {
'X-Guest-Token': guestToken,
'Content-Type': 'application/json',
};
Future<Map<String, dynamic>> readRoom(String room, String token) async {
final res = await http.get(Uri.parse('$base/rooms/$room/things'), headers: headers(token));
final body = jsonDecode(res.body) as Map<String, dynamic>;
if (res.statusCode != 200) throw Exception(body['error']['message']);
return body['data'] as Map<String, dynamic>;
}
/// Who is staying, and the language to show β new in the Client API
Future<Map<String, dynamic>?> readStay(String room, String token) async {
final res = await http.get(Uri.parse('$base/rooms/$room/stay'), headers: headers(token));
final body = jsonDecode(res.body) as Map<String, dynamic>;
if (res.statusCode != 200) throw Exception(body['error']['message']);
return (body['data'] as Map<String, dynamic>)['guest'] as Map<String, dynamic>?;
}
Future<List<dynamic>> readChannels(String token) async {
final res = await http.get(Uri.parse('$base/channels'), headers: headers(token));
return (jsonDecode(res.body) as Map<String, dynamic>)['data'] as List<dynamic>;
}
Future<void> setComponent(String room, String thing, String component, String value, String token) async {
final res = await http.put(
Uri.parse('$base/rooms/$room/things/$thing'),
headers: headers(token),
body: jsonEncode({'componentId': component, 'value': value}),
);
if (res.statusCode != 200) throw Exception(jsonDecode(res.body)['error']['message']);
}
For the stream, use any SSE package (for example http.Client().send with an Accept: text/event-stream request, or a
package such as eventsource) and reconnect when it ends.