https://docs.hungrymachines.io/ # Welcome to Hungry Machines Docs One optimizer, four ways to connect it — Home Assistant, Home Hub, Smart Satellite, or your own client on the API. Outsmart your electric bill - Hungry Machines Energy provides an energy management system that shifts HVAC, EV charging, home batteries, and other electric loads to cheaper hours for homes and buildings with solar power or variable electricity rates. Every route runs the same optimizer; the difference is what carries out the plan. - [Home Assistant](/home-assistant/): Add it to the Home Assistant you run — a HACS package, no new hardware. Shipping today. - [Home Hub](/home-hub/): A pre-built appliance that runs your HVAC, water heater, EV charger and battery together, no Home Assistant needed. Beta list. - [Smart Satellite](/satellite/): A WiFi device for one window AC or mini-split — no thermostat, no hub. Beta list. - [Build on the API](/for-developers/): Create your own frontend, Home Assistant integration, or device client. Public API contract with worked examples. Shipping today. ## New here? Start with your account Every path uses one Hungry Machines account. Create it at **[hungrymachines.io](https://hungrymachines.io)**, confirm your email, and use those same credentials everywhere — however you connect, and any integration you build. Then [pick your path](/getting-started/). ## Stay in the loop [Join our newsletter](https://hungrymachines.io/#newsletter) for energy-saving techniques and product updates — one email at a time, unsubscribe whenever you like. Canonical page: https://docs.hungrymachines.io/ --- https://docs.hungrymachines.io/getting-started/ # Getting Started Create your account and pick your path — Home Assistant, Home Hub, Smart Satellite, or the API for developers building their own. Hungry Machines optimizes your home's energy use without you having to think about it, through whichever of the four routes fits your home: your own Home Assistant, a Home Hub, a Smart Satellite, or a client you build on the API. You start the same place either way. ## Step 1 — Create your Hungry Machines account This step is the same for everyone. Go to **[hungrymachines.io](https://hungrymachines.io)** and sign up. Confirm your email when the verification message arrives. The email and password you set there are what you'll use everywhere — the Home Assistant app, the website dashboard, and any integration you build. > **Note** > > Always sign up through the website. It sets up billing, subscription state, and account onboarding. Signing up programmatically through the API skips those steps, so real accounts should always be created on the website. ## Step 2 — Choose your path - [Home Assistant](/home-assistant/): Install the Hungry Machines integration in the Home Assistant you run, sign in, add your appliances, and start saving. No code. - [Home Hub](/home-hub/): A pre-built appliance that runs your HVAC, water heater, EV charger and battery together on one schedule. Beta list. - [Smart Satellite](/satellite/): A WiFi device that connects with one window AC or mini-split the way its remote does, and schedules it around the cheapest hours. Beta list. - [Build on the API (developers)](/for-developers/): Build your own dashboard, Home Assistant integration, mobile app, or device client on the Hungry Machines API. ## Which path is right for me? | You are... | Best path | Availability | |---|---|---| | Running Home Assistant already | [Home Assistant](/home-assistant/) | Shipping today | | A homeowner who wants one box that runs every machine in the house | [Home Hub](/home-hub/) | Beta list | | Someone with one window AC or mini-split to optimize | [Smart Satellite](/satellite/) | Beta list | | A Home Assistant power user who wants to extend or replace the default UI | [Custom HA integration](/guides/custom-home-assistant/) | Shipping today | | A developer building a non-HA tool — mobile app, embedded device, custom hub | [Build your own client](/guides/build-your-own-client/) | Shipping today | | Just curious about the API surface | [API Reference](/api-reference/readings/) | Shipping today | Every path uses the same account, the same API, and the same optimizer. The only difference is what carries out the plan — and whether that hardware has shipped yet. ## Next steps - Running Home Assistant: [Install the integration](/home-assistant/) - Want a Home Hub or Satellite: join the beta list from the [Home Hub](/home-hub/) or [Satellite](/satellite/) page - Developers: [For Developers](/for-developers/) ## Stay in the loop [Join our newsletter](https://hungrymachines.io/#newsletter) for energy-saving techniques and product updates — one email at a time, unsubscribe whenever you like. Canonical page: https://docs.hungrymachines.io/getting-started/ --- https://docs.hungrymachines.io/home-assistant/ # Install the App Add Hungry Machines to Home Assistant in about ten minutes — install through HACS, sign in, and register the appliances you want optimized. The Hungry Machines app lives inside Home Assistant. It adds a **sidebar panel** for managing your appliances, schedules, and preferences, plus two optional **dashboard cards** for at-a-glance status. Once it's installed and signed in, it collects the readings the optimizer needs and applies each day's schedule to your devices automatically — no code, no API keys to manage. This is the path for **most people**. If you'd rather build your own integration or a non–Home Assistant app, see [For Developers](/for-developers/). ## What you need - **Home Assistant** — any installation type (HAOS, Container, Supervised, or Core). - **[HACS](https://hacs.xyz/)** installed and configured. - **A Hungry Machines account** — created at [hungrymachines.io](https://hungrymachines.io). - **For each HVAC you want optimized, a `climate.*` entity** in Home Assistant. A power sensor, a dedicated indoor-temperature sensor, and a humidity sensor are optional but help Hungry Machines learn your home faster. A `weather.*` entity is optional too — pick it later to feed the optimizer your local forecast. ## Step 1 — Create your Hungry Machines account Go to **[hungrymachines.io](https://hungrymachines.io)** and sign up. Confirm your email when the verification message arrives. The email and password you set there are what you'll use to sign in inside Home Assistant. > **Tip** > > You only need to do this once. The same credentials sign you in on the website, in the Home Assistant panel, and on any additional device you connect later. ## Step 2 — Install through HACS 1. In Home Assistant, open **HACS → ⋮ (top right) → Custom repositories**. 2. Add the repository: - **Repository:** `https://github.com/hungrymachines/energy-dashboard` - **Type:** `Integration` - Click **Add**. 3. Search for **Hungry Machines** in HACS and click **Download**. 4. **Restart Home Assistant** when HACS prompts you. > **Note** > > No `configuration.yaml` editing required — everything is done from the Home Assistant UI. ## Step 3 — Add the integration 1. Open **Settings → Devices & Services → Add Integration**. 2. Search for **Hungry Machines** and click it. 3. Enter your **hungrymachines.io email and password** and click **Submit**. That's the only thing the setup dialog asks for — you'll pick your appliances and their entities in the panel next. A **Hungry Machines** entry now appears in your sidebar, and the two dashboard cards become available in the card picker. The integration also starts working in the background: it captures readings from your appliances, pushes your weather forecast once a day, and each morning fetches and applies the day's optimized schedules. ## Step 4 — Sign in and add your appliances Click the **Hungry Machines** entry in the sidebar and sign in with the same email and password. The first time in, your dashboard is empty — use **Add appliance** to register each device you want optimized: - **Pick a type** — HVAC, EV charger, home battery, water heater, or solar. - **For HVAC**, choose the `climate.*` entity it controls, and optionally a dedicated indoor-temperature, humidity, or power sensor. Have more than one HVAC? Add each separately — every unit gets its own schedule and learns independently. - **For EV charger, battery, or water heater**, choose the switch and any state-of-charge or tank-temperature sensor. - **Solar** is forecast-only — enter your system size and orientation, no entity needed. Each appliance then shows its own card with today's schedule, an **Edit constraints** button, and a pause toggle. You can edit or remove an appliance any time. > **Tip** > > See [Your Dashboard, at a Glance](/home-assistant/dashboard/) for a tour of what each part of the panel does. ## Step 5 — Add the dashboard cards (optional) The panel is the main surface; the cards are extras you can drop onto your existing Lovelace dashboards. In any dashboard, click **Add card → search "Hungry Machines"**, then fill in the entity IDs that match your home: ```yaml type: custom:hm-thermostat-card # Optional: pick a specific HVAC when you have more than one registered. # With exactly one HVAC the card resolves it automatically. # appliance_id: 11111111-2222-3333-4444-555555555555 entities: indoor_temp: sensor.living_room_temp outdoor_temp: sensor.outside_temp hvac_action: sensor.hvac_action ``` ```yaml type: custom:hm-savings-card # Optional: scope the savings figure to one HVAC. # Omitted → whole-home average + next scheduled run. # appliance_id: 11111111-2222-3333-4444-555555555555 entities: power: sensor.home_power ``` Sign-in is shared with the panel — once you've signed in there, the cards light up immediately. ## Installing without HACS If you don't use HACS: 1. Download `hungry_machines.zip` from the [latest release](https://github.com/hungrymachines/energy-dashboard/releases). 2. Unzip it into your Home Assistant config at `custom_components/hungry_machines/` (create the directory if it doesn't exist). 3. **Restart Home Assistant.** 4. Continue from **Step 3** above. ## Keeping the app up to date When a new release ships, HACS shows an update badge. Click **Update**, then restart Home Assistant. Your sign-in and preferences carry across updates. ## Next steps - [Your Dashboard, at a Glance](/home-assistant/dashboard/) — what each part of the panel and cards shows. - [Day-to-Day Use](/home-assistant/day-to-day/) — living with Hungry Machines once it's running. - [Choosing Your Electricity Pricing](/home-assistant/pricing/) — set the rate plan the optimizer saves against. Canonical page: https://docs.hungrymachines.io/home-assistant/ --- https://docs.hungrymachines.io/home-assistant/dashboard/ # Your Dashboard, at a Glance A tour of the Hungry Machines panel and cards inside Home Assistant — what every appliance card, chart, and control does. Everything you manage day-to-day lives in the **Hungry Machines panel** in your Home Assistant sidebar. This page walks through what each part shows so you know where to look. ## The dashboard Open the panel and you land on the **Dashboard** — one card per appliance you've registered. Each card shows: - **Today's optimized schedule** for that appliance, drawn as a chart (see below). - **Today's estimated savings** compared with running the device on a plain, fixed schedule. - **Edit constraints** — opens the comfort or charge settings for that appliance. - **A pause toggle** — takes that one appliance out of optimization without deleting it. There's also a master switch that pauses everything at once. An **Add appliance** button lets you register more devices at any time. ## Reading the HVAC chart For an HVAC unit, the chart tells the whole story of the day in one picture: - **The solid line** is the indoor temperature Hungry Machines expects over the next 24 hours if the plan runs as scheduled. - **The dashed lines** are your comfort limits — the warmest and coolest you've said you're willing to be at each time of day. The solid line always stays between them. - **The bars behind them** are your electricity prices hour by hour. Tall bars are expensive hours; short bars are cheap ones. - **The savings figure above** is how much cheaper this plan is than not optimizing. Read together, the chart shows Hungry Machines pre-cooling (or pre-heating) during the cheap hours so your HVAC can coast through the expensive ones while keeping you inside your comfort limits. > **Tip** > > EV chargers, batteries, and water heaters get their own charts too — state-of-charge or tank temperature over the same 24-hour price backdrop, with a marker showing your target and deadline (for example, "80% by 07:00"). ## The two dashboard cards If you added the optional Lovelace cards ([Step 5 of Install](/home-assistant/#step-5-add-the-dashboard-cards-optional)), they give you the same information without opening the panel: - **Thermostat card** (`hm-thermostat-card`) — current indoor and outdoor temperature, the same optimization chart as the panel, and a savings-level slider you can adjust in place. - **Savings card** (`hm-savings-card`) — today's estimated savings, your home's current power draw, and the next scheduled appliance run. Both cards share the panel's sign-in, so once you're signed in they work everywhere on your dashboards. ## Health badges Each appliance card (and each dashboard card) shows a small **health badge** when its sensors look stale or disconnected. Hungry Machines can only optimize well on good data, so the badge is your early warning that a thermostat or sensor has dropped offline before it quietly costs you accuracy. For a full breakdown, the panel's per-appliance **diagnostics view** shows exactly which readings look healthy and which don't. If a badge turns red, see [Troubleshooting](/troubleshooting/) — the most common cause is a renamed or offline entity. ## Settings The **Settings** tab holds the account-wide options: - **Weather entity** — optionally pick the `weather.*` entity whose forecast is sent to the optimizer each day, so it plans against your local weather rather than generic data. - **Pricing source** — choose the electricity rate plan you're billed on. This has a big effect on your savings, so it has its own page: [Choosing Your Electricity Pricing](/home-assistant/pricing/). - **Send feedback** — a short form to send a comment, bug report, or idea to the team without leaving Home Assistant. - **Account** — sign out, or contact us if you'd like your account deleted. ## Where settings live A quick map of what's set where: | Setting | Where to change it | |---|---| | Comfort (base temperature, savings level, mode, away/home times) | Each HVAC card → **Edit constraints** | | Charge targets and deadlines (EV, battery) / tank limits (water heater) | Each appliance card → **Edit constraints** | | Pause one appliance | That appliance's **pause toggle** | | Pause everything | The master optimization switch | | Electricity pricing | **Settings → Pricing source** | | Weather forecast source | **Settings → Weather entity** | ## Next steps - [Day-to-Day Use](/home-assistant/day-to-day/) — how to actually live with it: adjusting comfort, pausing, and what your first week looks like. - [Choosing Your Electricity Pricing](/home-assistant/pricing/) — the single biggest lever on your savings. Canonical page: https://docs.hungrymachines.io/home-assistant/dashboard/ --- https://docs.hungrymachines.io/home-assistant/day-to-day/ # Day-to-Day Use What to expect from Hungry Machines once it's running — your first week, adjusting comfort and savings, pausing, and the one-time warm-up. Once Hungry Machines is installed and your appliances are registered, it mostly runs itself. This page covers what happens automatically, what you can adjust, and what your first week looks like. ## What runs on its own Every day, without you doing anything: 1. **Throughout the day**, the app collects readings from your appliances so the optimizer can learn how your home behaves. 2. **Once a day**, it sends your local weather forecast. 3. **Overnight**, Hungry Machines builds a fresh 24-hour plan for each appliance — shifting energy use toward the cheapest hours while staying inside the comfort and charge limits you set. It can replace the rest of today's plan during the day too, when you change a setting or conditions shift, and the app picks the new plan up on its own. 4. **Every 30 minutes**, your Home Assistant applies the current step of that plan: setpoint, mode, and fan for HVAC; on or off for EV chargers, batteries, and water heaters. You can watch all of this on the [dashboard](/home-assistant/dashboard/) — what's running now, what's coming next, and how much you're saving. ## Your first week Hungry Machines learns *your* home, not a generic one, so its plans get better over the first days of running. | When | What's happening | |---|---| | **Right after setup** | Each appliance gets its first plan from the first planning run after you add it: when you save a setting, or overnight at the latest. | | **The first few days** | Plans arrive every day, but there is little to go on yet, so the savings estimate can sit at zero. This is normal. | | **From then on** | Hungry Machines keeps learning from more weather and more of how your home responds. Savings tend to grow as the picture fills in. | > **Note** > > Consistent readings speed this up. Long gaps — a thermostat offline, a network outage — slow the learning down, because there's less data to learn from. If you're a week in and an appliance still has no plan, check the health badges on your appliance cards and see [Troubleshooting](/troubleshooting/). ## The one-time warm-up (calibration) On the first warm day after you add an HVAC, Hungry Machines runs a short, one-time **warm-up** for that unit: a controlled morning cooling pattern that lets it directly measure how your specific system responds. While it's running, the panel shows a banner — and a **Skip** button if you'd rather it didn't run that day. About a week later there's a second warm-up day, and it's much quieter: your unit holds three temperatures in a row, all inside your comfort band, so the room never drifts. It measures how quickly your system closes a small gap — something the first warm-up can't see. Both runs happen once per unit (unless you re-run the first one later, for example after servicing your HVAC). It's worth letting them finish: the measurements they gather make every schedule afterward more accurate. ## Adjusting comfort and savings Open an HVAC's **Edit constraints** (from its dashboard card) to tune how it behaves: - **Base temperature** — the temperature you'd like to sit at when you're home and comfortable. - **Comfort band** — how far the room may drift from your base temperature. Until you set your own hourly bands, every home gets the same default, which depends only on your base temperature and mode. When cooling it runs from 2°F below base to 1°F above overnight, and from 5°F below to 6°F above between 08:00 and 21:59. Heating mirrors it: 1°F below to 2°F above overnight, 6°F below to 5°F above by day. The savings level setting no longer changes the band. - **Mode** — cool, heat, auto, or off. - **Away / home times** — when you typically leave and return. They are stored with your settings; they do not change the default band. - **Hourly comfort bands** (advanced) — set your own high and low limit for each hour of the day. This is how you widen or narrow the default band, and the way to relax comfort while you're out. A wider band gives the optimizer more room to work, so it generally saves more — the trade-off is larger temperature swings. If your schedule chart looks nearly flat, a narrow band (or a flat-rate electricity plan) is usually why; see [Troubleshooting](/troubleshooting/#schedule-looks-like-flat-setpoints). For EV chargers, batteries, and water heaters, **Edit constraints** sets charge targets, minimums, deadlines, or tank temperature limits instead. > **Tip** > > After you save a change, Hungry Machines re-plans right away — you don't have to wait for the next night to see the new schedule on the chart. ## Pausing Two ways to step in: - **Pause one appliance** — its per-card toggle takes just that device out of optimization and hands control back to you, without losing its settings. - **Pause everything** — the master optimization switch stops Hungry Machines from applying any schedule at all. While paused, schedules are still calculated in the background, so turning optimization back on takes effect at the next 30-minute boundary — no waiting for the next night. ## Comfort always wins If your indoor temperature ever drifts outside your comfort band during a scheduled *off* period — an unusually hot afternoon, say — the app overrides the plan and runs your HVAC to bring you back into range. Savings never come at the cost of leaving you uncomfortable. ## Next steps - [Choosing Your Electricity Pricing](/home-assistant/pricing/) — make sure the optimizer is saving against the rates you actually pay. - [Troubleshooting](/troubleshooting/) — health badges, flat schedules, and sign-in problems. Canonical page: https://docs.hungrymachines.io/home-assistant/day-to-day/ --- https://docs.hungrymachines.io/home-assistant/pricing/ # Choosing Your Electricity Pricing Set the electricity rate plan Hungry Machines optimizes against — a preset time-of-use zone, your own hourly rates, or live daily-varying wholesale pricing. Hungry Machines saves money by moving energy use into your cheapest hours — so it needs to know **what you actually pay, and when.** That's the single biggest lever on your savings. Set it in the panel under **Settings → Pricing source**. > **Note** > > If your electricity costs the same all day (a flat rate), there's no cheap-versus-expensive signal to shift load toward, so savings will be small no matter what. Hungry Machines works best on a time-of-use or daily-varying plan. ## Static (time-of-use zone) The simplest option: pick a **preset rate plan** from the catalog. It covers common US utility time-of-use plans, each with its own off-peak, mid-peak, and peak windows. Choose the one that matches your utility and plan, and Hungry Machines optimizes against those rates. On ComEd, that catalog includes the four **ComEd Time-of-Day Pricing** plans — pick the one matching your home type (single- or multi-family) and heating type (standard or electric heat). ### Your own hourly rates If none of the presets matches your bill, switch on **hourly rate overrides** and enter your own price for each hour of the day in ¢/kWh. This is the most precise option — it's exactly what your plan charges. > **Tip** > > Don't see your utility or region in the catalog? Email [info@hungrymachines.io](mailto:info@hungrymachines.io) and we'll look at adding it. In the meantime, the hourly override lets you enter your plan's prices directly. ## Dynamic (changes daily) Some utilities bill residential customers on **wholesale prices that change every day** rather than a fixed time-of-use schedule. If you're on one of those plans, choose **Dynamic** and Hungry Machines follows the day-ahead prices for your grid region automatically. Fields appear when you pick Dynamic: - **Region** — your grid region. Supported regions today are **ComEd — Hourly Pricing** (Northern Illinois) and **Ameren Illinois** (Power Smart Pricing). More may be added over time. - **Delivery plan** *(ComEd only)* — leave this on **Flat estimate**, or pick your **ComEd Delivery Time-of-Day** class (single- or multi-family, standard or electric heat) if you've enrolled in that program. Choosing a class prefills its four published period prices — Morning, Mid-Day Peak, Evening, Overnight — and every one is yours to edit: check them against the Distribution Facility Charge on a recent bill and adjust to match. It stacks with Hourly Pricing. - **Delivery charge estimate** — a flat ¢/kWh you add on top of the wholesale price to approximate delivery, capacity, and taxes — the parts of your bill that aren't the raw energy price. Check a recent bill to estimate it. With a Delivery Time-of-Day class selected, this field only needs to cover what the delivery plan doesn't — taxes and supply riders — so it typically drops to around **2¢/kWh**. > **Tip** > > On a daily-varying plan in a region that isn't listed yet? Email [info@hungrymachines.io](mailto:info@hungrymachines.io) — and until it's added, the **Static → hourly rate overrides** option is a good stand-in for a typical day. ## Which should I choose? | Your situation | Pick | |---|---| | Your utility offers a named time-of-use plan and it's in the catalog | **Static** → the matching preset | | You know your exact hourly prices but no preset fits | **Static** → hourly rate overrides | | Your plan's prices change every day with the wholesale market | **Dynamic** → your region | | Your electricity is a flat rate all day | Any option — but expect modest savings (there's little to optimize) | ## Next steps - [Day-to-Day Use](/home-assistant/day-to-day/) — comfort and savings settings that work alongside your pricing. - [Your Dashboard, at a Glance](/home-assistant/dashboard/) — the price bars behind your schedule chart come from the plan you pick here. Canonical page: https://docs.hungrymachines.io/home-assistant/pricing/ --- https://docs.hungrymachines.io/satellite/ # Set up your Hungry Machines Smart Satellite Get a Hungry Machines Smart Satellite online in a few minutes — mint a claim code, join the device's setup network, and connect it to your account. A **Smart Satellite** is a small Hungry Machines WiFi device that reads the temperature and humidity in a room and connects with a mini-split or window air conditioner just like its original remote, but on an optimized schedule. There's nothing to wire and no app to install: you set it up from any phone's web browser. The whole process is three steps: **mint a claim code** on your dashboard, **join the device's setup network**, and **enter your details**. It takes about five minutes. > **Note** > > You'll need a Hungry Machines account first. If you don't have one, sign up at **[hungrymachines.io](https://hungrymachines.io)** and confirm your email — see [Getting Started](/getting-started/). ## Before you start - The Smart Satellite, plugged in within a few feet of the AC it will work with (like a remote, it needs a clear line of sight to the unit). - Your home WiFi **network name and password** (2.4 GHz — most Satellites don't join 5 GHz-only networks). - Your AC's **brand**, so the Satellite knows which remote "language" to speak. - A phone or laptop on that same WiFi. ## Step 1 — Mint a claim code The claim code is a one-time secret that proves the device belongs to your account. 1. Sign in to your dashboard at **[hungrymachines.io](https://hungrymachines.io)**. 2. Find the **Devices** section and choose **Add a device**. 3. The wizard shows a **claim code** and a **15-minute countdown**. Leave this screen open — you'll type the code into the device in Step 3. > **Caution** > > The code expires after **15 minutes**. If the countdown runs out before the device checks in, the wizard shows a **Generate a new code** button — mint a fresh one and continue. An expired code never silently "works"; the device will tell you it was rejected. ## Step 2 — Join the device's setup network When a Smart Satellite has never been set up, it broadcasts its own temporary WiFi network so you can talk to it directly. 1. On your phone, open **WiFi settings** and look for a network named **`hm-satellite-XXXX`** (the `XXXX` is unique to your device). 2. Join it. It's an open network — no password. 3. A setup page should **pop up automatically** (a "captive portal", the same kind you see joining hotel or café WiFi). If it doesn't appear within a few seconds, open a browser and go to **`http://192.168.4.1`**. > **Tip** > > Some phones warn that this network "has no internet" and ask whether to stay connected — choose **stay connected**. That's expected: the device's setup network isn't meant to reach the internet, it's just there so you can configure the device. ## Step 3 — Enter your details On the setup page: 1. Pick your **home WiFi network** from the list and enter its **password**. 2. Type the **claim code** from Step 1. 3. Choose your **AC brand** from the dropdown, and optionally give the unit a **name** (e.g. "Living Room Mini-Split"). 4. Submit. The device joins your WiFi, connects to your account, and **restarts**. Within a few seconds the device disappears from your phone's WiFi list (its setup network shuts down for good once it's connected) and appears back on your **dashboard**, where the Add-Device wizard advances to a success screen naming your new unit. From here on, the Satellite runs on its optimized schedule and you can adjust it from the dashboard. > **Tip** > > Rejoin your **home WiFi** on your phone after the device restarts — it won't switch you back automatically. ## If something goes wrong The setup page tells you exactly which step failed, so you can fix just that one: | Message | What it means | Fix | |---|---|---| | Couldn't join WiFi / check the password | Wrong WiFi password, or a 5 GHz-only network | Re-enter the password; make sure it's a 2.4 GHz network | | Invalid or expired code | The claim code was wrong or timed out | Mint a fresh code on the dashboard (Step 1) and try again | | Already claimed | This device is bound to an account already | If it's yours, [unclaim it first](#selling-or-moving-a-device); then it's claimable again | | Couldn't reach Hungry Machines | The device joined WiFi but couldn't reach our servers | Check your internet connection and retry | ## Factory reset A factory reset wipes the device's WiFi and account binding and returns it to first-time setup — the setup network from Step 2 comes back. 1. **Hold the device's button** (the recessed **BOOT** button) for **10 seconds**. 2. Release when the device restarts. 3. The `hm-satellite-XXXX` setup network reappears — start again from Step 2. Use this if you moved to a new home, changed your WiFi, or want to hand the device to someone else. ## Selling or moving a device To remove a Smart Satellite from your account — before you sell it, or because you're replacing it — open its card in the dashboard **Devices** section and choose **Unclaim**. > **Note** > > Unclaiming stops the old device from talking to your account and makes it claimable again by anyone (including you, on a fresh setup). The AC's **history stays** in your account — its readings, learned model, and past schedules aren't deleted, so a replacement device you claim to the same room keeps the learning. If you also want to delete that appliance and its history, remove the appliance separately from your dashboard. Canonical page: https://docs.hungrymachines.io/satellite/ --- https://docs.hungrymachines.io/home-hub/ # Home Hub A pre-built device that connects your HVAC, water heater, EV charger and battery to one optimized schedule. Currently in beta. The Home Hub is a pre-built device that connects your HVAC, water heater, EV charger and battery to one optimized schedule. The optimizer builds tomorrow's plan every night; the Hub runs it on its own hardware. > **Caution: Beta list, not yet shipping** > > The Home Hub is built and tested as a unit, but it isn't on sale yet — there's no price and no order page. Join the [beta list](https://hungrymachines.io/beta/home-hub) to hear when it's ready. Below is what ships with the first units. The Hub is factory-provisioned before it reaches you, so there's genuinely little else to read; deeper reference material lands once units are in customers' hands. ## What's in the box One small appliance, plugged into power and your network. Inside, it runs a full home-automation stack that we assemble, test and update as a single unit: the engine that reaches your Z-Wave and Matter devices, a local broker so a [Satellite](/satellite/) in the same house can attach to it instead of connecting on its own, and our own interface on top. You never see or manage that stack directly — the Hub presents its own dashboard, not a home-automation platform for you to learn. ## First boot The Hub arrives already set up for your account. There's no onboarding wizard to click through and no integrations to install — plug it into power and your network, and it starts running. From there, connecting your machines to it is the same one-time step every route uses; see [Choose your path](/getting-started/). ## The kiosk client Touchscreen units boot straight into the Hub's own dashboard, full-screen — the same screen you'd reach from a phone or laptop on the network. Headless units skip the touchscreen and use that same dashboard from any device. ## Keeping it updated Nothing on the Hub updates itself piece by piece, and there's no plugin store on it to manage. When we ship a change, it goes out as one tested bundle for the whole box, the same way the Hub arrived — one update authority, one thing to trust. For the product overview, see **[hungrymachines.io/home-hub](https://hungrymachines.io/home-hub)**. Canonical page: https://docs.hungrymachines.io/home-hub/ --- https://docs.hungrymachines.io/for-developers/ # For Developers Build your own frontend, Home Assistant integration, or device client on top of the Hungry Machines API. The Hungry Machines optimizer is available over a documented HTTP API, so you don't have to use the default Home Assistant app. You can build your own dashboard, a mobile app, a Home Assistant integration tuned to your home, or firmware on an embedded device — anything that can speak HTTPS. This section is the **public integration contract**: everything you need to send the right data and apply the results, with worked examples in several languages. > **Note: Building with an agent?** > > Start at [Build With an AI Agent](/guides/build-with-an-agent/) — the call sequence, the rules, and a copy-ready prompt naming the [OpenAPI spec](/api-reference/openapi/) and [llms-full.txt](/llms-full.txt). > **Note** > > The API is a clean boundary. You send readings, preferences, appliance definitions, weather, and rates; you receive ready-to-apply schedules. **How** the optimizer turns your data into a schedule — the learning models and the optimization itself — is proprietary and lives entirely in the backend. You never need to reproduce it: send well-formed inputs, apply the commands the API returns, and display the results as-is. Building against that boundary keeps your integration stable across backend releases. ## The three things a client does Whatever you build, the shape is the same: 1. **Register** the account's appliances and preferences (one-time). 2. **Push readings** so the optimizer can learn the home (ongoing, ~every 5 minutes). 3. **Pull the schedule** after each overnight run, and again whenever it changes during the day, and apply each 30-minute step to the device locally. Your client owns all local device control — hysteresis, safety limits, hardware I/O. The API owns the optimization. It's the same API our own Home Assistant integration is built on: the routes documented here are the ones it calls. ## Pick your path - [Custom Home Assistant integration](/guides/custom-home-assistant/): You run Home Assistant and want your own REST sensors, rest_command actions, and Lovelace cards instead of the default panel. - [Build your own client](/guides/build-your-own-client/): No Home Assistant — you're integrating from a Python script, a mobile app, an ESP32, or any other platform. Talk to the API directly. - [Authentication reference](/getting-started/authentication/): Token lifecycle: login, refresh, and how to handle 401s in an automated client. Start here before writing any request. - [API Reference](/api-reference/readings/): Per-endpoint request/response shapes, validation rules, and error codes — the canonical contract for everything the API accepts and returns. ## Before you start - **Accounts are created on the website.** Always have users sign up at [hungrymachines.io](https://hungrymachines.io) — that flow sets up billing, subscription state, and onboarding, none of which the API does on its own. Your client authenticates an existing account; it doesn't create real ones. See [Authentication](/getting-started/authentication/). - **Everything is one base URL.** Both authentication and product endpoints live on `https://api.hungrymachines.io`. Users sign in with the same email and password they use everywhere else — no separate API credentials. - **The default app is open source.** The Home Assistant panel and cards are MIT-licensed and a good reference (or fork target) if you want a head start. Patches and forks are welcome. ## What you send vs. what you receive | Direction | Endpoint | Typical cadence | Purpose | |---|---|---|---| | Push | [`POST /api/v1/readings`](/api-reference/readings/) | Every ~5 min | HVAC sensor data — the input the optimizer learns from | | Push | [`POST /api/v1/appliances/{id}/readings`](/api-reference/appliances/) | Every ~5 min (per appliance) | EV / battery / water-heater / solar state | | Push | [`POST /api/v1/weather`](/api-reference/weather/) | Once a day | Your local weather forecast | | Pull | [`GET /api/v1/schedules`](/api-reference/appliances/#get-apiv1schedules) | After the overnight run, and again when it changes | Ready-to-apply schedules for every appliance | | Pull | [`GET /api/v1/schedules/updated-at`](/api-reference/appliances/#get-apiv1schedulesupdated-at) | Every few minutes | One timestamp that changes whenever today's schedules are replaced | | Read/write | [`/api/v1/appliances`](/api-reference/appliances/) · [`/api/v1/preferences`](/api-reference/preferences/) · [`/api/v1/rates`](/api-reference/rates/) | As needed | Register devices, comfort settings, pricing | ## A note on treating outputs as opaque A few fields the API returns are for **display only** — `estimated_savings_pct`, `model_confidence`, and the predicted-temperature trajectory line. Render them as-is; don't build logic on how they're computed, and don't try to reverse-engineer the optimizer from them. The stable contract is: **apply `setpoint_temps` (and on/off `intervals`) verbatim, honor the documented fallbacks, and show the rest.** The [API Reference](/api-reference/schedule/) spells out each field and its fallback; [Errors, Limits & Stability](/api-reference/errors-and-limits/) covers the full list plus what you may do with each. ## Stay in the loop Get an email when the API contract changes — new endpoints, field additions, deprecations. Sign up for a developer account: https://hungrymachines.io/signup ## Next steps - [Authentication](/getting-started/authentication/) — get a token and keep it fresh. - [Custom Home Assistant integration](/guides/custom-home-assistant/) or [Build your own client](/guides/build-your-own-client/) — pick your path. - [API Reference](/api-reference/readings/) — the full endpoint-by-endpoint contract. - [Errors, Limits & Stability](/api-reference/errors-and-limits/) — error codes, rate limits, and what's stable within a major version. Canonical page: https://docs.hungrymachines.io/for-developers/ --- https://docs.hungrymachines.io/getting-started/authentication/ # Authentication (Reference) Token lifecycle reference — login, refresh, 401 handling. Only needed if you're building a custom Home Assistant integration or a custom client app. This is reference documentation for paths that talk to the API directly — [Custom Home Assistant integration](/guides/custom-home-assistant/) and [Build Your Own Client](/guides/build-your-own-client/). If you installed the [Energy Dashboard](/home-assistant/), you don't need any of this — the dashboard handles tokens for you. ## How accounts are created **Always sign up at [hungrymachines.io/signup](https://hungrymachines.io/signup).** That flow handles billing, subscription state, email verification, and account onboarding — none of which the API alone takes care of. Once your account exists, the two endpoints below are how you exchange your credentials for an API token. > **Caution** > > The API does expose `POST /auth/signup` for programmatic account creation, but it bypasses the Stripe customer setup and onboarding state. Use it only for test fixtures or short-lived dev accounts. Real users belong on the website signup flow. See [Auth Reference](/api-reference/auth/) for the full signup request/response shape. ## Two tokens, two lifetimes `POST /auth/login` and `POST /auth/refresh` both return the same shape: ```json { "access_token": "eyJhbGciOiJFUzI1NiIs...", "refresh_token": "v1.Mfg2...", "expires_in": 3600, "expires_at": 1735689600, "token_type": "bearer", "user": { "id": "550e8400-...", "email": "you@example.com" } } ``` | Token | Lifetime | Purpose | |---|---|---| | `access_token` | 1 hour | Sent with every API request as `Authorization: Bearer ...` | | `refresh_token` | 30 days | Exchanged for a new access token without re-entering credentials | The refresh token is rotated on each `/auth/refresh` call — always save the new one and discard the old. ## Log in ```bash curl -X POST https://api.hungrymachines.io/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your-secure-password"}' ``` ## Refresh an expired token ```bash curl -X POST https://api.hungrymachines.io/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token": "v1.Mfg2..."}' ``` ## Attaching the access token Every authenticated request uses the `Bearer` scheme: ```bash curl https://api.hungrymachines.io/auth/me \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` Full response shape in [Auth Reference](/api-reference/auth/). ```bash curl -X POST https://api.hungrymachines.io/api/v1/readings \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "readings": [...] }' ``` ## Handling 401 errors When the API returns `401 Unauthorized`, the token is missing, malformed, or expired. The `detail` field tells you which: ```json {"detail": "Not authenticated"} // no Authorization header {"detail": "Token expired"} // past the 1-hour lifetime {"detail": "Invalid token"} // malformed or wrong signature ``` **Recovery pattern for automated clients:** 1. Make the API call. 2. If `401 Token expired`, call `POST /auth/refresh` with your saved refresh token. 3. Save the new `access_token` and `refresh_token`. 4. Retry the original request with the new access token. 5. If refresh itself returns `400 Invalid Refresh Token`, the refresh token is stale (30-day expiry, or the user logged out elsewhere). Surface a "please sign in again" error. ## Password reset Users reset their password via [hungrymachines.io/reset-password](https://hungrymachines.io/reset-password) — the web flow sends a reset link to their registered email. Password reset is not currently exposed as an API endpoint. ## Language-specific examples ### Python (httpx) ```python import httpx from datetime import datetime, timezone API = "https://api.hungrymachines.io" class Client: def __init__(self, email: str, password: str): resp = httpx.post(f"{API}/auth/login", json={"email": email, "password": password}) resp.raise_for_status() session = resp.json() self.access_token = session["access_token"] self.refresh_token = session["refresh_token"] self.expires_at = session["expires_at"] def _refresh(self): resp = httpx.post(f"{API}/auth/refresh", json={"refresh_token": self.refresh_token}) resp.raise_for_status() session = resp.json() self.access_token = session["access_token"] self.refresh_token = session["refresh_token"] self.expires_at = session["expires_at"] def request(self, method: str, path: str, **kwargs): # Refresh proactively if we're within 5 minutes of expiry. now = int(datetime.now(timezone.utc).timestamp()) if now >= self.expires_at - 300: self._refresh() headers = kwargs.pop("headers", {}) headers["Authorization"] = f"Bearer {self.access_token}" return httpx.request(method, f"{API}{path}", headers=headers, **kwargs) client = Client("you@example.com", "secure-password") resp = client.request("GET", "/auth/me") print(resp.json()) ``` ### JavaScript (fetch) ```js class HMClient { constructor(session) { this.access_token = session.access_token; this.refresh_token = session.refresh_token; this.expires_at = session.expires_at; } static async login(email, password) { const resp = await fetch('https://api.hungrymachines.io/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!resp.ok) throw new Error('login failed'); return new HMClient(await resp.json()); } async _refresh() { const resp = await fetch('https://api.hungrymachines.io/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: this.refresh_token }), }); if (!resp.ok) throw new Error('refresh failed — sign in again'); const session = await resp.json(); this.access_token = session.access_token; this.refresh_token = session.refresh_token; this.expires_at = session.expires_at; } async request(path, opts = {}) { if (Math.floor(Date.now() / 1000) >= this.expires_at - 300) { await this._refresh(); } return fetch(`https://api.hungrymachines.io${path}`, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${this.access_token}` }, }); } } const client = await HMClient.login('you@example.com', 'secure-password'); const me = await (await client.request('/auth/me')).json(); ``` > **Tip** > > Working on something advanced that needs direct database SDK access (e.g. OAuth, magic links, row-level realtime subscriptions)? Email [info@hungrymachines.io](mailto:info@hungrymachines.io) and we'll send you the project URL + publishable key for your project. ## Next steps - [Custom Home Assistant integration](/guides/custom-home-assistant/) — REST sensors and rest_command setup - [Build Your Own Client](/guides/build-your-own-client/) — full integration loop with Python + ESP32 examples - [API Reference — Readings](/api-reference/readings/) — start pushing sensor data - [OpenAPI description](/api-reference/openapi/) — the auth endpoints' exact request/response shapes, machine-readable Canonical page: https://docs.hungrymachines.io/getting-started/authentication/ --- https://docs.hungrymachines.io/guides/build-with-an-agent/ # Build With an AI Agent The page to read first if an AI coding agent — or the person directing one — is building a client against the Hungry Machines API. ## Who this is for You're a coding agent (or someone prompting one) building a client that talks to the Hungry Machines API directly — not through Home Assistant. This page is the shortest path from nothing to a working client: the exact calls, in order, and the rules that keep a client correct across accounts in different states. ## What the API does Hungry Machines builds optimized operating schedules for a home's HVAC, EV charger, water heater and battery. A client registers the home's appliances and comfort preferences, pushes sensor readings on an ongoing basis, and pulls a schedule after each overnight run and whenever it changes during the day — 48 commands, one per 30-minute slot, ready to apply as given. The optimization itself is not part of this contract; treat the schedule you receive as the answer, not as something to re-derive. ## Setup, in order Every path below hangs off `https://api.hungrymachines.io`, and every call after login carries `Authorization: Bearer `. Run these once per account: 1. **`POST /auth/signup`** (`signUp`) — create the account, sending `timezone` (an IANA zone name) so schedules key to the right local day from the start, or send the person through signup on [hungrymachines.io](https://hungrymachines.io) instead, which also sets up billing and onboarding. Programmatic signup skips both, so use it for test accounts. 2. **`POST /auth/login`** (`logIn`) — exchange email and password for `access_token` and `refresh_token`. 3. **`PUT /api/v1/preferences`** (`updatePreferences`) — set comfort preferences (`base_temperature`, `optimization_mode`, `time_away`, `time_home`, and so on). All fields are optional; send only what the person configured. 4. **`POST /api/v1/appliances`** (`createAppliance`) — register each appliance: an `appliance_type` from `hvac`, `ev_charger`, `home_battery`, `water_heater`, `solar`, `dehumidifier`, `robot`, a `name`, and a type-specific `config`. Every type but `solar` requires `config.entity_id` — the entity your client reads sensors from and writes commands to. An HVAC unit is: ```json { "appliance_type": "hvac", "name": "Living Room AC", "config": { "entity_id": "climate.living_room", "hvac_type": "central_ac", "home_size_sqft": 2000 } } ``` The response is `{"appliance_id": ""}` — keep it, readings and per-appliance routes are keyed by it. Each type's full `config` table is on [Appliances & Unified Schedules](/api-reference/appliances/). 5. For EV chargers, batteries and water heaters only: **`POST /api/v1/appliances/{appliance_id}/constraints`** (`setApplianceConstraints`) — target charge, minimum charge, and deadline. HVAC comfort lives in step 3 and in per-appliance preferences, not here. ## The daily loop Once setup is done, run four recurring tasks: - **Every ~5 minutes — push readings.** HVAC appliances: **`POST /api/v1/readings`** (`pushReadings`). Every other appliance type: **`POST /api/v1/appliances/{appliance_id}/readings`** (`pushApplianceReadings`). This is the input the optimizer learns from; the more consistently it arrives, the better the schedules get. - **Once a day — push the weather forecast.** **`POST /api/v1/weather`** (`pushWeather`) sends the home's local hourly forecast. Skip it and the optimizer plans against a generic fallback for that location, which is the single largest avoidable loss of accuracy in a custom client. - **After the overnight run — pull the schedule.** **`GET /api/v1/schedules`** (`getSchedules`) returns today's schedule for every registered appliance in one call. Apply the command for the current slot locally; re-check which slot you're in every 30 minutes. - **Every few minutes — check for a newer schedule.** **`GET /api/v1/schedules/updated-at`** returns one timestamp. A plan for today can be replaced during the day, after a settings change and when the API re-plans the rest of the day. Keep the last value you saw and pull the schedule again when it changes. If the person changes a preference or constraint and the UI should reflect the new schedule immediately, call **`POST /api/v1/schedule/recompute`** (`recomputeSchedule`) instead of waiting for the next scheduled run. It replans while you wait (usually a few seconds; allow about 20 seconds per HVAC unit) and returns `date`, the same `appliances` entries as `getSchedules`, and a `results` array. It does not return the top-level `optimization_enabled`, `integration_health` or `divergence_summary`. ## Rules - **Apply schedules as given.** Each schedule is 48 commands, one per 30-minute slot. Don't reinterpret, smooth, or second-guess a slot's value — apply it and move to the next. - **Slot 0 is local midnight.** Convert your own clock to the home's local time to find the current slot (`slot = hour * 2 + (1 if minute >= 30 else 0)`); every timestamp the API sends or receives is UTC, but slot indexing is local. - **Handle both signup response shapes.** `POST /auth/signup` returns either an authenticated session (`access_token` and friends) or, when the account requires email confirmation, only a pending `user` row with no session fields. Branch on whether `access_token` is present. - **Handle `source: "defaults"`.** An appliance with no stored plan yet is flagged `"defaults"`. On `GET /api/v1/schedules` its `schedule` is an empty object, `{}`: there are no setpoints and no comfort band, so send that appliance no commands. The first plan is stored by the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run, and from then on `source` is `"optimization"` with `setpoint_temps` populated. The single-unit route `GET /api/v1/schedule` behaves differently: its defaults response carries a band in `high_temps` / `low_temps` with `setpoint_temps: null`, and there you apply the midpoint, `(high_temps[slot] + low_temps[slot]) / 2`. - **Check for a key before reading it.** On `GET /api/v1/schedules` a schedule key is present with a value or absent, never `null`. Read it defensively (`schedule.get("setpoint_temps")`, `schedule.setpoint_temps ?? null`) — indexing it blind raises on exactly the accounts a new client is most likely to be testing against. The same holds for `temp_trajectory`, `hvac_mode_schedule` and `fan_mode_schedule`. - **Gate on `optimization_enabled`.** Every entry carries the flag. Do not apply an entry's schedule while it is `false`. - **Send `client: {kind: "custom", name: ""}` on your readings payload.** `POST /api/v1/readings` accepts an optional `client` block; `kind` also takes `hacs` and `node` — the Hungry Machines Home Assistant integration and the Home Hub — but `"custom"` is the honest value for your own client, and `name` is a free-text label for it. The spec's example shows `kind: "hacs"` only because that example is the integration's own payload. ## What your client owns vs. what the API owns Your client owns local device I/O: reading sensors, writing setpoints and on/off commands to hardware, hysteresis, and any safety limits. The API owns the optimization: given your readings, preferences and constraints, it decides what the schedule should be. Don't try to recompute or validate that decision locally — apply it. Two optional command arrays need a rule your client has to get right, because applying them literally breaks real hardware: - **`fan_mode_schedule`** (present when fan control applies to the unit: its `optimize_hvac_fan` preference is on and it reports distinct fan speeds, it is registered as a `window_ac`, or it is a warm-up day) holds 48 values from `low`, `high`, `auto`. **`auto` and `off` are sentinels meaning "leave the unit's fan alone" — they are not commands.** Send a fan command only for a concrete `low` or `high`, and only when the entity's own fan-mode vocabulary has that option. On many mini-splits and Tuya-style thermostats, switching the fan to Auto engages an Eco preset that overrides the setpoint you just applied. - **`hvac_mode_schedule`** (present when mode control applies to the unit: its `optimize_hvac_mode` preference is on, it is registered as a `window_ac`, or it is a warm-up day) holds per-slot modes. When it is present, apply it; the only modes the API ever commands are `COOL`, `HEAT` and `OFF`, and here `OFF` is a real command. Both arrays come only from `GET /api/v1/schedules` and the per-appliance schedule route, never from `GET /api/v1/schedule`. ## Test it before the home has history A fresh account has no readings yet, so you can verify the whole loop without waiting days for real data: 1. Create an account and log in (or use an existing test account). 2. `PUT /api/v1/preferences` with a comfort band. 3. `POST /api/v1/appliances` to register one HVAC appliance. 4. `POST /api/v1/readings` with a handful of readings for that appliance. 5. `POST /api/v1/schedule/recompute`. Expect `results` to list the HVAC appliance with `status: "ok"`, and its entry in `appliances` to read `source: "optimization"` with a 48-value `setpoint_temps`. `savings_pct` can be `0.0` on an account with only a few readings; that is a correct result, not a bug in your client. If `results` comes back empty, the run could not start for the account, and the entry still reads `"schedule": {}` with `source: "defaults"`. Your client should handle both. ## Copy-ready prompt Paste this into a coding agent to start a client from scratch: ```text Build a client for the Hungry Machines API. Spec: https://docs.hungrymachines.io/openapi.json (OpenAPI 3.1 — use it to generate request/response types, or read it directly) Full docs as plain text: https://docs.hungrymachines.io/llms-full.txt Base URL: https://api.hungrymachines.io Follow the contract exactly: - Create the account with POST /auth/signup, sending timezone (an IANA zone name, e.g. "America/Los_Angeles") so schedules key to the right local day from the start. - Log in with POST /auth/login, refresh with POST /auth/refresh on a 401, and send Authorization: Bearer on every other call. - Set preferences with PUT /api/v1/preferences, then register appliances with POST /api/v1/appliances (appliance_type "hvac" plus a config whose entity_id names the local climate entity), in that order. - Push readings roughly every 5 minutes (POST /api/v1/readings for HVAC, POST /api/v1/appliances/{appliance_id}/readings for everything else). On POST /api/v1/readings, send the optional "client" block as {"kind": "custom", "name": ""}. - Push the home's hourly forecast once a day with POST /api/v1/weather. - Pull the day's schedule with GET /api/v1/schedules after the overnight run, poll GET /api/v1/schedules/updated-at every few minutes and pull again when its value changes. Apply each 30-minute slot's command as given, without reinterpreting it. Slot 0 is local midnight. Skip any entry whose optimization_enabled is false. - Handle both response shapes from POST /auth/signup (session vs. pending email confirmation). - Handle source: "defaults": on GET /api/v1/schedules the appliance's schedule is an empty object, so send it no commands. Schedule keys on that route are present or absent, never null; check before reading. - If hvac_mode_schedule is present, apply its per-slot "OFF"/"COOL"/"HEAT". If fan_mode_schedule is present, send a fan command only for "low" or "high"; "auto" and "off" there mean "do not send a fan command". Do not make any claims about energy savings or how the schedule is computed — treat the schedule as an opaque result to apply. ``` ## Next steps - [OpenAPI Description](/api-reference/openapi/) — the machine-readable spec this guide is built from - [docs llms.txt](/llms.txt) and [llms-full.txt](/llms-full.txt) — the whole site as plain text - [Errors, Limits & Stability](/api-reference/errors-and-limits/) — error codes, rate limits, what's safe to rely on - [Build your own client](/guides/build-your-own-client/) — the same material written for a person, with full worked examples - [Reference Clients](/guides/reference-clients/) — copy-ready, dependency-free Python and JavaScript files that implement this guide's calls Canonical page: https://docs.hungrymachines.io/guides/build-with-an-agent/ --- https://docs.hungrymachines.io/guides/custom-home-assistant/ # Custom Home Assistant Integration Extend or replace the default Energy Dashboard with your own Home Assistant REST sensors, rest_command actions, and Lovelace cards backed by the Hungry Machines API. Already a Home Assistant power user? You can build your own integration on top of the Hungry Machines API — custom Lovelace cards, automations that consume the schedule, dashboards tuned to your home. The default [Energy Dashboard](/home-assistant/) is open source and a good starting point if you want to fork it; this guide walks you through doing it from scratch. You'll get the most flexibility out of this path, but you're responsible for token refresh, sensor wiring, and applying setpoints. The default dashboard handles those for you — only take this path if you want that control. ## Before you start 1. **Sign up at [hungrymachines.io/signup](https://hungrymachines.io/signup)** and confirm your email. Always sign up through the website — it sets up your billing, profile, and subscription state. Programmatic signup skips those steps. 2. **Verify Home Assistant can reach the API:** ```bash curl -sI https://api.hungrymachines.io/health ``` You should see `HTTP/2 200`. If your HA instance is on a corporate network or VPN, make sure outbound HTTPS to `api.hungrymachines.io` is allowed. ## Step 1 — Get an access token You'll exchange your Hungry Machines credentials for an access token by calling the API directly. Save the result somewhere durable — you'll wire it into Home Assistant secrets next. ```bash curl -X POST https://api.hungrymachines.io/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your-password"}' ``` Response shape: ```json { "access_token": "eyJhbGciOiJFUzI1NiIs...", "refresh_token": "v1.Mfg2...", "expires_in": 3600, "expires_at": 1735689600, "token_type": "bearer", "user": { "id": "550e8400-e29b-41d4-a716-446655440000", "email": "you@example.com" } } ``` The `user.id` is your Hungry Machines account id; you don't need it for the daily loop, but it's handy when correlating logs. The access token is good for **1 hour**. The refresh token is good for **30 days**. Both rotate on every refresh — see the [Authentication reference](/getting-started/authentication/) for the full lifecycle. ## Step 2 — Wire credentials into Home Assistant secrets Add to `secrets.yaml`: ```yaml hm_access_token: "eyJhbGciOiJFUzI1NiIs..." hm_refresh_token: "v1.Mfg2..." ``` > **Caution** > > Access tokens expire after an hour. For a long-running deployment, set up an automation that calls `POST /auth/refresh` daily and rewrites `hm_access_token` in `secrets.yaml`. There's a recipe at the bottom of this page. ## Step 3 — Push readings every 5 minutes Define a `rest_command` in `configuration.yaml` that fires every 5 minutes: ```yaml rest_command: hm_push_reading: url: "https://api.hungrymachines.io/api/v1/readings" method: POST headers: Authorization: "Bearer !secret hm_access_token" Content-Type: "application/json" payload: > {"readings": [{ "timestamp": "{{ now().isoformat() }}", "indoor_temp": {{ states('sensor.indoor_temperature') }}, "hvac_state": "{{ states('climate.thermostat') | upper }}", "outdoor_temp": {{ states('sensor.outdoor_temperature') }} }]} automation: - alias: "HM — push reading every 5 min" trigger: - platform: time_pattern minutes: "/5" action: - service: rest_command.hm_push_reading ``` Adjust the entity IDs (`sensor.indoor_temperature`, `climate.thermostat`, `sensor.outdoor_temperature`) to match your setup. See [API Reference — Readings](/api-reference/readings/) for the full request schema (humidity, target temp, fan mode, power draw all optional). ## Step 4 — Pull the schedule Set up a REST sensor that pulls today's schedule. A plan for today can be replaced during the day, after a settings change and when the API re-plans the rest of the day, so refresh it every 15 minutes rather than once a day: ```yaml rest: - resource: "https://api.hungrymachines.io/api/v1/schedule" headers: Authorization: "Bearer !secret hm_access_token" scan_interval: 900 # every 15 minutes, so a daytime re-plan is picked up sensor: - name: "HM Schedule Source" value_template: "{{ value_json.source }}" - name: "HM Savings Pct" value_template: "{{ value_json.estimated_savings_pct }}" unit_of_measurement: "%" - name: "HM Schedule Date" value_template: "{{ value_json.date }}" ``` The full schedule object (48 half-hour intervals with `high_temps` and `low_temps`) is exposed under `state_attr('sensor.hm_schedule_source', 'all_attributes')` if you parse the JSON yourself, or you can split each field into a separate sensor entry. See [API Reference — Schedule](/api-reference/schedule/) for the full response shape. ## Step 5 — Apply setpoints based on the current interval Each schedule has 48 half-hour intervals (`interval = hour * 2 + (minute >= 30)`). The thermostat command for each interval lives in `schedule.setpoint_temps` — apply that single value, not the high/low band. The high/low arrays are display-only comfort bounds for charting. Build a template sensor that picks the current interval's target. Fall back to the midpoint of `high_temps`/`low_temps` when `setpoint_temps` is `null`, which is what `GET /api/v1/schedule` returns while `source` is `"defaults"` (no plan stored for the unit yet): ```yaml template: - sensor: - name: "HM Current Setpoint" unit_of_measurement: "°F" state: > {% set s = state_attr('sensor.hm_schedule_full', 'schedule') %} {% set idx = (now().hour * 2) + (1 if now().minute >= 30 else 0) %} {% if s.setpoint_temps %} {{ s.setpoint_temps[idx] }} {% else %} {{ (s.high_temps[idx] + s.low_temps[idx]) / 2 }} {% endif %} ``` Then a simple automation applies the setpoint to your thermostat whenever it changes (the 30-min interval boundary will trigger a state change): ```yaml automation: - alias: "HM — apply current setpoint" trigger: - platform: state entity_id: sensor.hm_current_setpoint action: - service: climate.set_temperature target: entity_id: climate.thermostat data: temperature: "{{ states('sensor.hm_current_setpoint') | float }}" ``` The exact `climate.set_temperature` payload depends on your thermostat integration — some climate entities want `temperature`, others `target_temp_low`/`target_temp_high`, others `target_temp_high` only. Check Developer Tools → Services for your specific entity. ## Step 6 — Custom Lovelace card Once your sensors are wired up, build a Lovelace card however you want. A minimal example: ```yaml type: vertical-stack cards: - type: markdown content: | ## Hungry Machines **Source:** {{ states('sensor.hm_schedule_source') }} **Savings:** {{ states('sensor.hm_savings_pct') }}% - type: gauge entity: sensor.hm_current_high_temp min: 60 max: 90 name: "Cool to" - type: gauge entity: sensor.hm_current_low_temp min: 60 max: 90 name: "Heat to" ``` Apex Charts, Mini Graph Card, or any other community Lovelace plugin can render the 48-interval schedule as a series. ## Daily token refresh recipe Add an automation that refreshes the access token every 23 hours and writes it back to `secrets.yaml`: ```yaml rest_command: hm_refresh_token: url: "https://api.hungrymachines.io/auth/refresh" method: POST headers: Content-Type: "application/json" payload: > {"refresh_token": "!secret hm_refresh_token"} automation: - alias: "HM — refresh access token" trigger: - platform: time_pattern hours: "/23" action: - service: rest_command.hm_refresh_token response_variable: refresh - service: shell_command.update_hm_secrets data: access_token: "{{ refresh.content.access_token }}" refresh_token: "{{ refresh.content.refresh_token }}" ``` Implementing the `shell_command.update_hm_secrets` is left to you — typically a small `sed` script that rewrites `secrets.yaml`. Or move the credentials into an `input_text` helper instead of `secrets.yaml` so you can update them without filesystem writes. > **Note** > > The default Energy Dashboard handles all of this internally. If the manual refresh wiring feels fragile, just install the dashboard and let it manage the lifecycle. ## What you're sending vs receiving | Direction | Endpoint | Frequency | Purpose | |---|---|---|---| | Push | `POST /api/v1/readings` | Every 5 min | Sensor data → optimizer trains thermal model | | Push | `POST /api/v1/appliances/{id}/readings` | Every 5 min (per appliance) | EV / battery / water heater / solar state | | Pull | `GET /api/v1/schedule` | After the overnight run, and again when it changes | One HVAC unit's setpoints for today | | Pull | `GET /api/v1/schedules` | After the overnight run, and again when it changes | All-appliance schedules in one call, including per-slot mode and fan schedules | | Pull | `GET /api/v1/schedules/updated-at` | Every few minutes | One timestamp that changes whenever today's schedules are replaced | | CRUD | `POST/GET/PUT /api/v1/appliances` | One-time | Register devices, set constraints | | CRUD | `GET/PUT /api/v1/preferences` | As-needed | Comfort settings | Full per-endpoint schemas are in the [API Reference](/api-reference/readings/). ## Troubleshooting ### Readings rejected with 401 Your access token expired. Refresh it and retry — see Step 1 of [Authentication](/getting-started/authentication/#refresh-an-expired-token). ### Schedule sensor returns empty Until the first plan is stored for the unit, `GET /api/v1/schedule` returns `source: "defaults"`: a comfort band based on your preferences, with `setpoint_temps` set to `null`. The template in Step 5 handles that by applying the band's midpoint. The first plan is stored by the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run, and from then on `source` is `"optimization"`. If you have 2+ HVAC units, add `?appliance_id=…` to the resource URL; without it the route answers `400`. ### Setpoints aren't being applied to the thermostat Check that your `climate.set_temperature` service call uses the right keys for your thermostat. The example in Step 5 sends a single `temperature` value (which matches what most modern integrations want, and what the optimizer's `setpoint_temps` is designed for); some older integrations expect `target_temp_high`/`target_temp_low` instead. Test the call manually from Developer Tools → Services first. ## Next steps - [Authentication](/getting-started/authentication/) — token lifecycle reference - [API Reference — Schedules](/api-reference/schedule/) — full schedule response shape - [API Reference — Appliances](/api-reference/appliances/) — registering EV chargers, batteries, water heaters - [Build Your Own Client](/guides/build-your-own-client/) — same API, non-HA platforms Canonical page: https://docs.hungrymachines.io/guides/custom-home-assistant/ --- https://docs.hungrymachines.io/guides/build-your-own-client/ # Build Your Own Client Build a non-Home-Assistant client against the Hungry Machines API — Python, mobile app, ESP32, or any platform that can speak HTTPS. This is the **most advanced path**. Take it when Home Assistant isn't an option — you're integrating Hungry Machines into a mobile app, an ESP32 firmware, a Python script on a Pi, or any other platform. You'll be responsible for the full integration loop: authentication, token refresh, sensor collection, schedule application, and any local device control. If you have Home Assistant, the [Energy Dashboard](/home-assistant/) is much faster. If you want to extend Home Assistant with custom cards or REST sensors, see [Custom Home Assistant integration](/guides/custom-home-assistant/). ## Integration overview A Hungry Machines client does two things: 1. **Push readings** — send sensor data every 5 minutes so the optimizer can learn your home's thermal behavior 2. **Pull schedules** — fetch optimized setpoints once daily and apply them locally Your client handles all local device control (hysteresis, safety limits, hardware communication). The API handles optimization math. > **Note** > > All authentication happens through `api.hungrymachines.io` — use your Hungry Machines email and password, no separate credentials required. See the [Authentication reference](/getting-started/authentication/) for the full lifecycle. ## Initial setup ### 1. Create your account Sign up at **[hungrymachines.io/signup](https://hungrymachines.io/signup)** and confirm your email. Always sign up through the website — programmatic signup skips billing and onboarding setup. ### 2. Get an access token Once your account is active, exchange credentials for a session: ```bash curl -X POST https://api.hungrymachines.io/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your-password"}' ``` Save `access_token` and `refresh_token` from the response. Access tokens expire after 1 hour — use the refresh token to get a fresh one without re-entering credentials. ### 3. Set preferences ```bash curl -X PUT https://api.hungrymachines.io/api/v1/preferences \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "base_temperature": 72.0, "savings_level": 2, "time_away": "08:00", "time_home": "17:00", "optimization_mode": "cool" }' ``` ### 4. Register appliances Every non-solar appliance config requires an `entity_id` — the Home Assistant entity (or analogous identifier in your platform) the integration will read sensors from and apply setpoints/toggles to. See [API Reference — Appliances](/api-reference/appliances/) for the full per-type config schema. ```bash curl -X POST https://api.hungrymachines.io/api/v1/appliances \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "appliance_type": "hvac", "name": "Main Floor AC", "config": { "hvac_type": "central_ac", "home_size_sqft": 2000, "entity_id": "climate.main_floor" } }' ``` ### 5. Set constraints (non-HVAC appliances) For EV chargers and batteries, set optimization constraints: ```bash curl -X POST https://api.hungrymachines.io/api/v1/appliances/$APPLIANCE_ID/constraints \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "target_charge_pct": 80, "min_charge_pct": 30, "deadline_time": "07:00", "current_charge_pct": 35 }' ``` ## Daily loop Once set up, your client runs two recurring tasks: ### Every 5 minutes: push readings Collect sensor data and POST it to the API. Include at minimum `timestamp`, `indoor_temp`, and `hvac_state`. ### After the overnight run, and whenever it changes: pull schedule After the overnight optimization has run, fetch the day's schedule and cache it locally. Apply the corresponding setpoints every 30 minutes. A plan for today can be replaced during the day, after a settings change and when the API re-plans the rest of the day. Poll [`GET /api/v1/schedules/updated-at`](/api-reference/appliances/#get-apiv1schedulesupdated-at) every few minutes, keep the last value you saw, and fetch the schedule again when it changes. ## The 48-interval schedule Every schedule contains **48 intervals** of 30 minutes each, covering a full 24-hour day: | Interval | Time window | |---|---| | 0 | 00:00–00:30 | | 1 | 00:30–01:00 | | 16 | 08:00–08:30 | | 34 | 17:00–17:30 | | 47 | 23:30–00:00 | To find the current interval: `interval = (hour * 2) + (1 if minute >= 30 else 0)`. All timestamps in the API are **UTC**. Convert to local time before applying setpoints. ## Handling `source: "defaults"` `source` is `"defaults"` only until the first plan is stored for the appliance. That happens on the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run, whichever comes first. What "defaults" looks like depends on the route: - **`GET /api/v1/schedule`** (one HVAC unit, used in the examples below) returns a comfort band built from your preferences in `high_temps` / `low_temps`, with `setpoint_temps` and `temp_trajectory` set to `null`. Apply the midpoint of the band instead: `(high_temps[idx] + low_temps[idx]) / 2`. Every key is always present on this route. - **`GET /api/v1/schedules`** (all appliances) returns `"schedule": {}` for that appliance. There is no band to fall back to, so send it no commands. On this route a schedule key is present or absent, never `null`, so read keys defensively — `s.get("setpoint_temps")` in Python, `s.setpoint_temps ?? null` in JavaScript. From the first stored plan onward, `source` is `"optimization"` and `setpoint_temps` is a 48-element array every day. `estimated_savings_pct` can still read `0.0` on early plans, for example before any readings have arrived. ## Example: Python client (httpx) ```python import httpx import time from datetime import datetime, timezone API = "https://api.hungrymachines.io" class HMClient: def __init__(self, email: str, password: str): resp = httpx.post(f"{API}/auth/login", json={"email": email, "password": password}) resp.raise_for_status() s = resp.json() self.access_token = s["access_token"] self.refresh_token = s["refresh_token"] self.expires_at = s["expires_at"] def _refresh(self): resp = httpx.post(f"{API}/auth/refresh", json={"refresh_token": self.refresh_token}) resp.raise_for_status() s = resp.json() self.access_token = s["access_token"] self.refresh_token = s["refresh_token"] self.expires_at = s["expires_at"] def _headers(self) -> dict: # Refresh proactively with 5 minutes of headroom. if int(datetime.now(timezone.utc).timestamp()) >= self.expires_at - 300: self._refresh() return {"Authorization": f"Bearer {self.access_token}"} def push_reading(self, indoor_temp: float, hvac_state: str, outdoor_temp: float): return httpx.post(f"{API}/api/v1/readings", headers=self._headers(), json={ "readings": [{ "timestamp": datetime.now(timezone.utc).isoformat(), "indoor_temp": indoor_temp, "hvac_state": hvac_state, "outdoor_temp": outdoor_temp, }], }).raise_for_status() def get_schedule(self): resp = httpx.get(f"{API}/api/v1/schedule", headers=self._headers()) resp.raise_for_status() return resp.json() def current_interval() -> int: now = datetime.now() return now.hour * 2 + (1 if now.minute >= 30 else 0) def apply_setpoints(schedule: dict): idx = current_interval() s = schedule["schedule"] setpoints = s.get("setpoint_temps") if setpoints is not None: target = setpoints[idx] else: # Defaults path (no stored plan yet) — fall back to the band midpoint. target = (s["high_temps"][idx] + s["low_temps"][idx]) / 2 print(f"Interval {idx}: thermostat target {target}F") # TODO: send to your thermostat hardware (e.g. climate.set_temperature) # Main loop client = HMClient("you@example.com", "secure-password") schedule = client.get_schedule() while True: indoor = read_sensor() # your hardware function outdoor = read_outdoor_temp() # your hardware function client.push_reading(indoor, "COOL", outdoor) apply_setpoints(schedule) time.sleep(300) ``` Persist `access_token`, `refresh_token`, and `expires_at` to disk so the client survives restarts without asking for credentials again. ## Example: ESP32 (pseudo-code) For microcontroller integrations, the pattern is the same — just adapted for constrained environments: ```cpp // ESP32 Arduino pseudo-code #include #include const char* API = "https://api.hungrymachines.io"; String accessToken; // load from NVS on boot String refreshToken; // load from NVS on boot bool refreshAccessToken() { HTTPClient http; http.begin(String(API) + "/auth/refresh"); http.addHeader("Content-Type", "application/json"); String body = "{\"refresh_token\":\"" + refreshToken + "\"}"; int code = http.POST(body); if (code == 200) { StaticJsonDocument<1024> doc; deserializeJson(doc, http.getString()); accessToken = doc["access_token"].as(); refreshToken = doc["refresh_token"].as(); saveToNVS(); // persist for reboots http.end(); return true; } http.end(); return false; } void pushReading(float indoorTemp, const char* hvacState) { HTTPClient http; http.begin(String(API) + "/api/v1/readings"); http.addHeader("Authorization", "Bearer " + accessToken); http.addHeader("Content-Type", "application/json"); StaticJsonDocument<256> doc; JsonArray readings = doc.createNestedArray("readings"); JsonObject r = readings.createNestedObject(); r["timestamp"] = getISOTimestamp(); // UTC ISO 8601 r["indoor_temp"] = indoorTemp; r["hvac_state"] = hvacState; String body; serializeJson(doc, body); int code = http.POST(body); if (code == 401 && refreshAccessToken()) { http.end(); pushReading(indoorTemp, hvacState); // retry once return; } http.end(); } float setpointTemps[48]; bool setpointTempsValid = false; float highTemps[48]; float lowTemps[48]; void fetchSchedule() { HTTPClient http; http.begin(String(API) + "/api/v1/schedule"); http.addHeader("Authorization", "Bearer " + accessToken); int code = http.GET(); if (code == 200) { // Parse schedule.setpoint_temps (preferred) into setpointTemps[]; // mark setpointTempsValid = false when the field is null (defaults // path) and fall back to high_temps / low_temps for display. // Always populate highTemps[] and lowTemps[] for UI rendering. } http.end(); } void loop() { float temp = readSensor(); pushReading(temp, "COOL"); int interval = (hour() * 2) + (minute() >= 30 ? 1 : 0); float target; if (setpointTempsValid) { target = setpointTemps[interval]; } else { target = (highTemps[interval] + lowTemps[interval]) / 2.0f; } applySetpoint(target); delay(300000); // 5 minutes } ``` > **Tip** > > Store both `access_token` and `refresh_token` in NVS (non-volatile storage) so they survive reboots. Refresh proactively (e.g. every 50 minutes) rather than waiting for a 401. ## Key points - **Sign up at hungrymachines.io** — programmatic signup via `/auth/signup` skips billing setup; always create accounts through the website. - **Two auth endpoints for tokens** — `/auth/login` and `/auth/refresh`. Both live on `api.hungrymachines.io`, both use email + password. - **1-hour access token, 30-day refresh token** — the refresh token is rotated on every `/auth/refresh` call, so save the new one each time. - **UTC timestamps** — all API timestamps are UTC. Convert to local time for display and setpoint timing. - **Apply `setpoint_temps[idx]`, not `high_temps`/`low_temps`** — the optimizer emits a per-interval target temperature directly. The high/low arrays are display-only comfort bounds. - **48 intervals** — every schedule is 48 half-hour slots. `interval = hour * 2 + (minute >= 30)`. - **Handle `source: "defaults"`** — it lasts only until the first plan is stored. On `GET /api/v1/schedule`, `setpoint_temps` is `null` and you apply the band midpoint. On `GET /api/v1/schedules` the appliance's `schedule` is `{}` and there is nothing to apply. - **Poll `GET /api/v1/schedules/updated-at`** — plans are replaced during the day; fetch the schedule again when the value changes. - **Omit the `client` block on readings** — its `kind` only accepts the two first-party clients. - **Local hysteresis** — your client handles thermostat hysteresis and safety limits. The API provides target setpoints, not direct control signals. - **Rate limit** — 600 readings per active HVAC or dehumidifier appliance, per account, per UTC day. Normal 5-minute reporting (288 readings a day per unit) is well within it; it only trips on runaway loops or large catch-up batches. ## Next steps - [Authentication](/getting-started/authentication/) — full token lifecycle reference - [API Reference — Readings](/api-reference/readings/) — exact request/response schemas - [API Reference — Schedule](/api-reference/schedule/) — 48-interval schedule shape - [API Reference — Appliances](/api-reference/appliances/) — multi-device registration + constraints - [Custom Home Assistant integration](/guides/custom-home-assistant/) — same API from inside Home Assistant if you change your mind - [OpenAPI description](/api-reference/openapi/) — every route in this guide as one machine-readable file, for generating a typed client - [Reference Clients](/guides/reference-clients/) — copy-ready, dependency-free Python and JavaScript files covering the same loop Canonical page: https://docs.hungrymachines.io/guides/build-your-own-client/ --- https://docs.hungrymachines.io/guides/reference-clients/ # Reference Clients Three small, dependency-free files that cover sign-up through session and schedule for the Hungry Machines API — copy them as a starting point. Three small files under `/examples/`, each dependency-free, that cover the same loop as the [agent guide](/guides/build-with-an-agent/): log in, refresh on a 401, set preferences, register an appliance, push readings and a weather forecast, pull or recompute a schedule, and apply the current 30-minute slot's command. Copy one as a starting point, or read it alongside the [OpenAPI description](/api-reference/openapi/) to see the same calls typed out. - [hm_client.py](/examples/hm_client.py): Python standard library only (urllib + zoneinfo). - [hm-client.mjs](/examples/hm-client.mjs): Plain JavaScript (fetch), no dependencies, with JSDoc types. - [simulate_readings.py](/examples/simulate_readings.py): A testing tool: pushes synthetic readings to a test account. ## What's in each file Both `hm_client.py` and `hm-client.mjs` implement the same client surface: - Sign up or log in (`POST /auth/signup`, `POST /auth/login`), handling both of signup's response shapes — a session, or a pending user when email confirmation is on - Refresh once on a 401 (`POST /auth/refresh`) and keep retrying the original call, not the caller - Get and set preferences (`GET`/`PUT /api/v1/preferences`) - Register an appliance (`POST /api/v1/appliances`) - Push HVAC readings (`POST /api/v1/readings`), leaving out the optional `client` block — its `kind` names Hungry Machines' own software, not yours - Push the home's hourly weather forecast once a day (`POST /api/v1/weather`) - Pull today's schedule (`GET /api/v1/schedules`) or force an immediate replan (`POST /api/v1/schedule/recompute`) - A `slot_command` / `slotCommand` helper that reads the current 30-minute slot from a given local time and returns the HVAC target temperature — falling back to the comfort band's midpoint, `(high_temps[slot] + low_temps[slot]) / 2`, when `setpoint_temps` is absent or `null` — or, for EV chargers, batteries, robots and water heaters, the boolean on/off command from `intervals[slot]` `simulate_readings.py` is a separate testing tool, not part of the client: it imports `hm_client.py` and pushes a stream of plausible synthetic indoor/outdoor readings to one HVAC appliance, 5 minutes apart, so you can exercise a client or watch an account leave `"defaults"` without wiring up real hardware. It reads `HM_EMAIL`, `HM_PASSWORD` and `HM_APPLIANCE_ID` from environment variables and never prints them — **run it against a test account only**. Check an entry's `source` before you hand it to the helper. An appliance with no stored plan yet comes back from `GET /api/v1/schedules` as `"schedule": {}` with `source: "defaults"`: it has no setpoints and no band, so the helper has nothing to read and there is no command to send. Skip that entry until `source` is `"optimization"`. The files also pull the schedule once; a long-running client should poll `GET /api/v1/schedules/updated-at` and pull again when its value changes. None of the three files cover non-HVAC readings (`POST /api/v1/appliances/{appliance_id}/readings`) or constraints (`POST /api/v1/appliances/{appliance_id}/constraints`) — see [Build your own client](/guides/build-your-own-client/) for the full appliance-type matrix. ## Next steps - [Build With an AI Agent](/guides/build-with-an-agent/) — the calls these files make, explained in order - [Build your own client](/guides/build-your-own-client/) — the same material written for a person, with the full per-appliance-type schedule shapes - [OpenAPI Description](/api-reference/openapi/) — every route these files call, as one machine-readable file Canonical page: https://docs.hungrymachines.io/guides/reference-clients/ --- https://docs.hungrymachines.io/api-reference/openapi/ # OpenAPI Description The public OpenAPI 3.1 document for the Hungry Machines API — download it or point a code generator at it. Everything in this API Reference section is also published as one machine-readable file: [`openapi.json`](https://docs.hungrymachines.io/openapi.json), an OpenAPI 3.1 document generated from the API's code. - [Download the OpenAPI description](/openapi.json): openapi.json — the full public specification. ## What it covers The file lists every public route under `/auth` and `/api/v1` — the same routes documented on the pages in this section. Internal and partner-only routes are left out. ## Where the descriptions and examples come from Every summary, description, and request/response example in the file comes from the same written contract this site's prose is built from, not from the API's internal source comments. A route with no response example yet says so rather than guessing one. ## Building a client from it An OpenAPI 3.1 document is the standard input for most OpenAPI-to-client generators. Point one at the file above and it produces request/response types and a thin client in your language, so you don't have to hand-write them from this page. ## Discovery The API host itself points back here, so a client that only has the bare address can still find everything above: `GET /` on the API returns its name, version and links to this spec, these docs and `/health`; `GET /llms.txt` on the API redirects to [the docs site's `llms.txt`](/llms.txt); and `GET /.well-known/api-catalog` serves the same links as a standard RFC 9727 link-set document. ## Next steps - [For Developers](/for-developers/) — the integration overview - [Build your own client](/guides/build-your-own-client/) — hand-written examples in Python and JavaScript - [Authentication](/getting-started/authentication/) — token lifecycle reference Canonical page: https://docs.hungrymachines.io/api-reference/openapi/ --- https://docs.hungrymachines.io/api-reference/appliances/ # Appliances & Unified Schedules Register devices, push appliance readings, set constraints and per-HVAC preferences, and pull unified schedules and health. The appliance endpoints let you register devices (HVAC, EV charger, home battery, water heater, solar, dehumidifier, home robot), push per-device sensor data, set constraints and per-HVAC preferences, and pull schedules and link-health. > **Caution** > > **Free tier:** one HVAC appliance only. Registering additional appliances or non-HVAC device types requires a Premium subscription — the API returns `403 Upgrade required` when the limit is exceeded. > **Tip** > > `entity_id` and the other keys ending in `_entity_id` are opaque identifiers of your choosing. The server stores them and returns them in each schedule's `entities` block so your client knows which device to command; it does not interpret them. `entity_id` must be at least 3 characters. Home Assistant clients put an entity id there, such as `climate.living_room`. **Solar is the exception** — it takes no `entity_id`. --- ## POST /api/v1/appliances Register a new appliance. **Auth:** Required ### Required vs. optional Every registration requires `appliance_type`, `name`, and a `config` object. `appliance_type` is one of `hvac`, `ev_charger`, `home_battery`, `water_heater`, `solar`, `dehumidifier`, `robot`, and cannot be changed later. Which `config` fields are required depends on the type — each table below marks them. A `config` that breaks a rule returns `400` with the offending field name in `detail`. ```bash curl -X POST https://api.hungrymachines.io/api/v1/appliances \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "appliance_type": "ev_charger", "name": "Tesla Model 3", "config": { "battery_capacity_kwh": 75, "max_charge_rate_kw": 7.2, "efficiency": 0.9, "entity_id": "switch.tesla_charger", "soc_entity_id": "sensor.tesla_battery_level" } }' ``` **Response (201):** ```json { "appliance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` ### Config shapes by type #### HVAC | Field | Type | Required | Constraints | |---|---|---|---| | `hvac_type` | string | **Yes** | `"central_ac"`, `"window_ac"`, `"heat_pump"`, `"furnace"`, or `"mini_split"` | | `home_size_sqft` | integer | **Yes** | 100 – 20 000 | | `entity_id` | string | **Yes** | Identifier of the thermostat or unit your client commands. At least 3 characters. | | `indoor_temp_entity_id` | string \| null | No | Identifier of a separate indoor temperature sensor (°F), for a unit that does not report its own room temperature. | | `indoor_humidity_entity_id` | string \| null | No | Identifier of an indoor relative-humidity sensor. | | `power_sensor_entity_id` | string \| null | No | Identifier of a sensor reporting the unit's power draw in watts. | | `setpoint_min_f` | integer \| null | No | Lowest setpoint the unit accepts, 40 – 99 °F. | | `setpoint_max_f` | integer \| null | No | Highest setpoint the unit accepts, 40 – 99 °F. Must be at least `setpoint_min_f`. When both are set, every setpoint and comfort-band value the schedule serves is kept inside this range. | #### EV Charger | Field | Type | Required | Constraints | |---|---|---|---| | `battery_capacity_kwh` | float | **Yes** | Greater than 0, up to 300 | | `max_charge_rate_kw` | float | **Yes** | Greater than 0, up to 50 | | `efficiency` | float | **Yes** | 0.5 – 1.0 | | `entity_id` | string | **Yes** | Identifier of the charger your client switches on and off. At least 3 characters. | | `soc_entity_id` | string \| null | No | Identifier of a sensor reporting state of charge, 0–100 %. | #### Home Battery | Field | Type | Required | Constraints | |---|---|---|---| | `capacity_kwh` | float | **Yes** | Greater than 0, up to 300 | | `max_charge_rate_kw` | float | **Yes** | Greater than 0, up to 50 | | `max_discharge_rate_kw` | float | **Yes** | Greater than 0, up to 50 | | `entity_id` | string | **Yes** | Identifier of the control your client uses to switch charge mode. At least 3 characters. | | `soc_entity_id` | string \| null | No | Identifier of a sensor reporting state of charge, 0–100 %. | #### Home Robot A robot vacuum or mower. Docking stands in for charging: a `true` slot in the schedule's `intervals` means the robot should be on its dock. Your client only ever sends the robot back to its dock; it never undocks it and never starts a task. | Field | Type | Required | Constraints | |---|---|---|---| | `battery_capacity_kwh` | float | **Yes** | Greater than 0, up to 10 | | `max_charge_rate_kw` | float | **Yes** | Greater than 0, up to 2 | | `entity_id` | string | **Yes** | Identifier of the robot your client docks. At least 3 characters. | | `soc_entity_id` | string \| null | No | Identifier of a sensor reporting battery level, 0–100 %. | #### Water Heater | Field | Type | Required | Constraints | |---|---|---|---| | `tank_size_gallons` | integer | **Yes** | Greater than 0, up to 200 | | `element_watts` | integer | **Yes** | Greater than 0, up to 10 000 | | `insulation_factor` | float | **Yes** | 0.01 – 0.05 | | `entity_id` | string | **Yes** | Identifier of the heater your client switches on and off. At least 3 characters. | | `temp_entity_id` | string \| null | No | Identifier of a sensor reporting tank temperature in °F. | #### Solar A solar array has no `entity_id` and receives no commands. Registering it lets the schedules for your other appliances account for daytime generation. | Field | Type | Required | Constraints | |---|---|---|---| | `system_size_kw` | float | **Yes** | Greater than 0, up to 100 (DC nameplate) | | `azimuth_degrees` | integer | No | 0 – 360. Default 180 (south). | | `tilt_degrees` | integer | No | 0 – 90. Default 20. | | `production_sensor_entity_id` | string \| null | No | Identifier of a sensor reporting the array's production. If you have one, push production in watts through the appliance readings route below to improve the generation forecast. | #### Dehumidifier Dehumidifiers are **recorded only**: no schedule is produced. Push their readings through [`POST /api/v1/readings`](/api-reference/readings/) with the dehumidifier's `appliance_id`, using `hvac_state` `"DRY"` (running) or `"OFF"` (idle). | Field | Type | Required | Constraints | |---|---|---|---| | `entity_id` | string | **Yes** | Identifier of the dehumidifier. At least 3 characters. | | `indoor_temp_entity_id` | string | **Yes** | Identifier of a room temperature sensor (°F) next to the unit. At least 3 characters. | | `indoor_humidity_entity_id` | string \| null | No | Identifier of a room relative-humidity sensor. | | `power_sensor_entity_id` | string \| null | No | Identifier of a sensor reporting power draw in watts. | | `capacity_pints_per_day` | float \| null | No | Nameplate capacity, greater than 0, up to 200. Informational. | ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `": "` | Per-type config validation failure, e.g. `"entity_id: Field required"` or `"entity_id: String should have at least 3 characters"` | | 403 | `"Upgrade required"` | Free-tier limit (1 HVAC only). Checked before the config. | | 422 | array `detail` | `appliance_type` is not one of the seven types, or `appliance_type` / `name` is missing | | 503 | `"Database unavailable"` or `"Failed to create appliance"` | The appliance could not be saved | --- ## GET /api/v1/appliances List all registered appliances. **Auth:** Required ```bash curl https://api.hungrymachines.io/api/v1/appliances \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response (200):** ```json [ { "id": "a1b2c3d4-...", "user_id": "550e8400-...", "appliance_type": "ev_charger", "name": "Tesla Model 3", "config": { "battery_capacity_kwh": 75, "max_charge_rate_kw": 7.2, "efficiency": 0.9, "entity_id": "switch.tesla_charger", "soc_entity_id": "sensor.tesla_battery_level" }, "is_active": true, "created_at": "2025-11-15T10:30:00+00:00", "optimization_enabled": true } ] ``` `optimization_enabled` is the per-appliance pause flag for non-HVAC types; `false` means paused. HVAC units are paused through [per-HVAC preferences](#per-hvac-preferences). ### Errors | Status | Detail | Cause | |---|---|---| | 503 | `"Database unavailable"` | The appliance list could not be read | --- ## PUT /api/v1/appliances/{'{appliance_id}'} Update an appliance's name, config, or pause flag. All three fields are optional; send only what changes. When updating `config`, **send the full per-type object**, including every required key — `config` replaces what is stored. `appliance_type` cannot be changed. **Auth:** Required ```bash curl -X PUT https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-... \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Tesla Model 3 (Garage)", "config": { "battery_capacity_kwh": 75, "max_charge_rate_kw": 11.5, "efficiency": 0.9, "entity_id": "switch.tesla_charger", "soc_entity_id": "sensor.tesla_battery_level" } }' ``` | Field | Type | Required | Description | |---|---|---|---| | `name` | string | No | New display name | | `config` | object | No | The complete config for the appliance's type | | `optimization_enabled` | boolean | No | Pause (`false`) or resume (`true`) a non-HVAC appliance. HVAC units are paused through [per-HVAC preferences](#per-hvac-preferences). | **Response (200):** Full appliance object (same shape as the GET list item). **Errors:** | Status | Detail | Cause | |---|---|---| | 400 | `": "` | Config validation failure, e.g. `"entity_id: Field required"` | | 404 | `"Appliance not found"` | Doesn't exist, or owned by another user | | 422 | array `detail` | A field of the wrong JSON type | | 503 | `"Database unavailable"` or `"Failed to update appliance"` | The change could not be saved | --- ## DELETE /api/v1/appliances/{'{appliance_id}'} Permanently delete an appliance the caller owns, along with its readings, schedules, and constraints. **Auth:** Required ```bash curl -X DELETE https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-... \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response (204):** No body. **Errors:** | Status | Detail | Cause | |---|---|---| | 404 | `"Appliance not found"` | Doesn't exist, or owned by another user | | 503 | `"Database unavailable"` or `"Failed to delete appliance"` | The appliance could not be deleted | --- ## POST /api/v1/appliances/{'{appliance_id}'}/readings Push state readings for a specific non-HVAC appliance (EV charger, home battery, robot, water heater, solar). HVAC and dehumidifier readings go through [`POST /api/v1/readings`](/api-reference/readings/) instead. **Auth:** Required ### Required vs. optional `readings` is required and holds 1 to 100 readings. In each reading, `timestamp`, `state`, and `value` are required; `power_watts` and `metadata` are optional. ```bash curl -X POST https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-.../readings \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "readings": [ { "timestamp": "2025-11-18T14:30:00+00:00", "state": "CHARGING", "value": 45.2, "power_watts": 7200.0, "metadata": { "voltage": 240, "current": 30 } } ] }' ``` | Field | Type | Required | Description | |---|---|---|---| | `timestamp` | string (ISO 8601) | **Yes** | | | `state` | string | **Yes** | Free-form label, e.g. `"CHARGING"`, `"IDLE"`, `"ON"`, `"OFF"`, `"DISCHARGING"`. | | `value` | float | **Yes** | Charge % (0–100) for EV charger, home battery and robot; tank temperature (60–180 °F) for water heater; production watts (0–100000) for solar. Validated per type. | | `power_watts` | float | No | Current power draw. For solar, 0–100000. | | `metadata` | object | No | Any additional data, stored as sent | A batch is accepted or rejected as a whole. **Response (201):** `{ "accepted": 1 }` **Daily limit:** an account can push **300 readings per UTC day for each active EV charger, home battery, water heater, solar array or robot it has registered** (floor of one device's worth), counted across all of those appliances together. A batch that would take the day's total past the cap is rejected whole with `429`. One appliance pushing every 5 minutes is 288 a day, comfortably under its 300 share — register more appliances and the cap grows with them. This limit is separate from the one on `POST /api/v1/readings`. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"Value must be 0-100 (percent) for ev_charger"` | Charge `value` out of range (also `... for home_battery`, `... for robot`) | | 400 | `"Value must be 60-180 (degrees F) for water_heater"` | Water heater `value` out of range | | 400 | `"Value must be 0-100000 (watts) for solar"` | Solar `value` out of range | | 400 | `"power_watts must be 0-100000 (watts) for solar"` | Solar `power_watts` out of range | | 404 | `"Appliance not found"` | Bad `appliance_id`, or not owned by the caller | | 422 | array `detail` | Empty batch, more than 100 readings, or a missing or mistyped field | | 429 | `"Rate limit exceeded: max readings per user per day (scaled to active appliance(s))"` | The account's daily total for this route would pass its scaled cap | | 503 | `"Database unavailable"` or `"Failed to write readings"` | The readings could not be saved | --- ## Constraints (non-HVAC appliances) Constraints are the limits an EV charger, home battery, robot, or water heater is scheduled within. (HVAC comfort settings use [per-HVAC preferences](#per-hvac-preferences) instead.) ### POST /api/v1/appliances/{'{appliance_id}'}/constraints Set the constraints. The body is a JSON object that **replaces** the stored constraints, so send the complete set each time. Saving also triggers a recompute of the schedule shortly afterwards; use [`POST /api/v1/schedule/recompute`](/api-reference/schedule/#post-apiv1schedulerecompute) when you want to wait for the result. An EV charger, home battery or robot gets no schedule until constraints have been saved. A water heater is scheduled on the defaults below until you set its own. **Auth:** Required Every key is optional; the default applies when a key is omitted. Keys are validated when present, per appliance type — a key for a type not listed below is stored but never checked, and a validation failure is a `400` naming the field. | Type | Key | Type | Required | Description | |---|---|---|---|---| | EV charger, home battery | `target_charge_pct` | number | No | Charge to reach by the deadline, 0–100. Default 80. | | EV charger, home battery | `min_charge_pct` | number | No | Charge never to drop below, 0–100, and never above `target_charge_pct` when both are sent. Default 20. | | EV charger, home battery | `deadline_time` | string | No | `HH:MM`, 24-hour local time (a real hour and minute). Default `"23:59"`. | | EV charger, home battery | `current_charge_pct` | number | No | Present charge, 0–100. Default 50. Used only when no recent state-of-charge reading has been pushed. | | Robot | `target_charge_pct` | number | No | Charge to reach before the tasks window, 0–100. Default 90. | | Robot | `min_charge_pct` | number | No | Charge never to drop below, 0–100, and never above `target_charge_pct` when both are sent. Default 25. | | Robot | `current_charge_pct` | number | No | Present charge, 0–100. Default 50. Used only when no recent battery reading has been pushed. | | Robot | `tasks_window_start` / `tasks_window_end` | string | No | `HH:MM`, 24-hour local time (a real hour and minute). Defaults `"09:00"` / `"17:00"`. The window is half-open and may wrap midnight; the robot is expected off its dock during it, and charging is scheduled around it. | | Water heater | `min_temp_f` | number | No | Lowest tank temperature, °F, 60–180. Default 110. | | Water heater | `max_temp_f` | number | No | Highest tank temperature, °F, 60–180. Must be above `min_temp_f` when both are sent. Default 140. | **EV Charger:** ```json { "target_charge_pct": 80, "min_charge_pct": 30, "deadline_time": "07:00", "current_charge_pct": 35 } ``` **Home Battery:** ```json { "target_charge_pct": 90, "min_charge_pct": 20, "deadline_time": "23:59" } ``` **Water Heater:** ```json { "max_temp_f": 140, "min_temp_f": 110 } ``` **Home Robot:** ```json { "target_charge_pct": 90, "min_charge_pct": 25, "current_charge_pct": 50, "tasks_window_start": "09:00", "tasks_window_end": "17:00" } ``` **Response (200):** `{ "status": "ok", "constraints": { "..." } }` — `constraints` echoes the object you sent. **Errors:** `400 ": "` (a key in the table above failed validation for this appliance's type), `404 "Appliance not found"`, `422` (the body is not a JSON object; array `detail`), `503 "Database unavailable"` or `503 "Failed to save constraints"`. ### GET /api/v1/appliances/{'{appliance_id}'}/constraints Read back the stored constraints so a client can re-display what the user last saved. The appliance list does not include constraints, so this is the read path for EV / battery / water-heater / robot constraints. **Auth:** Required **Response (200):** `constraints` is `{}` when none have been set. ```json { "status": "ok", "constraints": { "target_charge_pct": 80, "min_charge_pct": 30, "deadline_time": "07:00" } } ``` **Errors:** `404 "Appliance not found"`, `503 "Database unavailable"`. --- ## Per-HVAC Preferences Each HVAC unit carries its own comfort/savings/mode/pause preferences, so a two-HVAC home holds independent setpoints per unit. The shape mirrors the HVAC-relevant subset of [`/api/v1/preferences`](/api-reference/preferences/); account-level fields (like `newsletter_opt_in`) stay on the account endpoint. ### GET /api/v1/appliances/{'{appliance_id}'}/preferences **Auth:** Required ```json { "base_temperature": 72.0, "savings_level": 1, "time_away": "08:00", "time_home": "17:00", "optimization_mode": "cool", "hourly_high_temps_f": null, "hourly_low_temps_f": null, "optimize_hvac_fan": false, "optimize_hvac_mode": false, "optimization_enabled": true } ``` Field semantics match the corresponding fields on [`GET /api/v1/preferences`](/api-reference/preferences/). A unit whose preferences have never been saved returns the account's own current preferences (the standard defaults shown above only if the account hasn't customized them either) — the same values planning for that unit follows until the unit's own preferences are saved. **Errors:** `404 "Appliance not found"` (missing, not owned, or not an HVAC unit), `503 "Database unavailable"`. ### PUT /api/v1/appliances/{'{appliance_id}'}/preferences Update per-HVAC preferences. **All fields optional** — fields you do not send keep their saved values. The account-level preferences are left untouched. `optimization_enabled` here is the per-unit pause switch: a unit is optimized only when both it and the account-level master switch are `true`. A successful PUT triggers a debounced recompute. The first save for a unit makes that unit's own preferences authoritative for every field. On that first PUT, a field you omit is filled in from your current account-level preferences at that moment — not the standard default shown under GET. A later change to your account-level preferences does not retroactively change the unit's own row; send a PUT to this endpoint to update it. **Auth:** Required ```bash curl -X PUT https://api.hungrymachines.io/api/v1/appliances/a1b2c3d4-.../preferences \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "base_temperature": 70.0, "savings_level": 3, "optimization_mode": "auto", "optimization_enabled": false }' ``` Validation matches [`PUT /api/v1/preferences`](/api-reference/preferences/#put-apiv1preferences). **Response (200):** Full per-appliance preferences object (same shape as GET). **Errors:** `400` (hourly-band validation, string `detail`), `404 "Appliance not found"`, `422` (any other field out of range or of the wrong type, array `detail`), `503 "Database unavailable"`. ### GET /api/v1/appliances/{'{appliance_id}'}/preferences/default-bands Same preview as [`GET /api/v1/preferences/default-bands`](/api-reference/preferences/#get-apiv1preferencesdefault-bands), scoped to this appliance. An omitted query param falls back to this unit's own preferences once they have been saved, otherwise to the account-level preferences. **Auth:** Required Query params, validation, and the response shape are identical to the account-level endpoint. **Errors:** `400` (same validation messages), `404 "Appliance not found"`, `422` (a query value of the wrong type), `503 "Database unavailable"`. --- ## GET /api/v1/appliances/{'{appliance_id}'}/schedule Get the schedule for a single appliance. Same per-type `schedule` blob as one entry of `GET /api/v1/schedules`, plus the date. When none exists yet, `source` is `"defaults"`, `savings_pct` is `0.0`, and `schedule` is `{}`. `date` is today in the account's local timezone. **Auth:** Required — a user access token. Every response also carries `optimization_enabled`: the effective pause flag for this appliance, with the account's master switch already folded in. It is present on every appliance type. Do not apply the schedule while it is `false`. A response adds `"stale": true` when the schedule returned is from an earlier day because none has been produced for today. All schedules use **48 × 30-minute intervals**. #### HVAC ```json { "appliance_id": "...", "appliance_type": "hvac", "name": "Living Room AC", "date": "2025-11-18", "schedule": { "intervals": [0, 1, 2, "...", 47], "high_temps": [74.0, 74.0, "...(48 values)"], "low_temps": [70.0, 70.0, "...(48 values)"], "setpoint_temps": [72.0, 71.5, "...(48 values)"], "temp_trajectory": [72.1, 71.6, "...(48 values)"] }, "savings_pct": 18.5, "source": "optimization", "entities": { "entity_id": "climate.living_room", "indoor_temp_entity_id": "sensor.back_bedroom_temperature" }, "optimization_enabled": true } ``` **Apply `setpoint_temps[idx]`** at each 30-min boundary regardless of HVAC mode. `high_temps`/`low_temps` are the display-only comfort band; `temp_trajectory` is the display-only predicted line. An appliance with no stored plan yet returns `source: "defaults"` with an empty `schedule` object: there is nothing to apply, so leave the device as it is and check again after the next plan. On a stored plan read each key defensively; if `setpoint_temps` is missing but the band is present, use `(high_temps[idx] + low_temps[idx]) / 2`. HVAC entries also carry an inline `integration_health` block (see [health](#get-apiv1integrationhealth)). #### EV Charger ```json { "appliance_id": "...", "appliance_type": "ev_charger", "name": "Tesla Model 3", "date": "2025-11-18", "schedule": { "intervals": [false, false, "...", true, true, "...", false], "value_trajectory": [35.0, 35.0, "...", 57.5, 80.0, "...", 80.0], "unit": "percent" }, "savings_pct": 32.1, "source": "optimization", "entities": { "entity_id": "switch.tesla_charger", "soc_entity_id": "sensor.tesla_battery_level" }, "optimization_enabled": true } ``` `intervals`: boolean — `true` = charger on. `value_trajectory`: **display-only** predicted charge level (0–100 %). #### Home Battery Same shape as EV charger: `intervals` (boolean), `value_trajectory` (percent), `unit: "percent"`, plus `entities`. #### Home Robot Same shape as EV charger: `intervals` (boolean), `value_trajectory` (percent), `unit: "percent"`, plus `entities`. `intervals[idx] = true` means "should be on its dock" — docking is the charging proxy, since no robot exposes charge control directly. #### Water Heater ```json { "appliance_id": "...", "appliance_type": "water_heater", "name": "Basement Water Heater", "date": "2025-11-18", "schedule": { "intervals": [true, true, false, "...", false, true, "..."], "temp_trajectory": [120.0, 128.5, 135.2, "...", 118.3, 126.7, "..."], "unit": "fahrenheit" }, "savings_pct": 22.0, "source": "optimization", "entities": { "entity_id": "switch.water_heater", "temp_entity_id": "sensor.water_heater_temperature" }, "optimization_enabled": true } ``` `intervals`: boolean — `true` = element on. `temp_trajectory`: **display-only** predicted tank temperature. ### The `entities` block Every per-appliance entry carries an `entities` sub-object echoing the identifier keys from the appliance's config, so your client knows which device to command. Only keys the config actually set are included: `entity_id` for everything except solar, plus `soc_entity_id`, `temp_entity_id`, `indoor_temp_entity_id` or `production_sensor_entity_id` when set. It is `{}` when none are set, as for a solar array with no production sensor. **Errors:** `401` (missing, invalid or expired token), `404 "Appliance not found"`, `503 "Database unavailable"`. --- ## GET /api/v1/schedules Unified view of every appliance's schedule in one response, and the route that carries the per-slot mode and fan schedules. Pull it after the overnight run, then poll [`GET /api/v1/schedules/updated-at`](#get-apiv1schedulesupdated-at) and pull again whenever its value changes: a plan for today can be replaced during the day. **Auth:** Required ```bash curl https://api.hungrymachines.io/api/v1/schedules \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response (200):** ```json { "date": "2025-11-18", "appliances": [ { "appliance_id": "...", "appliance_type": "hvac", "name": "Living Room AC", "schedule": { "intervals": [0, 1, "...", 47], "high_temps": [74.0, "..."], "low_temps": [70.0, "..."], "setpoint_temps": [72.0, "..."], "room_target_temps": [72.0, "..."], "temp_trajectory": [72.1, "..."], "mode": "cool", "generated_at": "2025-11-18T04:15:00+00:00" }, "savings_pct": 18.5, "source": "optimization", "optimization_enabled": true, "entities": { "entity_id": "climate.living_room", "indoor_temp_entity_id": "sensor.back_bedroom_temperature" }, "integration_health": { "status": "healthy", "message": "Integration is healthy.", "lookback_hours": 6 } }, { "appliance_id": "...", "appliance_type": "ev_charger", "name": "Tesla Model 3", "schedule": { "intervals": [false, "...", true, "...", false], "value_trajectory": [30.0, "...", 80.0], "unit": "percent" }, "savings_pct": 32.1, "source": "optimization", "optimization_enabled": true, "entities": { "entity_id": "switch.tesla_charger" } } ], "optimization_enabled": true, "integration_health": { "status": "healthy", "message": "Integration is healthy.", "lookback_hours": 6, "appliances": ["..."] }, "divergence_summary": { "verdict": "healthy", "human_readable": "All three signals agree — recent readings are clean.", "sample_count": 288 } } ``` Each entry in `appliances` uses the per-type schedule shape above. | Field | Type | Notes | |---|---|---| | `appliance_id`, `appliance_type`, `name` | string | Identify the appliance and select the schedule shape. | | `schedule` | object | The stored plan, or `{}` when there is none. | | `savings_pct` | float | **Display-only.** `0.0` when there is no stored plan. | | `source` | string | `"optimization"` (a stored plan) or `"defaults"` (no stored plan yet). | | `stale` | boolean | Present, and `true`, only when there is no plan for today and the most recent stored plan is returned instead. Absent otherwise. | | `optimization_enabled` | boolean | Effective per-appliance pause flag. On every entry, of every type. | | `entities` | object | Entity ids from the appliance's config. Always present; `{}` when none are set. | | `integration_health` | object | HVAC entries only. | **No stored plan (`source: "defaults"`).** An appliance that has not been planned yet returns `"schedule": {}`, `"savings_pct": 0.0` and `"source": "defaults"`. There is no comfort band and no setpoint on this route to fall back to, so send that appliance no commands and leave it under its own control. If you want a default band to show or hold for an HVAC unit, call [`GET /api/v1/schedule?appliance_id=…`](/api-reference/schedule/), which returns one. The first plan is stored by the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run. **Present or absent, never `null`.** On this route `schedule` is the stored plan as written, so a schedule key is either present with a value or absent. Check for the key before reading it. ### HVAC schedule fields | Field | Type | Notes | |---|---|---| | `intervals` | int[48] | Always `[0, 1, …, 47]`. | | `high_temps`, `low_temps` | float[48] | The comfort band (°F). **Display-only.** | | `setpoint_temps` | float[48] | **The thermostat command.** Apply `setpoint_temps[idx]` at each 30-minute boundary. | | `room_target_temps` | float[48] | The room temperature the plan aims to hold in each slot (°F). It usually equals `setpoint_temps`; the two differ where the unit's own thermostat reads differently from the room. A client that reads a separate room sensor may adjust the setpoint to steer the room toward this value, and should then report it as `room_target` on its readings. Otherwise ignore it and apply `setpoint_temps`. | | `hvac_mode_schedule` | string[48] | Per-slot mode: `"OFF"`, `"COOL"` or `"HEAT"`. **When present, apply it** at each boundary along with the setpoint. Present when the unit's `optimize_hvac_mode` preference is on, always for a unit registered with `hvac_type: "window_ac"`, and on a warm-up day. It can be absent on any given plan; when absent, do not change the unit's mode. | | `fan_mode_schedule` | string[48] | Per-slot fan speed: `"low"`, `"high"` or `"auto"`. **`"auto"` (and `"off"`) means "do not send a fan command"** for that slot; send one only for `"low"` or `"high"`, and only when the unit has a matching fan option. Present when the unit's `optimize_hvac_fan` preference is on and the unit has reported distinct fan speeds in its readings, always for `hvac_type: "window_ac"`, and on a warm-up day. When absent, do not change the fan. | | `temp_trajectory` | float[48] | Predicted indoor temperature, **display-only**. Absent on a warm-up day. | | `mode` | string | `"cool"` or `"heat"`. | | `generated_at` | string | UTC timestamp of when the plan was produced. | An HVAC schedule can carry further keys. They are display-only and may change without notice; ignore any key not listed here. **`optimization_enabled` (top level):** the master pause switch. When `false`, **do not apply any appliance's schedule** until a later poll returns `true`. **`optimization_enabled` (per appliance):** the effective per-unit pause flag (already folds in the master switch). The apply loop should gate on this per appliance. It is on every entry, of every type. A paused unit still has a schedule entry, but do not apply it while the flag is `false`. **`integration_health`:** an inline link-health verdict so you can show a banner when data stops flowing — same payload as [`GET /api/v1/integration/health`](#get-apiv1integrationhealth), included here to save a round-trip. HVAC entries carry their own inline block; non-HVAC entries omit it. **`divergence_summary`:** a compact, display-only verdict comparing what your client reported commanding against what the thermostat reports: `verdict` (`healthy`, `entity_unreliable`, `thermostat_ignoring_commands`, `commanded_missing`, `no_data` or `unknown`), `human_readable` (a short description to show) and `sample_count`. It depends on the `commanded_*` fields your client sends with its readings; a client that sends none gets `commanded_missing`. ### Per-type schedule summary | Type | Schedule fields | Unit | |---|---|---| | `hvac` | `intervals` (int[48]), `high_temps` (float[48]), `low_temps` (float[48]), `setpoint_temps` (float[48]), `room_target_temps` (float[48]), `hvac_mode_schedule` and `fan_mode_schedule` (string[48], when they apply), `temp_trajectory` (float[48]), `mode`, `generated_at` — see [HVAC schedule fields](#hvac-schedule-fields) | Fahrenheit | | `ev_charger` | `intervals` (bool[48]), `value_trajectory` (float[48]), `unit`, plus display-only `min_value`, `target_value` (percent) and `deadline_interval` (slot index) | percent (0–100) | | `home_battery` | `intervals` (bool[48]), `value_trajectory` (float[48]), `unit`, plus display-only `min_value`, `target_value`, `deadline_interval` | percent (0–100) | | `robot` | `intervals` (bool[48]), `value_trajectory` (float[48]), `unit`, plus display-only `min_value`, `target_value`, `deadline_interval` | percent (0–100) — `true` means "should be on its dock" | | `water_heater` | `intervals` (bool[48]), `temp_trajectory` (float[48]), `unit`, plus display-only `high_temps` and `low_temps` (float[48], the tank's allowed range) | fahrenheit | | `solar` | `generation_kw_48` (float[48], this array's own curve), `unit`, `source` (`"learned"`, `"irradiance"` or `"nameplate"` — a display label for what the generation forecast is based on), `self_consumed_kw_48` (float[48]), `surplus_kw_48` (float[48]), `generation_kwh`, `self_consumed_kwh`, `surplus_kwh`, `export_value_cents` (float or null — null with no export-rate curve), `generated_at` — a forecast to display; there are no per-interval commands | kW | | `dehumidifier` | _No schedule_ — data-collection only (v1). The entry's `schedule` is `{}`. | — | Most stored plans also carry `pricing_cents_48` (float[48]), the price curve in cents/kWh the plan was made against, for display. The `entities` block is always present. It holds only the keys the appliance's config set — `entity_id`, `soc_entity_id`, `temp_entity_id`, `indoor_temp_entity_id`, or `production_sensor_entity_id` on a solar array — and is `{}` when none are set. To recompute now (e.g. right after the user saves a constraint) and get the same `appliances` entries plus a `results` array, use [`POST /api/v1/schedule/recompute`](/api-reference/schedule/#post-apiv1schedulerecompute). Its response does not carry the top-level `optimization_enabled`, `integration_health` or `divergence_summary`. --- ## GET /api/v1/integration/health A link-health probe over the recent reading stream, so a dashboard can show a banner when the integration silently stops delivering real data. Returns the same block that `GET /api/v1/schedules` inlines — poll this on a faster cadence (e.g. every 30 s) without pulling the full schedule payload. **Auth:** Required **Response (200):** ```json { "status": "frozen_sensor", "last_reading_at": "2026-05-29T02:00:00+00:00", "last_reading_age_seconds": 312, "sample_count": 71, "message": "Readings are arriving on time but indoor temperature has been stuck at 70.0°F for 6 hours. Your thermostat is likely disconnected, or your client's sensor mapping has changed.", "lookback_hours": 6, "appliances": [ { "appliance_id": "1111...", "name": "Living Room AC", "status": "healthy", "message": "Integration is healthy.", "lookback_hours": 6 }, { "appliance_id": "2222...", "name": "Bedroom AC", "status": "frozen_sensor", "message": "...", "lookback_hours": 6 } ] } ``` | Field | Type | Notes | |---|---|---| | `status` | string | See table below | | `last_reading_at` | string \| null | ISO timestamp of the most recent reading in the window | | `last_reading_age_seconds` | int \| null | Age of the most recent reading | | `sample_count` | int | Readings in the lookback window. Readings from a paused appliance are left out | | `message` | string | Short human-readable description, written to name no product or platform — safe for any client to render verbatim, or key its own wording on `status` instead. | | `lookback_hours` | int | Window inspected (currently 6 h) | | `appliances` | array | Per-HVAC breakdown (`appliance_id`, `name`, plus the fields above). Non-HVAC units omitted; empty when no HVAC is registered. | | `status` | Meaning | |---|---| | `healthy` | Recent readings present and indoor temperature varying. | | `stale_data` | Last reading > 30 minutes old. Readings have stopped arriving. | | `frozen_sensor` | Readings arriving on cadence but indoor temperature is flatlined. Sensor likely disconnected or the entity renamed. | | `no_data` | Zero readings in the window. The client is offline. Also returned, with an explanatory `message`, when recent readings could not be read. | This route answers `200` with a `status` in every case; it has no error status of its own beyond the auth errors. > **Note** > > The response may include additional diagnostic counters. They're informational and may change — render `status` and `message`, and don't build logic on undocumented fields. **Errors:** `401` (`"Not authenticated"`, `"Invalid token"` or `"Token expired"`). --- ## GET /api/v1/schedules/history Return the history of schedule rows for the account's appliances in a date range. `GET /schedule` and `/schedules` always return the **latest** schedule per `(appliance, date)`; this returns every stored one, so a UI can show how the plan for a day changed. **Auth:** Required ### Query parameters | Param | Required | Notes | |---|---|---| | `appliance_id` | No | Omit for all appliances the account owns. | | `start` | No | Date, exactly `YYYY-MM-DD`. Default: `end - 14 days`. | | `end` | No | Date, exactly `YYYY-MM-DD`. Default: today. | `end - start` must be ≤ 90 days. **Response (200):** ```json { "start": "2026-05-15", "end": "2026-05-29", "appliances": [ { "appliance_id": "0a0feccb-...", "appliance_type": "hvac", "name": "Bedroom AC", "history": [ { "id": 79, "appliance_id": "0a0feccb-...", "date": "2026-05-14", "schedule": { "setpoint_temps": ["..."], "temp_trajectory": ["..."] }, "constraints_used": { "optimization_mode": "cool", "base_temperature": 72.0 }, "baseline_cost": 0.42, "optimized_cost": 0.31, "savings_pct": 26.1, "created_at": "2026-05-14T21:35:04Z" } ] } ] } ``` Each entry carries `id`, `appliance_id`, `date`, `schedule` (the same object `GET /api/v1/schedules` returns for that appliance type), `constraints_used`, `baseline_cost`, `optimized_cost`, `savings_pct` and `created_at`. Entries are sorted by `date` ascending, then `created_at` ascending, so the last entry for a date is the one the schedule routes serve. `baseline_cost` / `optimized_cost` (dollars) and `savings_pct` are **display-only**. `constraints_used` is a fixed, documented field set: whichever of `optimization_mode`, `base_temperature`, `savings_level`, `max_temp_f`, `min_charge_pct`, `target_charge_pct`, `deadline_time`, `tasks_window_start`, `tasks_window_end` and `nameplate_kw` apply to this appliance's type (present when a value was recorded for this run, absent otherwise). Ignore any key not in this list. **Errors:** | Status | Detail | Cause | |---|---|---| | 400 | `"window exceeds 90 days"` / `"start must be <= end"` | Bad date range | | 400 | `"start must be an ISO date (YYYY-MM-DD)"` / `"end must be an ISO date (YYYY-MM-DD)"` | `start`/`end` is not a valid ISO date | | 401 | `"Not authenticated"`, `"Invalid token"` or `"Token expired"` | No `Authorization` header, or a bad or expired token | | 404 | `"appliance not found for this user"` | Only when `appliance_id` is set | ## GET /api/v1/schedules/updated-at Lightweight freshness check: returns the latest timestamp a schedule was written for the account, for today, without downloading the schedules themselves. **This is how a client notices a daytime re-plan.** Poll it every few minutes, keep the last value you saw, and fetch [`GET /api/v1/schedules`](#get-apiv1schedules) again when it changes. **Auth:** Required **Response (200):** ```json { "updated_at": "2026-09-05T14:32:07.123456+00:00" } ``` `updated_at` is `null` when the account has no appliances, or nothing has been scheduled for today yet. **Errors:** | Status | Detail | Cause | |---|---|---| | 401 | `"Not authenticated"`, `"Invalid token"` or `"Token expired"` | No `Authorization` header, or a bad or expired token | Canonical page: https://docs.hungrymachines.io/api-reference/appliances/ --- https://docs.hungrymachines.io/api-reference/auth/ # Auth (Reference) Programmatic auth endpoints: signup, profile fetch, profile update. Login and refresh live in Getting Started → Authentication. For most clients the login + refresh pair in [Getting Started → Authentication](/getting-started/authentication/) is all you need. This page documents the endpoints that aren't strictly part of the daily loop but are useful for programmatic onboarding (signup) and profile management (me). > **Caution** > > `POST /auth/signup` bypasses the billing and onboarding setup that the [hungrymachines.io](https://hungrymachines.io) signup flow handles. Use it only for test fixtures or short-lived dev accounts. Real users belong on the website signup flow. --- ## POST /auth/signup Create a new account. Optional profile fields are stored on the account and applied the first time the client calls `GET /auth/me`, so call `GET /auth/me` once after signup or login, before any other request. **Auth:** None ### Required vs. optional Only `email` and `password` are required. Every profile field below is optional and can be set (or changed) later via `PATCH /auth/me`. ### Example ```bash curl -X POST https://api.hungrymachines.io/auth/signup \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "password": "securepassword", "location_zip": "92101", "home_size_sqft": 2000, "pricing_location": 1, "timezone": "America/Los_Angeles" }' ``` ### Request body | Field | Type | Required | Notes | |---|---|---|---| | `email` | string | **Yes** | Must be unique | | `password` | string | **Yes** | Subject to the account password policy | | `location_zip` | string | No | US zip code, used for weather forecasts | | `home_size_sqft` | integer | No | Used to estimate appliance power draw | | `pricing_location` | integer | No | Rate-catalog id (see `available_pricing_zones` from [`GET /api/v1/rates`](/api-reference/rates/); default 1) | | `timezone` | string | No | IANA timezone name, for example `America/Los_Angeles`. `422` if it isn't a real zone. | | `newsletter_opt_in` | boolean | No | Newsletter checkbox state. Omitted means opted in. | Send `timezone` at signup so the account starts on the right local day — every schedule is keyed to the account's local midnight. An account created without one starts on `America/New_York` until `PATCH /auth/me` sets it. ### Response (200) — session returned When the account doesn't require email confirmation, the response is a full session: ```json { "access_token": "eyJhbGciOi...", "refresh_token": "JKLrM3...", "expires_in": 3600, "expires_at": 1762450800, "token_type": "bearer", "user": { "id": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com" } } ``` ### Response (200) — email confirmation pending When email confirmation is required, the response is still `200` but the session fields (`access_token`, `refresh_token`, `expires_in`, `expires_at`, `token_type`) are absent. For example: ```json { "user": { "id": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com" } } ``` **Clients must handle both shapes**: treat a `200` without `access_token` as "show a confirm-your-email screen", and do not depend on any other key in that body. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"User already registered"` | Email is already in use (or another signup failure message) | | 422 | `"Password should be at least 6 characters"` | Password policy rejection (string `detail`) | | 422 | `"timezone must be a valid IANA zone name, got '...'"` | `timezone` isn't a real IANA zone | | 422 | array `detail` | `email` or `password` missing or not a string. See [Errors, Limits & Stability](/api-reference/errors-and-limits/#error-shape). | | 502 | `"Auth service unreachable"` | Identity provider unreachable or failing | | 503 | `"Auth not configured"` | Auth not configured on the API | The `400` and the password-policy `422` come from the identity provider and are passed through with its status code and message. Treat the codes above as the common cases, not a closed list: handle any other `4xx` on this route as a failed signup and show its `detail`. --- ## GET /auth/me Get the current user's profile and subscription tier. Call it once right after signup or login, before any other request: the first call sets up the account's profile and applies the optional fields sent at signup. **Auth:** Required (Bearer JWT) ### Example ```bash curl https://api.hungrymachines.io/auth/me \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) ```json { "user_id": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com", "location_zip": "92101", "home_size_sqft": 2000, "pricing_location": 1, "timezone": "America/New_York", "subscription_tier": "free", "weather_entity_id": "weather.home" } ``` ### Response fields | Field | Type | Notes | |---|---|---| | `user_id` | string (UUID) | Account id (matches the JWT subject) | | `email` | string | | | `location_zip` | string | May be empty | | `home_size_sqft` | integer | May be 0 | | `pricing_location` | integer | Rate-catalog id (see `available_pricing_zones`) | | `timezone` | string | IANA timezone | | `subscription_tier` | string | `"free"` or `"premium"`. Defaults to `"free"`. | | `weather_entity_id` | string | The Home Assistant `weather.*` entity the user picked for forecasts. Empty when unset (a fallback weather source is then used). | :::note[Signup fields are typed-or-defaulted on this first call] When an account is created through the identity provider's own SDK rather than `POST /auth/signup`, nothing validated what went into the signup metadata. A value of the wrong type, or a `timezone` that is not a real IANA zone, is replaced with the field's default (`""`, `0`, `1`, `America/New_York`) on the profile this call creates, rather than being stored as sent. Set the real value with `PATCH /auth/me`, which validates and answers `422`. ::: ### Errors | Status | Detail | Cause | |---|---|---| | 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` / `"Token missing subject"` | Missing, malformed, or expired token | | 503 | `"Database unavailable"` / `"Auth not configured"` | Backend dependency unavailable | These `401` responses are the same on every route that requires auth. --- ## PATCH /auth/me Update the profile. Send only the fields you want to change. **Auth:** Required (Bearer JWT) ### Required vs. optional **All fields are optional** — include only what you want to change. An omitted field, or one sent as `null`, is left unchanged; `null` does not clear a value. To clear `location_zip` or `weather_entity_id`, send `""`. ### Example ```bash curl -X PATCH https://api.hungrymachines.io/auth/me \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "location_zip": "94110", "home_size_sqft": 1800, "pricing_location": 3, "timezone": "America/Los_Angeles" }' ``` ### Request body ```json { "location_zip": "94110", "home_size_sqft": 1800, "pricing_location": 3, "timezone": "America/Los_Angeles", "weather_entity_id": "weather.home" } ``` | Field | Type | Required | Notes | |---|---|---|---| | `location_zip` | string | No | US zip code. Send `""` to clear. Not checked as a real zip. | | `home_size_sqft` | integer | No | | | `pricing_location` | integer | No | A rate-catalog id from `available_pricing_zones` ([`GET /api/v1/rates`](/api-reference/rates/)). `422` if it isn't one. | | `timezone` | string | No | IANA timezone name, for example `America/Los_Angeles`. Schedules are keyed to it. `422` if it isn't a real zone. | | `weather_entity_id` | string | No | Home Assistant `weather.*` entity id. Send `""` to clear. | > **Tip** > > Calling `PATCH /auth/me` before `GET /auth/me` still creates the profile from the fields sent at signup (zip, size, pricing location, timezone, newsletter choice) — nothing is lost by calling it first. ### Response (200) Returns the full profile object after the update — same shape as `GET /auth/me`. ### Errors | Status | Detail | Cause | |---|---|---| | 401 | as on `GET /auth/me` | Missing, invalid or expired token | | 422 | array `detail` | A field has the wrong type | | 422 | `"timezone must be a valid IANA zone name, got '...'"` | `timezone` isn't a real IANA zone | | 422 | `"pricing_location must be one of the catalog ids: [...]"` | `pricing_location` isn't a known catalog id | | 503 | `"Database unavailable"` | Database not reachable | Canonical page: https://docs.hungrymachines.io/api-reference/auth/ --- https://docs.hungrymachines.io/api-reference/errors-and-limits/ # Errors, Limits & Stability The error shape, common error codes, documented rate limits, display-only fields, and what we promise not to change without warning. ## Error shape Every error response is JSON with a single `detail` field. It takes one of two forms. An error the API raises itself (`400`, `401`, `403`, `404`, `429`, `5xx`) carries a string: ```json {"detail": "..."} ``` A request that fails schema validation — a missing required field, a wrong type, or a value outside a field's allowed range — returns `422` with `detail` as an array, one object per problem, each carrying `loc`, `msg` and `type`: ```json { "detail": [ { "type": "value_error", "loc": ["body", "base_temperature"], "msg": "Value error, base_temperature must be between 60 and 85F" } ] } ``` Read `detail` as a string when it is one, and otherwise join the `msg` values. The one `422` with a string `detail` is a password-policy rejection on `POST /auth/signup`. ## Common error responses | Status | Meaning | Example | |---|---|---| | 400 | A request the API itself rejected | `{"detail": "hourly_high_temps_f and hourly_low_temps_f must each have exactly 24 values"}` | | 401 | Not authenticated / invalid token | `{"detail": "Not authenticated"}` / `{"detail": "Invalid token"}` / `{"detail": "Token expired"}` | | 403 | Forbidden | `{"detail": "Upgrade required"}` | | 404 | Resource not found | `{"detail": "Appliance not found"}` | | 422 | Unprocessable entity (schema validation) | Array `detail`, as shown above | | 429 | Rate limit exceeded | `{"detail": "Rate limit exceeded: ..."}` | | 500 | Server / upstream service error | `{"detail": "Payment service error"}` | | 502 | Upstream auth service unreachable | `{"detail": "Auth service unreachable"}` | | 503 | Dependency unavailable | `{"detail": "Database unavailable"}` | ## Rate limits - **HVAC readings** (`POST /api/v1/readings`): up to 600 readings per active HVAC or dehumidifier appliance, per account, per UTC day (600 for an account with none registered yet). A single unit pushing every 5 minutes uses 288 of those — the cap is a circuit breaker for a runaway client, not a bound on normal use. - **Other appliance readings** (`POST /api/v1/appliances/{id}/readings`): up to 300 readings per active EV charger, home battery, water heater, solar array or robot, per account, per UTC day (300 for an account with none registered yet), across every non-HVAC appliance combined. Exceeding any of these returns `429` with a `detail` naming the cap you hit. ## Handling errors - **401** — refresh your access token once with the saved refresh token. If the refresh call itself fails, the refresh token has expired or been revoked; ask the person to sign in again rather than retrying. - **429** — back off and retry later rather than retrying in a tight loop. - **5xx** — the API or one of its dependencies is temporarily unavailable. Back off and retry; this isn't something your request caused. ## Display-only fields A few fields in a schedule response describe what the API expects to happen — they aren't commands to send to a device. Render them as-is; don't branch on an exact value or try to reproduce how they're computed. | Field | What you may do with it | |---|---| | `model_confidence` | Reserved. Currently always `null`; don't build on it. | | `temp_trajectory` | Plot it as the predicted indoor-temperature line. On `GET /api/v1/schedule` it is `null` while `source` is `"defaults"` and on a warm-up day; on `GET /api/v1/schedules` the key is absent in those cases. | | `estimated_savings_pct` | Show it as an estimate, measured against a naive thermostat held at the middle of the comfort band — not against the home's actual prior usage. | | `derived_rates` | An opaque diagnostic object. Render it generically, or skip it — its keys aren't part of this contract. | ## Stability Public response shapes only grow: a field or endpoint documented on this site won't be removed or change meaning within a major version of this contract. New fields can appear at any time; ignore ones you don't recognize. > **Caution** > > This stability wording is the default pending the API owner's confirmation — treat it as directional, not yet final. ## Getting help Email [info@hungrymachines.io](mailto:info@hungrymachines.io) with questions about the API or this contract. ## Next steps - [API Reference](/api-reference/readings/) — per-endpoint request/response shapes - [OpenAPI Description](/api-reference/openapi/) — the machine-readable spec - [Authentication](/getting-started/authentication/) — a worked example of 401 handling Canonical page: https://docs.hungrymachines.io/api-reference/errors-and-limits/ --- https://docs.hungrymachines.io/api-reference/metrics/ # Customer Metrics Read the signed-in customer's savings, peaks avoided, flexibility, comfort, plan adherence, and a privacy-preserving ranking against comparable homes via /api/v1/me. The `/api/v1/me` endpoints return the signed-in customer's own headline numbers — savings, peaks avoided, flexibility, comfort, plan adherence, connected machines, and a ranking against comparable homes. Use them to build a "what did my system do for me" dashboard. Every query is filtered to the signed-in user: these routes return **only the caller's own figures** and never expose another customer's data. Metrics cover completed days, so a newly connected home fills in over its first days of operation. > **Caution: Render null as an em dash, never a zero** > > A metric with no data yet serializes as JSON `null`, never a fabricated `0`. Render `null` as an em dash ("—") rather than a zero — a zero would read as "did nothing" when the truth is "nothing measured yet". A customer with no history yet gets a `200` with null fields, **not a `404`**. ## GET /api/v1/me/metrics/summary Trailing-window aggregate of the caller's savings, peaks avoided, flexibility, comfort, plan adherence and connected machines — one call for a headline row. **Auth:** Required (Bearer JWT) ### Query parameters | Param | Required | Type | Notes | |---|---|---|---| | `window` | No | integer | Trailing days to aggregate. Default `30`, minimum `1`, maximum `90`. A value above 90 returns `400`; a value below 1, or a non-integer, returns `422`. | Cumulative fields (dollars saved, hours idle, override events) are **summed** over the window; intensity fields (percentages, degree-hours) are **meaned**, so those figures stay comparable regardless of how many days of data a home has. ### Example ```bash curl "https://api.hungrymachines.io/api/v1/me/metrics/summary?window=30" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) ```json { "window": 30, "latest_date": "2026-08-02", "days_counted": 2, "savings": { "modeled_usd": 4.0, "realized_usd": 2.0, "realized_days": 1, "modeled_days": 2, "savings_pct": 20.0, "degenerate": false }, "peaks": { "peak_hours_idle": 6.0, "top_peak_hours_idle": 3.0, "off_peak_kwh_share_pct": 55.0 }, "flexibility": { "degree_hours": 336.0, "percent_hours": null }, "comfort": { "in_band_home_pct": 96.0 }, "adherence": { "conformance_pct": 95.0, "override_events": 3, "override_by_hour": [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0] }, "machines": { "active": 2, "by_type": { "hvac": 1, "ev_charger": 1 } } } ``` ### Response fields | Field | Type | Description | |---|---|---| | `latest_date` | string \| null | Most recent day the window is anchored on (`YYYY-MM-DD`); `null` when the customer has no history yet. | | `days_counted` | integer | Number of days inside the window that contributed data. | | `savings.modeled_usd` | float \| null | Projected savings the plan aimed for, summed over the window (USD). | | `savings.realized_usd` | float \| null | Savings measured from actual consumption, summed over the window (USD). `null` when no day in the window could be measured. | | `savings.realized_days` / `savings.modeled_days` | int | How many days in the window contributed each total. The two differ whenever a day could not be measured, so present the day counts alongside the sums rather than comparing them directly. `0` when nothing contributed. | | `savings.savings_pct` | float \| null | Mean daily savings percent. | | `savings.degenerate` | boolean | `true` when `savings_pct` is ≤ 0 or ≥ 95 — a near-zero or implausibly-high figure a client should flag as a caution, not a win. `false` when `savings_pct` is `null`. | | `peaks.peak_hours_idle` | float \| null | Total hours the home's machines sat idle during the day's priciest quarter, summed over the window. | | `peaks.top_peak_hours_idle` | float \| null | Same measure over the **three priciest hours** only (max 3.0 per day), summed over the window. Always ≤ `peak_hours_idle`. | | `peaks.off_peak_kwh_share_pct` | float \| null | Mean share of daily kWh that landed in the cheapest slots. | | `flexibility.degree_hours` | float \| null | Mean daily comfort-band width, in degree-hours (thermal machines). Room the customer gave the plan to shift load. | | `flexibility.percent_hours` | float \| null | Mean charge-window slack percent (shiftable machines — EV, battery). | | `comfort.in_band_home_pct` | float \| null | Mean percent of the day the home stayed inside its comfort band. Show this alongside flexibility so the page never reads as "give up comfort to win". | | `adherence.conformance_pct` | float \| null | Mean plan-delivery conformance across the customer's machines — how reliably we delivered the plan. A shortfall is our delivery problem, kept separate from the customer's own overrides. | | `adherence.override_events` | float \| null | Total manual-override events over the window. | | `adherence.override_by_hour` | `array[int]` (length 24) \| null | Hour-of-day histogram (index `0` = local midnight) of when the customer overrode the plan, summed over the window — render it as a 24-bar chart. `null` (never a fabricated 24 zeros) until there is histogram data. Reflects the customer's decisions only; never combined with delivery conformance. | | `machines.active` | integer | Count of the customer's active machines. A real count — a `0` here is legitimate, not a missing metric. | | `machines.by_type` | object | Per-type count of active machines, keyed by machine type (`hvac`, `ev_charger`, `home_battery`, `water_heater`, `solar`, `dehumidifier`, `robot`). Only types the customer has appear. | ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"window exceeds 90 days"` | `window` greater than 90 | | 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token | | 422 | array `detail` | `window` below 1, or not an integer | --- ## GET /api/v1/me/metrics/daily The same savings / peaks / flexibility / comfort metrics as `/summary`, but **per day and un-aggregated**, so a dashboard can chart a trend rather than a single number. **Auth:** Required (Bearer JWT) ### Query parameters | Param | Required | Type | Notes | |---|---|---|---| | `start` | No | string (ISO date `YYYY-MM-DD`) | Window start, inclusive. Default `end − 30 days`. | | `end` | No | string (ISO date `YYYY-MM-DD`) | Window end, inclusive. Default the caller's local today. | Send both dates as `YYYY-MM-DD`. The window covers at most **90 calendar days inclusive** of both `start` and `end` (matching `/summary` and `/rankings`' `window` cap of 90); a wider span, or `start` after `end`, returns `400`. > **Note: Omit, don't zero-fill** > > Each entry is one calendar day's own metrics. Days with no data are **omitted** from `days` (not zero-filled), so a gap reads as "no data that day" rather than a fabricated `0`. Entries are ordered oldest → newest. Within a day, any metric with no value is JSON `null` (render as an em dash), never `0`. A window with no data returns a `200` with an empty `days` list, not a `404`. ### Example ```bash curl "https://api.hungrymachines.io/api/v1/me/metrics/daily?start=2026-07-04&end=2026-08-03" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) ```json { "start": "2026-07-04", "end": "2026-08-03", "days": [ { "date": "2026-08-01", "savings": { "modeled_usd": 1.0, "realized_usd": 0.5, "savings_pct": 10.0, "degenerate": false }, "peaks": { "peak_hours_idle": 2.0, "top_peak_hours_idle": 1.5, "off_peak_kwh_share_pct": 50.0 }, "flexibility": { "degree_hours": 336.0, "percent_hours": null }, "comfort": { "in_band_home_pct": 96.0 } } ] } ``` Per-day field meanings match the corresponding `/summary` fields above; `savings.degenerate` is computed for that single day. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"window exceeds 90 days"` / `"start must be <= end"` | `start` more than 90 days before `end`, or reversed range | | 400 | `"start must be an ISO date (YYYY-MM-DD)"` / `"end must be an ISO date (YYYY-MM-DD)"` | `start`/`end` is not a valid ISO date | | 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token | --- ## GET /api/v1/me/metrics/day/{'{date}'} One local calendar day's **plan versus actual** for the caller's HVAC: the plan the system intended (the 48-slot setpoints, the comfort band, the price curve), the measured indoor temperature, and where plan and reality diverged — split into two **separate** lists so an override (the customer's own decision) is never conflated with a conformance defect (a plan the service failed to deliver). **Auth:** Required (Bearer JWT) ### Path parameters | Param | Required | Type | Notes | |---|---|---|---| | `date` | Yes | string (ISO date `YYYY-MM-DD`) | The local calendar day to render. A non-ISO value returns `400`. | ### Query parameters | Param | Required | Type | Notes | |---|---|---|---| | `appliance_id` | No | string (uuid) | Which HVAC to render. Defaults to the caller's first active HVAC. An id that is not one of the caller's active HVAC appliances is ignored and the default is used; the response's `appliance_id` names the unit shown. | > **Note: Empty, never 404** > > A day with no plan, no active HVAC, or no readings returns a `200`: `overrides` and `conformance_defects` are `[]`, plan arrays are `null`, and `indoor_temps` is 48 `null`s. Missing indoor slots stay `null` (never `0`) so a chart breaks the line across a gap rather than drawing through it. ### Example ```bash curl "https://api.hungrymachines.io/api/v1/me/metrics/day/2026-06-08" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) ```json { "date": "2026-06-08", "appliance_id": "9191a36c-9b61-444a-ac3a-666b7912514e", "setpoints": [72.0, 72.0, "… 48 …"], "comfort_band": { "high": [74.0, "… 48 …"], "low": [70.0, "… 48 …"] }, "price_curve": [12.4, 12.1, "… 48 …"], "indoor_temps": [71.0, null, "… 48 …"], "overrides": [ { "start": "2026-06-08T10:10:00+00:00", "end": "2026-06-08T10:20:00+00:00", "start_hour": 10, "readings": 3 } ], "conformance_defects": [ { "start": "2026-06-08T12:10:00+00:00", "end": "2026-06-08T12:15:00+00:00", "start_hour": 12, "readings": 2 } ] } ``` ### Response fields | Field | Type | Description | |---|---|---| | `appliance_id` | string \| null | The HVAC this view describes; `null` when the caller has no active HVAC. | | `setpoints` | `array[float]` \| null | The 48-slot planned setpoints (slot 0 = local midnight). `null` when there is no plan for the day. | | `comfort_band.high` / `low` | `array[float]` \| null | The 48-slot comfort-band bounds, for display. `null` when absent. | | `price_curve` | `array[float]` \| null | The 48-slot price curve in the feed's own minor units (¢/kWh, pence, etc.). `null` when unavailable. | | `indoor_temps` | `array[float \| null]` | Measured indoor temperature averaged into the 48 local half-hour slots; a slot with no reading is `null`. | | `overrides` | `array[object]` | Episodes where the customer changed the setpoint away from the plan. Each: `{start, end, start_hour, readings}` — `start` / `end` are UTC ISO timestamps, `start_hour` is the local hour, `readings` is the run length. | | `conformance_defects` | `array[object]` | Runs where the plan was not delivered to the thermostat — **the service's** delivery failure, kept strictly apart from `overrides`. Same shape. Never mix these into an override count. | > **Caution: Overrides and defects are different things** > > An **override** is the customer's own decision; a **conformance defect** is a plan the service failed to deliver. They are returned in separate lists on purpose — never sum them together or present a defect as if the customer caused it. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"date must be ISO YYYY-MM-DD"` | Malformed `date` path value | | 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token | --- ## GET /api/v1/me/rankings How the caller stacks up against comparable homes — **without revealing anything about those homes**. For each of four higher-is-better metrics, the route compares the caller's own value over the window with comparable homes, and returns **only the caller's rank, percentile and own value** — never the cohort's size, and never another home's value. **Auth:** Required (Bearer JWT) ### Query parameters | Param | Required | Type | Notes | |---|---|---|---| | `window` | No | integer | Trailing days the ranking window spans. Default `30`, minimum `1`, maximum `90`. A value above 90 returns `400`; a value below 1, or a non-integer, returns `422`. Use the default: rankings are available for the 30-day window, and another window may return null ranks. | **Ranked metrics** — all higher-is-better, so `rank` is 1-based with `#1` the best: | Metric | What it measures | |---|---| | `savings_pct` | Savings as a percent, not dollars. | | `off_peak_kwh_share_pct` | Share of energy drawn in the cheapest hours. | | `flexibility_degree_hours` | Comfort-band width offered to the plan. | | `active_appliances` | Count of connected machine types. | **Cohorts.** The caller is ranked against the closest group of comparable homes available. `cohort_kind` names that group (`zip5`, `zip3`, `rate_peers` or `fleet`); `cohort_label` is a generic, share-safe string — `"your ZIP code"`, `"your area"`, `"your rate plan"`, `"all Hungry Machines homes"` — that **never contains raw ZIP digits or any identifier**. It is safe to render on a share card. > **Caution: Privacy contract** > > The response carries **no cohort population size** under any key (no `n`, `count`, `total`, `size`) and **no other home's value** — only the caller's `rank`, `percentile` and `your_value`. A metric the caller can't be ranked on yet (no eligible value, or no comparable cohort) carries a **`null` rank — never `0` and never a rank of `0`**. A home with no ranking data at all still gets a `200` with its own values and null ranks, not a `404`. ### Example ```bash curl "https://api.hungrymachines.io/api/v1/me/rankings?window=30" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) ```json { "window": 30, "latest_date": "2026-08-03", "rankings": [ { "metric": "savings_pct", "cohort_kind": "zip5", "cohort_label": "your ZIP code", "rank": 1, "percentile": 100.0, "your_value": 22.5 }, { "metric": "off_peak_kwh_share_pct", "cohort_kind": "rate_peers", "cohort_label": "your rate plan", "rank": 3, "percentile": 62.5, "your_value": 55.0 }, { "metric": "flexibility_degree_hours", "cohort_kind": "fleet", "cohort_label": "all Hungry Machines homes", "rank": null, "percentile": null, "your_value": null }, { "metric": "active_appliances", "cohort_kind": "zip5", "cohort_label": "your ZIP code", "rank": 1, "percentile": 100.0, "your_value": 2.0 } ] } ``` ### Response fields | Field | Type | Description | |---|---|---| | `latest_date` | string \| null | The date the ranking is anchored on (`YYYY-MM-DD`); `null` when the caller has no data. | | `rankings[].metric` | string | One of the four ranked metrics above. All four are always present. | | `rankings[].cohort_kind` | string \| null | The cohort the rank is against (`zip5` / `zip3` / `rate_peers` / `fleet`); `null` when no cohort qualifies. | | `rankings[].cohort_label` | string \| null | Generic, share-safe label for that cohort. Never contains ZIP digits. `null` when no cohort qualifies. | | `rankings[].rank` | integer \| null | 1-based, higher-is-better rank within the cohort. `null` (never `0`) when the caller has no eligible value or no cohort. | | `rankings[].percentile` | float \| null | Percent of the cohort at or below the caller's value (higher is better). `null` when unranked. | | `rankings[].your_value` | float \| null | The caller's **own** windowed value for the metric. `null` when they have no eligible day. | ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"window exceeds 90 days"` | `window` greater than 90 | | 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token | | 422 | array `detail` | `window` below 1, or not an integer | Canonical page: https://docs.hungrymachines.io/api-reference/metrics/ --- https://docs.hungrymachines.io/api-reference/preferences/ # Preferences Manage comfort and optimization preferences via GET/PUT /api/v1/preferences. The preferences endpoints let you configure your comfort settings and optimization mode. These values control how aggressively the optimizer shifts energy usage. > These are **account-level** preferences. For a home with more than one HVAC, each unit also has its own [per-HVAC preferences](/api-reference/appliances/#per-hvac-preferences) with the same shape. Planning for a unit follows these account-level values until that unit's own preferences are saved, and the account-level values stay authoritative for account-scope fields. > See also [Rates](/api-reference/rates/) for the electricity rate curve the optimizer saves against. ## GET /api/v1/preferences **Auth:** Required (Bearer JWT) ### Example ```bash curl https://api.hungrymachines.io/api/v1/preferences \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) ```json { "base_temperature": 72.0, "savings_level": 1, "time_away": "08:00", "time_home": "17:00", "optimization_mode": "cool", "hourly_high_temps_f": null, "hourly_low_temps_f": null, "optimize_hvac_fan": false, "optimize_hvac_mode": false, "optimization_enabled": true, "newsletter_opt_in": true, "spike_guard_enabled": true, "max_concurrent_appliances": null, "power_cap_kw": null, "base_load_reserve_kw": 0.0, "demand_charge_usd_per_kw": null, "demand_window_start": null, "demand_window_end": null } ``` If the user hasn't set preferences yet, defaults are returned. ### Fields | Field | Type | Default | Description | |---|---|---|---| | `base_temperature` | float | 72.0 | Desired temperature (°F) | | `savings_level` | integer | 1 | 1 = tight, 2 = moderate, 3 = aggressive. Stored and returned; it does not change the default comfort band. To widen or narrow comfort, set the two hourly arrays. | | `time_away` | string | `"08:00"` | When you typically leave home (HH:MM, 24-hour). Stored and returned; it does not change the default comfort band. | | `time_home` | string | `"17:00"` | When you typically return home (HH:MM, 24-hour). Stored and returned; it does not change the default comfort band. | | `optimization_mode` | string | `"cool"` | `"cool"`, `"heat"`, `"auto"`, or `"off"` | | `hourly_high_temps_f` | array[float] \| null | `null` | Optional 24-element per-hour upper comfort bound (°F). When set, the two hourly arrays are the comfort band. When `null`, the default band applies. | | `hourly_low_temps_f` | array[float] \| null | `null` | Optional 24-element per-hour lower comfort bound (°F). Must be set together with `hourly_high_temps_f`. | | `optimize_hvac_fan` | boolean | `false` | Opt-in: also emit a per-slot fan-speed schedule. | | `optimize_hvac_mode` | boolean | `false` | Opt-in: also emit a per-slot HVAC-mode schedule. | | `optimization_enabled` | boolean | `true` | Master pause switch. When `false`, clients must stop applying schedules entirely until re-enabled. Schedules are still computed, so re-enabling takes effect at the next half-hour boundary. | | `newsletter_opt_in` | boolean | `true` | Newsletter membership. Changing only this field does not trigger a schedule recompute. | | `spike_guard_enabled` | boolean | `true` | Automatically avoid real-time price spikes on plans that support it. Your comfort range always applies. Turn off any time. | | `max_concurrent_appliances` | integer \| null | `null` | Limit how many appliances (EV charger, home battery, water heater, etc.) may run at the same time. `null` = no limit. | | `power_cap_kw` | float \| null | `null` | A whole-home power ceiling (kW) the planner respects. `null` = no cap. | | `base_load_reserve_kw` | float | `0.0` | Power (kW) always reserved for everyday always-on draw before the planner spends the rest of `power_cap_kw`. | | `demand_charge_usd_per_kw` | float \| null | `null` | Your utility's demand charge rate ($/kW), if any, so the planner avoids a new monthly peak. `null` = no demand charge. | | `demand_window_start` | string \| null | `null` | HH:MM start of the hours your demand charge applies. `null` with `demand_window_end` also `null` = all day. | | `demand_window_end` | string \| null | `null` | HH:MM end of the hours your demand charge applies. | ### The default comfort band Without an explicit override, every home gets the same default band, set only by `base_temperature` and `optimization_mode`. At a base of 72 °F in cool mode it is 70–73 °F for hours 0–7 and 22–23, and 67–78 °F for hours 8–21. A different base shifts both bounds by the same amount, and heat mode mirrors the shape so the wider side is below the base. `savings_level`, `time_away` and `time_home` do not change it. See [default bands](#get-apiv1preferencesdefault-bands) below to preview the exact per-hour values. ### Hourly comfort bands (advanced) If you want different per-hour bounds — for example, a tighter band during occupied hours and a wider band overnight — submit `hourly_high_temps_f` and `hourly_low_temps_f` together. - Both arrays must be set together, or both must be `null` to clear the override and return to the default band. Always send both in the same request. - Each array is exactly 24 values, indexed `0` (midnight) through `23` (11 PM local time). - Each value must be in `[50, 90]` °F. - For every hour `i`, `hourly_low_temps_f[i] < hourly_high_temps_f[i]`. - When the override is set, the explicit bounds apply at every hour. Example: tighter `[68, 74]` band from 8 AM through 9 PM, wider `[64, 78]` band overnight. ```json { "hourly_high_temps_f": [78, 78, 78, 78, 78, 78, 78, 78, 74, 74, 74, 74, 74, 74, 74, 74, 74, 74, 74, 74, 74, 74, 78, 78], "hourly_low_temps_f": [64, 64, 64, 64, 64, 64, 64, 64, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 64, 64] } ``` --- ## PUT /api/v1/preferences **Auth:** Required (Bearer JWT) Update one or more preference fields. **All fields are optional** — only the fields you include are changed; others keep their current values. To leave a field unchanged, omit it; send `null` only for the fields the table below marks as clearable. Any change other than `newsletter_opt_in` alone triggers a debounced recompute of today's schedule. ### Example ```bash curl -X PUT https://api.hungrymachines.io/api/v1/preferences \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "base_temperature": 73.0, "savings_level": 2, "optimization_mode": "auto" }' ``` ### Request body All fields are optional — include only what you want to change. ```json { "base_temperature": 73.0, "savings_level": 2, "time_away": "07:30", "time_home": "18:00", "optimization_mode": "auto" } ``` ### Validation | Field | Constraints | |---|---| | `base_temperature` | 60.0–85.0 °F | | `savings_level` | 1, 2, or 3 | | `time_away` | HH:MM, 24-hour, `00:00`–`23:59` | | `time_home` | HH:MM, 24-hour, `00:00`–`23:59` | | `optimization_mode` | `"cool"`, `"heat"`, `"auto"`, `"off"` | | `hourly_high_temps_f` | Exactly 24 finite floats, each in `[50, 90]` °F | | `hourly_low_temps_f` | Exactly 24 finite floats, each in `[50, 90]` °F | | `hourly_low_temps_f[i]` < `hourly_high_temps_f[i]` | Per-hour lower bound must be strictly less than upper bound | | `hourly_high_temps_f` + `hourly_low_temps_f` | Both fields must be set together (or both `null` to clear) | | `optimize_hvac_fan` | `true` / `false` | | `optimize_hvac_mode` | `true` / `false` | | `optimization_enabled` | `true` / `false` | | `newsletter_opt_in` | `true` / `false` | | `spike_guard_enabled` | `true` / `false` | | `max_concurrent_appliances` | Integer >= 1, or `null` to clear | | `power_cap_kw` | Float > 0, or `null` to clear | | `base_load_reserve_kw` | Float >= 0 | | `demand_charge_usd_per_kw` | Float >= 0, or `null` to clear | | `demand_window_start` | HH:MM, 24-hour, `00:00`–`23:59`, or `null` | | `demand_window_end` | HH:MM, 24-hour, `00:00`–`23:59`, or `null` | ### Response (200) Returns the full preferences object after the update — every field shown under GET, not only the ones you sent. Abbreviated here: ```json { "base_temperature": 73.0, "savings_level": 2, "time_away": "07:30", "time_home": "18:00", "optimization_mode": "auto", "...": "the remaining GET fields" } ``` ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"hourly_high_temps_f and hourly_low_temps_f must be set together or both cleared"` | Only one of the two arrays was provided as a non-null value | | 400 | `"hourly_high_temps_f and hourly_low_temps_f must each have exactly 24 values"` | Wrong array length | | 400 | `"hourly bands at hour must be between 50 and 90 F"` | Value out of range at hour `` | | 400 | `"hourly bands at hour must be finite numbers between 50 and 90 F"` | Non-finite value at hour `` | | 400 | `"hourly low must be less than high at hour "` | `hourly_low_temps_f[i] >= hourly_high_temps_f[i]` | | 401 | `"Not authenticated"` | Missing or invalid token | | 422 | array `detail` | Any other field out of range or of the wrong type. The entry's `msg` names the rule, for example `"Value error, base_temperature must be between 60 and 85F"`. See [Errors, Limits & Stability](/api-reference/errors-and-limits/#error-shape). | | 503 | `"Database unavailable"` | Database not reachable | --- ## GET /api/v1/preferences/default-bands Preview the default hourly comfort band — the band that applies when no hourly arrays are saved — without saving anything. Use this to build a "Reset to defaults" control that shows what will actually apply, or to render the default band before a user has customized it. The result depends only on `base_temperature` and `optimization_mode`. **Auth:** Required (Bearer JWT) ### Example ```bash curl "https://api.hungrymachines.io/api/v1/preferences/default-bands?base_temperature=72&optimization_mode=cool" \ -H "Authorization: Bearer YOUR_TOKEN" ``` Every query param is optional — an omitted one falls back to your stored `/api/v1/preferences` value. | Param | Type | Validation | Falls back to | |---|---|---|---| | `base_temperature` | float | 60.0–85.0 °F | your stored `base_temperature` | | `optimization_mode` | string | `"cool"`, `"heat"`, or `"auto"` | your stored `optimization_mode` | | `savings_level` | integer | 1, 2, or 3 | Accepted for compatibility; does not change the result | | `time_away` | string | HH:MM | Accepted for compatibility; does not change the result | | `time_home` | string | HH:MM | Accepted for compatibility; does not change the result | `optimization_mode="auto"` resolves to the cool-shaped band for this preview. `"off"` is not accepted here. ### Response (200) ```json { "hourly_high_temps_f": [73.0, 73.0, "...(24 values)"], "hourly_low_temps_f": [70.0, 70.0, "...(24 values)"], "peak_hours": [13, 21], "precool_hours": [9, 13] } ``` `hourly_high_temps_f`/`hourly_low_temps_f` are 24-element arrays, hour `0` = midnight local time — the same shape you'd submit as an hourly override above. `peak_hours`/`precool_hours` are fixed `[start, end)` reference windows in local hours, returned so you can shade the usual expensive hours and the hours before them in a UI without hardcoding them. They do not vary with the query params. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"base_temperature must be between 60 and 85F"` | `base_temperature` out of range | | 400 | `"savings_level must be 1, 2, or 3"` | `savings_level` out of range | | 400 | `"Time must be in HH:MM format"` | `time_away` or `time_home` malformed | | 400 | `"optimization_mode must be cool, heat, or auto"` | Unknown mode, or `"off"` | | 401 | `"Not authenticated"` | Missing or invalid token | | 422 | array `detail` | A query value of the wrong type, for example a non-numeric `base_temperature` | Canonical page: https://docs.hungrymachines.io/api-reference/preferences/ --- https://docs.hungrymachines.io/api-reference/rates/ # Rates Fetch the electricity rate curve and choose a pricing source (zone, custom, dynamic, or tariff) via /api/v1/rates. The rates endpoints expose the electricity rate curve the optimizer saves against, plus the metadata to render and edit the user's pricing selection. Use them to color a schedule timeline by peak / off-peak pricing, or to let the user pick how their electricity is priced. All values are in **cents per kWh**. Clients displaying dollars divide by 100; clients submitting dollars multiply by 100 first. For a tariff or a dynamic zone outside the US, read the same numbers as that plan's own currency in minor units per kWh (pence, euro cents, and so on); nothing is converted. The response's `currency` field names which ISO 4217 currency that is. The rate curve has the same **48 × 30-minute** shape as schedules — each interval is a half-hour slot of the local day, indexed `0` (00:00–00:30) through `47` (23:30–00:00). ## Pricing sources A user's rates come from one of four sources, chosen via `PUT /api/v1/rates`: | Source | What it is | |---|---| | `zone` | A preset time-of-use plan from the API-served `available_pricing_zones` catalog (real residential utility plans). The default. | | `custom` | The user's own 24-hour `hourly_rates_cents_per_kwh` array, expanded to 48 half-hour slots. | | `dynamic` | Day-ahead pricing that changes daily, for the user's chosen `dynamic_zone`. Zones priced from a wholesale market add a flat per-kWh amount (`pricing_adder_cents_per_kwh`) for delivery and tax riders. The user can also select a delivery/distribution plan (`adder_grid_ruleset_id`, options in `available_delivery_tariffs`) — e.g. ComEd Hourly Pricing users on ComEd's Delivery Time-of-Day plan — and can enter their own four period prices (`delivery_tod_cents`) to match a real bill. When `delivery_tod_cents` is set it is used; otherwise the selected plan's published rates are used. When day-ahead prices are not available, the response carries the zone curve and reports `source: "zone"` while `pricing_source` still reads `"dynamic"`. | | `tariff` | A plan from the tariff library, linked via `tariff_ruleset_id` (browse with `GET /api/v1/rates/tariffs`). Rates are in the tariff's own currency (minor units per kWh — USD/CAD cents, GBP pence, EUR cents); the dynamic adder does not apply. When the linked tariff is missing or no longer in force, the response carries the zone curve and reports `source: "zone"` while `pricing_source` still reads `"tariff"`. | --- ## GET /api/v1/rates **Auth:** Required (Bearer JWT) ### Example ```bash curl https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) — zone ```json { "pricing_location": 1, "intervals": [0, 1, 2, "...", 47], "rates_cents_per_kwh": [24.7, 24.7, "... 48 values total ..."], "source": "zone", "season": "summer", "hourly_rates_cents_per_kwh": null, "pricing_source": "zone", "dynamic_zone": null, "pricing_adder_cents_per_kwh": null, "tariff_ruleset_id": null, "available_dynamic_zones": [ { "slug": "comed", "label": "ComEd — Hourly Pricing (Northern Illinois)" }, { "slug": "ameren", "label": "Ameren Illinois (Power Smart Pricing)" }, "... more entries (UK, EU/Nordic, Spain, Australia and California plans) ..." ], "available_pricing_zones": [ { "id": 1, "slug": "sdge-tou-dr1", "utility": "SDG&E", "plan": "TOU-DR1", "region": "San Diego, CA", "label": "SDG&E TOU-DR1 — San Diego", "notes": "peak 4-9pm" }, "... more entries ..." ], "adder_grid_ruleset_id": null, "available_delivery_tariffs": [ { "id": 12, "external_id": "comed-dtod-sf", "plan_name": "Delivery Time-of-Day — Single-Family", "utility": "ComEd", "region": "Northern Illinois", "period_rates": { "morning": 4.009, "midday_peak": 10.712, "evening": 3.747, "overnight": 2.984 } }, "... more entries ..." ], "delivery_tod_cents": null, "unit": "cents/kWh", "export_rates_cents_per_kwh": null, "currency": "USD" } ``` The `custom`, `dynamic`, and `tariff` responses use the **same shape**; only `source` and the source-specific fields change. A response can carry keys beyond those documented here; ignore them. - **custom** — `source: "custom"`, `hourly_rates_cents_per_kwh` holds the raw 24-element override. - **dynamic** — `source: "dynamic"`, `dynamic_zone` is the chosen slug, `pricing_adder_cents_per_kwh` is the per-user adder, `adder_grid_ruleset_id` is the selected delivery/distribution plan (if any), `delivery_tod_cents` is the user's own four period prices if set (used in place of the selected plan's published rates). - **tariff** — `source: "tariff"`, `tariff_ruleset_id` is the linked tariff-library id; `rates_cents_per_kwh` is in the tariff's currency (minor units per kWh). ### Response fields | Field | Type | Description | |---|---|---| | `pricing_location` | integer | Catalog id from `available_pricing_zones`; defaults to `1` when unset | | `intervals` | array[int] | Indices `0`–`47` (30-minute slots of the local day) | | `rates_cents_per_kwh` | array[float] | Exactly 48 values for the source reported in `source` (cents/kWh, or the plan currency's minor units for a tariff or a non-US dynamic zone). Display only. | | `source` | string | Which pricing source this response's curve came from: `"zone"`, `"custom"`, `"dynamic"`, or `"tariff"`. Differs from `pricing_source` when dynamic or tariff pricing was unavailable and the zone curve was returned instead. | | `season` | string | `"summer"` or `"winter"`, from the user's local date. Only some plans differ by season; others report the season but return the same curve. | | `hourly_rates_cents_per_kwh` | array[float] \| null | The user's raw 24-element override, whenever one is stored — regardless of the active `source`. `null` when none is stored. | | `pricing_source` | string | The user's stored preference: `"zone"`, `"custom"`, `"dynamic"`, or `"tariff"`. Reflect this in your UI toggle. | | `dynamic_zone` | string \| null | The user's chosen dynamic-pricing slug. `null` when none selected. | | `pricing_adder_cents_per_kwh` | number \| null | Per-user flat adder for dynamic pricing. `null` = the server default for the zone. | | `tariff_ruleset_id` | integer \| null | The user's linked tariff-library id (browse via `GET /api/v1/rates/tariffs`). `null` when no tariff linked. | | `available_dynamic_zones` | `array[{slug, label}]` | Catalog for the dynamic source. Use `slug` when sending `dynamic_zone`; render `label` — it names the plan or market (e.g. `"SCE TOU-D-4-9PM (California)"`, `"Netherlands day-ahead"`). | | `available_pricing_zones` | `array[{id, slug, utility, plan, region, label, notes}]` | Catalog of preset plans. Use `id` when sending `pricing_location`; render `label`. | | `adder_grid_ruleset_id` | integer \| null | The user's selected delivery/distribution plan (browse via `available_delivery_tariffs`). Only meaningful alongside `pricing_source="dynamic"`. `null` = no delivery plan selected. When `delivery_tod_cents` is set, those prices are used and this field records which plan they started from. | | `available_delivery_tariffs` | `array[{id, external_id, plan_name, utility, region, period_rates}]` | Delivery/distribution plans available for `adder_grid_ruleset_id`. `period_rates` is that plan's published `{morning, midday_peak, evening, overnight}` rates — use it to prefill `delivery_tod_cents` when the user selects this plan; `null` if it can't be derived. Empty when none apply to your utility. | | `delivery_tod_cents` | object \| null | The user's own four period prices — `{morning, midday_peak, evening, overnight}`, cents/kWh. `null` when unset. When set, these prices are used in place of the selected plan's published rates. | | `unit` | string | Always `"cents/kWh"`. For a tariff or a non-US dynamic zone, read it as that plan's currency minor units per kWh — see `currency` below for which one. | | `export_rates_cents_per_kwh` | array[float] \| null | What your utility pays for exported solar — 48 half-hour slots (slot 0 = local midnight), same units as `rates_cents_per_kwh`. `null` = no export credit configured. Settable via `PUT`. | | `currency` | string | ISO 4217 code of the curve actually served: `"USD"` for `zone`/`custom` sources and a USD dynamic zone, the plan's own code for a tariff, the zone's own code for a non-US dynamic zone (e.g. `"GBP"`, `"EUR"`, `"AUD"`). | ### Errors | Status | Detail | Cause | |---|---|---| | 401 | `"Not authenticated"` | Missing or invalid token | --- ## GET /api/v1/rates/tariffs Browse the tariff library to find a `tariff_ruleset_id` to select on `PUT /api/v1/rates`. Returns plan metadata only — the rate values appear in `GET /api/v1/rates` once a tariff is linked. **Auth:** Required (Bearer JWT) ### Query parameters All parameters are optional — with none, the full library is returned page by page. | Param | Type | Required | Notes | |---|---|---|---| | `country` | string | No | Exact country code, case-insensitive (`US`, `CA`). | | `region` | string | No | Case-insensitive substring match on `region`. | | `utility` | string | No | Exact utility name, case-insensitive. | | `q` | string | No | Free-text search over `utility`, `plan_name`, and `region` (case-insensitive substring). | | `limit` | integer | No | Page size, 1–500 (default 50). | | `offset` | integer | No | Rows to skip (default 0). | ### Example ```bash curl "https://api.hungrymachines.io/api/v1/rates/tariffs?country=US&q=tou" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) ```json { "total": 2, "limit": 50, "offset": 0, "country": "US", "region": null, "utility": null, "q": "tou", "tariffs": [ { "id": 7, "utility": "SCE", "plan_name": "TOU-D-4-9PM", "region": "Southern California", "country": "US", "currency": "USD", "verified": false, "effective_from": "2026-01-01", "disclaimer": "Community-sourced rate — verify against your utility bill" }, "... more entries ..." ] } ``` ### Response fields | Field | Type | Description | |---|---|---| | `total` | integer | Full match count **before** pagination — drive "page X of Y" from this, not `tariffs.length` | | `limit` / `offset` | integer | Echo of the applied pagination | | `country` / `region` / `utility` / `q` | string \| null | Echo of the applied filters | | `tariffs[].id` | integer | Send as `tariff_ruleset_id` on `PUT /api/v1/rates` | | `tariffs[].currency` | string | The tariff's currency (`USD`, `CAD`, `GBP`, `EUR`). Its rates are in this currency's minor units per kWh. | | `tariffs[].verified` | boolean | `true` once the tariff has been checked against the utility's filed rate sheet | | `tariffs[].disclaimer` | string | Present **only** on unverified tariffs: `"Community-sourced rate — verify against your utility bill"`. Render it beside the plan; verified tariffs omit the key. | | `tariffs[].effective_from` | string \| null | First day the tariff is in force (`YYYY-MM-DD`); `null` = open start | ### Errors | Status | Detail | Cause | |---|---|---| | 401 | `"Not authenticated"` | Missing or invalid token | | 422 | array `detail` | `limit` / `offset` out of range or not an integer | | 503 | `"Database unavailable"` or `"Failed to load tariff store"` | The tariff library could not be read | --- ## PUT /api/v1/rates Set or clear any subset of the user's pricing configuration. **Only fields you explicitly send are updated** — omitted fields are left unchanged. A request with none of the fields below changes nothing and returns the current state. **Auth:** Required (Bearer JWT) ### Required vs. optional All fields are optional. What you send depends on the source you want: - **Custom rates** — send `pricing_source: "custom"` together with `hourly_rates_cents_per_kwh` (24 values), unless one is already stored. Send `{"hourly_rates_cents_per_kwh": null}` to clear the stored array. - **Dynamic** — send `pricing_source: "dynamic"` plus a `dynamic_zone` (required here or already stored). `pricing_adder_cents_per_kwh` is optional. - **Preset zone** — send `pricing_source: "zone"` plus a `pricing_location` id. - **Tariff** — send `pricing_source: "tariff"` plus a `tariff_ruleset_id` (required here or already stored) from `GET /api/v1/rates/tariffs`. Sending `tariff_ruleset_id` alone links the tariff without changing the source; `null` clears the link. - **Delivery/distribution plan** (dynamic only) — send `adder_grid_ruleset_id` with an id from `available_delivery_tariffs`, e.g. a ComEd Delivery Time-of-Day class. Applies alongside `pricing_source="dynamic"`; `null` clears the selection. - **Your own DTOD period prices** (dynamic only) — send `delivery_tod_cents` with all four keys `morning`/`midday_peak`/`evening`/`overnight`, prefilled from `available_delivery_tariffs[].period_rates` and edited to match your bill. When set, these prices are used; send `adder_grid_ruleset_id` with them to record which plan the values started from. `null` clears it. - **Export rates** (solar) — send `export_rates_cents_per_kwh` (48 values) with what your utility pays per half-hour slot for power you send back. Independent of every other pricing field. `null` clears it (no export credit). ### Example — custom hourly rates ```bash curl -X PUT https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"pricing_source": "custom", "hourly_rates_cents_per_kwh": [22.0, 22.0, 22.0, 22.0, 22.0, 22.0, 28.0, 28.0, 28.0, 28.0, 28.0, 28.0, 28.0, 28.0, 28.0, 28.0, 45.0, 45.0, 45.0, 45.0, 45.0, 28.0, 28.0, 22.0]}' ``` Send the source and the array together. Clear the stored array with `{"hourly_rates_cents_per_kwh": null}`. ### Example — opt into dynamic pricing ```bash curl -X PUT https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"pricing_source": "dynamic", "dynamic_zone": "comed", "pricing_adder_cents_per_kwh": 6.5}' ``` ### Example — pick a preset zone ```bash curl -X PUT https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"pricing_source": "zone", "pricing_location": 7}' ``` ### Example — select a tariff from the library ```bash curl -X PUT https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"pricing_source": "tariff", "tariff_ruleset_id": 7}' ``` ### Example — select a delivery/distribution plan ```bash curl -X PUT https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"adder_grid_ruleset_id": 12}' ``` Send `{"adder_grid_ruleset_id": null}` to clear the selection. ### Example — edit the four DTOD period prices ```bash curl -X PUT https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"adder_grid_ruleset_id": 12, "delivery_tod_cents": {"morning": 4.5, "midday_peak": 11.0, "evening": 3.8, "overnight": 3.0}}' ``` When `delivery_tod_cents` is set, those prices are used; sending `adder_grid_ruleset_id` with it records which plan the values started from. It can also be sent alone; `{"delivery_tod_cents": null}` clears it. ### Example — set export rates ```bash curl -X PUT https://api.hungrymachines.io/api/v1/rates \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"export_rates_cents_per_kwh": [3.0, 3.0, "... 48 values total ..."]}' ``` Clear it with `{"export_rates_cents_per_kwh": null}`. ### Fields | Field | Type | Notes | |---|---|---| | `hourly_rates_cents_per_kwh` | array[float] \| null | Exactly 24 numbers (`00:00` → `23:00`), each in `[0, 200]`. Send it with `pricing_source: "custom"`. `null` clears the stored array. | | `pricing_source` | string \| null | `"zone"`, `"custom"`, `"dynamic"`, or `"tariff"` (case-insensitive). `null` clears the explicit selection. Required (here or already stored) when `"custom"`: send `hourly_rates_cents_per_kwh` too unless one is already stored. | | `dynamic_zone` | string \| null | Slug from `available_dynamic_zones` (case-insensitive). Required (here or already stored) when `pricing_source="dynamic"`. | | `pricing_adder_cents_per_kwh` | number \| null | Flat per-kWh adder for dynamic pricing, in `[0, 50]`. `null` clears the per-user override. | | `pricing_location` | integer | Catalog id from `available_pricing_zones`. Has no cleared state: `null` is rejected. | | `tariff_ruleset_id` | integer \| null | Id from `GET /api/v1/rates/tariffs`. Must reference a tariff that exists and is currently in force. `null` clears the link. Required (here or already stored) when `pricing_source="tariff"`. | | `adder_grid_ruleset_id` | integer \| null | Id from `available_delivery_tariffs`. Must reference an available delivery/distribution plan. `null` clears the selection. | | `delivery_tod_cents` | object \| null | Exactly the four keys `morning`/`midday_peak`/`evening`/`overnight`, each a number in `[0, 50]` cents/kWh. `null` clears it. Settable in the same request as `adder_grid_ruleset_id`. | | `export_rates_cents_per_kwh` | array[float] \| null | Exactly 48 floats (slot 0 = local midnight), each in `[0, 200]`. `null` clears it (no export credit). | ### Response (200) Same shape as `GET /api/v1/rates`, reflecting the updated state. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"hourly_rates_cents_per_kwh must have exactly 24 values"` | Array length is not 24 | | 400 | `"rate at hour must be between 0 and 200 cents/kWh"` | A value outside `[0, 200]` | | 400 | `"pricing_source must be one of ['zone', 'custom', 'dynamic', 'tariff']"` | Unknown source | | 400 | `"pricing_source custom requires hourly_rates_cents_per_kwh"` | `custom` selected with no override sent (or explicitly nulled) and none already stored | | 400 | `"pricing_source='dynamic' requires dynamic_zone (one of: [...])"` | Dynamic selected without a resolvable zone | | 400 | `"dynamic_zone must be one of: [...]"` | Unknown `dynamic_zone` slug | | 400 | `"pricing_adder_cents_per_kwh must be a number between 0 and 50 cents/kWh"` | Adder outside `[0, 50]` | | 400 | `"pricing_location must be one of the catalog ids: [...]"` | `pricing_location` not in `available_pricing_zones`, or sent as `null` | | 400 | `"tariff_ruleset_id must be a positive integer id"` | Non-positive id sent | | 400 | `"tariff_ruleset_id does not exist (browse GET /api/v1/rates/tariffs)"` | Id matches no tariff | | 400 | `"tariff_ruleset_id is not currently in force"` | Tariff's effective window excludes today | | 400 | `"pricing_source='tariff' requires tariff_ruleset_id (browse GET /api/v1/rates/tariffs)"` | Tariff selected with no id sent or stored | | 400 | `"adder_grid_ruleset_id does not reference an available delivery tariff (see available_delivery_tariffs on GET /api/v1/rates)"` | Id doesn't match a delivery/distribution plan | | 400 | `"delivery_tod_cents must be an object with exactly the keys [...]"` | Payload isn't an object | | 400 | `"delivery_tod_cents must have exactly the keys [...] (missing [...]; unexpected [...])"` | A key is missing or an unexpected key is present | | 400 | `"delivery_tod_cents. must be a number between 0 and 50 cents/kWh"` | A period value is a bool, non-numeric, non-finite, or out of range | | 400 | `"export_rates_cents_per_kwh must have exactly 48 values"` | Array length is not 48 | | 400 | `"export rate at slot must be between 0 and 200 cents/kWh"` | Value is a bool, non-numeric, non-finite, or out of range | | 422 | array `detail` | A field of the wrong JSON type (`hourly_rates_cents_per_kwh` not an array of numbers, a non-numeric adder, or a non-integer id) | | 401 | `"Not authenticated"` | Missing or invalid token | | 503 | `"Database unavailable"`, `"Failed to update rates configuration"`, `"Failed to validate tariff_ruleset_id"` or `"Failed to validate adder_grid_ruleset_id"` | The database could not be reached or the write failed | Canonical page: https://docs.hungrymachines.io/api-reference/rates/ --- https://docs.hungrymachines.io/api-reference/readings/ # Readings Push HVAC sensor data to Hungry Machines via POST /api/v1/readings. The readings endpoint accepts HVAC sensor data from your thermostat or Home Assistant integration. Push a reading every 5 minutes (or batch several) so Hungry Machines can learn how your home responds and produce accurate schedules. ## POST /api/v1/readings **Auth:** Required — a user access token. ### Required vs. optional Only **three** fields are required on each reading: `timestamp`, `indoor_temp`, and `hvac_state`. Everything else is **optional** — but the more you send, the more accurate your schedules become. Send whatever your device exposes; there's no penalty for omitting a field you don't have. > **Tip** > > There's one exception worth calling out: once an account has **more than one** reading-producing appliance (a second HVAC, or a dehumidifier), tag every reading with `appliance_id`. The API accepts a reading without it, but it can't attribute that reading to one unit. A single-HVAC account can omit it. ### Request body ```json { "readings": [ { "timestamp": "2025-11-18T14:30:00+00:00", "indoor_temp": 72.5, "hvac_state": "COOL", "indoor_humidity": 45.0, "outdoor_temp": 85.0, "target_temp": 72.0, "fan_mode": "auto", "power_watts": 298.0, "hvac_action": "cooling", "commanded_hvac_mode": "COOL", "commanded_fan_mode": "low", "commanded_setpoint": 71.0, "indoor_source": "sensor", "override_active": false, "room_target": 73.0, "appliance_id": "11111111-1111-1111-1111-111111111111" } ], "client": { "kind": "hacs", "version": "3.7.0", "node_bundle": null, "ha_version": "2026.8.1" } } ``` ### Fields | Field | Type | Required | Constraints / notes | |---|---|---|---| | `readings` | array | **Yes** | 1–100 reading objects (below) | | `client` | object | No | Identifies the client software. **A third-party client should send `kind: "custom"`**, optionally with `name` — see [Client identity](#client-identity). | #### Per-reading fields | Field | Type | Required | Constraints / notes | |---|---|---|---| | `timestamp` | string (ISO 8601) | **Yes** | UTC recommended | | `indoor_temp` | float | **Yes** | 30.0–120.0 °F | | `hvac_state` | string | **Yes** | `"HEAT"`, `"COOL"`, `"ECO"`, `"DRY"`, `"OFF"`, or `"FAN"`. Case-insensitive. Common aliases (`"cooling"`, `"heating"`, `"eco_cool"`, `"dehumidify"`, `"fan_only"`, `"idle"`) are normalized server-side. `"auto"` and `"heat_cool"` say what the unit may do, not what it is doing, and are recorded as `"OFF"`. Any other value is rejected with `422`. `"ECO"` and `"DRY"` are tracked separately from `"COOL"`. | | `indoor_humidity` | float | No | Percent (0–100) | | `outdoor_temp` | float | No | Fahrenheit | | `target_temp` | float | No | Current thermostat setpoint | | `fan_mode` | string | No | Fan-speed label, e.g. `"auto"`, `"low"`, `"high"`. Stored as sent. | | `power_watts` | float | No | Current power draw. **Send only if the meter measures this specific unit** — a shared/whole-home meter can mislead the model. | | `hvac_action` | string | No | The climate entity's `hvac_action` attribute (`"cooling"`, `"heating"`, `"idle"`, `"off"`, …) — whether the unit is *actively* conditioning right now, as opposed to its standing mode. Send when available; improves accuracy. | | `commanded_hvac_mode` | string | No | The mode your integration last commanded (`"COOL"`/`"HEAT"`/`"OFF"`). Send when available; improves accuracy. | | `commanded_fan_mode` | string | No | The fan label your integration last commanded. | | `commanded_setpoint` | float | No | The setpoint (°F) your integration last commanded. | | `indoor_source` | string | No | Which source produced `indoor_temp`: `"sensor"` if you read a separate room-temperature sensor, `"entity"` if you used the thermostat's own reading. Any other value is rejected with `422`. Send it when you know. | | `override_active` | boolean | No | `true` if your client's own local override, rather than the schedule, was commanding the unit when you took the reading. Leave it out if your client has no such override: omitted means "not reported", which is recorded differently from `false`. | | `room_target` | float | No | The room temperature the schedule asked for in that slot (`schedule.room_target_temps[slot]`, returned by [`GET /api/v1/schedules`](/api-reference/appliances/#get-apiv1schedules)), as opposed to `target_temp`, the setpoint the unit was carrying. Send it only if your client steers the unit toward `room_target_temps`; otherwise leave it out. | | `appliance_id` | string (UUID) | No | Tags the reading with a specific HVAC (or dehumidifier) unit. A reading without it is accepted but can't be attributed to one unit, so **send it on every reading once the account has more than one reading-producing appliance**. When present it must be an HVAC or dehumidifier appliance the caller owns, else `400`. | ### Client identity The batch-level `client` object names the client software. It never affects the schedules you get back, and leaving the whole block out changes nothing else. | Field | Type | Required | Constraints / notes | |---|---|---|---| | `kind` | string | **Yes** (within `client`) | `"hacs"` for a self-install integration, `"node"` for a managed fleet node, `"custom"` for any third-party client. Anything else is rejected with `422`. | | `version` | string | **Yes** (within `client`) | Your client's version string, 1–40 characters. | | `node_bundle` | string | No | The node bundle tag, if you run one. `null` otherwise. | | `ha_version` | string | No | Your Home Assistant version, if you have one. | | `name` | string | No | A label for your client, 1–40 characters (letters, digits, space, `.`, `_`, `-`). Send it with `kind: "custom"` to say what your client is. | **Building your own client? Send `{"kind": "custom", "name": ""}`.** Before this field existed, a third-party client had no honest value for `kind` and was told to omit the block entirely; `"custom"` is now that honest value. ### Example (minimum viable reading) ```bash curl -X POST https://api.hungrymachines.io/api/v1/readings \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "readings": [ { "timestamp": "2025-11-18T14:30:00+00:00", "indoor_temp": 72.5, "hvac_state": "COOL" } ] }' ``` ### Response (201) ```json { "accepted": 1 } ``` The `accepted` count reflects how many readings were stored. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"appliance_id is not a hvac or dehumidifier appliance owned by this user"` | `appliance_id` doesn't reference an HVAC or dehumidifier unit the caller owns | | 401 | `"Not authenticated"` | No `Authorization` header | | 401 | `"Invalid credentials"` | The token is not valid. An expired token gets this string on this route, so refresh it and retry. | | 422 | Array `detail` (see [Errors & limits](/api-reference/errors-and-limits/)) | A field failed validation: `indoor_temp` outside 30–120, an unrecognised `hvac_state`, zero or more than 100 readings, a non-string label, an `indoor_source` other than `"sensor"` / `"entity"`, a `client.kind` that isn't `"hacs"`, `"node"` or `"custom"`, a `client.version` that is empty or over 40 characters, or a `client.name` that's empty, over 40 characters, or has a character other than letters/digits/space/`.`/`_`/`-`. The whole batch is rejected. | | 429 | `"Rate limit exceeded: max readings per user per day (scaled to active reading-producing appliance(s))"` | Over the daily reading ceiling (see below) | | 503 | `"Database unavailable"` | Temporary; retry | ### Rate limit The ceiling is **600 readings per active reading-producing appliance (HVAC or dehumidifier), per account, per UTC day**. An account with no such appliance registered yet gets 600. One unit reporting every 5 minutes sends 288 a day, so the limit only trips on a stuck loop or a very large catch-up batch. > **Tip** > > You can batch up to **100 readings** in a single POST. Useful for catching up after a network outage: buffer readings locally and send them when connectivity returns. Prefer a steady cadence over large bursts. ### How readings are used Hungry Machines uses your readings to learn how your home heats and cools and to generate the optimized schedules you pull from [`/api/v1/schedule`](/api-reference/schedule/). Schedules improve as readings accumulate, so keep pushing consistently; gaps slow that down, because there's less to learn from. A stored schedule does not wait for readings: it appears with the first planning run after the appliance is registered (see [Schedule](/api-reference/schedule/#defaults-no-stored-plan-yet)). Canonical page: https://docs.hungrymachines.io/api-reference/readings/ --- https://docs.hungrymachines.io/api-reference/schedule/ # Schedule Retrieve and recompute optimized HVAC schedules via /api/v1/schedule. The schedule endpoint returns today's temperature plan for one HVAC unit: 48 half-hour intervals in a small fixed shape. It suits a client that drives a single thermostat by setpoint. For multi-appliance schedules (HVAC + EV charger + battery + water heater in one call), and for the per-slot mode and fan schedules, use [`/api/v1/schedules`](/api-reference/appliances/#get-apiv1schedules). This route never returns `hvac_mode_schedule`, `fan_mode_schedule` or `room_target_temps`. A plan for today can be replaced during the day, after a settings change and when the API re-plans the rest of the day. Poll [`GET /api/v1/schedules/updated-at`](/api-reference/appliances/#get-apiv1schedulesupdated-at) and fetch again when its value changes. ## GET /api/v1/schedule **Auth:** Required (Bearer JWT) ### Query parameters | Param | Required | Notes | |---|---|---| | `appliance_id` | Conditional | UUID of the HVAC unit whose schedule to return. **Required (returns `400`) when the account has 2+ HVAC units** — otherwise the endpoint can't tell which one you mean. Single-HVAC accounts may omit it; it resolves to their only HVAC. An id that is not one of the caller's own HVAC units returns the defaults payload below, not an error. | ### Example ```bash curl "https://api.hungrymachines.io/api/v1/schedule?appliance_id=a1b2c3d4-..." \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) — stored plan ```json { "date": "2025-11-18", "schedule": { "intervals": [0, 1, 2, "...", 47], "high_temps": [74.0, 74.0, "...(48 values)"], "low_temps": [70.0, 70.0, "...(48 values)"], "setpoint_temps": [72.0, 71.5, "...(48 values)"], "temp_trajectory": [72.1, 71.6, "...(48 values)"] }, "mode": "cool", "estimated_savings_pct": 18.5, "model_confidence": null, "generated_at": "2025-11-18T04:15:00+00:00", "stale": false, "source": "optimization" } ``` ### Response fields Every key below is present in every response. A value that does not apply is `null`, never missing. | Field | Type | Description | |---|---|---| | `date` | string | The date of the plan returned (YYYY-MM-DD); today's date on the defaults path | | `schedule.intervals` | int[48] | Always `[0, 1, ..., 47]` — 48 half-hour slots | | `schedule.high_temps` | float[48] | Per-interval upper comfort bound (°F). **Display-only** — the thermostat never sees this value. | | `schedule.low_temps` | float[48] | Per-interval lower comfort bound (°F). **Display-only.** | | `schedule.setpoint_temps` | float[48] or null | **The 48-element command to apply to the thermostat** at each 30-min boundary, regardless of HVAC mode. `null` when `source` is `"defaults"` (and on plans stored before the field existed) — fall back to `(high_temps[i] + low_temps[i]) / 2`. | | `schedule.temp_trajectory` | float[48] or null | Predicted indoor-temperature line for charting. **Display-only.** `null` when `source` is `"defaults"`, and on a warm-up day. | | `mode` | string | `"cool"` or `"heat"` on a stored plan. On the defaults path it echoes the account's `optimization_mode` preference, so it can also be `"auto"` or `"off"`. | | `estimated_savings_pct` | float | **Display-only** estimated % savings vs. a thermostat held at the middle of your comfort band. `0.0` on the defaults path. | | `model_confidence` | float or null | **Display-only**, reserved. Currently always `null`; don't build on it. | | `generated_at` | string | When the plan was produced (UTC ISO 8601); the time of the request on the defaults path | | `stale` | boolean | `false` when the plan returned is for today, and always `false` on the defaults path. `true` when there is no plan for today and the most recent stored plan is returned instead. | | `source` | string | `"optimization"` (a stored plan) or `"defaults"` (no stored plan yet) | > **Caution** > > **Apply `setpoint_temps`, not `high_temps`/`low_temps`.** The plan carries a per-interval target temperature in `setpoint_temps`. The high/low arrays are display values for charts. When `setpoint_temps` is `null` (`source: "defaults"`), fall back to the midpoint: `(high_temps[i] + low_temps[i]) / 2`. On this route the key is always present, with `null` when it does not apply. > **Caution** > > **Mode and fan schedules are not on this route.** `hvac_mode_schedule`, `fan_mode_schedule` and `room_target_temps` come only from [`GET /api/v1/schedules`](/api-reference/appliances/#get-apiv1schedules) and the per-appliance schedule route. A client for a window AC, or for any unit with mode or fan control turned on, should read those instead. In `fan_mode_schedule`, `auto` and `off` mean "do not send a fan command": send one only for `low` or `high`, and only when the unit has that fan option. ### The 48-interval shape Every schedule contains exactly **48 intervals**, each a 30-minute window: | Interval | Time | |---|---| | 0 | 00:00–00:30 | | 1 | 00:30–01:00 | | ... | ... | | 16 | 08:00–08:30 | | 34 | 17:00–17:30 | | 47 | 23:30–00:00 | Slot 0 begins at **local midnight** (the account's configured timezone). Compute the current interval as `interval = hour * 2 + (1 if minute >= 30 else 0)` using local time. ### Response variants #### Stale schedule If there is no plan for today, the most recent stored one is returned with `stale: true`, and `date` tells you which day it was made for: ```json { "date": "2025-11-17", "stale": true, "source": "optimization", "...": "..." } ``` #### Defaults (no stored plan yet) Until the first plan is stored for the unit, the response carries a comfort band built from the account's preferences, with `setpoint_temps` and `temp_trajectory` set to `null`: ```json { "date": "2025-11-18", "schedule": { "intervals": [0, 1, "...", 47], "high_temps": [73.5, 73.5, "...(all same)"], "low_temps": [70.5, 70.5, "...(all same)"], "temp_trajectory": null, "setpoint_temps": null }, "mode": "cool", "estimated_savings_pct": 0.0, "model_confidence": null, "generated_at": "2025-11-18T12:00:00+00:00", "stale": false, "source": "defaults" } ``` > **Note** > > `source` is `"defaults"` only until the first plan is stored for the unit. That happens on the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run, whichever comes first. From then on `source` is `"optimization"` and `setpoint_temps` is populated every day. Savings can still read `0.0` on early plans, for example before any readings have arrived. Your client doesn't need to detect any later transition; it applies whatever the current plan says. ### Integration pattern 1. Call `GET /api/v1/schedule` when your client starts and after the overnight run. 2. Cache the response locally. 3. Poll [`GET /api/v1/schedules/updated-at`](/api-reference/appliances/#get-apiv1schedulesupdated-at) every few minutes and repeat step 1 when its value changes. 4. Every 30 minutes, look up the current interval `idx` and apply `setpoint_temps[idx]`. If `setpoint_temps` is `null`, apply the midpoint of `high_temps[idx]` and `low_temps[idx]`. ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `"User has multiple HVAC appliances; pass appliance_id to disambiguate."` | 2+ HVAC units and no `appliance_id` supplied | | 401 | `"Not authenticated"`, `"Invalid token"` or `"Token expired"` | No `Authorization` header, or a bad or expired token | --- ## POST /api/v1/schedule/recompute Recompute schedules for the account now, wait for the result, and return it. Built for "user just hit Save on a constraint editor — show the new chart now" flows. The response has exactly three keys: `date`, `appliances` (the same per-appliance entries as [`GET /api/v1/schedules`](/api-reference/appliances/#get-apiv1schedules)) and a `results` array reporting each active appliance's outcome for this run. No request body. **Auth:** Required (Bearer JWT) ### Example ```bash curl -X POST https://api.hungrymachines.io/api/v1/schedule/recompute \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Response (200) The response does **not** carry the top-level `optimization_enabled`, `integration_health` or `divergence_summary` that `GET /api/v1/schedules` returns. Call that route when you need them. ```json { "date": "2025-11-18", "appliances": [""], "results": [ { "appliance_id": "uuid-1", "appliance_type": "hvac", "name": "Office AC", "status": "ok" }, { "appliance_id": "uuid-2", "appliance_type": "hvac", "name": "Front AC", "status": "failed", "detail": "internal error" } ] } ``` **`results[].status` values:** | Value | Meaning | |---|---| | `ok` | A fresh schedule was stored for this appliance. An `ok` entry may carry extra boolean diagnostic keys; ignore them. | | `failed` | This appliance was not re-planned; `detail` carries a backend-defined reason string (log or display it, don't parse it). Its previous schedule is still in `appliances`. Tell the user it didn't update. | | `calibration` | A warm-up day is in effect for this HVAC; constraint changes won't show on its chart until it completes. | | `forecast` | Solar — nothing to schedule, but a forecast row IS written (generation, self-consumption, surplus, export revenue). | | `observe` | Dehumidifier — data-collection only. | | `skipped` | Unknown appliance type. | `results` is an empty array, and `appliances` holds the previous entries, when the run could not start for the whole account: no profile yet, no active appliances, the account's `optimization_mode` is `"off"`, the weather forecast could not be loaded, or the service is temporarily unable to plan. An appliance that still has no stored plan appears in `appliances` with `"schedule": {}` and `"source": "defaults"`, exactly as on `GET /api/v1/schedules`. ### Latency Usually a few seconds; allow for about 20 seconds per HVAC unit before timing out. Show an "Optimizing…" overlay while the request is in flight. ### When to use it - Right after the user changes constraints, comfort bands, or `optimization_mode` and expects to see the impact immediately. - After saving a custom rate curve via `PUT /api/v1/rates`. For the regular fetch, use `GET /api/v1/schedule` or `GET /api/v1/schedules` — they read the most recent stored plan without triggering a recompute. ### Errors | Status | Detail | Cause | |---|---|---| | 401 | `"Not authenticated"`, `"Invalid token"` or `"Token expired"` | No `Authorization` header, or a bad or expired token | Canonical page: https://docs.hungrymachines.io/api-reference/schedule/ --- https://docs.hungrymachines.io/api-reference/weather/ # Weather Push your Home Assistant weather forecast to Hungry Machines via POST /api/v1/weather so the optimizer plans against your local weather instead of a generic fallback. If your Home Assistant has a `weather.*` entity wired up (most installs do), push the hourly forecast once daily so the optimizer plans against your local weather rather than a generic fallback. The Hungry Machines app does this for you automatically; custom integrations should set up a daily `rest_command`. Each push is stored for the next optimization run and retained as history that later helps fill outdoor-temperature gaps. If no fresh push is available when the overnight run happens (older than 24 hours, or never pushed), a fallback weather source is used for that account. ## POST /api/v1/weather **Auth:** Required (Bearer JWT) ### Required vs. optional Only `forecast.hourly_temps_f` (24–72 values) is required. `forecast.forecast_date` and the humidity, wind, cloud-cover, precipitation and solar-irradiance arrays are optional — send the ones your weather source exposes. ### Example ```bash curl -X POST https://api.hungrymachines.io/api/v1/weather \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "forecast": { "hourly_temps_f": [54.2, 53.8, "...(24-72 values)"], "hourly_humidity": [62, 64, "..."], "hourly_wind_mph": [3.1, 2.8, "..."] } }' ``` ### Request body ```json { "forecast": { "hourly_temps_f": [54.2, 53.8, 53.5, "..."], "hourly_humidity": [62, 64, 65, "..."], "hourly_wind_mph": [3.1, 2.8, 2.5, "..."] } } ``` | Field | Type | Required | Validation | |---|---|---|---| | `forecast.hourly_temps_f` | array[float] | Yes | 24–72 hourly values, each in `[-50, 150]` °F. Element 0 is local midnight (hour 0) of `forecast_date` in the account's timezone; the array walks forward one hour per element. | | `forecast.forecast_date` | string | No | `YYYY-MM-DD`: the local calendar date that element 0 covers. Defaults to today in the account's timezone. Validated as an ISO date; a malformed value is a `400`. | | `forecast.hourly_humidity` | array[float] \| null | No | Same length as `hourly_temps_f`. Each value in `[0, 100]` percent. Omit or send `null` to skip. | | `forecast.hourly_wind_mph` | array[float] \| null | No | Same length as `hourly_temps_f`. Each value in `[0, 200]` mph. Omit or send `null` to skip. | | `forecast.hourly_cloud_coverage_pct` | array[float] \| null | No | Same length as `hourly_temps_f`. Each value in `[0, 100]` percent. Omit or send `null` to skip. | | `forecast.hourly_precipitation_mm` | array[float] \| null | No | Same length as `hourly_temps_f`. Each value in `[0, 300]` mm. Omit or send `null` to skip. | | `forecast.hourly_solar_irradiance_w` | array[float] \| null | No | Same length as `hourly_temps_f`. Each value in `[0, 1500]` W/m². Omit or send `null` to skip. | > **Tip** > > The minimum 24 hours covers the next day's nightly optimization. Pushing 48–72 hours is fine — Home Assistant's `weather.get_forecasts` typically returns 3 days, and the extra hours give the weekly model refit more outdoor-temp data to gap-fill from. ### Response (201) ```json { "accepted_hours": 48, "pushed_at": "2025-11-18T03:30:00+00:00" } ``` | Field | Type | Notes | |---|---|---| | `accepted_hours` | integer | Number of hourly entries stored from `hourly_temps_f` | | `pushed_at` | string | UTC ISO 8601 timestamp when the row was saved | ### Errors | Status | Detail | Cause | |---|---|---| | 400 | `" must contain between 24 and 72 hourly values"` | Array length out of bounds. `` is the field path, e.g. `forecast.hourly_temps_f`. | | 400 | `"[]= is outside the allowed range [, ]"` | Value out of range | | 400 | `"[] is not a finite number"` | Non-finite value | | 400 | `" length must match hourly_temps_f"` | An optional array's length disagrees with `hourly_temps_f` | | 400 | `"forecast.forecast_date must be an ISO date (YYYY-MM-DD)"` | `forecast_date` is not a valid ISO date | | 401 | `"Not authenticated"` | Missing or invalid token | | 422 | array `detail` | `forecast` or `forecast.hourly_temps_f` missing, or an array element that is not a number. See [Errors, Limits & Stability](/api-reference/errors-and-limits/#error-shape). | | 503 | `"Database unavailable"` | Database not reachable | | 503 | `"Failed to store weather forecast"` | DB write failed | ### When to push Push **once per day**, before the account's overnight optimization run. Any push from the last 24 hours counts as fresh, so missing a single day simply falls back to a server-side weather source (or the most recent prior push). A typical Home Assistant `rest_command` + automation: ```yaml rest_command: hm_push_weather: url: "https://api.hungrymachines.io/api/v1/weather" method: POST headers: Authorization: "Bearer !secret hm_access_token" Content-Type: "application/json" payload: > { "forecast": { "hourly_temps_f": {{ state_attr('weather.home', 'forecast') | map(attribute='temperature') | list | tojson }} } } automation: - alias: "HM — push weather forecast at 03:30 UTC" trigger: - platform: time at: "03:30:00" action: - service: rest_command.hm_push_weather ``` Adjust the entity (`weather.home`) and forecast attribute path to match what your weather integration exposes. A typical HA weather integration returns `forecast` as a list of dicts with a `temperature` field; some integrations use `templow`/`temperature` per day instead — verify in Developer Tools → States first. Canonical page: https://docs.hungrymachines.io/api-reference/weather/ --- https://docs.hungrymachines.io/troubleshooting/ # Troubleshooting Common issues and how to resolve them when integrating with the Hungry Machines API. ## 401 with a fresh token **Symptom:** You just signed in, but API calls return `401 Token expired` or `401 Invalid token`. **Causes:** - **Clock skew.** JWT validation checks `exp` (expiration) against the server's clock. If your device's clock is more than a few minutes off, tokens may appear expired. Sync your system clock with NTP. - **Token copied incorrectly.** Make sure you're using the full `access_token` string with no trailing whitespace or line breaks. - **Wrong header format.** The header must be exactly `Authorization: Bearer ` — note the space after `Bearer` and no quotes around the token value. - **Using the wrong token.** `access_token` goes in the `Authorization` header. `refresh_token` is only for exchanging at `POST /auth/refresh`. **Fix:** Verify your system clock is accurate (`date -u` should match UTC within a few seconds). If the clock is fine, refresh your access token (see [Authentication](/getting-started/authentication/#refresh-an-expired-token)) and confirm the `Authorization` header is formatted correctly. --- ## Access token expired **Symptom:** API calls were working, now they return `401 Token expired`. **Cause:** Access tokens last **1 hour**. Your client needs to exchange the refresh token for a new access token before the current one expires. **Fix:** Exchange your refresh token for a new access token: ```bash curl -X POST https://api.hungrymachines.io/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token": "YOUR_REFRESH_TOKEN"}' ``` The response contains a new `access_token` and a new `refresh_token` — save both, then retry your original request with the new access token. If this call itself returns `400 Invalid Refresh Token`, the refresh token is expired (30-day max) — the user needs to log in again. --- ## Readings accepted but no schedule **Symptom:** `POST /api/v1/readings` returns `201 accepted`, but the schedule still reads `source: "defaults"` with zero savings. **Cause:** `source` is `"defaults"` until the first plan is stored for the appliance. Pushing readings does not store one. A plan is stored by a planning run: the first recompute (a settings save, or `POST /api/v1/schedule/recompute`) or the next overnight run. **What to expect:** 1. **Before the first plan:** `GET /api/v1/schedule` returns `source: "defaults"`, a comfort band built from your preferences, and `setpoint_temps: null`. `GET /api/v1/schedules` returns that appliance with `"schedule": {}` and `source: "defaults"`. 2. **From the first plan on:** `source` is `"optimization"` and `setpoint_temps` is a 48-element array, every day. Savings can read `0.0` on early plans, for example before any readings have arrived. 3. **Ongoing:** schedules improve as Hungry Machines sees more readings, more weather, and more of how your home responds. **Fix:** Call `POST /api/v1/schedule/recompute` and read its `results` array. An entry with `status: "ok"` means a plan was stored. An empty `results` means the run could not start for the account: check that an appliance is registered and that the account's `optimization_mode` is not `"off"`. While `source` is `"defaults"` on `GET /api/v1/schedule`, apply the band midpoint as your setpoint: `(high_temps[idx] + low_temps[idx]) / 2`. On `GET /api/v1/schedules` there is no band to fall back to, so send that appliance no commands. > **Note** > > Gaps in readings (network outages, sensor downtime) slow down learning — there's simply less data to learn from. Aim for continuous 5-minute readings for the fastest ramp-up. Pushing your `weather.*` forecast helps fill outdoor-temperature gaps so a sensor blip doesn't waste data (if you don't push one, a fallback weather source is used automatically). --- ## Schedule looks like flat setpoints **Symptom:** `source` is `"optimization"` but the `high_temps` and `low_temps` arrays have little variation — they look almost flat. **Causes:** - **A narrow comfort band.** The optimizer can only shift load inside the band you give it. The default band depends only on your base temperature and mode: when cooling it runs from 2 °F below base to 1 °F above overnight, and from 5 °F below to 6 °F above between 08:00 and 21:59; heating mirrors it (1 °F below to 2 °F above overnight, 6 °F below to 5 °F above by day). `savings_level` does not change it. If you have saved hourly bands that are tighter than that, widen them via `PUT /api/v1/preferences`. - **Flat rate plan.** If your utility charges the same rate all day, there's no price signal to optimize against. The optimizer works best with time-of-use (TOU) or real-time pricing. - **Mild weather.** When outdoor and indoor temps are close, the HVAC runs very little and there's less to optimize. **Fix:** Check your hourly comfort bands and `optimization_mode` in preferences. If you're on a flat-rate plan, the optimizer may not produce significant schedule variation — that's expected behavior, not a bug. You can inspect (and override) the rate curve the optimizer is using via [GET / PUT /api/v1/rates](/api-reference/rates/) — submitting your own 24-hour curve is the fastest way to give the optimizer a price signal to work with. --- ## Appliance registration returns 403 **Symptom:** `POST /api/v1/appliances` returns `403 Upgrade required`. **Cause:** Free-tier accounts are limited to **one HVAC appliance**. You'll get a 403 if you try to: - Register a second appliance (any type) - Register a non-HVAC device (EV charger, home battery, water heater) **Fix:** Upgrade to Premium via the dashboard at [hungrymachines.io/dashboard](https://hungrymachines.io/dashboard). Premium accounts can register unlimited appliances of any type. --- ## Rate limit exceeded (429) **Symptom:** `POST /api/v1/readings` returns `429 Rate limit exceeded`. **Cause:** The ceiling is 600 readings per active HVAC or dehumidifier appliance, per account, per UTC day (600 for an account with none registered yet). At 5-minute intervals one unit sends 288 a day, well under the cap. Large catch-up batches after an outage, or an uncoordinated set of sensors all pushing at once, can trip it. **Fix:** Push on a steady cadence rather than in bursts. If you're catching up after an outage, trim to the most recent readings. The ceiling scales with the number of active HVAC and dehumidifier appliances registered on the account; tag each reading with its `appliance_id` so it is attributed to the right unit. --- ## Still stuck? If none of the above resolves your issue: - **File a bug** on [GitHub Issues](https://github.com/hungrymachines/hungry-machines/issues) — include the endpoint, request shape, and full error response. - **Email us** at [info@hungrymachines.io](mailto:info@hungrymachines.io) for account-specific issues. Canonical page: https://docs.hungrymachines.io/troubleshooting/