Automatically runs Shelly smart-plug devices (heaters, water heaters), thermostats and an EV charger during the cheapest electricity hours, using ENTSO-E day-ahead spot prices at 15-minute resolution. Django app, deployed as a Docker/Podman container, optionally published to the internet behind a Cloudflare Tunnel with Cloudflare Access single sign-on.
- Fetches ENTSO-E day-ahead prices (Finland) and, per device, books the cheapest 15-minute periods to reach a daily run-hours target.
- One plan per day, made the afternoon before — prices are fetched as whole local days (today + tomorrow). When all of tomorrow's prices have arrived (~14:00 Finnish time), each device's run hours for tomorrow (00:00–24:00) are booked once; later fetches add nothing. A day whose prices are incomplete is never planned, so the hours can't be squeezed into whatever few hours happen to be known. If today has no plan at all (first install, app down when prices came out), the rest of today is planned with the matching share of the daily hours. The dashboard lists prices from 12 h back to the end of tomorrow.
- Relays and the EV charger are switched at the start of every 15-minute period (:00, :15, :30, :45, 30 s past the mark), independent of the price fetch.
- Price source fallback — if ENTSO-E fails (e.g. its own maintenance), falls back to Elering (Estonia's TSO, republishes Nord Pool prices for Finland on separate infrastructure, no API key needed) before giving up for that cycle.
- Block scheduling — instead of scattering runtime across the day, books a few long continuous runs in the cheapest stretches, trading a fraction of a percent of cost for far fewer on/off cycles (configurable per device).
- Night fallback — if price data is unavailable (e.g. an ENTSO-E outage), still runs the daily target hours at night, added 15 minutes at a time.
- Thermostat control — min-temperature failsafe (force heat when cold), max-temperature cutoff, and a headroom band to stop early when warm enough.
- Device overrides (dashboard, Shelly devices):
- Turn On For Selected Time (1 h / 5 h / 12 h / 1 / 2 / 5 days) — switches the device on immediately and keeps it on for the whole window, booking every 15-minute period. It deliberately ignores price, run hours and all thermostat rules, including the max-temperature cutoff — use it only when you want the device running regardless. The same button cancels it early; it also ends when you use Force Device Off or disable automation.
- Force Device Off — keeps the device off until released.
- EV charger (Tuya) — schedules charging on cheap hours, with a reduced-current
mode to protect fuses when other high-power devices run.
-
Battery size (Settings → EV charger, "Energy for a full charge") — the energy drawn from the wall to fill an empty battery. Charging time is worked out from it and the charger's usual power: the median of the last 30 days' readings (the reduced current until there is history). The Tuya charger doesn't measure power, so a reading is the set current x 230 V; checked against its energy counter over whole sessions, that matches the real average within a few percent. Each plan books a full charge's worth of the cheapest periods; the car stops drawing power when full, so this is never too little and costs nothing extra. 0 = no automatic charging. (This replaces the old "hours per day" setting; existing chargers were converted to the equivalent kWh.)
-
Cold weather — link a weather location (below) and the forecast temperature over each charging window decides how long to book. A cold battery has to be heated before it takes charge, so heating time is added from a small table on the charger:
Forecast Booking 0 °C or warmer normal, nothing extra -10 °C + 1 h (editable) -20 °C + 2 h (editable) In between it's in proportion, and colder than -20 °C continues at the same rate. At or below the "one continuous run" temperature (default -5 °C) the whole charge is booked as one run, so the battery is heated once instead of cooling and re-heating between short blocks; this accepts a slightly dearer stretch (the usual 5 % block-cost guard is off). If no gap-free stretch is long enough, the normal plan is used and a warning is logged. No forecast available = normal booking.
-
Work schedule — No work schedule, Monday–Friday (day shift, default 09:00–17:00) or a Rotation: a repeating pattern of D (day shift), N (night shift) and F (free day) from a start date, e.g.
DDNNFFF. The settings page shows only the fields the chosen type uses (none for No work schedule), and the next 10 days so a wrong start date is easy to spot. For every shift:- the pre-heat before it (default 1 h) is booked ahead as always-on, so the car can warm its battery and cabin from the grid;
- the battery should be full when pre-heat starts: the cheapest periods between the car getting home from the previous shift and that moment are booked up to a full charge;
- nothing is booked while the car is away (shift start until shift end + the "home again" minutes).
Free days get a full charge's worth of their cheapest at-home periods. The schedule is personal data: it lives only in the database, set in the app.
-
Always-on time slots (Settings → EV charger) — any number of daily windows (24 h start time in 15-minute steps, run 1 or 2 hours) when the charger runs regardless of price, e.g. so the car can heat its battery and cabin before the morning commute. Each slot can be ticked on/off. Nothing is booked ahead: the period is recorded (marked forced on the dashboard) only when its time comes, so disabling or removing a slot needs no clean-up. It still only starts when a car is plugged in, and doesn't run while automation is disabled.
-
- Weather locations (Settings → Weather locations) — a Finnish municipality,
picked from a list, whose hourly temperature forecast is fetched from the
Finnish Meteorological Institute open data
(no API key) every hour at :50 and right after the location is saved. The
list (
app/data/weather_places.json) holds all municipalities with the coordinates FMI resolves each to, and forecasts are asked for by those coordinates, so a picked place always works. Refresh it when municipalities change (mergers take effect on 1 January):python manage.py update_weather_places [--year 2027](Statistics Finland's municipality list, each checked against FMI; about 2 minutes). A standalone object any device type can link to; today the EV charger uses it. Which place you picked is personal data and lives only in the database. - Stored credentials (Shelly / Tuya / ENTSO-E keys) are encrypted at rest.
app/ Django app
models.py devices, thermostats, EV charger, prices, assignments, settings
price_views.py ENTSO-E fetch (+ Elering fallback) + cheapest-hours / block-scheduling algorithm
tasks.py scheduler jobs: device control, price fetch, night fallback
ev_schedule.py EV charging time (battery size, charging power, cold-weather heating); work schedule (pre-heat, deadlines, away)
weather.py FMI temperature forecasts for weather locations
data/weather_places.json municipality picker list (name + FMI coordinates)
thermostat_manager.py thermostat-driven assignment logic
device_assignment_manager.py, device_override_manager.py
views.py dashboard, in-app device Settings pages, override endpoints
forms.py login + owner-editable device/thermostat/charger settings
fields.py EncryptedCharField / EncryptedTextField (at-rest encryption)
cf_access.py Cloudflare Access JWT verification
middleware.py Cloudflare Access SSO auto-login
admin.py Django admin (superuser-only)
services/ Shelly + Tuya API clients
management/commands/ backtest_scheduling, backfill_ev_session_energy, update_weather_places
templates/app/ dashboard, settings, login, graphs
project/settings.py env-driven settings (DEBUG, TLS, SSO, security)
Dockerfile multi-stage build; entrypoint runs migrations + admin bootstrap
docker-prod.sh build & run with Docker (hardened)
podman-prod.sh build & run rootless with Podman (hardened)
deploy-common.sh shared deploy config, reads deploy.env
deploy.env.example template for deploy config (copy to deploy.env)
cloudflared/ Cloudflare Tunnel config example (locally-managed tunnels)
rotate-tunnel-token.sh rotate the tunnel token and restart the connector
update-cloudflared.sh update the tunnel connector image (auto-rollback on failure)
DEPLOYMENT.md full secure deployment: Tunnel, Access, Google SSO, hardening
There are two ways to run it. You do not need Cloudflare or SSO — that's only for publishing it to the internet.
Leave APP_DOMAIN and the CF_ACCESS_* values unset. The app then runs in local
mode: plain HTTP, the normal Django username/password login, no SSO, no forced
HTTPS redirect.
cp deploy.env.example deploy.env # leave APP_DOMAIN and CF_ACCESS_* blank
./docker-prod.sh # or ./podman-prod.sh for rootless- App listens on
127.0.0.1:8000by default. To reach it from the LAN during local use, setBIND_ADDRESS=0.0.0.0indeploy.env(only do this on a trusted network — see the note in DEPLOYMENT.md about why loopback is the safe default). - On first start a random admin password is generated and printed once — the
script shows it at the end (and it's in
docker logs django-shelly-prod). Log in, then change it.
Set APP_DOMAIN (and the Cloudflare Access values) in deploy.env. The app
switches into production mode automatically: secure cookies, HSTS, HTTPS-only,
and — when the CF_ACCESS_* values are set — Cloudflare Access SSO so authenticated
visitors are logged straight in without a second password.
Full step-by-step (tunnel, Access application, Google/Gmail identity provider, allow-listing emails, hardening) is in DEPLOYMENT.md.
Configuration lives in three places, by design:
| Where | What | Who edits it |
|---|---|---|
deploy.env (host file, gitignored) |
Deployment/security/infra: APP_DOMAIN, TLS, session length, HSTS, brute-force limits, Cloudflare Access CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD |
operator, at deploy time |
| Django admin → App Settings (superuser) | ENTSOE_API_KEY, SHELLY_STOP_REST_DEBUG, CLEAR_LOGS_ON_STARTUP, LOG_RETENTION_DAYS (default 14; an hourly job deletes older device logs) |
superuser, in the UI |
| In-app Settings page / admin | Per-device settings & credentials (run hours, block scheduling, prices, Shelly/Tuya keys, thermostat, night fallback, EV battery size, cold-weather table, work schedule, always-on slots; weather locations) | device owner (own devices) or superuser (all) |
Security/TLS/SSO settings are deliberately not editable from the web UI — see
DEPLOYMENT.md for why (a compromised admin session must not be able to weaken
authentication or transport security). Secrets (SECRET_KEY, the field encryption
key, the tunnel token) live in files/deploy config, never in the database.
See deploy.env.example for the full annotated list. Key ones:
| Key | Default | Meaning |
|---|---|---|
APP_DOMAIN |
(empty) | Public hostname. Empty = local mode. Setting it enables production TLS mode. |
BIND_ADDRESS / BIND_PORT |
127.0.0.1 / 8000 |
Where the container listens on the host. Keep loopback behind a tunnel. |
DJANGO_SESSION_DAYS |
30 |
"Remember me" session lifetime. |
AXES_FAILURE_LIMIT |
8 |
Failed logins before lockout. |
CF_ACCESS_TEAM_DOMAIN |
(empty) | e.g. your-team.cloudflareaccess.com. Empty = SSO off. |
CF_ACCESS_AUD |
(empty) | Access application Audience (AUD) tag. Empty = SSO off. |
- Add / edit devices in the in-app Settings page (each user manages their own) or in the Django admin (superuser sees all).
- Thermostat: add a Shelly Temperature device, then link it to a Shelly device. Fields: min temperature (winter/summer failsafe — force heat below min−0.5°C), max temperature (stop above max+0.5°C), and headroom (winter/summer — stop this many degrees below max when already warm).
- Users: the Django admin (superuser) manages users, including the Superuser / Staff checkboxes. Under Cloudflare Access SSO, a verified email is matched to an existing Django user by email address.
Backtest the block-scheduling algorithm against stored price history:
docker exec -it django-shelly-prod python manage.py backtest_scheduling --days 90The image tag is ‹VERSION›-‹build-timestamp›, e.g. 1.0.0-20260830-164123.
VERSION is a plain file in the repo root; edit it to change the version, then
rebuild. List images with docker images django-shelly-prod.
python3 -m venv venv && source venv/bin/activate
pip install --upgrade pip && pip install -r requirements.txt
python manage.py migrate
python manage.py runserver # local mode, normal login
python manage.py test app # run the test suiteGNU AGPL v3. You may use, run, modify and share the software, including commercially; you must provide source and license notices when distributing, and if you run a modified version as a network service you must provide its source to users of that service. Derivative works remain under AGPL v3.