Devices (Satellites)
A satellite is a Hungry Machines WiFi climate device: a small wall-plug gadget that reads temperature and humidity and drives a mini-split or window AC over infrared. Unlike a Home Assistant integration that runs on your own hub, a satellite talks to this API directly with its own scoped token — no account password ever lives on the device.
The device plane also serves a display-only stoplight: a small price-signal lamp that shows whether electricity is cheap, normal, or expensive right now. It claims the same way, but with device_type: "stoplight" and no HVAC appliance — it drives nothing, it only shows the current price tier. Its one extra endpoint is GET /api/v1/devices/price-schedule.
If you just want to set one up, follow Set up your satellite. This page is the API reference for building on the device plane.
Two tokens
Section titled “Two tokens”The device plane has two audiences, and two kinds of bearer token:
| Token | Held by | Used for |
|---|---|---|
| User JWT | Your account / dashboard | Mint claim codes, list devices, send live commands, unclaim. Same token as everywhere else in this API. |
| Device token | The device itself | Pull runtime config and schedule, push readings, long-poll for commands, echo applied state. Issued once at claim time. |
Shared endpoints
Section titled “Shared endpoints”A satellite reuses two endpoints you already know, authenticating with its device token instead of a JWT:
POST /api/v1/readings— the device pushes its temperature/humidity stream. Theappliance_idis taken from the device binding automatically, so the device omits it; a reading tagged with any other appliance is rejected400.GET /api/v1/appliances/{id}/schedule— the device pulls its schedule (the same opaque schedule shape as everywhere else). The id in the path must be the device’s own bound appliance; any other id returns403.
The claim flow
Section titled “The claim flow”Binding a new satellite to an account is a three-party handshake — the user proves intent by minting a short-lived code, and the device redeems it:
- The signed-in user calls
POST /api/v1/devices/enroll-tokenand gets a short-lived claim code. - The user enters the code into the device’s setup portal.
- The device calls
POST /api/v1/devices/claimwith the code and itsdevice_type. For anir-climatedevice the server creates the HVAC appliance the device will drive; a display-onlystoplightgets no appliance. Either way the device receives its permanent token. - The device authenticates every later call with that token.
POST /api/v1/devices/enroll-token
Section titled “POST /api/v1/devices/enroll-token”Auth: Required (user JWT)
Mint a single-use claim code so a signed-in user can add a new satellite. The code is valid for 15 minutes and is returned in plaintext exactly once — only a hash of it is stored server-side. Show it to the user, who types it into the device’s setup portal.
Response (200)
Section titled “Response (200)”{ "enroll_token": "sPmH...opaque...", "expires_at": "2026-07-10T18:15:00+00:00"}| Field | Type | Notes |
|---|---|---|
enroll_token | string | The plaintext claim code. Shown once; not retrievable later. |
expires_at | string (ISO 8601) | When the code stops being redeemable (15 minutes out). |
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 401 / 403 | Missing or invalid JWT |
| 503 | "Database unavailable" |
POST /api/v1/devices/claim
Section titled “POST /api/v1/devices/claim”Auth: Public (no token)
The claim code itself is proof of the minting user’s intent, so the device can call this before it has any account credentials. Redeems the code (must be unused and unexpired), binds the device to the account, and returns the device’s permanent bearer token exactly once. An ir-climate device also gets the HVAC appliance it drives; a stoplight is display-only and gets none.
Request body
Section titled “Request body”| Field | Type | Required | Notes |
|---|---|---|---|
enroll_token | string | Yes | The plaintext claim code from POST /api/v1/devices/enroll-token. |
device_id | string | Yes | The device’s factory serial, 3–128 chars. |
name | string | Yes | User-facing device name (1–200 chars), e.g. "Living Room Mini-Split" or "Kitchen Stoplight". |
device_type | string | No | ir-climate (default) or stoplight. Any other value is 422. Omitting it is the same as ir-climate. Determines whether an HVAC appliance is created. |
hvac_type | string | For ir-climate | One of central_ac, window_ac, heat_pump, furnace, mini_split. Required when device_type is ir-climate (or omitted); omit it for a stoplight. |
ir_protocol | string | No | The infrared brand protocol the device speaks, e.g. "daikin". Stored on the appliance; treated as opaque. Ignored for a stoplight. |
setpoint_min_f | int | No | Inclusive low setpoint bound (40–99 °F) the remote accepts. |
setpoint_max_f | int | No | Inclusive high setpoint bound (40–99 °F). Must be ≥ setpoint_min_f. |
When both setpoint bounds are supplied, every schedule the device later pulls has its setpoints clamped to [setpoint_min_f, setpoint_max_f], so the device never receives a temperature its remote can’t send. A satellite appliance has no entity_id — the device applies its own schedule over infrared. A stoplight claim creates no appliance, so its response appliance_id is null.
Response (200)
Section titled “Response (200)”{ "device_token": "kQ7v...opaque...", "appliance_id": "550e8400-e29b-41d4-a716-446655440000", "device_id": "hm-sat-abc123"}device_token is shown once and then lives only on the device; the server keeps only a hash of it. Use it as the Authorization: Bearer on every device-token endpoint. appliance_id is the created HVAC appliance for an ir-climate device, and null for a stoplight.
Errors
Section titled “Errors”| Status | Detail | Cause |
|---|---|---|
| 400 | "Invalid enrollment token" | The claim code is unknown |
| 400 | "Enrollment token already used" | The code was already redeemed (single-use) |
| 400 | "Enrollment token expired" | The code is past its 15-minute window |
| 409 | — | The device_id is already claimed by an account |
| 422 | — | Inverted setpoint range, an unknown hvac_type, an unknown device_type, or a missing hvac_type on an ir-climate claim |
| 503 | "Database unavailable" | Dependency unavailable |
GET /api/v1/devices/config
Section titled “GET /api/v1/devices/config”Auth: Device token
Returns the satellite’s runtime configuration, computed server-side for the owning account. The device ships no timezone database — it applies the 48-slot schedule on the local-midnight grid using the offset table this endpoint hands it.
Both device types share this endpoint, and it does not echo device_type (a device knows its own type). A display-only stoplight has no HVAC appliance, so it receives the default setpoint_min_f / setpoint_max_f and a default comfort_band — it ignores those HVAC fields and uses only the timezone offset table and the loop-cadence fields. A stoplight’s price data comes from GET /api/v1/devices/price-schedule, not from here.
Response (200)
Section titled “Response (200)”{ "timezone": "America/New_York", "utc_offset_minutes_now": -240, "next_transition": { "at_utc": "2026-11-01T06:00:00+00:00", "utc_offset_minutes_after": -300 }, "setpoint_min_f": 61, "setpoint_max_f": 86, "comfort_band": { "high_temps_f": [73.0, 73.0, "... 48 total ..."], "low_temps_f": [71.0, 71.0, "... 48 total ..."] }, "reading_push_interval_s": 3600, "capture_interval_s": 300, "command_poll_wait_s": 50}| Field | Type | Notes |
|---|---|---|
timezone | string | The account’s IANA timezone (the zone actually used, after any fallback). |
utc_offset_minutes_now | int | Signed UTC offset (minutes) in effect right now, e.g. -300 / -240. |
next_transition | object | null | The next daylight-saving flip: at_utc (UTC instant) + utc_offset_minutes_after (the offset that applies after it). null for zones that don’t observe DST. |
setpoint_min_f / setpoint_max_f | int | The remote’s setpoint clamp — from the appliance config, else defaults 61 / 86. |
comfort_band | object | Today’s 48-slot high_temps_f / low_temps_f, on the local-midnight grid. Display/apply data — treat as opaque. |
reading_push_interval_s | int | How often to flush the readings batch (default 3600). |
capture_interval_s | int | Sensor sample cadence (default 300). |
command_poll_wait_s | int | Suggested wait= for GET /api/v1/devices/commands (default 50). |
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 401 | Missing / unknown / revoked device token |
| 403 | Claimed device with no bound appliance (a stoplight is allowed through, having none by design) |
| 503 | "Database unavailable" |
GET /api/v1/devices/price-schedule
Section titled “GET /api/v1/devices/price-schedule”Auth: Device token
Returns a display-only device’s local price curve plus a per-slot tier for today and tomorrow, so a stoplight can cache a full day (or two) and keep showing the right signal when the network blips. Any device token may call it.
Each slot carries a tier of cheap, normal, or expensive. Treat the tier as opaque — how a price is placed into a tier is an implementation detail and may change, so don’t reconstruct it from price_cents. The tier here matches what the app shows for “right now”, so a device and the app always agree.
Response (200)
Section titled “Response (200)”{ "tz": "America/New_York", "generated_at": "2026-08-02T14:30:00+00:00", "currency": "USD", "slots": [ { "start": "2026-08-02T00:00:00-04:00", "end": "2026-08-02T00:30:00-04:00", "price_cents": 12.4, "tier": "normal" } ]}The example shows one slot; a real response has 96 — two days of 48 half-hour slots.
| Field | Type | Notes |
|---|---|---|
tz | string | The account’s IANA timezone (the zone actually used, after any fallback). |
generated_at | string (ISO 8601) | The UTC instant this response was built. Does not affect the ETag (see below). |
currency | string | Display-only currency label; defaults "USD". |
slots | array[object] | 96 slots, in order from local midnight today. |
slots[].start / slots[].end | string (ISO 8601) | Slot bounds in local time with UTC offset (daylight-saving-correct). |
slots[].price_cents | number | Slot price, rounded to one decimal place. |
slots[].tier | string | cheap, normal, or expensive. Opaque — see above. |
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 401 | Missing / unknown / revoked device token |
| 403 | Claimed device with no bound appliance that is not a stoplight |
| 503 | "Database unavailable" |
POST /api/v1/appliances/{appliance_id}/command
Section titled “POST /api/v1/appliances//command”Auth: Required (user JWT), owner-checked
Enqueue a live-control command for a satellite’s HVAC appliance — “make it cooler now” from the dashboard. The command is queued durably, and a device parked in a long-poll wakes and applies it within seconds. Returns 202 with the new command’s id.
Request body
Section titled “Request body”Every field is optional — the command is a partial override the device folds over its current state. At least one actionable field (power / mode / target_temp_f / fan) must be present.
| Field | Type | Required | Notes |
|---|---|---|---|
power | bool | No | Turn the unit on/off. |
mode | string | No | One of COOL, HEAT, OFF. |
target_temp_f | number | No | Setpoint in °F. Validated against the appliance’s [setpoint_min_f, setpoint_max_f] range when configured. |
fan | string | No | One of low, high, auto. |
hold | bool | No | Default false. true = hold until the user changes it; false = hold only until the next slot boundary. |
Response (202)
Section titled “Response (202)”{ "command_id": 42 }Errors
Section titled “Errors”| Status | Detail | Cause |
|---|---|---|
| 400 | — | No actionable field, or target_temp_f outside the configured setpoint range |
| 403 | — | A device token was presented (this is a user-scope endpoint) |
| 404 | "Appliance not found" | Appliance not found, or not owned by the caller |
| 409 | "no device bound" | The appliance has no bound device |
| 503 | "Database unavailable" | Dependency unavailable |
GET /api/v1/devices/commands
Section titled “GET /api/v1/devices/commands”Auth: Device token
Long-poll for pending live-control commands. The device calls this in a loop to receive user commands and schedule-refresh nudges within seconds.
Query params
Section titled “Query params”| Param | Type | Default | Notes |
|---|---|---|---|
wait | int | 0 | Seconds to park if nothing is pending. Clamped to [0, 55]. 0 = never block (a plain “anything for me?” check). |
The device delivers the bound appliance’s undelivered commands immediately, oldest first, marking each as delivered as it hands them over. When none are pending and wait > 0, it parks and returns the moment a command is enqueued, or [] on timeout.
Response (200)
Section titled “Response (200)”A JSON array (empty on timeout):
[ { "id": 42, "payload": { "kind": "control", "mode": "COOL", "target_temp_f": 71, "hold": false }, "created_at": "2026-07-10T18:20:00+00:00" }]payload.kind is one of:
"control"— a user live-control command. Apply it and acknowledge viaPOST /api/v1/devices/statewith itscommand_id."schedule_updated"— a nudge that the schedule changed. Re-pull it withGET /api/v1/appliances/{id}/schedule.
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 401 | Missing / unknown / revoked device token |
| 403 | Claimed device with no bound appliance |
| 503 | "Database unavailable" |
POST /api/v1/devices/state
Section titled “POST /api/v1/devices/state”Auth: Device token
The satellite reports the state it has actually applied. Infrared control gives no hardware feedback, so the device is the source of truth for what it commanded; this echo keeps the account’s view fresh between readings pushes and closes the loop on a live command.
Request body
Section titled “Request body”| Field | Type | Required | Notes |
|---|---|---|---|
source | string | Yes | Why the state was applied: schedule, command, override, or watchdog. |
mode | string | No | Applied mode: COOL, HEAT, OFF. |
target_temp_f | number | No | Applied setpoint (°F). |
fan | string | No | Applied fan: low, high, auto. |
power | bool | No | Applied power. An explicit false records the unit as OFF regardless of mode. |
command_id | int | No | The id of the command this state satisfies. When set, that command is acknowledged. |
firmware_version | string | No | Reported firmware; stored on the device record. |
rssi | int | No | Reported WiFi signal (dBm); stored on the device record. |
Response (200)
Section titled “Response (200)”{ "status": "ok", "command_acked": true, "reading_written": true }command_acked reflects whether a referenced command_id was found and acknowledged; reading_written reflects whether an applied-state record was stored (it is skipped when the device has no prior reading to anchor to).
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 401 | Missing / unknown / revoked device token |
| 403 | Claimed device with no bound appliance |
| 422 | Missing or invalid source |
| 503 | "Database unavailable" |
GET /api/v1/devices
Section titled “GET /api/v1/devices”Auth: Required (user JWT)
Lists the caller’s claimed devices with their type + health fields. Unbound factory devices never appear.
Response (200)
Section titled “Response (200)”A JSON array (empty when the account has no devices):
[ { "device_id": "hm-sat-abc123", "device_type": "ir-climate", "appliance_id": "550e8400-e29b-41d4-a716-446655440000", "appliance_name": "Living Room Mini-Split", "mode": "standalone", "firmware_version": "1.4.2", "last_seen_at": "2026-07-10T18:00:00+00:00", "rssi": -58, "claimed_at": "2026-07-10T17:45:00+00:00" }]Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 401 / 403 | Missing or invalid JWT |
| 503 | "Database unavailable" |
DELETE /api/v1/devices/{device_id}
Section titled “DELETE /api/v1/devices/”Auth: Required (user JWT), owner-checked
Unclaims a satellite the caller owns: the device’s token immediately stops authenticating, and the device becomes re-claimable. The bound appliance is NOT deleted — its readings, learned model, and schedule history remain. To also remove the appliance, delete it separately via DELETE /api/v1/appliances/{id}.
Response
Section titled “Response”204 No Content on success.
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 401 / 403 | Missing or invalid JWT |
| 404 | No such device, or it isn’t owned by the caller (a not-owned device is 404, not 403, so one account can’t probe another’s device ids) |
| 503 | "Database unavailable" |