Monday, April 6, 2026

Solar-Aware Appliance Scheduling with Homey — How I Built It

Since last time

A week ago I wrote about teaching my washing machine to pick its own schedule — one of my favorite smart home projects so far. At the end of that post I mentioned wanting cumulative savings tracking. That shipped in v1.3.0 about two days later — the device dashboard now shows per-schedule and lifetime savings.

A few other things landed between then and now:

  • v1.3.0 — Savings tracking, Frank Energie all-in pricing (spot + tax + markup + VAT), and a bunch of bug fixes including a UTC offset issue that served yesterday's prices during summer time.
  • v1.4.0 — Flow triggers for price changes and status changes, so you can build automations like "notify me when the price drops below X."
  • v1.5.0 — A "schedule cheapest start by time" action card. Instead of "within 6 hours," you say "before 14:00" and the scheduler figures out the window.
  • v1.5.1 — The rounding bug from the last post came back in disguise: low-power appliances like a phone charger showed EUR 0.00 savings because the rounding happened too early in the pipeline. Fixed by deferring all rounding to the display layer.

All useful, all driven by actually living with the app. But then I looked at my roof.

The solar problem

I have solar panels. When the sun is shining, I'm generating electricity that I either use or export to the grid. The feed-in tariff — what my energy company pays me for exported power — is about EUR 0.07/kWh. Meanwhile, buying from the grid costs EUR 0.15–0.35/kWh depending on the hour.

The old Power Profiler didn't know about any of this. It looked at grid prices and scheduled my washing machine at 3 AM because that's when electricity is cheapest. Technically correct. But if the sun is going to produce 3 kW of surplus at noon, running the washing machine then effectively costs me EUR 0.07/kWh (the feed-in I'm giving up) instead of EUR 0.25/kWh from the grid at 3 AM.

Simple fix, right? Just tell each device "solar hours are cheap." Except there's a catch.

I have a washing machine, a dryer, and a dishwasher. If all three independently decide that the noon slot is basically free, they'll all schedule into it. Their combined demand — maybe 4.5 kW — exceeds my 3 kW solar surplus, and the difference comes from the grid at full price. None of them accounted for the others. I call this the stampede problem.

Effective pricing

The solution is a centralized pricing service — a Virtual Energy Supplier — that computes an effective price per time slot. The effective price blends what your cheap sources (solar) and the grid contribute, weighted by how much capacity is actually available.

The math works like a waterfall:

  1. Take all cheap sources (PV panels, future: battery), sorted by cost
  2. Subtract base load — your fridge, router, standby devices eat into PV first
  3. Subtract capacity already reserved by other scheduled appliances
  4. Whatever cheap capacity is left goes to this appliance
  5. The rest comes from the grid

The blended price is then:

effectivePrice = (cheapKwh × cheapCost + gridKwh × gridPrice) / totalKwh

For a zero-cost source like solar, the "cost" is the feed-in tariff — the opportunity cost of not exporting. This is economically correct: using your own solar isn't free, it's worth whatever you'd have earned by selling it.

One guard: the effective price never exceeds the grid price. If the math somehow produces a higher number (it shouldn't, but floating point), we cap it.

Knowing your base load

Here's something I didn't think about initially: not all solar production is available for scheduled appliances. Your house has a base load — the fridge cycling, the router, standby power, HVAC — that eats into PV output before anything else gets a turn.

My house draws about 200–400W overnight, 500–800W during the day. A fixed estimate of "500W base load" works okay, but it's wrong for every specific half-hour of the day.

If you have a smart meter connected to Homey (most Dutch homes have a P1 dongle), the app now automatically detects it and learns your actual base load pattern. The formula is simple:

baseLoad = gridPower + pvPower

Where gridPower is your smart meter reading (positive = importing, negative = exporting) and pvPower is your inverter reading. This works regardless of whether you're importing or exporting. The app builds a 48-slot profile (one per 30 minutes) using an exponential moving average that adapts over about two weeks of observations.

No smart meter? The app falls back to a configurable constant (default 400W). Less accurate, but the system still works.

Solcast and correction factors

For solar forecasts, I integrated Solcast. Their Hobbyist plan gives you 10 API calls per day for free — enough for two forecast fetches and two actuals fetches, with budget to spare.

The forecasts are good, but they're not perfect for your specific installation. Maybe your panels face slightly north-west and get shaded by a tree after 15:00. Solcast doesn't know that.

So the app learns. For each 30-minute slot, it compares what Solcast predicted with what your inverter actually produced:

correctionFactor = actualYield / solcastEstimate

This ratio is smoothed with an EMA (window of 14 observations, so roughly two weeks). After a few days, the app knows that Solcast overestimates your afternoon production by 15% and adjusts accordingly. The correction factor is capped at 3.0× to prevent one cloudy outlier from making the forecast permanently pessimistic.

One thing I got wrong initially: I recorded an API call against the daily budget before the fetch completed. If the request failed due to a network error, the call was wasted. Now it only counts on success — or on a 429 rate limit response, since that means the API actually processed it.

The stampede problem

Back to our three appliances all wanting the noon slot. The Virtual Energy Supplier solves this with a reservation ledger.

When prices or forecasts update, the app triggers a full replan:

  1. Release all existing capacity reservations
  2. Sort all scheduled devices by deadline (earliest first — highest priority)
  3. For each device, compute effective prices with the current reservations factored in, find the optimal window, and reserve that capacity

The key insight: device #2 sees device #1's reservation already deducted from the available surplus. If the noon slot only has 1.5 kW left after the washing machine reserved its share, the dryer computes a higher effective price for that slot and might pick a different time instead.

Sequential scheduling by deadline priority means the most urgent device gets first pick of the cheap slots. Less urgent devices adapt. No coordination protocol, no negotiation — just a deterministic ordering that produces good results.

The dashboard

All this math is invisible to the user if they don't care about it. The scheduler just picks better times. But if you do want to see what's happening, there's a new Virtual Energy Provider device — a dashboard that shows:

  • Current effective price vs. grid price
  • Feed-in tariff
  • PV surplus and available capacity
  • Household base load
  • Number of scheduled appliances

It also comes with flow cards. You can trigger automations when surplus exceeds a threshold ("turn on the EV charger when surplus > 2000W"), when the effective price drops below a value, or when the spot price goes negative (yes, that happens in the Netherlands — you get paid to consume).

What I learned

The biggest lesson: solar scheduling isn't a pricing problem — it's a resource allocation problem. Giving each device a "solar discount" is simple but wrong. You need a central arbiter that knows total supply, base demand, and existing commitments.

The second lesson: base load matters more than you'd think. Without accounting for the 600W my house draws at noon, I was overestimating available PV by about 20%. That's the difference between "washing machine runs on solar" and "washing machine runs on solar plus a bit of grid at full price."

Third: correction factors are essential. Weather-based forecasts for solar are impressively good at the regional level and consistently wrong for your specific roof. The EMA correction is cheap to compute and makes the scheduling decisions noticeably better after just a few days.

v1.6.0 is currently in the Test channel. If you have a Homey Pro with solar panels and want to try it, I'd love feedback. Next up: battery storage support and getting this through Homey's app certification.

Saturday, April 4, 2026

I Built a $15 Smart Home Controller (and Why Phones Are Bad Dashboards)

ESP32 Cheap Yellow Display smart home controller showing appliance scheduling cards

In my previous post I wrote about how my washing machine and dryer pick their own schedule based on energy prices. That post was about the concept — a Homey app that finds the cheapest window to run your appliances. What I didn't mention was the thing on the kitchen wall that makes it actually usable.

Because here's the truth about smart home automation: if the only way to interact with it is through an app on your phone, it won't survive contact with your household.

The Problem With Apps

I call it the spouse test. If your partner needs to unlock their phone, find the right app, navigate to the right screen, and tap three buttons just to start the dryer at a cheap time — they're going to press the button on the dryer instead. And they'd be right to.

A physical device on the wall changes that dynamic entirely. It's always on, always showing the current state, and requires exactly one tap to do the thing. No login, no loading spinner, no "update available" popup. It's the difference between a light switch and a lighting app.

So I decided to build one. A small touch screen near the laundry area that shows energy prices, appliance status, and lets you schedule a run with a single tap.

$15 of Hardware, Infinite Ambition

The ESP-2432S028R — affectionately known as the "Cheap Yellow Display" or CYD — is one of those products that shouldn't exist at its price point. For about $15, you get an ESP32 microcontroller, a 2.8-inch color TFT display with touch input, WiFi, and enough GPIO pins to feel dangerous.

The screen is 320 by 240 pixels. That's not a lot. For context, the icon for your weather app is probably bigger than this entire display. But for a single-purpose device that shows two appliance cards and a price indicator, it's plenty.

The ESP32 handles WiFi, MQTT communication with my Homey hub, NTP time sync, and over-the-air firmware updates. All on a chip that draws about half a watt. The whole thing runs off a USB-C cable.

The First Pivot

I didn't start with custom firmware. Like any reasonable person, I started with ESPHome — the YAML-based framework that lets you configure ESP32 devices without writing C++. Define your sensors, your display layout, your automations, and ESPHome generates the firmware for you.

It worked. For about two hours.

The problem was MQTT topic structure. My Homey app publishes appliance data on specific topics with JSON payloads — state, pricing, scheduling, savings data. ESPHome's MQTT integration is designed for Home Assistant's auto-discovery format, and bending it to work with custom topic structures felt like writing C++ with extra steps. Worse steps, actually, because you're debugging generated code you didn't write.

So I pivoted to PlatformIO with the Arduino framework. Full C++ control, direct access to proven libraries like TFT_eSPI for the display and espMqttClient for MQTT, and — crucially — the ability to structure my code the way the problem demanded rather than the way a YAML schema allowed.

Was it more work? Absolutely. Was it the right call? Without question. Some projects fit neatly into a configuration-driven framework. This one needed a real codebase.

Building a UI on 320 by 240 Pixels

Designing a touch interface for a 2.8-inch screen is an exercise in brutal prioritization. There's no room for nice-to-haves. Every pixel has a job.

The layout I landed on has three elements. A top bar showing the current time, date, energy price, and WiFi status. Two appliance cards — one for the washer, one for the dryer — each showing their current state with a color-coded badge, key stats, and an action button. That's it.

The action button is context-sensitive. If the appliance is idle, it says PLAN and opens a scheduling modal. If it's already scheduled, it says CANCEL. The modal lets you pick a deadline: 4, 8, 12, or 24 hours from now. Tap your choice and the system finds the cheapest slot within that window.

Color does a lot of heavy lifting on a small screen. Green badge means idle and ready. Blue means scheduled. Yellow means the system is still learning this appliance's power profile. Red means something needs attention. You can read the state of both appliances from across the room without your glasses.

One design choice I'm particularly happy with: when an appliance is scheduled, the card shows the start time, the expected price per cycle, and how much you're saving compared to running it right now. That last number — "Saving €0.38" — turns out to be incredibly motivating. It makes the abstract concept of energy optimization tangible.

The Bugs That Taught Me Things

Embedded development has a way of humbling you. Here are three problems that took longer to solve than I'd like to admit.

Dual SPI buses. The CYD board has two SPI peripherals: one for the display (ILI9341) and one for the touch controller (XPT2046). Most example code assumes they share a bus. They don't — and they can't, because the display's SPI runs at 40 MHz while the touch controller maxes out at 2.5 MHz. Sharing a bus means reconfiguring speed on every swap, which causes timing glitches. The fix was putting them on separate hardware SPI buses (HSPI and VSPI), each with their own pins. Obvious in hindsight. Took a full afternoon to figure out.

SPI reentrancy crashes. This one was subtle. MQTT messages arrive asynchronously via callbacks. My first implementation updated the display directly from the MQTT callback — parse the JSON, update the card, done. It worked great until a message arrived while the display was mid-draw. Two SPI transactions on the same bus at once: instant crash, no useful stack trace.

The solution is almost embarrassingly simple: the MQTT callback sets a boolean flag. The main loop checks the flag, and if it's set, redraws the screen. No concurrent SPI access, no crashes. It's the embedded equivalent of "don't update the DOM from a web worker" — except the consequence isn't a console warning, it's a hard reset.

Touch calibration. Every CYD unit has slightly different touch calibration values. The raw coordinates from the XPT2046 don't map 1:1 to screen pixels — they need to be scaled and offset. My first unit worked perfectly with the default calibration. My second unit registered taps about 30 pixels to the left. The fix was a calibration routine and storing per-unit values, but the debugging process involved a lot of tapping one spot and watching a dot appear somewhere else. It felt like playing Operation with an invisible board.

The Result

The finished device sits on the wall near our washing machine. It shows the current energy price, the status of both appliances, and lets you schedule a run with two taps: hit PLAN, pick your deadline. The system finds the cheapest slot within your window and confirms the schedule.

It updates over-the-air, reconnects automatically if WiFi drops, and uses about as much power as a phone charger. The total hardware cost was under €20.

But the real measure of success isn't technical. It's that my partner uses it without thinking about it. There's no app to open, no concept to explain. Tap the card, pick when you need it done. The rest happens automatically.

Sometimes the best smart home upgrade isn't smarter software — it's a simple screen on the wall that does exactly one thing well.

Solar-Aware Appliance Scheduling with Homey — How I Built It

Since last time A week ago I wrote about teaching my washing machine to pick its own schedule — one of my favorite smart home projects so ...