FocusPi — a Raspberry Pi desk clock that guards my focus
A desk clock, an Android app and a focus timer that puts the phone on Do Not Disturb. Notes on making the Pi the source of truth, surviving a killed app, and counting streaks offline.
Anurag Kumar
· 4 min read
The hardest part of learning something after work is not the material. It's the phone.
FocusPi is my answer: a Raspberry Pi on the desk showing time, date and weather on a small OLED, and an Android app that starts a focus session. When a session starts, the phone switches to Do Not Disturb in calls only mode — calls still ring, everything else lands silently — the countdown runs on both the phone and the OLED, and the Pi records the session and keeps a daily learning streak.
It's a small project, but almost every interesting decision in it came from one question: what happens when a piece of this goes away?
One source of truth
The phone and the Pi both know about a session, and there are two ways to arrange that. The phone could own the timer and tell the Pi about it, or the Pi could own it and let the phone mirror it.
I picked the Pi.
┌─────────── Android phone ───────────┐ ┌─────────── Raspberry Pi ───────────┐
│ React Native app (Expo) │ Wi-Fi │ focuspi-api (Flask + waitress) │
│ • Focus / Progress / Settings │ ───────► │ • /api/focus/start|stop, /status │
│ • FocusDnd native module (Kotlin) │ REST │ • SQLite: data/focus.db │
│ – DND "calls only" + restore │ ◄─────── │ • streak, stats, weather cache │
│ – AlarmManager ends it on time │ │ focuspi-oled (luma.oled) │
└─────────────────────────────────────┘ └────────────────┬────────────────────┘
│ I2C
128x64 OLED
The Pi starts, times and records sessions. It has no battery to run out, no app to be swiped away, and it's already on. The phone asks what the current state is and reflects it. The OLED process is a separate service that polls the same API, so the display is just another client — if it crashes, your session keeps running.
Do Not Disturb that survives the app
The one thing the phone must own is DND, because only the phone can silence itself. In Android that means a native module (Kotlin), because this is nowhere near the JavaScript layer: notification policy access, interruption filters, and the question of who is still allowed to ring you.
The failure case drove the design. If the app turns DND on and is then killed — by the user, by the battery optimiser, by a reboot — your phone stays silent forever. That's a worse outcome than a missed focus session.
So ending the session does not depend on the app being alive:
- an AlarmManager alarm fires at the end time and restores the previous DND state from a broadcast receiver, with no app process required;
- the previous state is saved first, so the restore puts back what you had rather than blanket-enabling notifications;
- a boot receiver cleans up after a reboot.
"Calls only" was deliberate too. Fully silent is the kind of setting you stop trusting after the first missed call from home; with calls allowed through, the phone stays out of the way without making you anxious about it.
Offline, without double counting
A streak only works if it counts everything, and I'm not always at my desk. So the app can run a
session with no Pi in sight: it keeps the running session locally, queues the finished one, and
pushes the queue to /api/focus/sync the next time the Pi is reachable.
The catch with any retry queue is duplicates. A sync that times out after the Pi wrote the row looks
exactly like a sync that never arrived, and retrying it would inflate the streak — the one number
the whole app exists to protect. Every session carries a client_id generated on the phone, and the
Pi treats it as an idempotency key, so re-sending is always safe.
Streaks are counted per local day, which is why the deploy script asks about the timezone instead of assuming UTC. A session finished at 11:30 pm should count for today, not tomorrow.
Making it installable
A side project stops being fun when you have to remember how to run it. pi/deploy.sh does the
whole thing: installs packages, enables I2C, writes the config, checks the timezone, creates the
venv, scans the I2C bus so you can see whether the OLED is detected, and installs two systemd
services (focuspi-api and focuspi-oled). It prints the server URL and API key to type into the
app, and deploy.sh status | restart | logs | uninstall cover the rest. Re-running it after a
git pull updates in place and keeps your data.
Writing that script took about as long as a feature, and it's the reason the project is still running on my desk months later.
What I'd tell myself before starting
- Decide which device is authoritative on day one. Nearly every bug I hit early was two devices each believing they owned the timer.
- Design for the process being killed, not for the happy path. On a phone, "my app is running" is an assumption, not a fact.
- Give anything that retries an idempotency key. It costs one field and saves the data you care about.
- The install story is part of the project. If redeploying is scary, you stop changing things.
Wiring diagram, API reference and build steps are in the repo: github.com/i-m-anurag/FocusPi.