Skip to content

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.

The device plane has two audiences, and two kinds of bearer token:

TokenHeld byUsed for
User JWTYour account / dashboardMint claim codes, list devices, send live commands, unclaim. Same token as everywhere else in this API.
Device tokenThe device itselfPull runtime config and schedule, push readings, long-poll for commands, echo applied state. Issued once at claim time.

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. The appliance_id is taken from the device binding automatically, so the device omits it; a reading tagged with any other appliance is rejected 400.
  • 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 returns 403.

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:

  1. The signed-in user calls POST /api/v1/devices/enroll-token and gets a short-lived claim code.
  2. The user enters the code into the device’s setup portal.
  3. The device calls POST /api/v1/devices/claim with the code. The server creates the HVAC appliance the satellite will drive and returns the device’s permanent token.
  4. The device authenticates every later call with that 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.

{
"enroll_token": "sPmH...opaque...",
"expires_at": "2026-07-10T18:15:00+00:00"
}
FieldTypeNotes
enroll_tokenstringThe plaintext claim code. Shown once; not retrievable later.
expires_atstring (ISO 8601)When the code stops being redeemable (15 minutes out).
StatusCause
401 / 403Missing or invalid JWT
503"Database unavailable"

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.

FieldTypeRequiredNotes
enroll_tokenstringYesThe plaintext claim code from POST /api/v1/devices/enroll-token.
device_idstringYesThe device’s factory serial, 3–128 chars.
namestringYesUser-facing appliance name (1–200 chars), e.g. "Living Room Mini-Split".
hvac_typestringYesOne of central_ac, window_ac, heat_pump, furnace, mini_split.
ir_protocolstringNoThe infrared brand protocol the device speaks, e.g. "daikin". Stored on the appliance; treated as opaque.
setpoint_min_fintNoInclusive low setpoint bound (40–99 °F) the remote accepts.
setpoint_max_fintNoInclusive 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.

{
"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.

StatusDetailCause
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
409The device_id is already claimed by an account
422Inverted setpoint range, or an unknown hvac_type
503"Database unavailable"Dependency unavailable

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.

{
"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
}
FieldTypeNotes
timezonestringThe account’s IANA timezone (the zone actually used, after any fallback).
utc_offset_minutes_nowintSigned UTC offset (minutes) in effect right now, e.g. -300 / -240.
next_transitionobject | nullThe 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_fintThe remote’s setpoint clamp — from the appliance config, else defaults 61 / 86.
comfort_bandobjectToday’s 48-slot high_temps_f / low_temps_f, on the local-midnight grid. Display/apply data — treat as opaque.
reading_push_interval_sintHow often to flush the readings batch (default 3600).
capture_interval_sintSensor sample cadence (default 300).
command_poll_wait_sintSuggested wait= for GET /api/v1/devices/commands (default 50).
StatusCause
401Missing / unknown / revoked device token
403Claimed 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.

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.

FieldTypeRequiredNotes
powerboolNoTurn the unit on/off.
modestringNoOne of COOL, HEAT, OFF.
target_temp_fnumberNoSetpoint in °F. Validated against the appliance’s [setpoint_min_f, setpoint_max_f] range when configured.
fanstringNoOne of low, high, auto.
holdboolNoDefault false. true = hold until the user changes it; false = hold only until the next slot boundary.
{ "command_id": 42 }
StatusDetailCause
400No actionable field, or target_temp_f outside the configured setpoint range
403A 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

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.

ParamTypeDefaultNotes
waitint0Seconds 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.

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 via POST /api/v1/devices/state with its command_id.
  • "schedule_updated" — a nudge that the schedule changed. Re-pull it with GET /api/v1/appliances/{id}/schedule.
StatusCause
401Missing / unknown / revoked device token
403Claimed device with no bound appliance
503"Database unavailable"

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.

FieldTypeRequiredNotes
sourcestringYesWhy the state was applied: schedule, command, override, or watchdog.
modestringNoApplied mode: COOL, HEAT, OFF.
target_temp_fnumberNoApplied setpoint (°F).
fanstringNoApplied fan: low, high, auto.
powerboolNoApplied power. An explicit false records the unit as OFF regardless of mode.
command_idintNoThe id of the command this state satisfies. When set, that command is acknowledged.
firmware_versionstringNoReported firmware; stored on the device record.
rssiintNoReported WiFi signal (dBm); stored on the device record.
{ "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).

StatusCause
401Missing / unknown / revoked device token
403Claimed device with no bound appliance
422Missing or invalid source
503"Database unavailable"

Auth: Required (user JWT)

Lists the caller’s claimed satellites with their health fields. Unbound factory devices never appear.

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"
}
]
StatusCause
401 / 403Missing 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}.

204 No Content on success.

StatusCause
401 / 403Missing or invalid JWT
404No 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"