Skip to content

REST API

monikapurpl3 edited this page Aug 21, 2026 · 2 revisions

REST API

Base URL: http://<host>:8420 (or your HTTPS proxy URL). All secret comparisons are constant-time. Responses are compressed with brotli (gzip fallback) when the client sends Accept-Encoding — disable with AC_COMPRESSION=0.

Pairing — /api/auth

POST /api/auth/enroll/start     X-API-Key       → {session_id, user_code, expires_in}
                                  body {label, auth_version?, public_key?}  (public_key for v2)
POST /api/auth/enroll/poll      X-API-Key       → {status, token_id, label, auth_version,
                                                    expires_at[, device_token]}  (token: v1 only)
POST /api/auth/enroll/approve   X-API-Key + LAN → {token_id, label}                    (admin)
POST /api/auth/upgrade          X-API-Key + cred → {token_id, auth_version:2}
                                  body {public_key}; re-keys the calling device to v2 in place
GET  /api/auth/whoami           X-API-Key + cred → {token_id, label, auth_version,
                                                     created_at, expires_at, last_used}
                                  (the *calling* device; not LAN-gated)
GET  /api/auth/devices          X-API-Key + LAN → [{token_id, label, auth_version, created_at, ...}]  (admin)
DELETE /api/auth/devices/{id}   X-API-Key + LAN → 204                                   (admin)

Meta — /api

Health is public; version needs the API key.

GET   /api/health               → {status:"ok"}                 (public liveness probe)
GET   /api/version   X-API-Key  → {name, version, commit, features[],
                                    auth_versions:[1,2], min_auth_version, units}   (feature-detect)
GET   /metrics       X-API-Key  → Prometheus text: unit online/temps (last known,
                                    no live fetch) + scheduler counters + build info

System — /api/system

Everything this deployment knows about itself, in one response — it backs the Breeze app's Nerd screen. Requires the API key and a device credential: it reports host details and the enrolled-device list, so it sits at the same bar as controlling a unit. Added in 3.0.5; feature flag system_info.

GET /api/system   X-API-Key + cred → {
    server:      {name, version, commit, python{}, started_at, uptime_seconds,
                  installed_at, timezone, utc_offset_seconds, server_time,
                  features[], auth_versions[]},
    os:          {system, pretty_name, distro_id, distro_version, kernel,
                  kernel_version, hostname, libc},
    init:        {name, detail}        # systemd | openrc | runit | rc.d | launchd …
    cpu:         {arch, model, cores, endianness},
    components:  {msmart-ng, fastapi, uvicorn, starlette, pydantic, …},
    network:     {hostname, local_addresses[], bind_host, bind_port},
    connection:  {client_ip, client_is_private, request_url, host_header,
                  scheme, http_version, forwarded_for, behind_proxy_enabled},
    paths:       {config, devices, programs, package},
    settings:    {…the effective runtime settings…},
    units:       [{id, name, ip, port, has_v3_credentials, connected,
                   capabilities, samples}],
    devices:     [{token_id, label, auth_version, created_at, last_used,
                   expires_at, expires_in_seconds, expired}],
    programs:    {total, by_kind}, scheduler: {…}, storage: {…}, process: {…},
    machine_uptime_seconds }

Two design rules worth knowing as a client author. Every field can be null — each fact is probed defensively and an undeterminable one degrades rather than failing the request, so render missing values, don't assume them. And units[].capabilities is only populated for units the server already has a live connection to: filling it for the rest would cost a LAN round-trip each (~0.7 s on real hardware). Ask /api/units/{id}/capabilities per unit if you need the others.

It contains no secrets — not the API key, not device public keys, not the per-unit V3 token/key (has_v3_credentials is a boolean).

Units — /api/units

All of these require the API key and a device token.

GET    /api/units               → [{id, name, ip}]
GET    /api/units/state         → {states:[…], errors:[{id,name,ip,detail}]}  (all units,
                                   fanned out concurrently; unreachable units → online:false
                                   in states, or in errors — never 503s the whole batch)
GET    /api/units/{id}/state    → full state (connects + refreshes the unit)
GET    /api/units/{id}/capabilities → what the unit supports (see below) — lets
                                   clients hide controls the hardware lacks
GET    /api/units/{id}/history  → {id, samples:[…]} recent in-memory readings
                                   (indoor/outdoor/target/mode/power) for a graph;
                                   best-effort, non-persistent, empty until polled
GET    /api/units/stream        → text/event-stream: live per-unit state pushed
                                   as it changes (SSE). `event: state` frames +
                                   `: keepalive` comments. See "Live updates" below.
POST   /api/units/{id}/control  → full state (applies only the fields present)
PATCH  /api/units/{id}          → rename a unit (body {name}) → sanitized unit view
GET    /api/units/scan          → {subnet, candidates:[{ip, port, known}]}  (LAN TCP scan
                                   of ports 6440–6449; ?subnet=CIDR override, ?timeout=;
                                   read-only — adding still goes through POST /api/units)
POST   /api/units               → add a unit by LAN IP (body {ip, name?}); discovers
                                   it and writes config.json → 201 sanitized unit view
DELETE /api/units/{id}          → remove a unit from config → 204
GET    /api/config              → sanitized config: [{id,name,ip,port,has_v3_credentials}]
                                   (never returns the api_key or V3 token/key secrets)

State object:

{ "id":"","name":"","ip":"","online":true,"power_state":true,
  "operational_mode":"COOL","target_temperature":22.0,
  "indoor_temperature":26.3,"outdoor_temperature":31.0,
  "fan_speed":102,"swing_mode":"BOTH","eco":false,"turbo":false }

Errors: 401 bad key/credential · 404 unknown unit · 422 out-of-range value · 426 device auth-version below min_auth_version (upgrade the client) · 503 unreachable/apply-failed.

Capabilities object (GET /api/units/{id}/capabilities) — from msmart's get_capabilities(), read defensively (a field is null when the firmware/msmart doesn't report it, so clients "show it" rather than wrongly hide it):

{ "id":"","operational_modes":["AUTO","COOL","DRY","HEAT","FAN_ONLY"],
  "swing_modes":["OFF","VERTICAL","BOTH"],
  "supports_vertical_swing":true,"supports_horizontal_swing":false,
  "fan_speeds":["LOW","MEDIUM","HIGH","AUTO"],"supports_custom_fan_speed":true,
  "min_target_temperature":16.0,"max_target_temperature":30.0,
  "supports_eco":true,"supports_turbo":true,"supports_display_control":true,
  "supports_freeze_protection":false,"supports_humidity":false }

Live updates — /api/units/stream (SSE)

GET /api/units/stream is a Server-Sent Events stream (full auth, same as the other unit routes). Instead of each client polling, the server polls the units once (only while ≥1 client is connected) and fans changes out to all streams — the app can drop its own poll loop (battery), and a change made by a schedule or another client shows up on the next tick.

: connected                       ← sent immediately on open
event: state                      ← one frame per unit, whenever it changes
data: {"id":"…","online":true, … same shape as GET /state}

: keepalive                       ← comment every ~15 s to hold the connection

Notes:

  • It pushes the same state object as /state; a client applies each frame to its cached unit. Cadence while connected is AC_STREAM_TICK (default 5 s) — Midea units have no push, so the server still polls them; SSE only centralises that polling.
  • Compression is disabled for this route (Content-Encoding: identity), so a client that can't decode brotli still reads it; the response also sets X-Accel-Buffering: no so nginx doesn't buffer it (see REVERSE-PROXY.md).

Programs — /api/programs

Require API key + token; stored and executed server-side by the background scheduler.

GET    /api/programs            → [Program]
POST   /api/programs            → 201 Program
GET/PUT/DELETE /api/programs/{id}
POST   /api/programs/{id}/apply → [UnitState]   (favourite→scene; curve→now; schedule→400)
GET    /api/programs/status     → {running, tick_seconds, runs, errors, last_run}

A program targets units (unit_ids, empty = all) and is one kind:

  • favourite — a saved scene, applied on demand.
  • schedule{days:[0–6, empty=daily], time:"HH:MM", settings} triggers, fired when the clock crosses the minute.
  • curve{operational_mode, fan_speed, points:[{time,temperature}]}; the scheduler sets the interpolated setpoint (cyclic over the day, snapped to 0.5°). Times are server-local.

Timers — /api/timers

One-shot "do this in N minutes". Full auth, like control. See Timers for the reasoning.

Method Path Notes
GET /api/timers pending timers, each with seconds_remaining computed server-side
POST /api/timers {unit_ids, minutes, settings?, label?} → 201. minutes is 1–1440; settings defaults to power off. Creating one for a unit replaces that unit's existing timer. 404 for an unknown unit id
DELETE /api/timers/{id} 204, or 404 if it has already fired
GET /api/timers/status runner health: pending count, next fire time, tick, counters

minutes, not a wall-clock time: the server computes the moment from its own clock, so no client needs to know the server's timezone.

Clone this wiki locally