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.
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. The server creates the HVAC appliance the satellite will drive and returns the device’s 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), creates the HVAC appliance the satellite drives, binds the device to it, and returns the device’s permanent bearer token exactly once.
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 appliance name (1–200 chars), e.g. "Living Room Mini-Split". |
hvac_type | string | Yes | One of central_ac, window_ac, heat_pump, furnace, mini_split. |
ir_protocol | string | No | The infrared brand protocol the device speaks, e.g. "daikin". Stored on the appliance; treated as opaque. |
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.
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.
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, or an unknown hvac_type |
| 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.
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 |
| 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 satellites with their 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", "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" |