Why · Features · How it works · Architecture · Hardware · Engineering · Get started · Testing · Built with Claude Code · License
![]() |
![]() |
![]() |
| SET THE TIMER Swipe hours, minutes and seconds |
REAL BATTERY GAUGE A hand-modified MAX17043 on I²C |
EMERGENCY OVERRIDE Press 25× to get out early |
App blockers are one settings toggle from defeat. A drawer is one weak moment away. The only thing that reliably works is putting the phone somewhere you genuinely can't reach it.
A screen-time toggle can be disabled in two taps. This lock is real, so it can't.
The hard design problem isn't "make a box that closes". It's the emergency override. Make it too rigid and you can't take a call that matters. Make it too loose and the lock is theater. So the override has tunable friction: you choose how many presses (5 to 500) it takes to get out early. Incoming calls can also light the screen while the box stays locked.
| Phone Box | kSafe | Generic Amazon box | Brick | Opal | GoAro | |
|---|---|---|---|---|---|---|
| Physically locks the phone away | ✅ | ✅ | Partial | ❌ | ❌ | ✅ |
| Touchscreen interface | ✅ | Dial | Dial | App | App | App |
| Tunable emergency override | ✅ | None | Holes | ❌ | Toggle | App |
| Rechargeable & opaque | ✅ | Clear | Varies | n/a | n/a | ✅ |
To be fair, a commodity box is cheaper and Brick fits in a pocket. Neither has a touchscreen or a tunable override, and building one ourselves was half the point.
|
Fully offline. No account needed. 🔒 A real lock. A servo-driven latch holds the lid shut until the timer ends. 👆 Touchscreen UI. A 1.47″ 172×320 display. Swipe to set up to 9 hours, then watch a live countdown. 🆘 Tunable override. 5 to 500 presses in steps of 5 (default 25). Every early release is logged. 🔋 True battery %. From a hand-modified MAX17043 fuel gauge, not a guess from ADC voltage. 💾 Survives power cuts. Settings, schedules and streaks live in on-chip NVM. ⚡ Brownout recovery. A |
iOS · React Native + Expo 📡 Live status over BLE. Lock state, countdown and battery, streamed from the box. 📞 Call alert-through. An incoming call flashes the box's screen while the latch stays shut. Unlock-on-call is a separate setting, off by default. 📊 Focus stats. Streaks, totals, a calendar, and custom labels and topics per session. 🎯 Goals and reminders. Scheduled sessions with push reminders from Cloud Functions. 🔐 Sign in anywhere. Apple, Google or email, with cross-device sync through Firestore. |
Firebase Hosting 🪧 Marketing site. The live site at phonebox-d14b7.web.app. 📈 Signed-in dashboard. Mirrors the app's stats, goals and planned sessions in the browser. 🔔 Web Push. Session reminders reach the browser too. ♿ Audited. Includes a UX and accessibility review ( |
Under the hood: the firmware state machine
stateDiagram-v2
[*] --> idle
idle --> closed: lid closed (servo latches)
closed --> idle: status-bar tap (open)
idle --> picking: tap LOCK
closed --> picking: tap LOCK
idle --> confirming: tap LOCK with a topic pushed from the app
closed --> confirming: tap LOCK with a topic pushed from the app
confirming --> picking: CHANGE
confirming --> running: CONFIRM
picking --> running: select topic
picking --> idle: cancel
picking --> closed: cancel (lid was closed)
running --> done: timer expires (COMPLETED)
running --> done: N override presses (OVERRIDDEN)
done --> idle: unlock animation / OPEN
The state names are the exact values the firmware sends over BLE. The app's
BoxState type in app/src/ble/protocol.ts mirrors them one-for-one.
Three runtimes that never import from each other: Python on a microcontroller, TypeScript and Swift on a phone, and Node in the cloud. One custom Bluetooth protocol and one Firestore schema hold them together.
flowchart LR
subgraph Box["📦 Phone Box (ESP32-S3 · CircuitPython)"]
direction TB
UI["Touch UI<br/>AXS5106L driver"]
SM["Lock state machine<br/>lock_controller"]
SERVO["Servo latch<br/>GPIO5 PWM"]
GAUGE["MAX17043<br/>fuel gauge (I²C)"]
NVM[("NVM<br/>settings · streaks")]
UI --> SM --> SERVO
GAUGE --> SM
SM <--> NVM
end
subgraph Phone["📱 iOS app (React Native · Expo)"]
direction TB
BLE["PhoneBoxClient<br/>react-native-ble-plx"]
CALL["CallObserver<br/>native Swift module"]
WAKE["BackgroundWake<br/>native Swift module"]
STORE["zustand store<br/>+ Firestore sync"]
CALL --> BLE
WAKE --> BLE
BLE <--> STORE
end
subgraph Cloud["☁️ Firebase"]
direction TB
AUTH["Auth<br/>Apple · Google · email"]
FS[("Firestore<br/>owner-scoped rules")]
FN["Cloud Functions<br/>reminders · push receipts"]
HOST["Hosting<br/>site + dashboard"]
FN --> FS
end
SM <==>|"Custom BLE GATT service<br/>(8 characteristics)"| BLE
STORE <--> FS
STORE --> AUTH
HOST <--> FS
FN -- "Expo / Web Push" --> Phone
| Layer | Stack | Size |
|---|---|---|
| Firmware | CircuitPython 10 on a Waveshare ESP32-S3-Touch-LCD-1.47. Servo lock state machine, custom AXS5106L touch driver, MAX17043 fuel gauge | ~9k lines |
| App | React Native 0.86 / Expo 57, TypeScript, react-native-ble-plx, zustand, two native Swift modules |
~32k lines |
| Backend | Firebase Auth + Firestore, Cloud Functions on Node 20 (scheduled reminders, Expo and Web Push) | ~1k lines |
| Web | Static HTML/CSS/ES modules on Firebase Hosting, with a signed-in dashboard | ~7k lines |
| Enclosure | SolidWorks, FDM printed | 2 full revisions |
The BLE GATT protocol: 1 service, 8 characteristics
One custom service (6b9a7e00-…-0001). The UUIDs are defined twice, in
firmware/lib/lock_config.py and app/src/ble/protocol.ts, and a contract test
(tests/contracts/bleUuids.test.js) fails if the two ever drift apart.
| Characteristic | Direction | Purpose |
|---|---|---|
status |
box → app · read / notify | State, remaining time, battery, config |
history |
box → app · read / notify | RAM queue of sessions finished while no phone was connected |
command |
app → box · write | lock, unlock, start, dur, historyAck… (rate-limited to 1/s) |
settings |
round-trip · read / write | Override count, brightness, servo angles, sleep… |
timeSync |
app → box · write | Epoch seconds, sets the box clock for session timestamps |
alert |
app → box · write | Incoming-call label, which flashes the screen |
labels |
app → box · write | Custom session labels as compact JSON |
pendingTopic |
app → box · write | A topic suggestion for the next session |
The servo runs from the battery rail with bulk capacitance, not from 3.3 V. The MAX17043 shares the touchscreen's I²C bus at 0x36, so it costs no extra GPIO.
|
Bill of materials · ~$44 per prototype
Full breakdown and cost-down levers in |
Enclosure CAD Two full revisions in SolidWorks, drawn for FDM printing. Printed fit-test coupons (hinge, port, screen bezel, power switch, locking tab) were checked against the real parts before each full assembly. 📐 |
| Problem | What was actually wrong | Fix |
|---|---|---|
| ⚙️ The servo kept dying Only on real hardware, only sometimes |
Bench-cycling the lock until the pattern appeared showed two faults at once. The CPU clock was scaling out from under the servo's PWM timer, and a motor drawing close to an amp was hanging off a 3.3 V rail that couldn't supply it. | The firmware re-asserts 50 Hz on every move. The servo now runs from the battery rail with bulk capacitance, and an NVM brownout counter drives automatic recovery. |
| 🔋 No battery reading The board has no fuel gauge |
A raw ADC voltage is a poor proxy for a LiPo's state of charge, and the voltage divider read consistently off against a multimeter. | We added a MAX17043 breakout, modified by hand (cut the traces tying its 3 V rail to the battery rail, then soldered and crimped it in), on the existing I²C bus. |
| 👆 A touch chip with no driver AXS5106L |
No CircuitPython driver existed, and the controller intermittently drops frames mid-touch. | A custom I²C driver with a debounce fix for the frames the controller drops. |
| 🔗 One protocol, two languages Python ↔ TypeScript |
The box and the app share a wire contract but never share code, so a UUID or payload change on one side silently breaks the other. | Both sides are documented against each other, a cross-project contract test guards the UUIDs, and a project skill teaches the AI agent the contract. |
.
├── firmware/ CircuitPython firmware (copied to the CIRCUITPY drive)
│ ├── code.py Entry point and main loop
│ ├── safemode.py Brownout auto-recovery
│ └── lib/ lock_* modules, AXS5106L touch + MAX17043 drivers, vendored Adafruit libs
├── app/ Expo / React Native iOS companion app
│ ├── src/ ble/, screens/, sync/, auth/, stats/, goals/, push/, ui/ …
│ ├── modules/ Native Swift modules: call-observer, background-wake
│ ├── firestore.rules Security rules (deployed via firebase.json)
│ └── tests/ Firestore rules suites (run against the emulator)
├── functions/ Firebase Cloud Functions: scheduled reminders, push delivery
├── website/ Marketing site + signed-in web dashboard (Firebase Hosting root)
├── hardware/
│ ├── cad/ SolidWorks (.sldprt) and STEP enclosure parts
│ └── wiring-diagram.svg
├── tests/ Host-side firmware tests, website tests, cross-project contract tests
├── scripts/ Demo-account seeding, app-icon generation, doc conversion
├── docs/ RFCs, design reviews, BOM and procurement, handoffs (see docs/README.md)
├── .claude/ Claude Code project skills and subagents
├── fraim/ FRAIM agent-workflow config and project learnings
├── firebase.json Hosting, Functions and Firestore config
└── AGENTS.md, CLAUDE.md, DESIGN.md, PRODUCT.md Context files read by AI coding tools
BLE and the native modules do not run in Expo Go. The app needs a development build on a real iPhone (macOS, Xcode and an Apple Developer account). You also need your own Firebase project and the Firebase CLI. Host-side tests need Python 3.11+ and Node 20+.
📟 Firmware
- Flash CircuitPython 10 onto the Waveshare ESP32-S3-Touch-LCD-1.47.
- Copy the firmware onto the
CIRCUITPYdrive:cp -r firmware/code.py firmware/safemode.py firmware/lib /Volumes/CIRCUITPY/
- The board auto-reloads. Pins, colours and every tunable live in
firmware/lib/lock_config.py.
More in firmware/README.md.
📱 iOS app
cd app
npm ci
cp .env.example .env # fill in your own Firebase web config
npx expo prebuild --clean # generates ios/ and links the native modules
npx expo run:ios --device # build onto a real iPhoneMore in app/README.md.
☁️ Backend and website
cd functions && npm ci && npm run build
firebase deploy --only functions,firestore,hosting --project <your-project-id>website/ is served as-is. Point website/js/firebaseConfig.js at your own project.
| Suite | Command | Size |
|---|---|---|
| 📟 Firmware (host-side) | python3 tests/run_firmware_tests.py |
17 files · 672 checks |
| 🌐 Website + BLE contract | npm test |
261 tests |
| 📱 iOS app | cd app && npm run test:app |
112 suites · 1,548 tests |
🔐 Firestore Scan report · 2026-10-03
From the balcony · 0 of 3 clappedPrincess, Crusoe and Schnitzel read it and passed. Their reasons are on the balcony, with every other verdict. Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are. report this listing— log in to report |





0 comments
log in to comment.