GCal Glance
Ambient Google Calendar display for BUSY Bar — at-a-glance meeting countdowns, alerts, and live schedule awareness.
By Kilo59 · ▲ 0 · Rate and Discuss
How It Works
GCal Glance: Google Calendar on Aero Horizon for BUSY Bar
A dedicated BUSY Bar application engineered as a high-density, near-field desktop flight deck for Google Calendar on a 72×16 RGB LED matrix. Designed to solve temporal blindness and deliver continuous situational awareness through dynamic radar horizons, procedural micro-glyphs, and multi-phase meeting lifecycle management.

🛫 Aero Horizon Design System
Aero Horizon divides the 72×16 canvas into 3 functional vertical tiers:
+------------------------------------------------------------------------+ y=0
| 10:48 AM 42m Focus Runway [NEXT 11:30A] | Tier 1 (y=0..7): Telemetry Deck
+------------------------------------------------------------------------+ y=7/8
| [📹] 11:30 AM - Sprint Planning - Room 3B - Then: 1:00 PM 1-on-1 | Tier 2 (y=8..13): Marquee Stream (65px)
+------------------------------------------------------------------------+ y=13/14
| NOW ● ▓▓▓▓▓ (11:30-12:00) ░░░░░░░░░ ▓▓▓▓▓▓ (13:00-13:45) | Tier 3 (y=14..15): Dual-Mode Horizon
+------------------------------------------------------------------------+ y=15
x=0 x=71
1. Tier 1: Near-Field Telemetry Deck (y=0..7)
- Idle Mode: 12h Wall Clock (
10:48 AM), Dynamic Focus Runway (42m Focus Runway), and Compact Temporal Bookmark Pill:- Today's upcoming event:
[NEXT 11:30A] - Tomorrow's first event:
[TMRW 5:51A] - Multi-day future event:
[WED 8/19 10A] - Clear schedule:
[FREE]
- Today's upcoming event:
- Milestone Alerts: High-contrast amber badge (
[⚡ IN 5 MIN]/[⚡ STARTING]), scheduled start time, and live seconds countdown. - Active Mode: Wall clock / start time, state badge (
[● LIVE CALL],[● LIVE],[⚡ WRAP UP],[+04m OVER]), and live remaining time.
2. Tier 2: Micro-Glyph & Wide Marquee Stream (y=8..13)
- 100% Procedural 5×5 Micro-Glyphs: Zero binary assets. Composed dynamically using 3 rectangle primitives:
- Video Call (
video): 4x5 bezel + 1x3 optical lens + 2x3 cutout - Coffee / Break (
coffee): 4x4 mug + 2x1 vapor plume + 1x2 handle - Studio Headphones (
focus): 5x3 earcups + 3x2 headband arch + 1x4 inner gap - Cruiser Airplane (
travel): 1x5 fuselage + 5x1 wingspan + 3x1 tail stabilizer - Activity Dumbbell (
fitness): 5x3 bar + 3x5 weight plates + cutout - Celebration Star (
celebrate): 1x5 vertical ray + 5x1 horizontal ray + 3x3 diamond burst - Overtime Hourglass (
overtime): 5x5 hourglass vessel + waist pinch - Calendar Grid (
calendar): 5x5 frame + top header cutout + day pin
- Video Call (
- Wide 65px Text Viewport: Marquee scrolling for primary event title, location/platform, and configurable downstream schedule lookahead (default: 3 events).
3. Tier 3: Dual-Mode Dynamic Horizon (y=14..15)
- Idle Proximity Radar: 6-hour rolling window at $5\text{ min/px}$ (default, configurable via
--radar-window-minutes). Visualizes current time anchor (NOW ●beacon atx=0..1) and proportional upcoming event blocks with accurate temporal spacing. - Precision Elapsed Horizon: 72px active meeting gauge with 25%, 50%, and 75% tick marks (
#303030FF) and a bright 1px white playhead needle (#FFFFFFFF). - Tight-Turn Pip (
▌): Illuminates amber atx=70when the downstream meeting buffer is $<5\text{ minutes}$.
🔄 Multi-Phase Meeting Lifecycle
stateDiagram-v2
[*] --> Idle: No Active Meeting
Idle --> Alert: T-15m / T-5m / T-2m / T-0m Checkpoint
Alert --> Idle: 15s Timeout Dismissal
Idle --> Phase1: Event Starts (T-0s)
Alert --> Phase1: Event Starts (T-0s)
state "Active Meeting Lifecycle" as Active {
Phase1: Phase 1 — Title Verification (0..15s)\n[● LIVE CALL] - Location - Room
Phase2: Phase 2 — Focus Flight Deck\n[● LIVE] - 25/50/75% Ticks - Playhead
Phase3: Phase 3 — Wrap-Up Cue (Last 3m)\n[⚡ WRAP UP] - Tight-Turn Pip
Overtime: Phase 4 — Overtime Count-Up\n[+04m OVER] - Crimson Flash
Phase1 --> Phase2: After 15s
Phase2 --> Phase3: Rem <= 3m
Phase3 --> Overtime: Rem < 0s
}
Active --> InterruptedAlert: Incoming Event Alert Overlay\n(Top Alert Banner + Preserved Horizon Bar)
InterruptedAlert --> Active: 15s Alert Timeout
Active --> Breather: Call Ends & Gap >= 10m\n[☕ 15m BREAK]
Active --> Idle: Call Ends & Back to Runway
Breather --> Idle: Next Call Approaches
🎮 Hardware Controls & Tactile Navigation
GCal Glance integrates directly with the BUSY Bar's physical rotary encoder dial and push buttons for responsive, tactile desktop interaction:
| Input Control | Hardware Action | Operational Behavior |
|---|---|---|
| Rotary Dial (Encoder) | Rotate Left / Right | Interactive Agenda Peek: Scrubs through upcoming calendar events one-by-one (1/N, 2/N, TMRW 3/N, etc.) across the lookahead queue. Displays exact start time, duration, room/video link, and location in real-time. Automatically dismisses back to the live flight deck after 8 seconds of inactivity. |
OK Button |
Press | Instant Manual Sync: Triggers an immediate re-fetch and re-parse of your Google Calendar feed, bypassing the normal 120-second polling interval. A bright blue LED confirmation pulse acknowledges the sync request. |
BACK Button |
Press | Exit & Dismiss: Immediately exits Agenda Peek view back to the active flight deck, or dismisses an active approaching alert banner early. |
START Button |
Press | Dismiss Alert: Clears an active milestone alert banner early to return directly to the standard runway or active meeting view. |
⚙️ Configuration & Options
All configuration options can be set via command-line arguments or .env environment variables (prefixed with GCAL_GLANCE_, with GCAL_ and CALSYNC_ supported as fallbacks):
| Setting | CLI Flag | Env Variable | Default | Description |
|---|---|---|---|---|
| Radar Window | --radar-window-minutes |
GCAL_GLANCE_RADAR_WINDOW_MINUTES |
360 |
Duration of idle rolling radar window in minutes (60–1440). 360 = 6h window ($5\text{ min/px}$). |
| Include All-Day | --include-all-day |
GCAL_GLANCE_INCLUDE_ALL_DAY |
false |
Include all-day calendar events (VALUE=DATE). |
| Lookahead Count | --lookahead-count |
GCAL_GLANCE_LOOKAHEAD_COUNT |
8 |
Number of upcoming events to cycle in the idle marquee stream & dial peek (2–20). |
| iCal Feed URL | --ical-url |
GCAL_GLANCE_ICAL_URL |
None |
Google Calendar secret address in iCal format (.ics). |
| Device Host | --host |
GCAL_GLANCE_HOST |
10.0.4.20 |
BUSY Bar host IP or hostname (USB is always 10.0.4.20). |
| Demo Mode | --demo |
GCAL_GLANCE_DEMO |
false |
Run simulated multi-event schedule without a live calendar feed. |
| Sound | --sound--no-sound |
GCAL_GLANCE_SOUND |
true |
Enable audible alert chimes on approaching checkpoints and event start. |
| Volume | --volume |
GCAL_GLANCE_VOLUME |
None |
Speaker volume level (0–100). Default None keeps device's current volume. |
| Poll Interval | — | GCAL_GLANCE_CALENDAR_POLL_INTERVAL_SECONDS |
120 |
Frequency in seconds to re-fetch and parse the iCal feed. |
| Alert Sequence | — | GCAL_GLANCE_UPCOMING_ALERT_SEQUENCE_MINUTES |
15,5,2,0 |
Alert checkpoint intervals before event start. |
| Alert Duration | — | GCAL_GLANCE_ALERT_BANNER_DURATION_SECONDS |
15 |
Seconds the alert banner overlay stays on screen. |
🚀 Getting Started
Prerequisites
- Python 3.10+
- uv (recommended) or standard Python virtual environment
1. Normal Usage (Direct to Hardware)
The app is completely self-contained and drives the BUSY Bar directly over HTTP. Over USB, the physical bar is always reachable at 10.0.4.20.
- Open Google Calendar Settings $\rightarrow$ Select your calendar $\rightarrow$ Scroll to Integrate calendar.
- Copy your Secret address in iCal format (
https://calendar.google.com/calendar/ical/.../basic.ics). - Run the app:
uv run python app.py --ical-url "https://calendar.google.com/calendar/ical/.../basic.ics"
(Tip: To run this continuously as a background service, check out the community busybar-manager.)
2. Local Development & Hot-Reloading
For quick design iteration without relying on live calendar events or physical hardware, you can use Demo Mode and the local emulator.
Demo Mode: Run with a rich simulated multi-event schedule to preview all Aero Horizon phases and transitions:
Hot-Reloading (Dev Loop):
For instant feedback as you edit app.py, use watchfiles to automatically restart the script whenever you save a change. You can target either the physical device or the local emulator:
# Option A: Hot-reload directly to the physical BUSY Bar
uv run watchfiles "uv run python app.py --demo" app.py
# Option B: Hot-reload to the local busybar-emulator
uv run watchfiles "uv run python app.py --demo --host 127.0.0.1:8080" app.py
🏷️ Procedural 5×5 Micro-Glyphs & Emoji Engine
The Aero Horizon engine analyzes incoming calendar entries (emojis, keyword summaries, bracketed tags, and colors) and dynamically renders an illuminated 5×5 procedural micro-glyph (x=0..4, y=8..12) using 3 lightweight rectangle primitives with zero external assets.
Micro-Glyph Visual Matrix Reference
| Micro-Glyph | 5×5 LED Pixel Matrix | Category & Keywords | Supported UTF-8 Emojis | Theme Color |
|---|---|---|---|---|
Video Callvideo |
█ █ █ █ ·█ · · █ ██ · · █ ██ · · █ ██ █ █ █ · |
meet, zoom, teams, sync, 1:1, standup, call |
📹, 🎥, 📞, 📱, 🎙️, 💬, 👥, 👔 |
Blue#4285F4 |
Coffee / Breakcoffee |
· █ █ · ·█ █ █ █ ██ █ █ █ ██ █ █ █ ·█ █ █ █ · |
coffee, break, lunch, tea, hydrate, decompress |
☕, 🍵, 🧋, 🍺, 🍕, 🍔, 😎, 🌴, 🧘 |
Green#34A853 |
Deep Focusfocus(Headphones) |
· █ █ █ ·· █ · █ ·█ █ · █ ██ █ · █ ██ █ · █ █ |
focus, deep work, coding, study, dnd, reading |
🎯, 🎧, 🧠, 💡, 💻, ⌨️, 📝, 📚, 🔬 |
Purple#A142F4 |
Transit / Traveltravel(Airplane) |
· · █ · ·· · █ · ·█ █ █ █ █· · █ · ·· █ █ █ · |
travel, flight, transit, train, bus, hotel, trip |
✈️, 🛫, 🛬, 🚌, 🚍, 🚗, 🚆, 🚇, 🧳 |
Amber#FBBC05 |
Fitness / Healthfitness(Dumbbell) |
· █ █ █ ·█ █ █ █ ██ · · · ██ · · · █· █ █ █ · |
run, gym, workout, fitness, walk, swim, cycle |
🏃, 🏋️, 🚴, 🏊, ⚽, 🏀, 🥊, ❤️, 🩺 |
Green#34A853 |
Celebrationcelebrate(Sparkle Star) |
· · █ · ·· █ █ █ ·█ █ █ █ █· █ █ █ ·· · █ · · |
party, birthday, celebration, anniversary, happy hour |
🎉, 🎈, 🎂, 🥳, 🎁, 🍾, ✨, ⭐, 🌟 |
Crimson#EA4335 |
Overtimeovertime(Hourglass) |
█ █ █ █ ██ · · · ██ · █ · ██ · · · ██ █ █ █ █ |
Meeting overrun ($>0\text{m}$) | ⏳, ⌛ |
Coral Red#EA4335 |
Calendar Gridcalendar |
█ █ █ █ ██ · · · ██ █ █ █ ██ █ █ █ ██ █ █ █ █ |
General scheduled task / default fallback | 📅, 🗓️, 📆, ⏰, ⏱️ |
Blue#4285F4 |
🎨 Semantic Color Palette System
Aero Horizon employs a functional, aviation-inspired multi-tone semantic color palette designed for high contrast, zero color bleeding, and effortless peripheral recognition on the 72×16 RGB LED matrix:
| Color Swatch | Hex Code | Semantic Role | Meaning & UI Context |
|---|---|---|---|
| Ice Blue / Google Blue | #8AB4F8#4285F4#AECBFA |
Calm Idle & Meetings | Telemetry wall clock during idle states, standard scheduled meetings, virtual video calls, upcoming event radar blocks, soft sky blue marquee text, and default calendar indicators. |
| Royal Purple & Lavender | #A142F4#7B1FA2#D7AEFB |
Deep Focus & Concentration | Deep work sessions, solo concentration blocks, coding/study tags, studio headphones micro-glyph, soft lavender marquee text, and [FOCUS] status badge. |
| Emerald & Sage Green | #34A853#81C995 |
Rest, Meals & Fitness | Post-call breather mode ([15m BREAK]), lunch/coffee intervals, fitness and wellness workouts (fitness), soft mint green marquee text, and restful downtime. |
| Warning Amber | #FBBC05#FDD663 |
Urgency, Transit & Marquee | Approaching milestone alerts ([⚡ IN 5 MIN], [⚡ STARTING]), flight & transit events (travel), soft golden amber marquee stream, wrap-up warning cues ([⚡ WRAP UP]), and tight-turn buffer pip. |
| Signal Red & Coral | #EA4335#F28B82 |
Active Calls & Overtime | Active live meetings ([● LIVE CALL], [● LIVE]), Do Not Disturb status ([DO NOT DISTURB]), celebration events, soft coral marquee stream, and pulsing overtime overrun alerts ([+04m OVER]). |
| Crisp Chalk White | #FFFFFF |
Primary Foci | Top telemetry badge text (badge_txt), playhead needle (playhead), countdown timer digits, and high-priority indicators. |
| Slate Gray | #9AA0A6#303030 |
Subtext & Telemetry | Secondary metadata, physical room locations, and 25%, 50%, 75% milestone horizon track tick marks. |
| Deep Space Obsidian | #141414 |
Base Track & Contrast | Deep dark base horizon rail (radar_bg) providing true black contrast behind the rolling radar blocks and progress bars. |
🧭 Hardware Indicators & Horizon Symbols Reference
Beyond the 5×5 micro-glyphs, Aero Horizon utilizes dedicated hardware symbols and telemetry indicators across all 3 tiers:
| Indicator / Symbol | Location | Visual Element | Purpose & Behavior |
|---|---|---|---|
| Current Time Beacon | Tier 3 Left | NOW ● (x=0..1) |
Anchors the start of the rolling proximity radar window (default: 6h at $5\text{ min/px}$) at the current timestamp. |
| Active Playhead Needle | Tier 3 Rail | │ (x=0..71) |
1px crisp white playhead needle (#FFFFFF) tracking real-time linear progress through an active meeting. |
| Quarter Milestones | Tier 3 Rail | 25% · 50% · 75% |
Subtle slate tick marks (#303030) at x=18, x=36, and x=54 providing instant pacing awareness without reading numbers. |
| Tight-Turn Pip | Tier 3 Far Right | ▌ (x=70..71) |
Flashing warning pip that illuminates in Amber when downstream meeting buffer is $<5\text{ minutes}$, alerting you to prepare for an immediate next call. |
| Context Status Pill | Tier 1 Right | [NEXT 11:30A] / [TMRW 5:51A] |
Inverted badge displaying operational state or immediate next start time on the telemetry deck. |
| Milestone Alert Badge | Tier 1 Left | [⚡ IN 5 MIN] |
High-contrast amber pill displayed during the 15-second pre-meeting urgency alert window. |
| Live Call Badge | Tier 1 Left | [● LIVE CALL] |
High-contrast crimson pill verifying that a meeting is currently in session. |
| Wrap-Up Cue Badge | Tier 1 Left | [⚡ WRAP UP] |
High-contrast amber badge triggered in the final 3 minutes of a meeting to signal prompt wrap-up. |
| Overtime Pill | Tier 1 Left | [+04m OVER] |
Flashing crimson alert badge counting up exact minutes elapsed past scheduled meeting end time. |
| Breather Badge | Tier 1 Left | [☕ 15m BREAK] |
Soothing emerald pill celebrating a buffer window $\ge 10\text{ minutes}$ after a call ends. |
🧪 Testing & Verification
Run the test suite and type checker:
# Unit test suite
uv run pytest apps/gcal-glance/tests -v
# Lint & style check
uv run ruff check apps/gcal-glance
uv run ruff format --check apps/gcal-glance
Declared Dependencies
# Note: All dependencies below (httpx, pydantic, pydantic-settings) are already
# sub-dependencies of busylib, which is pre-approved according to project conventions.
busylib>=1.3.0
httpx>=0.28.0
pydantic>=2.11
pydantic-settings>=2.13Source Files
These are the mirrored source bytes. The complete package also includes every asset and supporting file.
.pre-commit-config.yaml
files: ^apps/gcal-glance/
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=lf]
- id: check-yaml
- id: check-tomlREADME.md
# GCal Glance: Google Calendar on Aero Horizon for BUSY Bar
A dedicated BUSY Bar application engineered as a high-density, near-field desktop flight deck for Google Calendar on a 72×16 RGB LED matrix. Designed to solve temporal blindness and deliver continuous situational awareness through dynamic radar horizons, procedural micro-glyphs, and multi-phase meeting lifecycle management.

---
## 🛫 Aero Horizon Design System
Aero Horizon divides the 72×16 canvas into 3 functional vertical tiers:
```
+------------------------------------------------------------------------+ y=0
| 10:48 AM 42m Focus Runway [NEXT 11:30A] | Tier 1 (y=0..7): Telemetry Deck
+------------------------------------------------------------------------+ y=7/8
| [📹] 11:30 AM - Sprint Planning - Room 3B - Then: 1:00 PM 1-on-1 | Tier 2 (y=8..13): Marquee Stream (65px)
+------------------------------------------------------------------------+ y=13/14
| NOW ● ▓▓▓▓▓ (11:30-12:00) ░░░░░░░░░ ▓▓▓▓▓▓ (13:00-13:45) | Tier 3 (y=14..15): Dual-Mode Horizon
+------------------------------------------------------------------------+ y=15
x=0 x=71
```
### 1. Tier 1: Near-Field Telemetry Deck (`y=0..7`)
- **Idle Mode**: 12h Wall Clock (`10:48 AM`), Dynamic Focus Runway (`42m Focus Runway`), and Compact Temporal Bookmark Pill:
- Today's upcoming event: `[NEXT 11:30A]`
- Tomorrow's first event: `[TMRW 5:51A]`
- Multi-day future event: `[WED 8/19 10A]`
- Clear schedule: `[FREE]`
- **Milestone Alerts**: High-contrast amber badge (`[⚡ IN 5 MIN]` / `[⚡ STARTING]`), scheduled start time, and live seconds countdown.
- **Active Mode**: Wall clock / start time, state badge (`[● LIVE CALL]`, `[● LIVE]`, `[⚡ WRAP UP]`, `[+04m OVER]`), and live remaining time.
### 2. Tier 2: Micro-Glyph & Wide Marquee Stream (`y=8..13`)
- **100% Procedural 5×5 Micro-Glyphs**: Zero binary assets. Composed dynamically using 3 rectangle primitives:
- Video Call (`video`): 4x5 bezel + 1x3 optical lens + 2x3 cutout
- Coffee / Break (`coffee`): 4x4 mug + 2x1 vapor plume + 1x2 handle
- Studio Headphones (`focus`): 5x3 earcups + 3x2 headband arch + 1x4 inner gap
- Cruiser Airplane (`travel`): 1x5 fuselage + 5x1 wingspan + 3x1 tail stabilizer
- Activity Dumbbell (`fitness`): 5x3 bar + 3x5 weight plates + cutout
- Celebration Star (`celebrate`): 1x5 vertical ray + 5x1 horizontal ray + 3x3 diamond burst
- Overtime Hourglass (`overtime`): 5x5 hourglass vessel + waist pinch
- Calendar Grid (`calendar`): 5x5 frame + top header cutout + day pin
- **Wide 65px Text Viewport**: Marquee scrolling for primary event title, location/platform, and configurable downstream schedule lookahead (default: 3 events).
### 3. Tier 3: Dual-Mode Dynamic Horizon (`y=14..15`)
- **Idle Proximity Radar**: 6-hour rolling window at $5\text{ min/px}$ (default, configurable via `--radar-window-minutes`). Visualizes current time anchor (`NOW ●` beacon at `x=0..1`) and proportional upcoming event blocks with accurate temporal spacing.
- **Precision Elapsed Horizon**: 72px active meeting gauge with 25%, 50%, and 75% tick marks (`#303030FF`) and a bright 1px white playhead needle (`#FFFFFFFF`).
- **Tight-Turn Pip (`▌`)**: Illuminates amber at `x=70` when the downstream meeting buffer is $<5\text{ minutes}$.
---
## 🔄 Multi-Phase Meeting Lifecycle
```mermaid
stateDiagram-v2
[*] --> Idle: No Active Meeting
Idle --> Alert: T-15m / T-5m / T-2m / T-0m Checkpoint
Alert --> Idle: 15s Timeout Dismissal
Idle --> Phase1: Event Starts (T-0s)
Alert --> Phase1: Event Starts (T-0s)
state "Active Meeting Lifecycle" as Active {
Phase1: Phase 1 — Title Verification (0..15s)\n[● LIVE CALL] - Location - Room
Phase2: Phase 2 — Focus Flight Deck\n[● LIVE] - 25/50/75% Ticks - Playhead
Phase3: Phase 3 — Wrap-Up Cue (Last 3m)\n[⚡ WRAP UP] - Tight-Turn Pip
Overtime: Phase 4 — Overtime Count-Up\n[+04m OVER] - Crimson Flash
Phase1 --> Phase2: After 15s
Phase2 --> Phase3: Rem <= 3m
Phase3 --> Overtime: Rem < 0s
}
Active --> InterruptedAlert: Incoming Event Alert Overlay\n(Top Alert Banner + Preserved Horizon Bar)
InterruptedAlert --> Active: 15s Alert Timeout
Active --> Breather: Call Ends & Gap >= 10m\n[☕ 15m BREAK]
Active --> Idle: Call Ends & Back to Runway
Breather --> Idle: Next Call Approaches
```
---
## 🎮 Hardware Controls & Tactile Navigation
GCal Glance integrates directly with the BUSY Bar's physical rotary encoder dial and push buttons for responsive, tactile desktop interaction:
| Input Control | Hardware Action | Operational Behavior |
| :--- | :---: | :--- |
| **Rotary Dial (Encoder)** | **Rotate Left / Right** | **Interactive Agenda Peek**: Scrubs through upcoming calendar events one-by-one (`1/N`, `2/N`, `TMRW 3/N`, etc.) across the lookahead queue. Displays exact start time, duration, room/video link, and location in real-time. Automatically dismisses back to the live flight deck after 8 seconds of inactivity. |
| **`OK` Button** | **Press** | **Instant Manual Sync**: Triggers an immediate re-fetch and re-parse of your Google Calendar feed, bypassing the normal 120-second polling interval. A bright blue LED confirmation pulse acknowledges the sync request. |
| **`BACK` Button** | **Press** | **Exit & Dismiss**: Immediately exits Agenda Peek view back to the active flight deck, or dismisses an active approaching alert banner early. |
| **`START` Button** | **Press** | **Dismiss Alert**: Clears an active milestone alert banner early to return directly to the standard runway or active meeting view. |
---
## ⚙️ Configuration & Options
All configuration options can be set via command-line arguments or `.env` environment variables (prefixed with `GCAL_GLANCE_`, with `GCAL_` and `CALSYNC_` supported as fallbacks):
| Setting | CLI Flag | Env Variable | Default | Description |
| :--- | :--- | :--- | :---: | :--- |
| **Radar Window** | `--radar-window-minutes` | `GCAL_GLANCE_RADAR_WINDOW_MINUTES` | `360` | Duration of idle rolling radar window in minutes (60–1440). `360` = 6h window ($5\text{ min/px}$). |
| **Include All-Day** | `--include-all-day` | `GCAL_GLANCE_INCLUDE_ALL_DAY` | `false` | Include all-day calendar events (`VALUE=DATE`). |
| **Lookahead Count** | `--lookahead-count` | `GCAL_GLANCE_LOOKAHEAD_COUNT` | `8` | Number of upcoming events to cycle in the idle marquee stream & dial peek (2–20). |
| **iCal Feed URL** | `--ical-url` | `GCAL_GLANCE_ICAL_URL` | `None` | Google Calendar secret address in iCal format (`.ics`). |
| **Device Host** | `--host` | `GCAL_GLANCE_HOST` | `10.0.4.20` | BUSY Bar host IP or hostname (USB is always `10.0.4.20`). |
| **Demo Mode** | `--demo` | `GCAL_GLANCE_DEMO` | `false` | Run simulated multi-event schedule without a live calendar feed. |
| **Sound** | `--sound`<br>`--no-sound` | `GCAL_GLANCE_SOUND` | `true` | Enable audible alert chimes on approaching checkpoints and event start. |
| **Volume** | `--volume` | `GCAL_GLANCE_VOLUME` | `None` | Speaker volume level (0–100). Default `None` keeps device's current volume. |
| **Poll Interval** | — | `GCAL_GLANCE_CALENDAR_POLL_INTERVAL_SECONDS` | `120` | Frequency in seconds to re-fetch and parse the iCal feed. |
| **Alert Sequence** | — | `GCAL_GLANCE_UPCOMING_ALERT_SEQUENCE_MINUTES` | `15,5,2,0` | Alert checkpoint intervals before event start. |
| **Alert Duration** | — | `GCAL_GLANCE_ALERT_BANNER_DURATION_SECONDS` | `15` | Seconds the alert banner overlay stays on screen. |
---
## 🚀 Getting Started
### Prerequisites
- Python 3.10+
- [uv](https://github.com/astral-sh/uv) (recommended) or standard Python virtual environment
### 1. Normal Usage (Direct to Hardware)
The app is completely self-contained and drives the BUSY Bar directly over HTTP. Over USB, the physical bar is always reachable at `10.0.4.20`.
1. Open Google Calendar Settings $\rightarrow$ Select your calendar $\rightarrow$ Scroll to **Integrate calendar**.
2. Copy your **Secret address in iCal format** (`https://calendar.google.com/calendar/ical/.../basic.ics`).
3. Run the app:
```bash
uv run python app.py --ical-url "https://calendar.google.com/calendar/ical/.../basic.ics"
```
*(Tip: To run this continuously as a background service, check out the community [busybar-manager](https://github.com/maxswinkels/busybar-manager).)*
### 2. Local Development & Hot-Reloading
For quick design iteration without relying on live calendar events or physical hardware, you can use Demo Mode and the local emulator.
**Demo Mode:**
Run with a rich simulated multi-event schedule to preview all Aero Horizon phases and transitions:
**Hot-Reloading (Dev Loop):**
For instant feedback as you edit `app.py`, use `watchfiles` to automatically restart the script whenever you save a change. You can target either the physical device or the local emulator:
```bash
# Option A: Hot-reload directly to the physical BUSY Bar
uv run watchfiles "uv run python app.py --demo" app.py
# Option B: Hot-reload to the local busybar-emulator
uv run watchfiles "uv run python app.py --demo --host 127.0.0.1:8080" app.py
```
---
## 🏷️ Procedural 5×5 Micro-Glyphs & Emoji Engine
The Aero Horizon engine analyzes incoming calendar entries (emojis, keyword summaries, bracketed tags, and colors) and dynamically renders an illuminated 5×5 procedural micro-glyph (`x=0..4, y=8..12`) using 3 lightweight rectangle primitives with zero external assets.
### Micro-Glyph Visual Matrix Reference
| Micro-Glyph | 5×5 LED Pixel Matrix | Category & Keywords | Supported UTF-8 Emojis | Theme Color |
| :--- | :---: | :--- | :--- | :--- |
| **Video Call**<br>`video` | `█ █ █ █ ·`<br>`█ · · █ █`<br>`█ · · █ █`<br>`█ · · █ █`<br>`█ █ █ █ ·` | `meet`, `zoom`, `teams`, `sync`, `1:1`, `standup`, `call` | `📹`, `🎥`, `📞`, `📱`, `🎙️`, `💬`, `👥`, `👔` | Blue<br>`#4285F4` |
| **Coffee / Break**<br>`coffee` | `· █ █ · ·`<br>`█ █ █ █ █`<br>`█ █ █ █ █`<br>`█ █ █ █ ·`<br>`█ █ █ █ ·` | `coffee`, `break`, `lunch`, `tea`, `hydrate`, `decompress` | `☕`, `🍵`, `🧋`, `🍺`, `🍕`, `🍔`, `😎`, `🌴`, `🧘` | Green<br>`#34A853` |
| **Deep Focus**<br>`focus`<br>*(Headphones)* | `· █ █ █ ·`<br>`· █ · █ ·`<br>`█ █ · █ █`<br>`█ █ · █ █`<br>`█ █ · █ █` | `focus`, `deep work`, `coding`, `study`, `dnd`, `reading` | `🎯`, `🎧`, `🧠`, `💡`, `💻`, `⌨️`, `📝`, `📚`, `🔬` | Purple<br>`#A142F4` |
| **Transit / Travel**<br>`travel`<br>*(Airplane)* | `· · █ · ·`<br>`· · █ · ·`<br>`█ █ █ █ █`<br>`· · █ · ·`<br>`· █ █ █ ·` | `travel`, `flight`, `transit`, `train`, `bus`, `hotel`, `trip` | `✈️`, `🛫`, `🛬`, `🚌`, `🚍`, `🚗`, `🚆`, `🚇`, `🧳` | Amber<br>`#FBBC05` |
| **Fitness / Health**<br>`fitness`<br>*(Dumbbell)* | `· █ █ █ ·`<br>`█ █ █ █ █`<br>`█ · · · █`<br>`█ · · · █`<br>`· █ █ █ ·` | `run`, `gym`, `workout`, `fitness`, `walk`, `swim`, `cycle` | `🏃`, `🏋️`, `🚴`, `🏊`, `⚽`, `🏀`, `🥊`, `❤️`, `🩺` | Green<br>`#34A853` |
| **Celebration**<br>`celebrate`<br>*(Sparkle Star)* | `· · █ · ·`<br>`· █ █ █ ·`<br>`█ █ █ █ █`<br>`· █ █ █ ·`<br>`· · █ · ·` | `party`, `birthday`, `celebration`, `anniversary`, `happy hour` | `🎉`, `🎈`, `🎂`, `🥳`, `🎁`, `🍾`, `✨`, `⭐`, `🌟` | Crimson<br>`#EA4335` |
| **Overtime**<br>`overtime`<br>*(Hourglass)* | `█ █ █ █ █`<br>`█ · · · █`<br>`█ · █ · █`<br>`█ · · · █`<br>`█ █ █ █ █` | Meeting overrun ($>0\text{m}$) | `⏳`, `⌛` | Coral Red<br>`#EA4335` |
| **Calendar Grid**<br>`calendar` | `█ █ █ █ █`<br>`█ · · · █`<br>`█ █ █ █ █`<br>`█ █ █ █ █`<br>`█ █ █ █ █` | General scheduled task / default fallback | `📅`, `🗓️`, `📆`, `⏰`, `⏱️` | Blue<br>`#4285F4` |
---
## 🎨 Semantic Color Palette System
Aero Horizon employs a functional, aviation-inspired multi-tone semantic color palette designed for high contrast, zero color bleeding, and effortless peripheral recognition on the 72×16 RGB LED matrix:
| Color Swatch | Hex Code | Semantic Role | Meaning & UI Context |
| :--- | :--- | :--- | :--- |
| **Ice Blue / Google Blue** | `#8AB4F8`<br>`#4285F4`<br>`#AECBFA` | **Calm Idle & Meetings** | Telemetry wall clock during idle states, standard scheduled meetings, virtual video calls, upcoming event radar blocks, soft sky blue marquee text, and default calendar indicators. |
| **Royal Purple & Lavender** | `#A142F4`<br>`#7B1FA2`<br>`#D7AEFB` | **Deep Focus & Concentration** | Deep work sessions, solo concentration blocks, coding/study tags, studio headphones micro-glyph, soft lavender marquee text, and `[FOCUS]` status badge. |
| **Emerald & Sage Green** | `#34A853`<br>`#81C995` | **Rest, Meals & Fitness** | Post-call breather mode (`[15m BREAK]`), lunch/coffee intervals, fitness and wellness workouts (`fitness`), soft mint green marquee text, and restful downtime. |
| **Warning Amber** | `#FBBC05`<br>`#FDD663` | **Urgency, Transit & Marquee** | Approaching milestone alerts (`[⚡ IN 5 MIN]`, `[⚡ STARTING]`), flight & transit events (`travel`), soft golden amber marquee stream, wrap-up warning cues (`[⚡ WRAP UP]`), and tight-turn buffer pip. |
| **Signal Red & Coral** | `#EA4335`<br>`#F28B82` | **Active Calls & Overtime** | Active live meetings (`[● LIVE CALL]`, `[● LIVE]`), Do Not Disturb status (`[DO NOT DISTURB]`), celebration events, soft coral marquee stream, and pulsing overtime overrun alerts (`[+04m OVER]`). |
| **Crisp Chalk White** | `#FFFFFF` | **Primary Foci** | Top telemetry badge text (`badge_txt`), playhead needle (`playhead`), countdown timer digits, and high-priority indicators. |
| **Slate Gray** | `#9AA0A6`<br>`#303030` | **Subtext & Telemetry** | Secondary metadata, physical room locations, and 25%, 50%, 75% milestone horizon track tick marks. |
| **Deep Space Obsidian** | `#141414` | **Base Track & Contrast** | Deep dark base horizon rail (`radar_bg`) providing true black contrast behind the rolling radar blocks and progress bars. |
---
## 🧭 Hardware Indicators & Horizon Symbols Reference
Beyond the 5×5 micro-glyphs, Aero Horizon utilizes dedicated hardware symbols and telemetry indicators across all 3 tiers:
| Indicator / Symbol | Location | Visual Element | Purpose & Behavior |
| :--- | :---: | :---: | :--- |
| **Current Time Beacon** | Tier 3 Left | `NOW ●` (`x=0..1`) | Anchors the start of the rolling proximity radar window (default: 6h at $5\text{ min/px}$) at the current timestamp. |
| **Active Playhead Needle** | Tier 3 Rail | `│` (`x=0..71`) | 1px crisp white playhead needle (`#FFFFFF`) tracking real-time linear progress through an active meeting. |
| **Quarter Milestones** | Tier 3 Rail | `25%` · `50%` · `75%` | Subtle slate tick marks (`#303030`) at `x=18`, `x=36`, and `x=54` providing instant pacing awareness without reading numbers. |
| **Tight-Turn Pip** | Tier 3 Far Right | `▌` (`x=70..71`) | Flashing warning pip that illuminates in Amber when downstream meeting buffer is $<5\text{ minutes}$, alerting you to prepare for an immediate next call. |
| **Context Status Pill** | Tier 1 Right | `[NEXT 11:30A]` / `[TMRW 5:51A]` | Inverted badge displaying operational state or immediate next start time on the telemetry deck. |
| **Milestone Alert Badge** | Tier 1 Left | `[⚡ IN 5 MIN]` | High-contrast amber pill displayed during the 15-second pre-meeting urgency alert window. |
| **Live Call Badge** | Tier 1 Left | `[● LIVE CALL]` | High-contrast crimson pill verifying that a meeting is currently in session. |
| **Wrap-Up Cue Badge** | Tier 1 Left | `[⚡ WRAP UP]` | High-contrast amber badge triggered in the final 3 minutes of a meeting to signal prompt wrap-up. |
| **Overtime Pill** | Tier 1 Left | `[+04m OVER]` | Flashing crimson alert badge counting up exact minutes elapsed past scheduled meeting end time. |
| **Breather Badge** | Tier 1 Left | `[☕ 15m BREAK]` | Soothing emerald pill celebrating a buffer window $\ge 10\text{ minutes}$ after a call ends. |
---
## 🧪 Testing & Verification
Run the test suite and type checker:
```bash
# Unit test suite
uv run pytest apps/gcal-glance/tests -v
# Lint & style check
uv run ruff check apps/gcal-glance
uv run ruff format --check apps/gcal-glance
```app.py
#!/usr/bin/env python3
"""GCal Glance: Google Calendar Flight Deck & Dynamic Runway (Aero Horizon).
python app.py # BUSY Bar over USB (always 10.0.4.20)
python app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
python app.py --demo # test with rich simulated schedule
python app.py --ical-url <URL> # Google Calendar secret iCal URL
"""
import argparse
import asyncio
import calendar
import json
import re
import sys
from collections.abc import Generator
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, ClassVar, Final, Literal, TypedDict
try:
from zoneinfo import ZoneInfo
except ImportError:
ZoneInfo = None # type: ignore
import httpx
from pydantic import AliasChoices, AnyHttpUrl, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
APP = "gcal-glance"
GRID_WIDTH = 72
RADAR_WINDOW_MIN = 360 # 6-Hour rolling action window (1px = 5 min)
# --- Type Definitions -------------------------------------------------------
ElementType = Literal["rectangle", "text"]
GlyphType = Literal[
"video",
"coffee",
"focus",
"travel",
"fitness",
"celebrate",
"overtime",
"calendar",
]
FontType = Literal["tiny", "small"]
AlignType = Literal["top_left", "top_right"]
FillStyle = Literal["solid", "none"]
class LabelMapping(TypedDict):
"""Configuration mapping for an event category."""
color: str
icon: GlyphType
label: str
class RectangleElement(TypedDict, total=False):
"""BUSY Bar rectangle display primitive."""
id: str
type: Literal["rectangle"]
x: int
y: int
width: int
height: int
fill: FillStyle
fill_colors: list[str]
border_width: int
border_color: str
class TextElement(TypedDict, total=False):
"""BUSY Bar text display primitive."""
id: str
type: Literal["text"]
x: int
y: int
font: FontType
color: str
text: str
align: AlignType
width: int
scroll_rate: int
scroll_start_delay: int
DisplayElement = RectangleElement | TextElement
class DisplayExtra(TypedDict):
"""Auxiliary display control metadata (priority, LED notification color)."""
priority: int
led_notification_color: str | None
# --- Settings (pydantic-settings) -------------------------------------------
# All fields are readable from environment variables with the GCAL_GLANCE_ prefix
# (or GCAL_ / CALSYNC_ fallbacks).
# Example: GCAL_GLANCE_ALERT_BANNER_DURATION_SECONDS=30
class AppConfig(BaseSettings):
model_config: ClassVar[SettingsConfigDict] = {
"env_prefix": "GCAL_GLANCE_",
"env_file": (Path(__file__).resolve().parent / ".env", ".env"),
"extra": "ignore",
}
# BUSY Bar host (overridable by --host CLI arg too)
host: str = Field(
default="10.0.4.20",
validation_alias=AliasChoices(
"GCAL_GLANCE_HOST", "GCAL_HOST", "CALSYNC_HOST", "host"
),
)
# Google Calendar secret iCal feed URL
ical_url: AnyHttpUrl | None = Field(
default=None,
validation_alias=AliasChoices(
"GCAL_GLANCE_ICAL_URL", "GCAL_ICAL_URL", "CALSYNC_ICAL_URL", "ical_url"
),
)
# Run with a simulated demo schedule instead of a live calendar
demo: bool = Field(
default=False,
validation_alias=AliasChoices(
"GCAL_GLANCE_DEMO", "GCAL_DEMO", "CALSYNC_DEMO", "demo"
),
)
# Alert interval sequence before event start (minutes, descending order)
upcoming_alert_sequence_minutes: list[int] = Field(
default=[15, 5, 2, 0],
validation_alias=AliasChoices(
"GCAL_GLANCE_UPCOMING_ALERT_SEQUENCE_MINUTES",
"GCAL_UPCOMING_ALERT_SEQUENCE_MINUTES",
"CALSYNC_UPCOMING_ALERT_SEQUENCE_MINUTES",
"upcoming_alert_sequence_minutes",
),
)
# How long (seconds) each upcoming-event alert banner stays on screen
alert_banner_duration_seconds: int = Field(
default=15,
validation_alias=AliasChoices(
"GCAL_GLANCE_ALERT_BANNER_DURATION_SECONDS",
"GCAL_ALERT_BANNER_DURATION_SECONDS",
"CALSYNC_ALERT_BANNER_DURATION_SECONDS",
"alert_banner_duration_seconds",
),
)
# How often (seconds) to re-fetch the calendar feed
calendar_poll_interval_seconds: int = Field(
default=120,
validation_alias=AliasChoices(
"GCAL_GLANCE_CALENDAR_POLL_INTERVAL_SECONDS",
"GCAL_CALENDAR_POLL_INTERVAL_SECONDS",
"CALSYNC_CALENDAR_POLL_INTERVAL_SECONDS",
"calendar_poll_interval_seconds",
),
)
# Rolling proximity radar window in minutes (default: 360 = 6h = 5 min/px)
radar_window_minutes: int = Field(
default=360,
ge=60,
le=1440,
validation_alias=AliasChoices(
"GCAL_GLANCE_RADAR_WINDOW_MINUTES",
"GCAL_RADAR_WINDOW_MINUTES",
"CALSYNC_RADAR_WINDOW_MINUTES",
"radar_window_minutes",
),
)
# Include all-day calendar events (default: False)
include_all_day: bool = Field(
default=False,
validation_alias=AliasChoices(
"GCAL_GLANCE_INCLUDE_ALL_DAY",
"GCAL_INCLUDE_ALL_DAY",
"CALSYNC_INCLUDE_ALL_DAY",
"include_all_day",
),
)
# Number of upcoming events to cycle in idle marquee & dial peek (2-20, default: 8)
lookahead_count: int = Field(
default=8,
ge=2,
le=20,
validation_alias=AliasChoices(
"GCAL_GLANCE_LOOKAHEAD_COUNT",
"GCAL_LOOKAHEAD_COUNT",
"CALSYNC_LOOKAHEAD_COUNT",
"lookahead_count",
),
)
# Enable audible alert chimes (default: True)
sound: bool = Field(
default=True,
validation_alias=AliasChoices(
"GCAL_GLANCE_SOUND",
"GCAL_SOUND",
"CALSYNC_SOUND",
"sound",
),
)
# Optional speaker volume level 0-100 (default: None to preserve hardware volume)
volume: int | None = Field(
default=None,
ge=0,
le=100,
validation_alias=AliasChoices(
"GCAL_GLANCE_VOLUME",
"GCAL_VOLUME",
"CALSYNC_VOLUME",
"volume",
),
)
@field_validator("upcoming_alert_sequence_minutes", mode="before")
@classmethod
def _parse_sequence(cls, v: object) -> object:
"""Allow GCAL_GLANCE_UPCOMING_ALERT_SEQUENCE_MINUTES='15,5,2,0' from env."""
if isinstance(v, str):
return [int(x.strip()) for x in v.split(",") if x.strip()]
return v
_CONFIG: Final[AppConfig] = AppConfig()
def get_config() -> AppConfig:
return _CONFIG
def set_config(**cli_overrides: Any) -> None:
global _CONFIG
_CONFIG = AppConfig(**cli_overrides) # type: ignore[misc] # only allowed here
# User-configurable event label/color mappings (structured — not env-driven)
LABEL_ICON_MAPPINGS: Final[dict[str, LabelMapping]] = {
"meeting": {"color": "#4285F4FF", "icon": "video", "label": "MEETING"},
"dnd": {"color": "#EA4335FF", "icon": "focus", "label": "DO NOT DISTURB"},
"focus": {"color": "#A142F4FF", "icon": "focus", "label": "DEEP WORK"},
"break": {"color": "#34A853FF", "icon": "coffee", "label": "ON BREAK"},
"coffee": {"color": "#34A853FF", "icon": "coffee", "label": "ON BREAK"},
"lunch": {"color": "#34A853FF", "icon": "coffee", "label": "LUNCH"},
"travel": {"color": "#FBBC05FF", "icon": "travel", "label": "TRAVEL"},
"fitness": {"color": "#34A853FF", "icon": "fitness", "label": "FITNESS"},
"celebrate": {"color": "#EA4335FF", "icon": "celebrate", "label": "EVENT"},
"overtime": {"color": "#EA4335FF", "icon": "overtime", "label": "OVERTIME"},
"calendar": {"color": "#4285F4FF", "icon": "calendar", "label": "CALENDAR"},
}
# fmt: off
# Common UTF-8 Emoji to 5x5 Micro-Glyph Mapping Table
EMOJI_GLYPH_MAPPINGS: Final[dict[str, GlyphType]] = {
# Transit & Travel
"🚌": "travel", "🚍": "travel", "🚎": "travel", "🚐": "travel", "🚑": "travel",
"🚒": "travel", "🚓": "travel", "🚕": "travel", "🚗": "travel", "🚙": "travel",
"🚚": "travel", "🚛": "travel", "🚜": "travel", "🏎": "travel", "🏍": "travel",
"🛵": "travel", "🚲": "travel", "🛴": "travel", "🚊": "travel", "🚝": "travel",
"🚄": "travel", "🚅": "travel", "🚆": "travel", "🚇": "travel", "🚈": "travel",
"🚉": "travel", "🚂": "travel", "✈": "travel", "🛫": "travel", "🛬": "travel",
"🛩": "travel", "🚀": "travel", "🛸": "travel", "🚁": "travel", "🛶": "travel",
"⛵": "travel", "🚤": "travel", "🛳": "travel", "🚢": "travel", "⚓": "travel",
"🧳": "travel", "🗺": "travel",
# Coffee, Food, Drinks, Break & Leisure
"☕": "coffee", "🍵": "coffee", "🧋": "coffee", "🥤": "coffee", "🍺": "coffee",
"🍻": "coffee", "🍷": "coffee", "🍸": "coffee", "🍹": "coffee", "🥂": "coffee",
"🍽": "coffee", "🍴": "coffee", "🥄": "coffee", "🍕": "coffee", "🍔": "coffee",
"🍟": "coffee", "🌭": "coffee", "🍿": "coffee", "🥪": "coffee", "🌮": "coffee",
"🌯": "coffee", "🥗": "coffee", "🍜": "coffee", "🍲": "coffee", "🍛": "coffee",
"🍣": "coffee", "🍱": "coffee", "🥟": "coffee", "🍦": "coffee", "🍧": "coffee",
"🍨": "coffee", "🍩": "coffee", "🍪": "coffee", "🎂": "celebrate", "🍰": "coffee",
"🍫": "coffee", "🍬": "coffee", "🍭": "coffee", "😎": "coffee", "🌴": "coffee",
"🏖": "coffee", "☀️": "coffee", "🧘": "coffee", "💆": "coffee", "🛋": "coffee",
"🎮": "coffee", "🎧": "coffee", "🎵": "coffee",
# Focus, Deep Work, Coding, Reading, Thinking
"🎯": "focus", "🧠": "focus", "💡": "focus", "💻": "focus", "🖥": "focus",
"⌨": "focus", "🖱": "focus", "🖨": "focus", "📝": "focus", "✏": "focus",
"🖊": "focus", "🖋": "focus", "📚": "focus", "📖": "focus", "📕": "focus",
"📗": "focus", "📘": "focus", "📙": "focus", "📓": "focus", "📒": "focus",
"🔬": "focus", "🔭": "focus", "🧪": "focus", "📐": "focus", "📏": "focus",
"🛠": "focus", "🔧": "focus", "🔨": "focus", "⚙": "focus", "⚡": "focus",
"🔥": "focus", "🔍": "focus", "🔎": "focus", "📊": "focus", "📈": "focus",
# Video & Calls
"📹": "video", "🎥": "video", "🎬": "video", "📺": "video", "📞": "video",
"📱": "video", "🎙": "video", "🎤": "video", "💬": "video", "🗣": "video",
"👥": "video", "👤": "video", "🤝": "video", "👔": "video",
# Celebration & Social
"🎉": "celebrate", "🎊": "celebrate", "🎈": "celebrate", "🥳": "celebrate",
"🎁": "celebrate", "🍾": "celebrate", "✨": "celebrate", "⭐": "celebrate",
"🌟": "celebrate", "🎆": "celebrate", "🎇": "celebrate",
# Fitness & Health
"🏃": "fitness", "🏋": "fitness", "🚴": "fitness", "🏊": "fitness", "⚽": "fitness",
"🏀": "fitness", "🏈": "fitness", "⚾": "fitness", "🎾": "fitness", "🏐": "fitness",
"🥊": "fitness", "🧗": "fitness", "🥋": "fitness", "❤️": "fitness", "🩺": "fitness",
"💊": "fitness",
# Calendar, Clock, Time
"📅": "calendar", "🗓": "calendar", "📆": "calendar", "⏰": "calendar",
"⏱": "calendar", "⏲": "calendar", "⏳": "overtime", "⌛": "overtime",
}
# fmt: on
# Display theme colours (#RRGGBBAA) - Multi-Tone Semantic Palette
COLOR_CLOCK = "#8AB4F8FF" # Soft Ice Blue (calm wall time)
COLOR_CLOCK_ALERT = "#FDD663FF" # Warm Amber (alert wall time)
COLOR_CLOCK_OVER = "#F28B82FF" # Coral Red (overtime wall time)
COLOR_CLOCK_BREAK = "#81C995FF" # Sage Green (break wall time)
COLOR_WHITE = "#FFFFFFFF" # Crisp Chalk White (main titles & playhead needle)
COLOR_MUTED = "#9AA0A6FF" # Slate Gray (subtext & metadata)
COLOR_SKY = "#AECBFAFF" # Muted Sky Blue (runway metadata)
COLOR_BLUE = "#4285F4FF" # Google Blue (meetings)
COLOR_BLUE_DARK = "#1A73E8FF" # Inverted Blue Plate
COLOR_AMBER = "#FBBC05FF" # Warning Amber (alerts & wrap-up)
COLOR_AMBER_DARK = "#7A4B00FF" # Inverted Amber Plate
COLOR_RED = "#EA4335FF" # Crimson Red (active calls & DND)
COLOR_RED_DARK = "#D93025FF" # Inverted Red Plate
COLOR_RED_DIM = "#5C130CFF" # Dim red pulse
COLOR_GREEN = "#34A853FF" # Emerald Green (breaks & lunches)
COLOR_GREEN_DARK = "#1E8E3EFF" # Inverted Green Plate
COLOR_PURPLE = "#A142F4FF" # Royal Purple (deep focus)
COLOR_PURPLE_DARK = "#9334E6FF" # Inverted Purple Plate
COLOR_RADAR_BG = "#141414FF" # Deep Obsidian Base Rail (high contrast)
COLOR_TICK = "#303030FF" # Dark Slate Milestone Pips
# Dynamic Category Marquee Soft-Tint Palette
GLYPH_TINT_COLORS: Final[dict[GlyphType, str]] = {
"travel": "#FDD663FF", # Soft Warm Amber
"video": "#AECBFAFF", # Soft Sky Blue
"focus": "#D7AEFBFF", # Soft Lavender
"coffee": "#81C995FF", # Soft Mint Green
"fitness": "#81C995FF", # Soft Mint Green
"celebrate": "#F28B82FF", # Soft Coral
"overtime": "#F28B82FF", # Soft Coral Red
"calendar": "#AECBFAFF", # Soft Sky Blue
}
def resolve_glyph_tint_color(glyph_type: GlyphType) -> str:
"""Resolve a soft illuminated text color for the marquee stream."""
return GLYPH_TINT_COLORS.get(glyph_type, COLOR_SKY)
# --- Phase 2 Tactile Controls & LED Halo Settings ---------------------------
PEEK_INACTIVITY_TIMEOUT_SECONDS: Final[int] = 6
SYNC_ACK_FLASH_DURATION_SECONDS: Final[float] = 0.5
# Top-surface LED Notification Halo Colors (#RRGGBBAA)
LED_COLOR_ALERT: Final[str] = "#FBBC05FF" # Amber pulse during alert banner
LED_COLOR_MEETING: Final[str] = "#4285F4FF" # Solid Blue during active meeting
LED_COLOR_DND: Final[str] = "#EA4335FF" # Solid Red during DND
LED_COLOR_SYNC_ACK: Final[str] = "#34A853FF" # Green flash on manual sync
# --- BUSY Bar HTTP API (Async) ----------------------------------------------
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="GCal Glance: Google Calendar for BUSY Bar (Aero Horizon)"
)
cfg = get_config()
p.add_argument("--host", default=cfg.host)
p.add_argument(
"--ical-url",
default=cfg.ical_url,
help="Google Calendar secret iCal feed URL (or set GCAL_GLANCE_ICAL_URL)",
)
p.add_argument(
"--demo",
action="store_true",
default=cfg.demo,
help=(
"use fake events for testing without a calendar feed (or set"
" GCAL_GLANCE_DEMO=true)"
),
)
p.add_argument(
"--lookahead-count",
type=int,
default=cfg.lookahead_count,
help=(
"Number of upcoming events to cycle in idle marquee & dial peek"
" (2-20, default: 8)"
),
)
p.add_argument(
"--radar-window-minutes",
type=int,
default=cfg.radar_window_minutes,
help=(
"Duration of rolling proximity radar window in minutes (60-1440,"
" default: 360 = 5 min/px)"
),
)
p.add_argument(
"--include-all-day",
action="store_true",
default=cfg.include_all_day,
help=(
"Include all-day calendar events (or set GCAL_GLANCE_INCLUDE_ALL_DAY=true)"
),
)
p.add_argument(
"--sound",
action=argparse.BooleanOptionalAction,
default=cfg.sound,
help="Enable audible alert chimes (default: True, use --no-sound to mute)",
)
p.add_argument(
"--volume",
type=int,
default=cfg.volume,
help="Speaker volume 0-100 (default: keep device volume)",
)
args, _ = p.parse_known_args()
set_config(
host=args.host,
ical_url=args.ical_url,
demo=args.demo,
lookahead_count=args.lookahead_count,
radar_window_minutes=args.radar_window_minutes,
include_all_day=args.include_all_day,
sound=args.sound,
volume=args.volume,
)
return args
ARGS: Final[argparse.Namespace] = _parse_args()
BASE: Final[str] = "http://" + get_config().host.replace("http://", "").rstrip("/")
async def draw(
client: httpx.AsyncClient, elements: list[DisplayElement], **extra: Any
) -> None:
extra = {k: v for k, v in extra.items() if v is not None}
body: dict[str, Any] = {"application_name": APP, "elements": elements, **extra}
resp = await client.post(f"{BASE}/api/display/draw", json=body, timeout=5.0)
if resp.status_code == 400:
print(f"[draw] Bad payload: {json.dumps(body, indent=2)}", file=sys.stderr)
resp.raise_for_status()
STOCK_SOUND_EVENT_START: Final[str] = "calendar_event_starts"
STOCK_SOUND_REMINDER: Final[str] = "calendar_reminder_ends"
_pending_audio_chimes: list[str] = []
_background_tasks: set[asyncio.Task[Any]] = set()
async def play_sound(
client: httpx.AsyncClient,
stock_path: str,
volume: int | None = None,
) -> None:
"""Play a stock audio chime on the BUSY Bar speaker."""
try:
if volume is not None:
try:
await client.post(
f"{BASE}/api/audio/volume",
json={"volume": volume},
timeout=2.0,
)
except Exception:
pass
await client.post(
f"{BASE}/api/audio/play",
json={"application_name": APP, "stock_path": stock_path},
timeout=3.0,
)
except Exception as e:
print(f"[audio] playback error: {e}", file=sys.stderr, flush=True)
# --- Data Models ------------------------------------------------------------
@dataclass
class CalendarEvent:
"""A single calendar event with start/end times and metadata."""
uid: str # Unique event identifier (from iCal UID)
summary: str # Event title / summary
start: datetime # Start time (timezone-aware)
end: datetime # End time (timezone-aware)
color: str = "" # Google Calendar color label (if available)
description: str = "" # Event description body
location: str = "" # Room, meeting URL, or location
@dataclass
class AlertState:
"""Tracks a currently-active alert banner on screen."""
event: CalendarEvent # The event this alert is for
checkpoint_minutes: int # Which alert milestone triggered (e.g. 15, 5, 2, 0)
expires_at: datetime # Wall-clock time when this banner should dismiss
def is_expired(self, now: datetime) -> bool:
return now >= self.expires_at
# Fired checkpoint tracker: set of (event_uid, checkpoint_minutes)
fired_checkpoints: set[tuple[str, int]] = set()
# Currently displayed alert banner (or None)
active_alert: AlertState | None = None
@dataclass
class AgendaPeekState:
"""Active Agenda Peek state for interactive rotary dial scrubbing."""
queue: list[CalendarEvent]
selected_index: int # 0-indexed
expires_at: datetime # Inactivity expiration wall-clock time
def is_expired(self, now: datetime) -> bool:
return now >= self.expires_at
# Currently active agenda peek overlay (or None)
active_peek: AgendaPeekState | None = None
# Manual sync feedback flash tracker & async trigger event
manual_sync_flash_until: datetime | None = None
_manual_sync_event: asyncio.Event = asyncio.Event()
# --- Calendar Fetcher -------------------------------------------------------
def _unfold_ical(text_content: str) -> list[str]:
"""Unfold lines in iCal according to RFC 5545."""
lines: list[str] = []
for line in text_content.splitlines():
if line.startswith((" ", "\t")) and lines:
lines[-1] += line[1:]
else:
lines.append(line)
return lines
def _unescape_ical(text_val: str) -> str:
"""Unescape special characters in iCal text fields."""
return (
text_val.replace(r"\n", "\n")
.replace(r"\,", ",")
.replace(r"\;", ";")
.replace(r"\\", "\\")
)
def _parse_ical_datetime(
val: str, params: str = "", as_utc: bool = True
) -> datetime | None:
"""Parse an iCal DTSTART/DTEND string into a timezone-aware datetime."""
val = val.strip()
is_all_day = "VALUE=DATE" in params or len(val) == 8
if is_all_day:
if not get_config().include_all_day:
return None
try:
# All-day event YYYYMMDD -> start of day in local timezone (or UTC)
dt = datetime.strptime(val, "%Y%m%d")
local_dt = dt.astimezone()
return local_dt if not as_utc else local_dt.astimezone(timezone.utc)
except Exception:
return None
try:
if val.endswith("Z"):
dt = datetime.strptime(val, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
return dt if not as_utc else dt.astimezone(timezone.utc)
if "T" in val:
dt = datetime.strptime(val, "%Y%m%dT%H%M%S")
tzid_match = re.search(r"TZID=([^;:]+)", params, re.IGNORECASE)
if tzid_match and ZoneInfo is not None:
tz_name = tzid_match.group(1).strip()
try:
tz_dt = dt.replace(tzinfo=ZoneInfo(tz_name))
return tz_dt if not as_utc else tz_dt.astimezone(timezone.utc)
except Exception:
pass
# Fallback to local system timezone -> UTC
local_dt = dt.astimezone()
return local_dt if not as_utc else local_dt.astimezone(timezone.utc)
except Exception:
pass
return None
_DAY_NAMES: Final[dict[str, int]] = {
"MO": 0,
"TU": 1,
"WE": 2,
"TH": 3,
"FR": 4,
"SA": 5,
"SU": 6,
}
def _get_nth_weekday_of_month(
year: int, month: int, weekday: int, n: int
) -> date | None:
"""Find nth weekday of month (e.g. 3rd Friday: n=3; last Friday: n=-1)."""
cal = calendar.Calendar(firstweekday=0)
month_days = [
d
for d in cal.itermonthdates(year, month)
if d.month == month and d.weekday() == weekday
]
if not month_days:
return None
if n > 0 and n <= len(month_days):
return month_days[n - 1]
elif n < 0 and abs(n) <= len(month_days):
return month_days[n]
return None
def _parse_byday(byday_str: str) -> list[tuple[int | None, int]]:
"""Parse BYDAY string (e.g. '3FR', 'MO,TU,WE') into (pos, weekday_int) list."""
results: list[tuple[int | None, int]] = []
for item in byday_str.split(","):
item = item.strip().upper()
m = re.match(r"^([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)$", item)
if m:
pos = int(m.group(1)) if m.group(1) else None
wd = _DAY_NAMES[m.group(2)]
results.append((pos, wd))
return results
def _expand_rrule(
dtstart: datetime,
rrule_str: str,
window_start: datetime,
window_end: datetime,
max_occurrences: int = 100,
) -> list[datetime]:
"""Pure stdlib RFC 5545 recurrence expansion preserving timezone across DST."""
props: dict[str, str] = {}
for part in rrule_str.split(";"):
if "=" in part:
k, v = part.split("=", 1)
props[k.strip().upper()] = v.strip()
freq = props.get("FREQ", "DAILY").upper()
interval = max(1, int(props.get("INTERVAL", "1")))
count = int(props["COUNT"]) if "COUNT" in props else None
until_dt: datetime | None = None
if "UNTIL" in props:
u_val = props["UNTIL"]
if len(u_val) == 8:
u_y, u_m, u_d = int(u_val[:4]), int(u_val[4:6]), int(u_val[6:8])
until_dt = datetime(u_y, u_m, u_d, 23, 59, 59, tzinfo=timezone.utc)
elif "T" in u_val:
u_clean = u_val.rstrip("Z")
u_y, u_m, u_d = int(u_clean[:4]), int(u_clean[4:6]), int(u_clean[6:8])
hh, mm = int(u_clean[9:11]), int(u_clean[11:13])
ss = int(u_clean[13:15]) if len(u_clean) >= 15 else 0
until_dt = datetime(u_y, u_m, u_d, hh, mm, ss, tzinfo=timezone.utc)
byday = _parse_byday(props["BYDAY"]) if "BYDAY" in props else []
bymonthday = (
[int(x) for x in props["BYMONTHDAY"].split(",") if x.isdigit()]
if "BYMONTHDAY" in props
else []
)
tz = dtstart.tzinfo or timezone.utc
start_date = dtstart.date()
results: list[datetime] = []
seen_count = 0
def check_and_add(cand_date: date) -> bool:
nonlocal seen_count
if cand_date < start_date:
return True
try:
cand_dt = datetime.combine(cand_date, dtstart.time(), tzinfo=tz)
except Exception:
return True
cand_utc = cand_dt.astimezone(timezone.utc)
if until_dt is not None and cand_utc > until_dt:
return False
seen_count += 1
if count is not None and seen_count > count:
return False
if cand_utc >= window_start:
if cand_utc <= window_end:
results.append(cand_utc)
elif cand_utc > window_end:
return False
return len(results) < max_occurrences
if freq == "DAILY":
curr = start_date
win_start_local = window_start.astimezone(tz).date()
if count is None and curr < win_start_local:
days_behind = (win_start_local - curr).days
steps = days_behind // interval
curr += timedelta(days=steps * interval)
while curr <= window_end.astimezone(tz).date() + timedelta(days=1):
if not check_and_add(curr):
break
curr += timedelta(days=interval)
elif freq == "WEEKLY":
curr_week_start = start_date - timedelta(days=start_date.weekday())
win_start_local = window_start.astimezone(tz).date()
if count is None and curr_week_start < win_start_local - timedelta(days=7):
weeks_behind = (win_start_local - curr_week_start).days // 7
steps = weeks_behind // interval
curr_week_start += timedelta(weeks=steps * interval)
target_days = [wd for (_, wd) in byday] if byday else [dtstart.weekday()]
target_days.sort()
stop = False
while not stop:
for wd in target_days:
cand_date = curr_week_start + timedelta(days=wd)
if cand_date < start_date:
continue
if not check_and_add(cand_date):
stop = True
break
curr_week_start += timedelta(weeks=interval)
if curr_week_start > window_end.astimezone(tz).date() + timedelta(days=7):
break
elif freq == "MONTHLY":
curr_year = start_date.year
curr_month = start_date.month
win_start_local = window_start.astimezone(tz).date()
if count is None:
months_diff = (win_start_local.year - curr_year) * 12 + (
win_start_local.month - curr_month
)
if months_diff > 1:
steps = months_diff // interval
total_months = (curr_year * 12 + curr_month - 1) + steps * interval
curr_year = total_months // 12
curr_month = (total_months % 12) + 1
stop = False
while not stop:
if byday:
for pos, wd in byday:
if pos is not None:
d_pos = _get_nth_weekday_of_month(
curr_year, curr_month, wd, pos
)
if d_pos and not check_and_add(d_pos):
stop = True
break
else:
cal = calendar.Calendar(firstweekday=0)
for d_day in cal.itermonthdates(curr_year, curr_month):
if d_day.month == curr_month and d_day.weekday() == wd:
if not check_and_add(d_day):
stop = True
break
elif bymonthday:
for bmd in bymonthday:
try:
d_bmd = date(curr_year, curr_month, bmd)
if not check_and_add(d_bmd):
stop = True
break
except ValueError:
pass
else:
try:
d_simple = date(curr_year, curr_month, start_date.day)
if not check_and_add(d_simple):
stop = True
break
except ValueError:
pass
total_m = (curr_year * 12 + curr_month - 1) + interval
curr_year = total_m // 12
curr_month = (total_m % 12) + 1
if date(curr_year, curr_month, 1) > window_end.astimezone(
tz
).date() + timedelta(days=32):
break
elif freq == "YEARLY":
curr_year = start_date.year
win_start_local = window_start.astimezone(tz).date()
if count is None and curr_year < win_start_local.year - 1:
years_diff = win_start_local.year - curr_year
steps = years_diff // interval
curr_year += steps * interval
while True:
try:
d_yr = date(curr_year, start_date.month, start_date.day)
if not check_and_add(d_yr):
break
except ValueError:
pass
curr_year += interval
if curr_year > window_end.astimezone(tz).date().year + 1:
break
results.sort()
return results
async def fetch_events(
client: httpx.AsyncClient, ical_url: AnyHttpUrl | str
) -> list[CalendarEvent] | None:
"""Fetch an iCal .ics URL and return future CalendarEvents sorted by start time."""
try:
resp = await client.get(
str(ical_url),
headers={"User-Agent": "BUSYBar-CalendarSync/1.0"},
timeout=15.0,
)
resp.raise_for_status()
content = resp.text
except Exception as e:
print(
f"[fetch] warning: failed to fetch ical feed: {e}",
file=sys.stderr,
flush=True,
)
return None
try:
lines = _unfold_ical(content)
raw_events: list[dict[str, Any]] = []
in_vevent = False
event_data: dict[str, Any] = {}
now = datetime.now(timezone.utc)
window_end = now + timedelta(days=60)
for line in lines:
line_str = line.strip()
if line_str == "BEGIN:VEVENT":
in_vevent = True
event_data = {"EXDATES": []}
elif line_str == "END:VEVENT":
if in_vevent:
in_vevent = False
raw_events.append(dict(event_data))
elif in_vevent:
if ":" in line:
key_part, val = line.split(":", 1)
if ";" in key_part:
key, params = key_part.split(";", 1)
else:
key, params = key_part, ""
key_upper = key.upper()
if key_upper == "EXDATE":
event_data.setdefault("EXDATES", []).append((val, params))
elif key_upper == "RECURRENCE-ID":
event_data["RECURRENCE_ID_VAL"] = val
event_data["RECURRENCE_ID_PARAMS"] = params
elif key_upper in (
"UID",
"SUMMARY",
"DESCRIPTION",
"LOCATION",
"COLOR",
"X-APPLE-CALENDAR-COLOR",
"RRULE",
"STATUS",
):
event_data[key_upper] = val
elif key_upper == "DTSTART":
event_data["DTSTART_VAL"] = val
event_data["DTSTART_PARAMS"] = params
elif key_upper == "DTEND":
event_data["DTEND_VAL"] = val
event_data["DTEND_PARAMS"] = params
# 1. Collect overridden recurrence instances to suppress base RRULE occurrences
overridden_recs: set[tuple[str, datetime]] = set()
for ed in raw_events:
uid = ed.get("UID", "")
rec_val = ed.get("RECURRENCE_ID_VAL")
rec_params = ed.get("RECURRENCE_ID_PARAMS", "")
if rec_val and uid:
rec_dt = _parse_ical_datetime(rec_val, rec_params, as_utc=True)
if rec_dt is not None:
overridden_recs.add((uid, rec_dt))
events: list[CalendarEvent] = []
for ed in raw_events:
uid = ed.get("UID", "")
status = ed.get("STATUS", "").upper()
if status == "CANCELLED":
continue
summary = _unescape_ical(ed.get("SUMMARY", "(No title)"))
description = _unescape_ical(ed.get("DESCRIPTION", ""))
location = _unescape_ical(ed.get("LOCATION", ""))
color = ed.get("COLOR", "") or ed.get("X-APPLE-CALENDAR-COLOR", "")
dtstart_val = ed.get("DTSTART_VAL", "")
dtstart_params = ed.get("DTSTART_PARAMS", "")
dtend_val = ed.get("DTEND_VAL", "")
dtend_params = ed.get("DTEND_PARAMS", "")
start_dt_raw = _parse_ical_datetime(
dtstart_val, dtstart_params, as_utc=False
)
if start_dt_raw is None:
continue
end_dt_raw = (
_parse_ical_datetime(dtend_val, dtend_params, as_utc=False)
if dtend_val
else None
)
is_all_day_evt = (
"VALUE=DATE" in dtstart_params or len(dtstart_val.strip()) == 8
)
if end_dt_raw is None:
end_dt_raw = start_dt_raw + (
timedelta(days=1) if is_all_day_evt else timedelta(hours=1)
)
duration = max(timedelta(seconds=0), end_dt_raw - start_dt_raw)
start_dt = start_dt_raw.astimezone(timezone.utc)
end_dt = end_dt_raw.astimezone(timezone.utc)
rrule_str = ed.get("RRULE")
if rrule_str:
exdates: set[datetime] = set()
for ex_val, ex_params in ed.get("EXDATES", []):
for part in ex_val.split(","):
ex_dt = _parse_ical_datetime(
part.strip(), ex_params, as_utc=True
)
if ex_dt is not None:
exdates.add(ex_dt)
try:
for occ_start in _expand_rrule(
start_dt_raw,
rrule_str,
now - timedelta(hours=1),
window_end,
):
if occ_start in exdates or (uid, occ_start) in overridden_recs:
continue
occ_end = occ_start + duration
if occ_end > now:
events.append(
CalendarEvent(
uid=f"{uid}_{occ_start.isoformat()}",
summary=summary,
start=occ_start,
end=occ_end,
color=color,
description=description,
location=location,
)
)
except Exception:
if end_dt > now:
events.append(
CalendarEvent(
uid=uid,
summary=summary,
start=start_dt,
end=end_dt,
color=color,
description=description,
location=location,
)
)
else:
if end_dt > now:
events.append(
CalendarEvent(
uid=uid,
summary=summary,
start=start_dt,
end=end_dt,
color=color,
description=description,
location=location,
)
)
events.sort(key=lambda e: e.start)
return events
except Exception as e:
print(
f"[fetch] warning: error parsing ical feed: {e}",
file=sys.stderr,
flush=True,
)
return None
def demo_events() -> list[CalendarEvent]:
"""Return realistic fake events for testing Aero Horizon states."""
now = datetime.now(timezone.utc)
return [
CalendarEvent(
"demo-1",
"Sprint Planning",
now + timedelta(minutes=4),
now + timedelta(minutes=34),
location="Room 3B",
),
CalendarEvent(
"demo-2",
"[focus] Deep Work / Architecture",
now + timedelta(minutes=36),
now + timedelta(minutes=90),
location="Desk",
),
CalendarEvent(
"demo-3",
"[coffee] 1-on-1 Sync",
now + timedelta(minutes=105),
now + timedelta(minutes=135),
location="Cafeteria",
),
]
# --- Invariant Element & Helper Functions ------------------------------------
def make_rect(
elem_id: str,
x: int,
y: int,
width: int,
height: int,
fill: str,
border_width: int = 0,
border_color: str | None = None,
) -> RectangleElement:
"""Create a fully-specified rectangle with fill and explicit border width."""
elem: RectangleElement = {
"id": elem_id,
"type": "rectangle",
"x": x,
"y": y,
"width": max(1, width),
"height": max(1, height),
"fill": "none" if fill.endswith("00") else "solid",
"fill_colors": [fill],
"border_width": border_width,
}
if border_color:
elem["border_color"] = border_color
return elem
def park_rect(elem_id: str) -> RectangleElement:
"""Park an inactive rectangle safely off-screen."""
return make_rect(elem_id, -100, -100, 1, 1, "#00000000")
def park_text(elem_id: str, text: str = "") -> TextElement:
"""Park an inactive text element safely off-screen."""
return {
"id": elem_id,
"type": "text",
"x": -100,
"y": -100,
"font": "tiny",
"color": "#00000000",
"text": text,
}
def fmt_12h(dt: datetime, include_ampm: bool = True) -> str:
"""Format a timezone-aware datetime in 12-hour format without leading zero."""
local_dt = dt.astimezone()
hour = local_dt.hour % 12
if hour == 0:
hour = 12
ampm = "AM" if local_dt.hour < 12 else "PM"
if include_ampm:
return f"{hour}:{local_dt.minute:02d} {ampm}"
return f"{hour}:{local_dt.minute:02d}"
def fmt_compact_12h(dt: datetime) -> str:
"""Format datetime into compact 12h representation for status pills.
Examples: '5:51A', '10A', '1:30P'.
"""
local_dt = dt.astimezone()
hour = local_dt.hour % 12
if hour == 0:
hour = 12
ampm = "A" if local_dt.hour < 12 else "P"
if local_dt.minute == 0:
return f"{hour}{ampm}"
return f"{hour}:{local_dt.minute:02d}{ampm}"
def fmt_countdown(total_seconds: float) -> str:
"""Format seconds into compact mm:ss or XhXXm."""
sec = max(0, int(total_seconds))
if sec < 3600:
m, s = divmod(sec, 60)
return f"{m:02d}:{s:02d}"
h, m = divmod(sec // 60, 60)
return f"{h}h{m:02d}m"
def fmt_remaining(delta: timedelta) -> str:
"""Format a timedelta as a compact remaining-time string."""
total_minutes = max(0, int(delta.total_seconds()) // 60)
if total_minutes >= 60:
h, m = divmod(total_minutes, 60)
return f"{h}h{m:02d}m"
return f"{total_minutes}m"
def resolve_label(event: CalendarEvent) -> str:
"""Resolve an event to a display label using LABEL_ICON_MAPPINGS."""
title = event.summary.lower()
for key, mapping in LABEL_ICON_MAPPINGS.items():
if f"[{key}]" in title:
return mapping["label"]
for mapping in LABEL_ICON_MAPPINGS.values():
if event.color and event.color.upper().startswith(mapping["color"][:7].upper()):
return mapping["label"]
return "IN MEETING"
def clean_location(location: str, description: str = "") -> str:
"""Sanitize and extract a clean room name or meeting platform label."""
raw = location.strip() if location else ""
desc = description.strip() if description else ""
# If location field is populated
if raw:
if "http://" in raw or "https://" in raw:
parts = re.split(r"https?://\S+", raw)
clean_part = parts[0].strip(" -·,;/")
if clean_part:
return clean_part[:30]
# Location only contained a URL
raw_lower = raw.lower()
if "meet.google.com" in raw_lower:
return "Google Meet"
if "zoom.us" in raw_lower or "zoomgov.com" in raw_lower:
return "Zoom"
if "teams.microsoft.com" in raw_lower or "teams.live.com" in raw_lower:
return "Teams"
if "webex.com" in raw_lower:
return "Webex"
if "slack.com" in raw_lower:
return "Slack Huddle"
return "Video Link"
return raw[:30]
# If location is empty, check description for platform links
if desc:
desc_lower = desc.lower()
if "meet.google.com" in desc_lower:
return "Google Meet"
if "zoom.us" in desc_lower or "zoomgov.com" in desc_lower:
return "Zoom"
if "teams.microsoft.com" in desc_lower or "teams.live.com" in desc_lower:
return "Teams"
if "webex.com" in desc_lower:
return "Webex"
if "slack.com" in desc_lower:
return "Slack Huddle"
return ""
def sanitize_display_text(text: str) -> str:
"""Sanitize text to printable ASCII to prevent missing glyph boxes on hardware."""
if not text:
return ""
# Map common unicode punctuation/symbols to clean ASCII equivalents
text = (
text.replace("\u00b7", " - ") # Middle dot
.replace("\u2022", " - ") # Bullet
.replace("\u2014", " - ") # Em dash
.replace("\u2013", " - ") # En dash
.replace("\u201c", '"') # Left double quote
.replace("\u201d", '"') # Right double quote
.replace("\u2018", "'") # Left single quote
.replace("\u2019", "'") # Right single quote
.replace("\u2026", "...") # Horizontal ellipsis
)
# Strip any remaining non-ASCII characters (e.g. emojis, symbols)
cleaned = "".join(c for c in text if 32 <= ord(c) <= 126)
# Normalize multiple whitespace
cleaned = re.sub(r"\s+", " ", cleaned)
# Normalize multiple dashes
cleaned = re.sub(r"(\s+-\s+)+", " - ", cleaned)
return cleaned.strip(" -")
def format_idle_badge(now: datetime, upcoming_events: list[CalendarEvent]) -> str:
"""Format a compact temporal bookmark pill for Tier 1 top-right."""
if not upcoming_events:
return "FREE"
next_evt = upcoming_events[0]
now_local = now.astimezone()
next_start_local = next_evt.start.astimezone()
compact_time = fmt_compact_12h(next_evt.start)
delta_days = (next_start_local.date() - now_local.date()).days
if delta_days <= 0:
return f"NEXT {compact_time}"
elif delta_days == 1:
return f"TMRW {compact_time}"
elif delta_days < 7:
day_name = next_start_local.strftime("%a").upper()
return f"{day_name} {compact_time}"
else:
day_name = next_start_local.strftime("%a").upper()
return (
f"{day_name} {next_start_local.month}/{next_start_local.day} {compact_time}"
)
def format_idle_marquee(
now: datetime,
upcoming_events: list[CalendarEvent],
lookahead_count: int,
) -> str:
"""Build a rich marquee stream with configurable multi-event lookahead."""
if not upcoming_events:
return "Schedule clear"
now_local = now.astimezone()
next_evt = upcoming_events[0]
next_start_local = next_evt.start.astimezone()
first_summary = sanitize_display_text(next_evt.summary) or "Event"
first_loc = sanitize_display_text(
clean_location(next_evt.location, next_evt.description)
)
first_loc_part = f" - {first_loc}" if first_loc else ""
# If first event is today, include start time (e.g. '5:51 PM - Travel')
# If on a future day, its start time and day are in the pill [TMRW 5:51A],
# so we start directly with the event title and details!
if next_start_local.date() == now_local.date():
time_str = fmt_12h(next_evt.start, include_ampm=True)
parts = [f"{time_str} - {first_summary}{first_loc_part}"]
else:
parts = [f"{first_summary}{first_loc_part}"]
# Downstream lookahead stream (up to lookahead_count items)
prev_date = next_start_local.date()
for evt in upcoming_events[1 : max(1, lookahead_count)]:
evt_local = evt.start.astimezone()
evt_summary = sanitize_display_text(evt.summary) or "Event"
evt_loc = sanitize_display_text(clean_location(evt.location, evt.description))
evt_loc_part = f" ({evt_loc})" if evt_loc else ""
if evt_local.date() == prev_date:
time_str = fmt_12h(evt.start, include_ampm=True)
parts.append(f"[THEN {time_str}] {evt_summary}{evt_loc_part}")
else:
delta_days = (evt_local.date() - now_local.date()).days
time_str = fmt_12h(evt.start, include_ampm=True)
day_name = evt_local.strftime("%a")
if delta_days < 7:
parts.append(
f"[THEN {day_name} {time_str}] {evt_summary}{evt_loc_part}"
)
else:
date_str = f"{evt_local.month}/{evt_local.day}"
tag = f"[THEN {day_name} {date_str} {time_str}]"
parts.append(f"{tag} {evt_summary}{evt_loc_part}")
prev_date = evt_local.date()
return " - ".join(parts)
def build_peek_queue(
now: datetime,
events: list[CalendarEvent],
lookahead_count: int,
) -> list[CalendarEvent]:
"""Compose the lookahead peek queue for rotary dial navigation."""
if not events:
return []
now_local = now.astimezone()
today_date = now_local.date()
today_events = [e for e in events if e.start.astimezone().date() == today_date]
today_events.sort(key=lambda e: e.start)
future_events = [e for e in events if e.start.astimezone().date() > today_date]
future_events.sort(key=lambda e: e.start)
if today_events:
return list(today_events) + future_events[: max(1, lookahead_count)]
return future_events[: max(1, lookahead_count)]
def format_peek_bookmark(
now: datetime,
event: CalendarEvent,
index: int,
total: int,
) -> str:
"""Format the index bookmark pill for dial peek (e.g. 1/4, TMRW 2/4, WED 3/4)."""
now_local = now.astimezone()
evt_start_local = event.start.astimezone()
delta_days = (evt_start_local.date() - now_local.date()).days
if delta_days <= 0:
return f"{index}/{total}"
elif delta_days == 1:
return f"TMRW {index}/{total}"
elif delta_days < 7:
day_name = evt_start_local.strftime("%a").upper()
return f"{day_name} {index}/{total}"
else:
day_name = evt_start_local.strftime("%a").upper()
return (
f"{day_name} {evt_start_local.month}/{evt_start_local.day} {index}/{total}"
)
def format_relative_delta(now: datetime, event: CalendarEvent) -> str:
"""Format relative delta badge for peek view (e.g. 'in 42m', 'LIVE')."""
if event.start <= now < event.end:
return "LIVE"
if now >= event.end:
ended_min = max(0, int((now - event.end).total_seconds() / 60.0))
if ended_min < 60:
return f"ended {ended_min}m"
ended_hrs = ended_min // 60
return f"ended {ended_hrs}h"
delta_sec = (event.start - now).total_seconds()
delta_min = max(0, int(delta_sec / 60.0))
if delta_min < 60:
return f"in {delta_min}m"
delta_hrs = delta_min // 60
if delta_hrs < 24:
return f"in {delta_hrs}h"
delta_days = (event.start.astimezone().date() - now.astimezone().date()).days
return f"in {delta_days}d"
def resolve_glyph_type(event: CalendarEvent, is_overtime: bool = False) -> GlyphType:
"""Determine the 5x5 procedural micro-glyph type for an event."""
if is_overtime:
return "overtime"
# 1. UTF-8 Emoji scanning across summary and description
content = f"{event.summary} {event.description}"
for emoji_char, glyph in EMOJI_GLYPH_MAPPINGS.items():
if emoji_char in content:
return glyph
# 2. Tag brackets (e.g. [focus], [video], [travel])
title = event.summary.lower()
for key, mapping in LABEL_ICON_MAPPINGS.items():
if f"[{key}]" in title:
return mapping["icon"]
# 3. Keyword semantic analysis
if re.search(
r"\b(travel|flight|transit|airport|train|bus|drive|commute|trip|hotel|reservation)\b",
title,
):
return "travel"
if re.search(
r"\b(run|running|gym|workout|fitness|exercise|walk|swim|cycle|biking)\b",
title,
):
return "fitness"
if re.search(
r"\b(party|birthday|celebration|anniversary|happy hour|ceremony)\b",
title,
):
return "celebrate"
if re.search(r"\b(coffee|break|lunch|hydrate|tea|decompress|relax)\b", title):
return "coffee"
if re.search(
r"\b(focus|deep\s+work|dnd|study|coding|code|deepwork|reading)\b",
title,
):
return "focus"
if re.search(
r"\b(zoom|meet|teams|sync|standup|call|meeting|1:1|1-on-1)\b",
title,
):
return "video"
# 4. Location meeting URLs
loc_desc = f"{event.location} {event.description}".lower()
if any(
k in loc_desc
for k in ("meet.google.com", "zoom.us", "teams.microsoft.com", "webex.com")
):
return "video"
# 5. Calendar color hints
if event.color:
c = event.color.upper()
if c.startswith("#34A853"):
return "coffee"
if c.startswith("#A142F4"):
return "focus"
if c.startswith("#FBBC05"):
return "travel"
return "calendar"
def make_glyph_elements(glyph_type: GlyphType, color: str) -> list[RectangleElement]:
"""Generate 3 procedural rectangle primitives for a 5x5 micro-glyph."""
if glyph_type == "video":
# 5x5 Camera Body with Right Lens
return [
make_rect("glyph_0", 0, 8, 4, 5, color),
make_rect("glyph_1", 1, 9, 2, 3, "#000000FF"),
make_rect("glyph_2", 4, 9, 1, 3, color),
]
elif glyph_type == "coffee":
# 5x5 Coffee Mug with Handle and Steam Plume
return [
make_rect("glyph_0", 0, 9, 4, 4, color),
make_rect("glyph_1", 1, 8, 2, 1, color),
make_rect("glyph_2", 4, 9, 1, 2, color),
]
elif glyph_type == "focus":
# 5x5 Studio Headphones (Overhead arch + Cushioned earcups)
return [
make_rect("glyph_0", 0, 10, 5, 3, color),
make_rect("glyph_1", 1, 8, 3, 2, color),
make_rect("glyph_2", 2, 9, 1, 4, "#000000FF"),
]
elif glyph_type == "travel":
# 5x5 Airplane (Fuselage + 5px Wingspan + 3px Tail Stabilizer)
return [
make_rect("glyph_0", 2, 8, 1, 5, color),
make_rect("glyph_1", 0, 10, 5, 1, color),
make_rect("glyph_2", 1, 12, 3, 1, color),
]
elif glyph_type == "fitness":
# 5x5 Dumbbell / Activity profile
return [
make_rect("glyph_0", 0, 9, 5, 3, color),
make_rect("glyph_1", 1, 8, 3, 5, color),
make_rect("glyph_2", 1, 10, 3, 2, "#000000FF"),
]
elif glyph_type == "celebrate":
# 5x5 Sparkle / Celebration star
return [
make_rect("glyph_0", 2, 8, 1, 5, color),
make_rect("glyph_1", 0, 10, 5, 1, color),
make_rect("glyph_2", 1, 9, 3, 3, color),
]
elif glyph_type == "overtime":
# 5x5 Hourglass Vessel
return [
make_rect("glyph_0", 0, 8, 5, 5, color),
make_rect("glyph_1", 1, 9, 3, 3, "#000000FF"),
make_rect("glyph_2", 2, 10, 1, 1, color),
]
else:
# Default 5x5 Calendar Frame with Header Cutout
return [
make_rect("glyph_0", 0, 8, 5, 5, color),
make_rect("glyph_1", 1, 9, 3, 1, "#000000FF"),
make_rect("glyph_2", 2, 11, 1, 1, COLOR_WHITE),
]
# --- Exact Font Measurement Engine (Firmware True) ---------------------------
# fmt: off
TINY_FONT_ADVANCES: Final[dict[str, int]] = {
"0": 4, "1": 4, "2": 4, "3": 4, "4": 4, "5": 4, "6": 4, "7": 4, "8": 4, "9": 4,
" ": 3, "!": 2, '"': 4, "#": 6, "$": 4, "%": 5, "&": 5, "'": 2, "(": 3, ")": 3,
"*": 4, "+": 4, ",": 2, "-": 4, ".": 2, "/": 5, ":": 2, ";": 2, "<": 4, "=": 4,
">": 4, "?": 4, "@": 4, "[": 3, "\\": 5, "]": 3, "^": 4, "_": 4, "`": 3, "{": 4,
"|": 2, "}": 4, "~": 5,
"A": 4, "B": 4, "C": 4, "D": 4, "E": 4, "F": 4, "G": 4, "H": 4, "I": 4, "J": 4,
"K": 5, "L": 4, "M": 6, "N": 5, "O": 5, "P": 4, "Q": 5, "R": 4, "S": 4, "T": 4,
"U": 5, "V": 6, "W": 6, "X": 4, "Y": 6, "Z": 5,
"a": 4, "b": 4, "c": 4, "d": 4, "e": 4, "f": 3, "g": 3, "h": 4, "i": 2, "j": 2,
"k": 4, "l": 2, "m": 6, "n": 4, "o": 4, "p": 4, "q": 4, "r": 3, "s": 4, "t": 4,
"u": 4, "v": 4, "w": 6, "x": 4, "y": 4, "z": 4,
}
TINY_FONT_WIDTHS: Final[dict[str, int]] = {
"0": 3, "1": 3, "2": 3, "3": 3, "4": 3, "5": 3, "6": 3, "7": 3, "8": 3, "9": 3,
" ": 0, "!": 1, '"': 3, "#": 5, "$": 3, "%": 4, "&": 4, "'": 1, "(": 2, ")": 2,
"*": 3, "+": 3, ",": 1, "-": 3, ".": 1, "/": 4, ":": 1, ";": 1, "<": 3, "=": 3,
">": 3, "?": 3, "@": 3, "[": 2, "\\": 4, "]": 2, "^": 3, "_": 3, "`": 2, "{": 3,
"|": 1, "}": 3, "~": 4,
"A": 3, "B": 3, "C": 3, "D": 3, "E": 3, "F": 3, "G": 3, "H": 3, "I": 3, "J": 3,
"K": 4, "L": 3, "M": 5, "N": 4, "O": 4, "P": 3, "Q": 4, "R": 3, "S": 3, "T": 3,
"U": 4, "V": 5, "W": 5, "X": 3, "Y": 5, "Z": 4,
"a": 3, "b": 3, "c": 3, "d": 3, "e": 3, "f": 2, "g": 2, "h": 3, "i": 1, "j": 1,
"k": 3, "l": 1, "m": 5, "n": 3, "o": 3, "p": 3, "q": 3, "r": 2, "s": 3, "t": 3,
"u": 3, "v": 3, "w": 5, "x": 3, "y": 3, "z": 3,
}
# fmt: on
def measure_tiny_text(text: str) -> int:
"""Measure exact visual pixel width (lit bounds) rendered by BUSY Bar tiny font."""
pen = 0
max_right = 0
for ch in text:
w = TINY_FONT_WIDTHS.get(ch, 3)
adv = TINY_FONT_ADVANCES.get(ch, 4)
max_right = max(max_right, pen + w)
pen += adv
return max(1, max_right)
def measure_badge_width(text: str, left_pad: int = 1, right_pad: int = 1) -> int:
"""Calculate exact width of the background plate so text NEVER overflows."""
return measure_tiny_text(text) + left_pad + right_pad
def make_pill(
pill_id: str,
text: str,
x: int = 0,
y: int = 0,
bg_color: str = COLOR_BLUE_DARK,
text_color: str = COLOR_WHITE,
font: FontType = "tiny",
margin_x: int = 1,
margin_y: int = 0,
height: int = 7,
align: AlignType = "top_left",
) -> tuple[RectangleElement, TextElement]:
"""Generate an encapsulated status pill component (bg rectangle + text).
Automatically computes the exact rendered pixel width of `text` based on
firmware font glyph metrics and wraps it with `margin_x` on both sides.
Returns (bg_rectangle_element, text_element).
"""
text_w = measure_tiny_text(text)
plate_w = text_w + (margin_x * 2)
if align == "top_right":
rect_x = x - plate_w + 1
else:
rect_x = x
text_x = rect_x + margin_x
rect_elem = make_rect(f"{pill_id}_bg", rect_x, y, plate_w, height, bg_color)
text_elem: TextElement = {
"id": f"{pill_id}_txt",
"type": "text",
"x": text_x,
"y": y + margin_y,
"font": font,
"color": text_color,
"text": text,
"align": "top_left",
}
return rect_elem, text_elem
# --- Aero Horizon Render Functions ------------------------------------------
def render_aero_idle(
now: datetime, upcoming_events: list[CalendarEvent]
) -> tuple[list[DisplayElement], DisplayExtra]:
"""Render Aero Horizon State 1: Idle Focus Runway with Rolling 3h Radar."""
next_evt = upcoming_events[0] if upcoming_events else None
clock_str = fmt_12h(now, include_ampm=True)
if next_evt:
now_local = now.astimezone()
next_start_local = next_evt.start.astimezone()
if next_start_local.date() == now_local.date():
free_min = max(0, int((next_evt.start - now).total_seconds() / 60.0))
runway_str = f"{free_min}m Focus Runway"
elif next_start_local.date() == now_local.date() + timedelta(days=1):
runway_str = "Rest of Day Free"
else:
runway_str = "Schedule Clear"
next_pill = format_idle_badge(now, upcoming_events)
lookahead = get_config().lookahead_count
title_str = format_idle_marquee(now, upcoming_events, lookahead_count=lookahead)
glyph_type = resolve_glyph_type(next_evt)
badge_bg = COLOR_BLUE_DARK
title_color = resolve_glyph_tint_color(glyph_type)
else:
runway_str = "Schedule Clear"
next_pill = "FREE"
title_str = "Schedule clear"
glyph_type = "calendar"
badge_bg = "#253342FF"
title_color = COLOR_SKY
badge_bg_elem, badge_txt_elem = make_pill(
"badge", next_pill, x=71, y=0, bg_color=badge_bg, align="top_right"
)
elements: list[DisplayElement] = [
# Tier 1: Clean 2-Point Telemetry (Left Clock + Right Status Badge)
{
"id": "clock_txt",
"type": "text",
"x": 0,
"y": 0,
"font": "small",
"color": COLOR_CLOCK,
"text": clock_str,
},
park_text("mid_txt", text=runway_str),
badge_bg_elem,
badge_txt_elem,
park_text("tele_txt"),
]
# Tier 2: Micro-glyph + Marquee Stream
elements.extend(make_glyph_elements(glyph_type, badge_bg))
elements.append(
{
"id": "title_txt",
"type": "text",
"x": 6,
"y": 8,
"width": 66,
"font": "tiny",
"color": title_color,
"text": title_str,
"scroll_rate": 500,
"scroll_start_delay": 1200,
}
)
# Tier 3: Rolling 3-Hour Proximity Runway
elements.extend(
[
make_rect("radar_bg", 0, 14, 72, 2, COLOR_RADAR_BG),
park_rect("tick_25"),
park_rect("tick_50"),
park_rect("tick_75"),
make_rect("bar_fill", 0, 14, 2, 2, COLOR_CLOCK), # Origin Beacon (NOW)
park_rect("playhead"),
park_rect("tight_pip"),
]
)
# Render upcoming meeting blocks in the rolling runway window
radar_win_min = get_config().radar_window_minutes
for i in range(3):
blk_id = f"radar_blk_{i}"
if i < len(upcoming_events):
evt = upcoming_events[i]
offset_start_sec = (evt.start - now).total_seconds()
offset_end_sec = (evt.end - now).total_seconds()
window_sec = radar_win_min * 60.0
if 0 <= offset_start_sec < window_sec:
x_start = min(
71, max(0, int((offset_start_sec / window_sec) * GRID_WIDTH))
)
x_end = min(
72,
max(
x_start + 1,
int((offset_end_sec / window_sec) * GRID_WIDTH),
),
)
width = max(1, x_end - x_start)
evt_type = resolve_glyph_type(evt)
mapping = LABEL_ICON_MAPPINGS.get(evt_type)
evt_color = mapping["color"] if mapping else COLOR_BLUE
elements.append(make_rect(blk_id, x_start, 14, width, 2, evt_color))
else:
elements.append(park_rect(blk_id))
else:
elements.append(park_rect(blk_id))
extra: DisplayExtra = {"priority": 30, "led_notification_color": None}
return elements, extra
def render_aero_alert(
now: datetime, alert: AlertState, upcoming_events: list[CalendarEvent]
) -> tuple[list[DisplayElement], DisplayExtra]:
"""Render Aero Horizon State 2: Approaching Milestone Alert."""
delta_sec = (alert.event.start - now).total_seconds()
cd_str = f"{fmt_countdown(delta_sec)} left"
if alert.checkpoint_minutes == 0:
badge_str = "STARTING"
else:
badge_str = f"IN {alert.checkpoint_minutes} MIN"
start_str = f"Starts at {fmt_12h(alert.event.start, False)}"
loc = sanitize_display_text(
clean_location(alert.event.location, alert.event.description)
)
loc_part = f" - {loc}" if loc else ""
start_fmt = fmt_12h(alert.event.start, True)
summary = sanitize_display_text(alert.event.summary) or "Event"
title_str = f"{summary}{loc_part} - [STARTS {start_fmt}]"
glyph_type = resolve_glyph_type(alert.event)
title_color = resolve_glyph_tint_color(glyph_type)
checkpoint_total_sec = max(60.0, float(alert.checkpoint_minutes * 60))
approach_pct = max(
0.0, min(1.0, 1.0 - (max(0.0, delta_sec) / checkpoint_total_sec))
)
fill_w = max(2, int(approach_pct * GRID_WIDTH))
badge_bg_elem, badge_txt_elem = make_pill(
"badge", badge_str, x=0, y=0, bg_color=COLOR_AMBER, text_color="#000000FF"
)
elements: list[DisplayElement] = [
# Tier 1: Alert Banner (Left Amber Plate + Right Urgency Countdown)
park_text("clock_txt"),
badge_bg_elem,
badge_txt_elem,
park_text("mid_txt", text=start_str),
{
"id": "tele_txt",
"type": "text",
"x": 71,
"y": 0,
"font": "small",
"align": "top_right",
"color": COLOR_CLOCK_ALERT,
"text": cd_str,
},
]
# Tier 2: Micro-glyph + Marquee
elements.extend(make_glyph_elements(glyph_type, COLOR_AMBER))
elements.append(
{
"id": "title_txt",
"type": "text",
"x": 6,
"y": 8,
"width": 66,
"font": "tiny",
"color": title_color,
"text": title_str,
"scroll_rate": 500,
"scroll_start_delay": 1200,
}
)
# Tier 3: Approach Rail
elements.extend(
[
make_rect("radar_bg", 0, 14, 72, 2, COLOR_RADAR_BG),
park_rect("tick_25"),
park_rect("tick_50"),
park_rect("tick_75"),
make_rect("bar_fill", 0, 14, fill_w, 2, COLOR_AMBER),
make_rect("playhead", min(71, fill_w), 14, 1, 2, COLOR_WHITE),
park_rect("tight_pip"),
park_rect("radar_blk_0"),
park_rect("radar_blk_1"),
park_rect("radar_blk_2"),
]
)
extra: DisplayExtra = {"priority": 45, "led_notification_color": COLOR_AMBER}
return elements, extra
def render_aero_active(
now: datetime,
event: CalendarEvent,
upcoming_events: list[CalendarEvent],
active_alert: AlertState | None = None,
) -> tuple[list[DisplayElement], DisplayExtra]:
"""Render Aero Horizon Active Meeting lifecycle phases (States 3, 4, 5, 6, 7)."""
elapsed_sec = (now - event.start).total_seconds()
total_sec = max(1.0, (event.end - event.start).total_seconds())
rem_sec = total_sec - elapsed_sec
is_overtime = rem_sec < 0
is_wrapup = 0 <= rem_sec <= 180
# Downstream buffer calculations
next_evt = next((e for e in upcoming_events if e.start >= event.end), None)
buffer_min = (
((next_evt.start - event.end).total_seconds() / 60.0) if next_evt else 999.0
)
is_tight_turn = buffer_min < 5.0
# Progress bar calculation
progress_ratio = max(0.0, min(1.0, elapsed_sec / total_sec))
fill_w = (
GRID_WIDTH if is_overtime else min(GRID_WIDTH, int(progress_ratio * GRID_WIDTH))
)
playhead_x = min(71, fill_w)
evt_type = resolve_glyph_type(event)
mapping = LABEL_ICON_MAPPINGS.get(evt_type)
evt_color = mapping["color"] if mapping else COLOR_RED
title_color = resolve_glyph_tint_color(evt_type)
summary = sanitize_display_text(event.summary) or "Event"
loc = sanitize_display_text(clean_location(event.location, event.description))
loc_part = f" - {loc}" if loc else ""
# -------------------------------------------------------------------------
# STATE 7: Interrupted Alert Overlay (Horizon & Progress Preserved)
# -------------------------------------------------------------------------
if active_alert and not active_alert.is_expired(now):
badge_bg_col = COLOR_AMBER
badge_txt_col = "#000000FF"
badge_txt_val = (
"STARTING"
if active_alert.checkpoint_minutes == 0
else f"IN {active_alert.checkpoint_minutes} MIN"
)
tele_txt_val = f"{max(1, round(rem_sec / 60.0))}m on call"
tele_col = COLOR_RED
alert_loc = sanitize_display_text(
clean_location(active_alert.event.location, active_alert.event.description)
)
alert_loc_part = f" - {alert_loc}" if alert_loc else ""
alert_summary = sanitize_display_text(active_alert.event.summary) or "Event"
title_str = (
f"[NEXT] {alert_summary} starts at"
f" {fmt_12h(active_alert.event.start, True)}{alert_loc_part}"
)
glyph_type = resolve_glyph_type(active_alert.event)
title_color = resolve_glyph_tint_color(glyph_type)
bar_col = evt_color
led_color = COLOR_AMBER
priority = 60
badge_bg_elem, badge_txt_elem = make_pill(
"badge",
badge_txt_val,
x=0,
y=0,
bg_color=badge_bg_col,
text_color=badge_txt_col,
)
elements: list[DisplayElement] = [
park_text("clock_txt"),
badge_bg_elem,
badge_txt_elem,
park_text("mid_txt", text=f"Next: {alert_summary[:10]}"),
{
"id": "tele_txt",
"type": "text",
"x": 71,
"y": 0,
"font": "small",
"align": "top_right",
"color": tele_col,
"text": tele_txt_val,
},
]
# -------------------------------------------------------------------------
# STATE 6: Dedicated Overtime Phase
# -------------------------------------------------------------------------
elif is_overtime:
overtime_min = int(abs(rem_sec) // 60)
badge_bg_col = COLOR_RED_DARK
badge_txt_col = COLOR_WHITE
badge_txt_val = f"+{overtime_min:02d}m OVER"
tele_txt_val = f"End: {fmt_12h(event.end, False)}"
tele_col = COLOR_CLOCK_OVER
bar_col = COLOR_RED if int(now.timestamp()) % 2 == 0 else COLOR_RED_DIM
next_gap = (
int(max(0, (next_evt.start - now).total_seconds() / 60.0))
if next_evt
else 0
)
if next_evt:
next_sum = sanitize_display_text(next_evt.summary) or "Event"
title_str = (
f"{summary} Overrun (+{overtime_min}m) - Next in"
f" {next_gap}m: {next_sum}"
)
else:
title_str = (
f"{summary} Overrun (+{overtime_min}m) - Scheduled end was"
f" {fmt_12h(event.end, True)}"
)
glyph_type = "overtime"
led_color = COLOR_RED
priority = 60
badge_bg_elem, badge_txt_elem = make_pill(
"badge",
badge_txt_val,
x=0,
y=0,
bg_color=badge_bg_col,
text_color=badge_txt_col,
)
elements = [
park_text("clock_txt", text=fmt_12h(now, False)),
badge_bg_elem,
badge_txt_elem,
park_text("mid_txt"),
{
"id": "tele_txt",
"type": "text",
"x": 71,
"y": 0,
"font": "small",
"align": "top_right",
"color": tele_col,
"text": tele_txt_val,
},
]
# -------------------------------------------------------------------------
# STATE 5: Active Meeting Phase 3 (Wrap-Up Cue - Last 3 Minutes)
# -------------------------------------------------------------------------
elif is_wrapup:
badge_bg_col = COLOR_AMBER
badge_txt_col = "#000000FF"
badge_txt_val = "WRAP UP"
tele_txt_val = f"{int(rem_sec // 60):02d}:{int(rem_sec % 60):02d} rem"
tele_col = COLOR_AMBER
bar_col = COLOR_AMBER
if is_tight_turn and next_evt:
next_loc = sanitize_display_text(
clean_location(next_evt.location, next_evt.description)
)
next_loc_part = f" - {next_loc}" if next_loc else ""
next_sum = sanitize_display_text(next_evt.summary) or "Event"
title_str = (
f"[TIGHT TURN] {next_sum} starts in {int(buffer_min)}m!{next_loc_part}"
)
elif next_evt:
next_start_fmt = fmt_12h(next_evt.start, True)
next_sum = sanitize_display_text(next_evt.summary) or "Event"
title_str = f"Wrap up soon - [NEXT {next_start_fmt}] {next_sum}"
else:
title_str = f"Wrap up soon - [ENDS {fmt_12h(event.end, True)}] {summary}"
glyph_type = resolve_glyph_type(event)
title_color = COLOR_AMBER
led_color = COLOR_AMBER
priority = 60
badge_bg_elem, badge_txt_elem = make_pill(
"badge",
badge_txt_val,
x=0,
y=0,
bg_color=badge_bg_col,
text_color=badge_txt_col,
)
elements = [
park_text("clock_txt", text=fmt_12h(now, False)),
badge_bg_elem,
badge_txt_elem,
park_text("mid_txt"),
{
"id": "tele_txt",
"type": "text",
"x": 71,
"y": 0,
"font": "small",
"align": "top_right",
"color": tele_col,
"text": tele_txt_val,
},
]
# -------------------------------------------------------------------------
# STATE 3: Active Meeting Phase 1 (First 0..15 Seconds: Title Verification)
# -------------------------------------------------------------------------
elif elapsed_sec <= 15:
badge_bg_col = COLOR_RED_DARK
badge_txt_col = COLOR_WHITE
badge_txt_val = "LIVE CALL"
tele_txt_val = f"{round(total_sec / 60.0)}m call"
tele_col = COLOR_RED
bar_col = evt_color
title_str = f"{summary}{loc_part}"
glyph_type = resolve_glyph_type(event)
led_color = COLOR_RED
priority = 60
badge_bg_elem, badge_txt_elem = make_pill(
"badge",
badge_txt_val,
x=0,
y=0,
bg_color=badge_bg_col,
text_color=badge_txt_col,
)
elements = [
park_text("clock_txt", text=fmt_12h(event.start, False)),
badge_bg_elem,
badge_txt_elem,
park_text("mid_txt"),
{
"id": "tele_txt",
"type": "text",
"x": 71,
"y": 0,
"font": "small",
"align": "top_right",
"color": tele_col,
"text": tele_txt_val,
},
]
# -------------------------------------------------------------------------
# STATE 4: Active Meeting Phase 2 (Underway: Focus Flight Deck & Lookahead)
# -------------------------------------------------------------------------
else:
if evt_type == "focus":
badge_bg_col = "#7B1FA2FF"
badge_txt_val = "FOCUS"
tele_col = COLOR_PURPLE
elif evt_type == "coffee":
badge_bg_col = COLOR_GREEN_DARK
badge_txt_val = "BREAK"
tele_col = COLOR_CLOCK_BREAK
else:
badge_bg_col = COLOR_RED_DARK
badge_txt_val = "LIVE"
tele_col = COLOR_RED
badge_txt_col = COLOR_WHITE
tele_txt_val = f"{max(1, round(rem_sec / 60.0))}m left"
bar_col = evt_color
if next_evt:
next_loc = sanitize_display_text(
clean_location(next_evt.location, next_evt.description)
)
next_loc_str = f" ({next_loc})" if next_loc else ""
next_start_fmt = fmt_12h(next_evt.start, True)
next_sum = sanitize_display_text(next_evt.summary) or "Event"
title_str = (
f"{summary}{loc_part} - [NEXT {next_start_fmt}] "
f"{next_sum}{next_loc_str} (in {int(buffer_min)}m)"
)
else:
title_str = f"{summary}{loc_part}"
glyph_type = resolve_glyph_type(event)
title_color = resolve_glyph_tint_color(glyph_type)
led_color = evt_color
priority = 60
badge_bg_elem, badge_txt_elem = make_pill(
"badge",
badge_txt_val,
x=0,
y=0,
bg_color=badge_bg_col,
text_color=badge_txt_col,
)
elements = [
park_text("clock_txt", text=fmt_12h(now, False)),
badge_bg_elem,
badge_txt_elem,
park_text("mid_txt"),
{
"id": "tele_txt",
"type": "text",
"x": 71,
"y": 0,
"font": "small",
"align": "top_right",
"color": tele_col,
"text": tele_txt_val,
},
]
# Tier 2: Micro-glyph + Marquee Stream
elements.extend(make_glyph_elements(glyph_type, badge_bg_col))
elements.append(
{
"id": "title_txt",
"type": "text",
"x": 6,
"y": 8,
"width": 66,
"font": "tiny",
"color": title_color,
"text": title_str,
"scroll_rate": 500,
"scroll_start_delay": 1200,
}
)
# Tier 3: Precision Progress Horizon (25%, 50%, 75% Ticks & Live Playhead)
elements.extend(
[
make_rect("radar_bg", 0, 14, 72, 2, COLOR_RADAR_BG),
make_rect("tick_25", 18, 14, 1, 2, COLOR_TICK),
make_rect("tick_50", 36, 14, 1, 2, COLOR_TICK),
make_rect("tick_75", 54, 14, 1, 2, COLOR_TICK),
make_rect("bar_fill", 0, 14, fill_w, 2, bar_col),
make_rect("playhead", playhead_x, 14, 1, 2, COLOR_WHITE),
make_rect(
"tight_pip",
70 if (is_tight_turn and (is_wrapup or is_overtime)) else -100,
14 if (is_tight_turn and (is_wrapup or is_overtime)) else -100,
2 if (is_tight_turn and (is_wrapup or is_overtime)) else 0,
2 if (is_tight_turn and (is_wrapup or is_overtime)) else 0,
COLOR_AMBER,
),
park_rect("radar_blk_0"),
park_rect("radar_blk_1"),
park_rect("radar_blk_2"),
]
)
extra: DisplayExtra = {"priority": priority, "led_notification_color": led_color}
return elements, extra
def render_aero_breather(
now: datetime, next_event: CalendarEvent
) -> tuple[list[DisplayElement], DisplayExtra]:
"""Render Aero Horizon State 8: Post-Meeting Breather / Buffer Window."""
free_sec = (next_event.start - now).total_seconds()
free_min = max(0, int(free_sec / 60.0))
cd_str = fmt_countdown(free_sec)
badge_str = f"{free_min}m BREAK"
loc = sanitize_display_text(
clean_location(next_event.location, next_event.description)
)
loc_part = f" - {loc}" if loc else ""
next_sum = sanitize_display_text(next_event.summary) or "Event"
title_str = (
"Breather Window - Hydrate & Stretch - [NEXT"
f" {fmt_12h(next_event.start, True)}] {next_sum}{loc_part}"
)
# Draining bar calculation (drains toward next meeting)
drain_w = max(2, min(72, int((free_sec / (20 * 60.0)) * GRID_WIDTH)))
badge_bg_elem, badge_txt_elem = make_pill(
"badge", badge_str, x=0, y=0, bg_color=COLOR_GREEN_DARK, text_color=COLOR_WHITE
)
elements: list[DisplayElement] = [
# Tier 1: Breather Banner (Left Green Plate + Right Countdown)
park_text("clock_txt", text=fmt_12h(now, False)),
badge_bg_elem,
badge_txt_elem,
park_text("mid_txt"),
{
"id": "tele_txt",
"type": "text",
"x": 71,
"y": 0,
"font": "small",
"align": "top_right",
"color": COLOR_CLOCK_BREAK,
"text": cd_str,
},
]
# Tier 2: Micro-glyph + Marquee
elements.extend(make_glyph_elements("coffee", COLOR_GREEN))
elements.append(
{
"id": "title_txt",
"type": "text",
"x": 7,
"y": 8,
"width": 65,
"font": "tiny",
"color": COLOR_CLOCK_BREAK,
"text": title_str,
"scroll_rate": 500,
"scroll_start_delay": 1200,
}
)
# Tier 3: Breather Drain Horizon
elements.extend(
[
make_rect("radar_bg", 0, 14, 72, 2, COLOR_RADAR_BG),
park_rect("tick_25"),
park_rect("tick_50"),
park_rect("tick_75"),
make_rect("bar_fill", 0, 14, drain_w, 2, COLOR_GREEN),
make_rect("playhead", min(71, drain_w), 14, 1, 2, COLOR_WHITE),
park_rect("tight_pip"),
park_rect("radar_blk_0"),
park_rect("radar_blk_1"),
park_rect("radar_blk_2"),
]
)
extra: DisplayExtra = {"priority": 30, "led_notification_color": COLOR_GREEN}
return elements, extra
# --- Aero Horizon State: Interactive Agenda Peek (Phase 2) ------------------
def render_aero_peek(
now: datetime,
peek: AgendaPeekState,
) -> tuple[list[DisplayElement], DisplayExtra]:
"""Render Aero Horizon State: Interactive Agenda Peek (Rotary Dial Navigation)."""
if not peek.queue:
return render_aero_idle(now, [])
idx = max(0, min(len(peek.queue) - 1, peek.selected_index))
evt = peek.queue[idx]
total = len(peek.queue)
curr_1idx = idx + 1
pill_str = format_peek_bookmark(now, evt, curr_1idx, total)
start_str = fmt_12h(evt.start, include_ampm=False)
end_str = fmt_12h(evt.end, include_ampm=True)
time_range_str = f"{start_str}-{end_str}"
delta_str = format_relative_delta(now, evt)
# Dynamic badge background
if delta_str == "LIVE":
badge_bg = COLOR_RED_DARK
badge_color = COLOR_WHITE
led_color: str | None = LED_COLOR_MEETING
else:
badge_bg = "#253342FF"
badge_color = COLOR_SKY
led_color = None
badge_bg_elem, badge_txt_elem = make_pill(
"badge",
f"[{delta_str}]",
x=71,
y=0,
bg_color=badge_bg,
text_color=badge_color,
align="top_right",
)
glyph_type = resolve_glyph_type(evt)
title_color = resolve_glyph_tint_color(glyph_type)
summary_str = sanitize_display_text(evt.summary) or "Event"
loc_str = sanitize_display_text(clean_location(evt.location, evt.description))
full_title = f"{summary_str} - {loc_str}" if loc_str else summary_str
elements: list[DisplayElement] = [
# Tier 1: Telemetry (Bookmark Index + Time Range on left/mid, Delta on right)
{
"id": "clock_txt",
"type": "text",
"x": 0,
"y": 0,
"font": "tiny",
"color": COLOR_CLOCK,
"text": f"[{pill_str}] {time_range_str}",
},
park_text("mid_txt"),
badge_bg_elem,
badge_txt_elem,
park_text("tele_txt"),
]
# Tier 2: 5x5 Micro-glyph + Title Marquee
elements.extend(make_glyph_elements(glyph_type, badge_bg))
elements.append(
{
"id": "title_txt",
"type": "text",
"x": 6,
"y": 8,
"width": 66,
"font": "tiny",
"color": title_color,
"text": full_title,
"scroll_rate": 500,
"scroll_start_delay": 1200,
}
)
# Tier 3: Proportional Stepper Rail
elements.extend(
[
make_rect("radar_bg", 0, 14, 72, 2, COLOR_RADAR_BG),
park_rect("tick_25"),
park_rect("tick_50"),
park_rect("tick_75"),
park_rect("bar_fill"),
park_rect("playhead"),
park_rect("tight_pip"),
park_rect("radar_blk_0"),
park_rect("radar_blk_1"),
park_rect("radar_blk_2"),
]
)
# Dynamic pips for total queue length
for k in range(total):
pip_id = f"stepper_{k}"
if total > 1:
x_pip = int(k * 66 / (total - 1)) + 3
else:
x_pip = 35
if k == idx:
elements.append(make_rect(pip_id, max(0, x_pip - 1), 14, 3, 2, COLOR_WHITE))
else:
elements.append(make_rect(pip_id, x_pip, 14, 1, 2, "#404040FF"))
extra: DisplayExtra = {
"priority": 50,
"led_notification_color": led_color,
}
return elements, extra
# --- FSM Engine -------------------------------------------------------------
def evaluate_display_with_hardware(
now: datetime, events: list[CalendarEvent]
) -> tuple[list[DisplayElement], DisplayExtra]:
"""Evaluate time against events, alerts, and tactile hardware state.
Returns (elements, extra).
"""
global active_alert, active_peek, fired_checkpoints, manual_sync_flash_until
cfg = get_config()
# 1. Prune fired checkpoints for events that have ended
ended_uids = {e.uid for e in events if e.end <= now}
fired_checkpoints = {
(uid, cp) for (uid, cp) in fired_checkpoints if uid not in ended_uids
}
# 2. Check for active alert banner expiration
if active_alert is not None and active_alert.is_expired(now):
active_alert = None
# 2.5 Check for active peek overlay expiration
if active_peek is not None and active_peek.is_expired(now):
active_peek = None
# 3. If Agenda Peek is active, render peek display
if active_peek is not None and not active_peek.is_expired(now):
elements, extra = render_aero_peek(now, active_peek)
if manual_sync_flash_until is not None and now < manual_sync_flash_until:
extra["led_notification_color"] = LED_COLOR_SYNC_ACK
return elements, extra
# 4. Identify active event or overtime event
current_event = None
for evt in events:
if evt.start <= now < evt.end:
current_event = evt
break
elif evt.end <= now < evt.end + timedelta(minutes=15):
# Overtime candidate: only if no new event has started
if not any(e.start <= now < e.end for e in events):
current_event = evt
break
if (
current_event
and current_event.start <= now < current_event.end
and (current_event.uid, 0) not in fired_checkpoints
):
fired_checkpoints.add((current_event.uid, 0))
if (
now - current_event.start
).total_seconds() < cfg.alert_banner_duration_seconds and cfg.sound:
_pending_audio_chimes.append(STOCK_SOUND_EVENT_START)
upcoming_events = [e for e in events if e.start > now and e != current_event]
upcoming_events.sort(key=lambda e: e.start)
# 5. Check for upcoming alert triggers
checkpoints = sorted(cfg.upcoming_alert_sequence_minutes, reverse=True)
candidate_upcoming = [e for e in events if e.end > now and e != current_event]
candidate_upcoming.sort(key=lambda e: e.start)
for evt in candidate_upcoming:
delta_minutes = (evt.start - now).total_seconds() / 60.0
eligible_cps = [cp for cp in checkpoints if delta_minutes <= cp]
if eligible_cps:
target_cp = min(eligible_cps)
if evt.start <= now and target_cp != 0:
continue
if (
target_cp == 0
and (now - evt.start).total_seconds()
>= cfg.alert_banner_duration_seconds
):
fired_checkpoints.add((evt.uid, 0))
continue
for cp in checkpoints:
if cp > target_cp:
fired_checkpoints.add((evt.uid, cp))
if (evt.uid, target_cp) not in fired_checkpoints:
fired_checkpoints.add((evt.uid, target_cp))
active_alert = AlertState(
event=evt,
checkpoint_minutes=target_cp,
expires_at=now
+ timedelta(seconds=cfg.alert_banner_duration_seconds),
)
if cfg.sound:
chime = (
STOCK_SOUND_EVENT_START
if target_cp == 0
else STOCK_SOUND_REMINDER
)
_pending_audio_chimes.append(chime)
break
break
# 6. Render based on FSM State
if current_event:
elements, extra = render_aero_active(
now, current_event, upcoming_events, active_alert
)
elif active_alert and not active_alert.is_expired(now):
elements, extra = render_aero_alert(now, active_alert, upcoming_events)
else:
elements, extra = render_aero_idle(now, upcoming_events)
if manual_sync_flash_until is not None and now < manual_sync_flash_until:
extra["led_notification_color"] = LED_COLOR_SYNC_ACK
return elements, extra
def evaluate_display(
now: datetime, events: list[CalendarEvent]
) -> list[DisplayElement]:
"""Compatibility wrapper returning elements list."""
elements, _ = evaluate_display_with_hardware(now, events)
return elements
# --- Hardware Input Stream & Protobuf Wire Decoder --------------------------
BTN_OK: Final[int] = 0
BTN_BACK: Final[int] = 1
BTN_START: Final[int] = 2
ACTION_PRESS: Final[int] = 0
ACTION_RELEASE: Final[int] = 1
def _read_varint(buf: bytes, pos: int) -> tuple[int, int]:
"""Read a protobuf varint from buf at pos."""
value = 0
shift = 0
while pos < len(buf):
b = buf[pos]
pos += 1
value |= (b & 0x7F) << shift
if not (b & 0x80):
return value, pos
shift += 7
if shift > 63:
raise ValueError("protobuf varint too long")
raise ValueError("truncated protobuf varint")
def _iter_proto_fields(buf: bytes) -> Generator[tuple[int, int, Any], None, None]:
"""Yield (field_number, wire_type, value) for a protobuf buffer."""
pos = 0
while pos < len(buf):
key, pos = _read_varint(buf, pos)
field_no, wire = key >> 3, key & 7
if wire == 0:
value, pos = _read_varint(buf, pos)
yield field_no, wire, value
elif wire == 1:
if pos + 8 > len(buf):
return
yield field_no, wire, buf[pos : pos + 8]
pos += 8
elif wire == 2:
n, pos = _read_varint(buf, pos)
end = pos + n
if end > len(buf):
return
yield field_no, wire, buf[pos:end]
pos = end
elif wire == 5:
if pos + 4 > len(buf):
return
yield field_no, wire, buf[pos : pos + 4]
pos += 4
else:
return
def decode_input_events(frame: bytes) -> list[tuple[str, Any]]:
"""Decode binary status frame -> [('button', (id, act)), ('encoder', delta)]."""
events: list[tuple[str, Any]] = []
for field_no, wire, update in _iter_proto_fields(frame):
if field_no != 2 or wire != 2: # State.updates (StateUpdate)
continue
for uf, uw, uv in _iter_proto_fields(update):
if uf != 11 or uw != 2: # StateUpdate.input (InputEvent)
continue
for ef, ew, ev in _iter_proto_fields(uv):
if ef == 1 and ew == 2: # InputEvent.button_event
button = action = 0
for bf, bw, bv in _iter_proto_fields(ev):
if bw == 0 and bf == 1:
button = int(bv)
elif bw == 0 and bf == 2:
action = int(bv)
events.append(("button", (button, action)))
elif ef == 2 and ew == 2: # InputEvent.encoder_event
delta = 0
for enc_f, enc_w, enc_v in _iter_proto_fields(ev):
if enc_f == 1 and enc_w == 0:
val = int(enc_v)
if val > 0x7FFFFFFF:
val -= 0x100000000
delta = val
if delta != 0:
events.append(("encoder", delta))
return events
def apply_hardware_input(
now: datetime,
event_type: str,
value: Any,
events: list[CalendarEvent],
) -> bool:
"""Process hardware input event. Returns True if display state changed."""
global active_alert, active_peek, manual_sync_flash_until
cfg = get_config()
state_changed = False
if event_type == "button":
btn_id, action = value
if action == ACTION_PRESS:
# 1. Early alert dismissal (START or BACK)
if btn_id in (BTN_START, BTN_BACK) and active_alert is not None:
active_alert = None
state_changed = True
# 2. Exit peek view (BACK)
if btn_id == BTN_BACK and active_peek is not None:
active_peek = None
state_changed = True
# 3. Manual instant sync (OK)
if btn_id == BTN_OK:
manual_sync_flash_until = now + timedelta(
seconds=SYNC_ACK_FLASH_DURATION_SECONDS
)
_manual_sync_event.set()
state_changed = True
elif event_type == "encoder":
delta = int(value)
if delta != 0:
timeout = timedelta(seconds=PEEK_INACTIVITY_TIMEOUT_SECONDS)
if active_peek is None or active_peek.is_expired(now):
queue = build_peek_queue(
now, events, lookahead_count=cfg.lookahead_count
)
if queue:
# Find initial index (active event or first upcoming event)
start_idx = 0
for idx, evt in enumerate(queue):
if evt.start <= now < evt.end:
start_idx = idx
break
elif evt.start > now:
start_idx = idx
break
target_idx = max(0, min(len(queue) - 1, start_idx))
active_peek = AgendaPeekState(
queue=queue,
selected_index=target_idx,
expires_at=now + timeout,
)
state_changed = True
else:
new_idx = max(
0,
min(len(active_peek.queue) - 1, active_peek.selected_index + delta),
)
active_peek.selected_index = new_idx
active_peek.expires_at = now + timeout
state_changed = True
return state_changed
class InputListener:
"""Stream button/encoder events off a background asyncio task or busylib."""
def __init__(self, host: str, token: str | None = None) -> None:
self.host = host
self.token = token
self.queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
self._stop = asyncio.Event()
def stop(self) -> None:
self._stop.set()
async def run(self) -> None:
"""Connect to WebSocket and stream input events with reconnection backoff."""
# Try busylib AsyncBusyBar first if available
try:
from busylib import AsyncBusyBar # type: ignore[import-untyped]
backoff = 1.0
while not self._stop.is_set():
kwargs: dict[str, Any] = {"token": self.token} if self.token else {}
bb = AsyncBusyBar(self.host, **kwargs)
try:
async for msg in bb.stream_status_ws():
if self._stop.is_set():
break
backoff = 1.0
if not isinstance(msg, dict):
continue
for upd in msg.get("updates", []):
inp = upd.get("input")
if not inp:
continue
if "button_event" in inp:
be = inp["button_event"]
btn_name = str(be.get("button", "OK")).upper()
btn_id = (
BTN_START
if btn_name == "START"
else BTN_BACK
if btn_name == "BACK"
else BTN_OK
)
action_name = str(be.get("action", "PRESS")).upper()
action_id = (
ACTION_RELEASE
if action_name == "RELEASE"
else ACTION_PRESS
)
self.queue.put_nowait(("button", (btn_id, action_id)))
if "encoder_event" in inp:
delta = int(inp["encoder_event"].get("delta", 0))
if delta != 0:
self.queue.put_nowait(("encoder", delta))
except asyncio.CancelledError:
break
except Exception:
pass
finally:
try:
await bb.aclose()
except Exception:
pass
if self._stop.is_set():
break
await asyncio.sleep(backoff)
backoff = min(10.0, backoff * 1.5)
return
except ImportError:
pass
# Raw websockets client fallback
try:
import websockets
except ImportError:
print(
"[input] neither busylib nor websockets available; hardware controls"
" disabled",
flush=True,
)
return
raw_host = self.host.rstrip("/")
if "://" not in raw_host:
raw_host = "http://" + raw_host
import urllib.parse
parsed = urllib.parse.urlparse(raw_host)
scheme = "wss" if parsed.scheme == "https" else "ws"
path = parsed.path.rstrip("/") + "/api/status/ws"
url = urllib.parse.urlunparse((scheme, parsed.netloc, path, "", "", ""))
backoff = 1.0
while not self._stop.is_set():
try:
async with websockets.connect(
url, open_timeout=5, ping_interval=None, ping_timeout=None
) as ws:
backoff = 1.0
async for msg in ws:
if self._stop.is_set():
break
if isinstance(msg, (bytes, bytearray)):
for evt in decode_input_events(bytes(msg)):
self.queue.put_nowait(evt)
except asyncio.CancelledError:
break
except Exception:
if self._stop.is_set():
break
await asyncio.sleep(backoff)
backoff = min(10.0, backoff * 1.5)
# --- Main Loop (AsyncIO) ----------------------------------------------------
_events: list[CalendarEvent] = []
def _update_events(new_events: list[CalendarEvent]) -> None:
global _events
_events = list(new_events)
def _get_events() -> list[CalendarEvent]:
return list(_events)
async def _sync_worker(client: httpx.AsyncClient, ical_url: AnyHttpUrl) -> None:
"""Background coroutine: periodically fetch calendar and update shared store."""
while True:
try:
events = await fetch_events(client, ical_url)
if events is not None:
_update_events(events)
print(f"[sync] fetched {len(events)} event(s)", flush=True)
except Exception as e:
print(f"[sync] error: {e}", file=sys.stderr, flush=True)
poll_interval = get_config().calendar_poll_interval_seconds
try:
await asyncio.wait_for(_manual_sync_event.wait(), timeout=poll_interval)
_manual_sync_event.clear()
print("[sync] manual sync requested via OK button", flush=True)
except asyncio.TimeoutError:
pass
_last_payload_str: str | None = None
async def _display_loop(
client: httpx.AsyncClient, input_listener: InputListener | None = None
) -> None:
"""Display loop coroutine processing inputs and 1 Hz refreshes."""
global _last_payload_str
while True:
now = datetime.now(timezone.utc)
events = _get_events()
if input_listener is not None:
while not input_listener.queue.empty():
evt_type, val = input_listener.queue.get_nowait()
apply_hardware_input(now, evt_type, val, events)
elements, extra = evaluate_display_with_hardware(now, events)
# Dispatch any pending alert chimes asynchronously
if _pending_audio_chimes:
cfg = get_config()
while _pending_audio_chimes:
chime = _pending_audio_chimes.pop(0)
task = asyncio.create_task(play_sound(client, chime, volume=cfg.volume))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
import json
payload_str = json.dumps({"elements": elements, "extra": extra}, sort_keys=True)
if payload_str != _last_payload_str:
try:
await draw(client, elements, **extra)
_last_payload_str = payload_str
except httpx.HTTPStatusError as e:
if e.response.status_code == 409:
try:
await client.delete(f"{BASE}/api/display/draw", timeout=2.0)
await draw(client, elements, **extra)
_last_payload_str = payload_str
except Exception:
pass
else:
print(
f"[draw] HTTP {e.response.status_code}: {e.response.text}",
file=sys.stderr,
)
except httpx.HTTPError as e:
print(f"[draw] network error: {e}", file=sys.stderr, flush=True)
sleep_duration = (
0.1
if (active_peek or (input_listener and not input_listener.queue.empty()))
else 0.5
)
await asyncio.sleep(sleep_duration)
async def async_main() -> None:
cfg = get_config()
input_listener = InputListener(cfg.host)
input_task = asyncio.create_task(input_listener.run())
async with httpx.AsyncClient(timeout=10.0) as client:
try:
if cfg.demo:
_update_events(demo_events())
print(
f"{APP} → {BASE} [Aero Horizon demo mode] (Ctrl-C to stop)",
flush=True,
)
await _display_loop(client, input_listener)
elif cfg.ical_url:
print(f"{APP} → {BASE} (Ctrl-C to stop)", flush=True)
try:
initial_events = await fetch_events(client, cfg.ical_url)
if initial_events is not None:
_update_events(initial_events)
except Exception as e:
print(
f"[sync] initial fetch failed: {e}", file=sys.stderr, flush=True
)
await asyncio.gather(
_display_loop(client, input_listener),
_sync_worker(client, cfg.ical_url),
)
else:
print(
f"{APP} → {BASE} [no calendar source, use --ical-url, --demo, or"
" GCAL_GLANCE_ICAL_URL]",
flush=True,
)
print("Showing Aero Horizon idle focus runway display.", flush=True)
await _display_loop(client, input_listener)
finally:
input_listener.stop()
input_task.cancel()
try:
await input_task
except asyncio.CancelledError:
pass
def main() -> None:
try:
asyncio.run(async_main())
except KeyboardInterrupt:
print("\nstopped.")
if __name__ == "__main__":
main()conftest.py
"""Pytest configuration and fixtures for GCal Glance."""
from collections.abc import Generator
import pytest
import respx
from app import BASE
@pytest.fixture
def mock_busy_bar_api() -> Generator[respx.MockRouter, None, None]:
"""Mock router scoped to the BUSY Bar BASE URL (no real API calls)."""
with respx.mock(base_url=BASE, assert_all_mocked=True) as respx_mock:
yield respx_mockmanifest.yaml
name: GCal Glance
author: Kilo59
description: Ambient Google Calendar display for BUSY Bar — at-a-glance meeting countdowns, alerts, and live schedule awareness.
tags:
- google-calendar
- calendar
- productivity
- info
preview: ./preview.gifpyproject.toml
[project]
name = "gcal-glance"
version = "0.1.0"
description = "Google Calendar Glance (GCal Glance) for BUSY Bar"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"busylib>=1.3.0",
"httpx>=0.28.0",
"pydantic>=2.11",
"pydantic-settings>=2.13",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.0",
"pytest-asyncio>=0.23.0",
"respx>=0.22.0",
"ruff>=0.16.3",
"mypy>=2.3.1",
"watchfiles>=0.21.0",
"python-dateutil>=2.9.0",
"types-python-dateutil>=2.9.0",
"prek>=0.4.14",
]
[tool.pytest]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.ruff]
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "RUF", "PERF"]
[tool.mypy]
python_version = "3.10"
strict = truerequirements.txt
# Note: All dependencies below (httpx, pydantic, pydantic-settings) are already
# sub-dependencies of busylib, which is pre-approved according to project conventions.
busylib>=1.3.0
httpx>=0.28.0
pydantic>=2.11
pydantic-settings>=2.13tests/test_app.py
"""Unit tests for GCal Glance application (Aero Horizon)."""
import json
from collections.abc import Generator
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
import pytest
import respx
import app
from app import (
APP,
GRID_WIDTH,
AlertState,
CalendarEvent,
DisplayElement,
GlyphType,
_expand_rrule,
_parse_ical_datetime,
_unescape_ical,
_unfold_ical,
_update_events,
clean_location,
draw,
evaluate_display,
evaluate_display_with_hardware,
fetch_events,
fmt_12h,
fmt_compact_12h,
fmt_countdown,
fmt_remaining,
format_idle_badge,
format_idle_marquee,
make_glyph_elements,
render_aero_active,
render_aero_alert,
render_aero_breather,
resolve_glyph_tint_color,
resolve_glyph_type,
resolve_label,
sanitize_display_text,
)
def make_el_map(elements: list[DisplayElement]) -> dict[str, dict[str, Any]]:
return {str(el["id"]): dict(el) for el in elements}
@pytest.fixture(autouse=True)
def reset_module_state() -> Generator[None, None, None]:
"""Reset module-level mutable state before and after each test."""
app.fired_checkpoints.clear()
app.active_alert = None
_update_events([])
yield
app.fired_checkpoints.clear()
app.active_alert = None
_update_events([])
# --- 1. iCal Parsing & Timezone Tests ---------------------------------------
def test_parse_ical_datetime_utc() -> None:
dt = _parse_ical_datetime("20260815T140000Z")
assert dt is not None
assert dt.tzinfo == timezone.utc
assert dt.year == 2026 and dt.month == 8 and dt.day == 15
assert dt.hour == 14 and dt.minute == 0 and dt.second == 0
def test_parse_ical_datetime_tzid() -> None:
# EDT is UTC-4 during August
dt = _parse_ical_datetime("20260815T100000", "TZID=America/New_York")
assert dt is not None
assert dt.tzinfo == timezone.utc
assert dt.hour == 14 and dt.minute == 0
def test_parse_ical_datetime_allday_events(monkeypatch: pytest.MonkeyPatch) -> None:
# By default (include_all_day=False), all-day events return None
assert _parse_ical_datetime("20260815", "VALUE=DATE") is None
assert _parse_ical_datetime("20260815") is None
# When include_all_day=True, all-day events are parsed into a valid datetime
monkeypatch.setattr(app.get_config(), "include_all_day", True)
dt = _parse_ical_datetime("20260815", "VALUE=DATE")
assert dt is not None
def test_unfold_and_unescape_ical() -> None:
raw = (
"SUMMARY:Line 1\r\n continued\r\n"
"DESCRIPTION:Escaped\\, chars\\; and\\nnewline\r\n"
"LOCATION:Room\\, 3B"
)
unfolded = _unfold_ical(raw)
assert len(unfolded) == 3
assert unfolded[0] == "SUMMARY:Line 1 continued"
assert _unescape_ical(unfolded[1]) == "DESCRIPTION:Escaped, chars; and\nnewline"
assert _unescape_ical(unfolded[2]) == "LOCATION:Room, 3B"
# --- 2. HTTP Interaction Tests (using mock_busy_bar_api fixture) -------------
SAMPLE_ICS = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Google Inc//Google Calendar 70.9054//EN
BEGIN:VEVENT
UID:event-1@google.com
SUMMARY:Sprint Standup
LOCATION:Room 2B
DTSTART:20300815T140000Z
DTEND:20300815T143000Z
DESCRIPTION:Daily sync
END:VEVENT
BEGIN:VEVENT
UID:event-2@google.com
SUMMARY:[dnd] Deep Focus Block
LOCATION:Desk
DTSTART:20300815T150000Z
DTEND:20300815T160000Z
END:VEVENT
END:VCALENDAR
"""
@pytest.mark.asyncio
async def test_fetch_events_success(mock_busy_bar_api: respx.MockRouter) -> None:
ical_url = "https://calendar.google.com/calendar/ical/feed/basic.ics"
mock_busy_bar_api.get(ical_url).respond(status_code=200, text=SAMPLE_ICS)
async with httpx.AsyncClient() as client:
events = await fetch_events(client, ical_url)
assert events is not None
assert len(events) == 2
assert events[0].uid == "event-1@google.com"
assert events[0].summary == "Sprint Standup"
assert events[0].location == "Room 2B"
assert events[1].uid == "event-2@google.com"
assert events[1].summary == "[dnd] Deep Focus Block"
assert events[1].location == "Desk"
@pytest.mark.asyncio
async def test_fetch_events_recurring_rrule_upcoming(
mock_busy_bar_api: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A recurring event with past DTSTART should produce upcoming occurrences."""
# Freeze datetime to 2026-08-21 14:00:00 UTC (10:00 AM EDT)
fixed_now = datetime(2026, 8, 21, 14, 0, 0, tzinfo=timezone.utc)
# 1. Weekly recurring Friday 18:00 UTC (2:00 PM EDT) started in Jan 2026
# 2. Future single event on Saturday Aug 22, 2026
recurring_ics = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Google Inc//Google Calendar 70.9054//EN
BEGIN:VEVENT
UID:recurring-weekly@google.com
SUMMARY:Weekly Team Sync
LOCATION:Meet
DTSTART:20260102T180000Z
DTEND:20260102T183000Z
RRULE:FREQ=WEEKLY;BYDAY=FR
END:VEVENT
BEGIN:VEVENT
UID:future-single@google.com
SUMMARY:Saturday Workshop
LOCATION:Office
DTSTART:20260822T130000Z
DTEND:20260822T140000Z
END:VEVENT
END:VCALENDAR
"""
ical_url = "https://calendar.google.com/calendar/ical/feed/basic.ics"
mock_busy_bar_api.get(ical_url).respond(status_code=200, text=recurring_ics)
# Monkeypatch datetime.now in app module
class FixedDateTime(datetime):
@classmethod
def now(cls, tz: Any = None) -> datetime:
if tz is not None:
return fixed_now.astimezone(tz)
return fixed_now
monkeypatch.setattr(app, "datetime", FixedDateTime)
async with httpx.AsyncClient() as client:
events = await fetch_events(client, ical_url)
assert events is not None
# The immediate next event MUST be the occurrence on 2026-08-21 18:00 UTC
assert len(events) >= 2
assert events[0].summary == "Weekly Team Sync"
assert events[0].start == datetime(2026, 8, 21, 18, 0, 0, tzinfo=timezone.utc)
assert events[0].end == datetime(2026, 8, 21, 18, 30, 0, tzinfo=timezone.utc)
assert events[1].summary == "Saturday Workshop"
@pytest.mark.asyncio
async def test_fetch_events_recurring_exdate_and_recurrence_id(
mock_busy_bar_api: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
"""EXDATE skips cancelled occurrences; RECURRENCE-ID overrides instances."""
fixed_now = datetime(2026, 8, 21, 14, 0, 0, tzinfo=timezone.utc)
# Weekly Friday event at 18:00 UTC.
# Aug 21 instance is overridden with RECURRENCE-ID and moved to 19:00 UTC.
# Aug 28 instance is excluded via EXDATE.
ics_content = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Google Inc//Google Calendar 70.9054//EN
BEGIN:VEVENT
UID:recurring-base@google.com
SUMMARY:Friday Coffee
DTSTART:20260102T180000Z
DTEND:20260102T183000Z
RRULE:FREQ=WEEKLY;BYDAY=FR
EXDATE:20260828T180000Z
END:VEVENT
BEGIN:VEVENT
UID:recurring-base@google.com
RECURRENCE-ID:20260821T180000Z
SUMMARY:Friday Coffee (Rescheduled)
DTSTART:20260821T190000Z
DTEND:20260821T193000Z
END:VEVENT
BEGIN:VEVENT
UID:cancelled-event@google.com
SUMMARY:Cancelled Meeting
STATUS:CANCELLED
DTSTART:20260821T200000Z
DTEND:20260821T210000Z
END:VEVENT
END:VCALENDAR
"""
ical_url = "https://calendar.google.com/calendar/ical/feed/basic.ics"
mock_busy_bar_api.get(ical_url).respond(status_code=200, text=ics_content)
class FixedDateTime(datetime):
@classmethod
def now(cls, tz: Any = None) -> datetime:
if tz is not None:
return fixed_now.astimezone(tz)
return fixed_now
monkeypatch.setattr(app, "datetime", FixedDateTime)
async with httpx.AsyncClient() as client:
events = await fetch_events(client, ical_url)
assert events is not None
# Cancelled event must not appear
assert not any(e.summary == "Cancelled Meeting" for e in events)
# Aug 21 occurrence is overridden to 19:00
assert events[0].summary == "Friday Coffee (Rescheduled)"
assert events[0].start == datetime(2026, 8, 21, 19, 0, 0, tzinfo=timezone.utc)
# Aug 28 occurrence was in EXDATE, so next base occurrence is Sep 04
assert events[1].summary == "Friday Coffee"
assert events[1].start == datetime(2026, 9, 4, 18, 0, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_fetch_events_monthly_rrule(
mock_busy_bar_api: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Monthly BYDAY recurrence (e.g. 3rd Friday of month)."""
fixed_now = datetime(2026, 8, 21, 14, 0, 0, tzinfo=timezone.utc)
# 3rd Friday of every month at 14:00 America/New_York (18:00 UTC)
# Aug 21, 2026 is 3rd Friday of August 2026
monthly_ics = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Google Inc//Google Calendar 70.9054//EN
BEGIN:VEVENT
UID:monthly-3fr@google.com
SUMMARY:Bill + Gabriel
DTSTART;TZID=America/New_York:20251121T140000
DTEND;TZID=America/New_York:20251121T144000
RRULE:FREQ=MONTHLY;BYDAY=3FR
END:VEVENT
END:VCALENDAR
"""
ical_url = "https://calendar.google.com/calendar/ical/feed/basic.ics"
mock_busy_bar_api.get(ical_url).respond(status_code=200, text=monthly_ics)
class FixedDateTime(datetime):
@classmethod
def now(cls, tz: Any = None) -> datetime:
if tz is not None:
return fixed_now.astimezone(tz)
return fixed_now
monkeypatch.setattr(app, "datetime", FixedDateTime)
async with httpx.AsyncClient() as client:
events = await fetch_events(client, ical_url)
assert events is not None
assert len(events) >= 1
assert events[0].summary == "Bill + Gabriel"
assert events[0].start == datetime(2026, 8, 21, 18, 0, 0, tzinfo=timezone.utc)
assert events[0].end == datetime(2026, 8, 21, 18, 40, 0, tzinfo=timezone.utc)
def test_expand_rrule_differential_validation_against_dateutil() -> None:
"""Validate stdlib _expand_rrule directly against dateutil.rrule across rules."""
import re
from zoneinfo import ZoneInfo
from dateutil import rrule
def normalize_for_dateutil(rrule_str: str) -> str:
rrule_str = re.sub(r"UNTIL=(\d{8})(?!T)", r"UNTIL=\1T235959Z", rrule_str)
rrule_str = re.sub(r"UNTIL=(\d{8}T\d{6})(?!Z)", r"UNTIL=\1Z", rrule_str)
return rrule_str
tz_ny = ZoneInfo("America/New_York")
test_cases = [
(datetime(2025, 1, 1, 9, 0, tzinfo=tz_ny), "FREQ=DAILY;INTERVAL=3"),
(
datetime(2025, 1, 1, 10, 0, tzinfo=tz_ny),
"FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH",
),
(
datetime(2025, 1, 1, 11, 0, tzinfo=tz_ny),
"FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE,FR",
),
(datetime(2025, 11, 21, 14, 0, tzinfo=tz_ny), "FREQ=MONTHLY;BYDAY=3FR"),
(datetime(2025, 1, 15, 16, 30, tzinfo=tz_ny), "FREQ=MONTHLY;BYMONTHDAY=15"),
(datetime(2024, 8, 15, 12, 0, tzinfo=tz_ny), "FREQ=YEARLY"),
]
window_start = datetime(2026, 8, 1, 0, 0, tzinfo=timezone.utc)
window_end = datetime(2026, 10, 1, 0, 0, tzinfo=timezone.utc)
for dtstart, rule_str in test_cases:
stdlib_res = _expand_rrule(dtstart, rule_str, window_start, window_end)
norm_rule = normalize_for_dateutil(rule_str)
du_rule = rrule.rrulestr(norm_rule, dtstart=dtstart)
local_tz = dtstart.tzinfo or timezone.utc
local_win_start = window_start.astimezone(local_tz)
local_win_end = window_end.astimezone(local_tz)
du_res = [
occ.astimezone(timezone.utc)
for occ in du_rule.between(local_win_start, local_win_end, inc=True)
]
assert stdlib_res == du_res, f"Mismatch for {rule_str}"
@pytest.mark.asyncio
async def test_fetch_events_http_error_returns_none(
mock_busy_bar_api: respx.MockRouter,
) -> None:
ical_url = "https://calendar.google.com/calendar/ical/feed/basic.ics"
mock_busy_bar_api.get(ical_url).respond(
status_code=500, text="Internal Server Error"
)
async with httpx.AsyncClient() as client:
events = await fetch_events(client, ical_url)
assert events is None
@pytest.mark.asyncio
async def test_fetch_events_network_failure_returns_none(
mock_busy_bar_api: respx.MockRouter,
) -> None:
ical_url = "https://calendar.google.com/calendar/ical/feed/basic.ics"
mock_busy_bar_api.get(ical_url).mock(
side_effect=httpx.ConnectError("Connection refused")
)
async with httpx.AsyncClient() as client:
events = await fetch_events(client, ical_url)
assert events is None
@pytest.mark.asyncio
async def test_draw_dispatches_proper_payload(
mock_busy_bar_api: respx.MockRouter,
) -> None:
draw_route = mock_busy_bar_api.post("/api/display/draw").respond(
status_code=200, json={"status": "ok"}
)
elements: list[DisplayElement] = [
{"id": "clock_txt", "type": "text", "text": "10:48 AM"}
]
async with httpx.AsyncClient() as client:
await draw(client, elements, priority=45, led_notification_color="#FBBC05FF")
assert draw_route.called
req = draw_route.calls.last.request
payload = json.loads(req.content)
assert payload["application_name"] == APP
assert payload["elements"] == elements
assert payload["priority"] == 45
assert payload["led_notification_color"] == "#FBBC05FF"
@pytest.mark.asyncio
async def test_draw_raises_on_http_error(mock_busy_bar_api: respx.MockRouter) -> None:
mock_busy_bar_api.post("/api/display/draw").respond(
status_code=409, text="Display busy"
)
elements: list[DisplayElement] = [
{"id": "clock_txt", "type": "text", "text": "Conflict test"}
]
async with httpx.AsyncClient() as client:
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await draw(client, elements)
assert exc_info.value.response.status_code == 409
# --- 3. Aero Horizon Invariant Element Map & Procedural Glyphs --------------
REQUIRED_INVARIANT_IDS = {
"clock_txt",
"badge_bg",
"badge_txt",
"mid_txt",
"tele_txt",
"glyph_0",
"glyph_1",
"glyph_2",
"title_txt",
"radar_bg",
"tick_25",
"tick_50",
"tick_75",
"bar_fill",
"playhead",
"tight_pip",
}
def test_invariant_element_ids_present_across_all_states() -> None:
now = datetime.now(timezone.utc)
future_evt = CalendarEvent(
"e1", "Design Review", now + timedelta(minutes=40), now + timedelta(minutes=70)
)
active_evt = CalendarEvent(
"e2", "Sprint Standup", now - timedelta(minutes=10), now + timedelta(minutes=20)
)
alert = AlertState(future_evt, 5, now + timedelta(seconds=15))
states = [
evaluate_display(now, []),
evaluate_display(now, [future_evt]),
evaluate_display(now, [active_evt]),
render_aero_alert(now, alert, [future_evt])[0],
render_aero_breather(now, future_evt)[0],
render_aero_active(now, active_evt, [future_evt], alert)[0],
]
for state_elements in states:
element_ids = {el["id"] for el in state_elements}
for req_id in REQUIRED_INVARIANT_IDS:
assert req_id in element_ids, f"Missing required element ID {req_id}"
def test_procedural_micro_glyphs() -> None:
glyph_types: list[GlyphType] = [
"video",
"coffee",
"focus",
"travel",
"fitness",
"celebrate",
"overtime",
"calendar",
]
for gtype in glyph_types:
elems = make_glyph_elements(gtype, "#4285F4FF")
assert len(elems) == 3
assert [e["id"] for e in elems] == ["glyph_0", "glyph_1", "glyph_2"]
assert all(e["type"] == "rectangle" for e in elems)
def test_resolve_glyph_type() -> None:
e_video = CalendarEvent(
"1", "Team Zoom Sync", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_video) == "video"
e_coffee = CalendarEvent(
"2",
"Quick Coffee & Catchup",
datetime.now(timezone.utc),
datetime.now(timezone.utc),
)
assert resolve_glyph_type(e_coffee) == "coffee"
e_focus = CalendarEvent(
"3",
"Deep Work [focus]",
datetime.now(timezone.utc),
datetime.now(timezone.utc),
)
assert resolve_glyph_type(e_focus) == "focus"
e_overtime = CalendarEvent(
"4", "Late Sync", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_overtime, is_overtime=True) == "overtime"
e_cal = CalendarEvent(
"5", "General Task", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_cal) == "calendar"
# UTF-8 Emoji detection
e_bus = CalendarEvent(
"6", "🚌 Travel", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_bus) == "travel"
e_plane = CalendarEvent(
"7", "Flight to ORD ✈️", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_plane) == "travel"
e_run = CalendarEvent(
"8", "Morning Run 🏃", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_run) == "fitness"
e_party = CalendarEvent(
"9", "Team Party 🎉", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_party) == "celebrate"
e_chill = CalendarEvent(
"10", "😎 Decompress", datetime.now(timezone.utc), datetime.now(timezone.utc)
)
assert resolve_glyph_type(e_chill) == "coffee"
# --- 4. Aero Horizon FSM & Lifecycle Tests -----------------------------------
def test_fsm_idle_state_no_events() -> None:
now = datetime.now(timezone.utc)
elements, extra = evaluate_display_with_hardware(now, [])
el_map = make_el_map(elements)
assert el_map["mid_txt"]["text"] == "Schedule Clear"
assert el_map["badge_txt"]["text"] == "FREE"
assert el_map["title_txt"]["text"] == "Schedule clear"
assert el_map["radar_bg"]["fill_colors"] == ["#141414FF"]
assert extra["priority"] == 30
def test_fsm_idle_state_with_upcoming_events() -> None:
now = datetime.now(timezone.utc)
evt1 = CalendarEvent(
"e1",
"Sprint Planning",
now + timedelta(minutes=42),
now + timedelta(minutes=72),
location="Room 3B",
)
evt2 = CalendarEvent(
"e2", "1-on-1", now + timedelta(minutes=100), now + timedelta(minutes=130)
)
elements, _ = evaluate_display_with_hardware(now, [evt1, evt2])
el_map = make_el_map(elements)
assert el_map["mid_txt"]["text"] == "42m Focus Runway"
assert "NEXT" in el_map["badge_txt"]["text"]
assert "Sprint Planning" in el_map["title_txt"]["text"]
assert "Room 3B" in el_map["title_txt"]["text"]
assert "[THEN" in el_map["title_txt"]["text"]
assert "1-on-1" in el_map["title_txt"]["text"]
assert "queued" not in el_map["title_txt"]["text"].lower()
# Check 3-hour radar meeting block rendering
radar_blk_0 = el_map.get("radar_blk_0")
assert radar_blk_0 is not None
assert radar_blk_0["width"] > 0
assert radar_blk_0["x"] > 0
def test_fsm_checkpoint_alert_sequence() -> None:
now = datetime.now(timezone.utc)
evt = CalendarEvent(
"e1",
"Architecture Review",
now + timedelta(minutes=4),
now + timedelta(minutes=34),
location="Room 2A",
)
# T-4m triggers the 5 MIN checkpoint banner
res_5m, meta_5m = evaluate_display_with_hardware(now, [evt])
el_5m = make_el_map(res_5m)
assert el_5m["badge_txt"]["text"] == "IN 5 MIN"
assert "Architecture Review" in el_5m["title_txt"]["text"]
assert meta_5m["priority"] == 45
assert meta_5m["led_notification_color"] == "#FBBC05FF"
# Active alert banner persists on next tick
res_persist, _ = evaluate_display_with_hardware(now + timedelta(seconds=5), [evt])
el_persist = make_el_map(res_persist)
assert el_persist["badge_txt"]["text"] == "IN 5 MIN"
# After 15s expiration, returns to idle focus runway
res_idle, meta_idle = evaluate_display_with_hardware(
now + timedelta(seconds=20), [evt]
)
el_idle = make_el_map(res_idle)
assert el_idle["badge_txt"]["text"] != "IN 5 MIN"
assert "Focus Runway" in el_idle["mid_txt"]["text"]
assert meta_idle["priority"] == 30
# T-2m checkpoint
res_2m, _ = evaluate_display_with_hardware(now + timedelta(minutes=2), [evt])
el_2m = make_el_map(res_2m)
assert el_2m["badge_txt"]["text"] == "IN 2 MIN"
# At start time: enters Active Meeting Phase 1 Title Verification
res_active_p1, _ = evaluate_display_with_hardware(
now + timedelta(minutes=4, seconds=5), [evt]
)
el_active_p1 = make_el_map(res_active_p1)
assert el_active_p1["badge_txt"]["text"] == "LIVE CALL"
def test_fsm_active_phase_1_verification() -> None:
now = datetime.now(timezone.utc)
evt = CalendarEvent(
"e1",
"Quarterly Planning",
now - timedelta(seconds=10),
now + timedelta(minutes=30),
location="Boardroom",
)
elements, extra = evaluate_display_with_hardware(now, [evt])
el_map = make_el_map(elements)
assert el_map["badge_txt"]["text"] == "LIVE CALL"
assert "Boardroom" in el_map["title_txt"]["text"]
assert el_map["tele_txt"]["color"] == "#EA4335FF"
assert extra["priority"] == 60
assert extra["led_notification_color"] == "#EA4335FF"
def test_fsm_active_phase_2_flight_deck() -> None:
now = datetime.now(timezone.utc)
evt = CalendarEvent(
"e1",
"Quarterly Planning",
now - timedelta(minutes=15),
now + timedelta(minutes=15),
)
next_evt = CalendarEvent(
"e2",
"1-on-1 Catchup",
now + timedelta(minutes=30),
now + timedelta(minutes=60),
)
elements, _ = evaluate_display_with_hardware(now, [evt, next_evt])
el_map = make_el_map(elements)
assert el_map["badge_txt"]["text"] == "LIVE"
assert "15m left" in el_map["tele_txt"]["text"]
assert "[NEXT" in el_map["title_txt"]["text"]
assert "1-on-1 Catchup" in el_map["title_txt"]["text"]
# Precision Horizon Milestone Ticks & Playhead Needle
assert el_map["tick_25"]["x"] == 18
assert el_map["tick_50"]["x"] == 36
assert el_map["tick_75"]["x"] == 54
assert el_map["playhead"]["fill_colors"] == ["#FFFFFFFF"]
assert el_map["playhead"]["x"] == 36 # 50% elapsed of 30m call = 36px
def test_fsm_active_phase_3_wrapup_and_tight_turn() -> None:
now = datetime.now(timezone.utc)
evt = CalendarEvent(
"e1",
"Design Review",
now - timedelta(minutes=28),
now + timedelta(minutes=2), # 2 minutes remaining -> Wrap-up
)
# Next event starts in 6 minutes -> 4m gap (<5m -> Tight Turn)
tight_next = CalendarEvent(
"e2",
"Executive Sync",
now + timedelta(minutes=6),
now + timedelta(minutes=36),
)
# Mark earlier 15m checkpoint as already fired
app.fired_checkpoints.add(("e2", 15))
elements, extra = evaluate_display_with_hardware(now, [evt, tight_next])
el_map = make_el_map(elements)
assert el_map["badge_txt"]["text"] == "WRAP UP"
assert "rem" in el_map["tele_txt"]["text"]
assert "[TIGHT TURN]" in el_map["title_txt"]["text"]
assert el_map["tight_pip"]["x"] == 70
assert el_map["tight_pip"]["fill_colors"] == ["#FBBC05FF"]
assert extra["priority"] == 60
assert extra["led_notification_color"] == "#FBBC05FF"
def test_fsm_active_overtime_phase() -> None:
now = datetime.now(timezone.utc)
evt = CalendarEvent(
"e1",
"Sprint Standup",
now - timedelta(minutes=34),
now - timedelta(minutes=4), # 4 minutes overdue
)
elements, extra = evaluate_display_with_hardware(now, [evt])
el_map = make_el_map(elements)
assert el_map["badge_txt"]["text"] == "+04m OVER"
assert "End:" in el_map["tele_txt"]["text"]
assert "Overrun" in el_map["title_txt"]["text"]
assert el_map["bar_fill"]["width"] == GRID_WIDTH
assert extra["priority"] == 60
assert extra["led_notification_color"] == "#EA4335FF"
def test_fsm_interrupted_active_state_preserves_horizon() -> None:
now = datetime.now(timezone.utc)
current_meeting = CalendarEvent(
"m1",
"Current Strategy Meeting",
now - timedelta(minutes=15),
now + timedelta(minutes=15),
)
next_meeting = CalendarEvent(
"m2",
"Next Architecture Sync",
now + timedelta(minutes=4),
now + timedelta(minutes=34),
)
elements, _ = evaluate_display_with_hardware(now, [current_meeting, next_meeting])
el_map = make_el_map(elements)
# Top banner shows the upcoming checkpoint alert
assert el_map["badge_txt"]["text"] == "IN 5 MIN"
assert "[NEXT] Next Architecture" in el_map["title_txt"]["text"]
assert "on call" in el_map["tele_txt"]["text"]
# Bottom horizon bar PRESERVES current meeting's 50% progress!
assert el_map["bar_fill"]["width"] == 36
assert el_map["bar_fill"]["fill_colors"] == ["#EA4335FF"]
assert el_map["playhead"]["x"] == 36
def test_render_aero_breather() -> None:
now = datetime.now(timezone.utc)
next_meeting = CalendarEvent(
"m1", "Design Review", now + timedelta(minutes=15), now + timedelta(minutes=45)
)
elements, extra = render_aero_breather(now, next_meeting)
el_map = make_el_map(elements)
assert el_map["badge_txt"]["text"] == "15m BREAK"
assert "Breather Window" in el_map["title_txt"]["text"]
assert el_map["bar_fill"]["fill_colors"] == ["#34A853FF"]
assert extra["priority"] == 30
assert extra["led_notification_color"] == "#34A853FF"
def test_fsm_ended_event_pruning() -> None:
now = datetime.now(timezone.utc)
past_event = CalendarEvent(
"past-1", "Past Sync", now - timedelta(minutes=60), now - timedelta(minutes=30)
)
app.fired_checkpoints.add(("past-1", 5))
evaluate_display(now, [past_event])
assert ("past-1", 5) not in app.fired_checkpoints
# --- 5. Helper & Formatting Tests -------------------------------------------
def test_resolve_label_precedence() -> None:
now = datetime.now(timezone.utc)
e_dnd = CalendarEvent("1", "[dnd] Critical release", now, now + timedelta(hours=1))
assert resolve_label(e_dnd) == "DO NOT DISTURB"
e_focus = CalendarEvent(
"2", "Deep focus block [focus]", now, now + timedelta(hours=1)
)
assert resolve_label(e_focus) == "DEEP WORK"
e_break = CalendarEvent(
"3", "Team Coffee", now, now + timedelta(hours=1), color="#34A853"
)
assert resolve_label(e_break) == "ON BREAK"
e_generic = CalendarEvent("4", "Regular Sync", now, now + timedelta(hours=1))
assert resolve_label(e_generic) == "IN MEETING"
def test_fmt_12h_format() -> None:
dt = datetime(2026, 8, 15, 14, 5, tzinfo=timezone.utc)
res = fmt_12h(dt, include_ampm=True)
assert "AM" in res or "PM" in res
assert fmt_12h(dt, include_ampm=False).endswith(":05")
def test_fmt_countdown() -> None:
assert fmt_countdown(45) == "00:45"
assert fmt_countdown(125) == "02:05"
assert fmt_countdown(3660) == "1h01m"
def test_fmt_remaining() -> None:
assert fmt_remaining(timedelta(minutes=45)) == "45m"
assert fmt_remaining(timedelta(minutes=90)) == "1h30m"
def test_clean_location() -> None:
# Physical rooms
assert clean_location("Room 3B") == "Room 3B"
assert clean_location(" Building A, Floor 2 ") == "Building A, Floor 2"
assert clean_location("") == ""
# Google Meet URLs
assert clean_location("https://meet.google.com/abc-defg-hij") == "Google Meet"
assert (
clean_location("", description="Join: https://meet.google.com/xyz")
== "Google Meet"
)
assert clean_location("Room 3B - https://meet.google.com/abc") == "Room 3B"
# Zoom URLs
assert clean_location("https://us02web.zoom.us/j/123456789?pwd=xyz") == "Zoom"
assert clean_location("", description="Zoom link: https://zoom.us/j/999") == "Zoom"
# Teams URLs
assert (
clean_location("https://teams.microsoft.com/l/meetup-join/19%3ameeting...")
== "Teams"
)
# Webex & Generic URLs
assert clean_location("https://company.webex.com/meet/user") == "Webex"
assert clean_location("https://custom-service.com/room1") == "Video Link"
def test_fmt_compact_12h() -> None:
now_local = datetime.now().astimezone()
dt_am = now_local.replace(hour=5, minute=51)
assert fmt_compact_12h(dt_am) == "5:51A"
dt_am_zero = now_local.replace(hour=10, minute=0)
assert fmt_compact_12h(dt_am_zero) == "10A"
dt_pm = now_local.replace(hour=13, minute=30)
assert fmt_compact_12h(dt_pm) == "1:30P"
dt_noon = now_local.replace(hour=12, minute=0)
assert fmt_compact_12h(dt_noon) == "12P"
dt_midnight = now_local.replace(hour=0, minute=0)
assert fmt_compact_12h(dt_midnight) == "12A"
def test_format_idle_badge() -> None:
now = datetime.now().astimezone()
# 1. Empty schedule
assert format_idle_badge(now, []) == "FREE"
# 2. Next event today
evt_today = CalendarEvent(
"1",
"Standup",
now.replace(hour=10, minute=30),
now.replace(hour=11, minute=0),
)
assert format_idle_badge(now, [evt_today]) == "NEXT 10:30A"
# 3. Next event tomorrow
evt_tomorrow = CalendarEvent(
"2",
"Travel",
(now + timedelta(days=1)).replace(hour=5, minute=51),
(now + timedelta(days=1)).replace(hour=7, minute=30),
)
assert format_idle_badge(now, [evt_tomorrow]) == "TMRW 5:51A"
# 4. Next event within 7 days (weekday only)
future_date = now + timedelta(days=3)
day_name = future_date.strftime("%a").upper()
evt_future = CalendarEvent(
"3",
"All Hands",
future_date.replace(hour=15, minute=0),
future_date.replace(hour=16, minute=0),
)
assert format_idle_badge(now, [evt_future]) == f"{day_name} 3P"
# 5. Next event past 7 days (weekday + month/day)
far_future_date = now + timedelta(days=9)
far_day_name = far_future_date.strftime("%a").upper()
evt_far = CalendarEvent(
"4",
"Offsite",
far_future_date.replace(hour=10, minute=0),
far_future_date.replace(hour=11, minute=0),
)
assert (
format_idle_badge(now, [evt_far])
== f"{far_day_name} {far_future_date.month}/{far_future_date.day} 10A"
)
def test_format_idle_marquee() -> None:
now = datetime.now().astimezone().replace(hour=10, minute=0)
# 1. Empty schedule
assert format_idle_marquee(now, [], lookahead_count=6) == "Schedule clear"
# 2. Single event today (starts with time)
evt_today = CalendarEvent(
"1",
"Standup",
now.replace(hour=10, minute=30),
now.replace(hour=11, minute=0),
location="Room 2B",
)
res_today = format_idle_marquee(now, [evt_today], lookahead_count=6)
assert res_today.startswith("10:30 AM - Standup")
assert "Room 2B" in res_today
assert "queued" not in res_today.lower()
# 3. Multiple events today with lookahead_count = 3
evt_today_2 = CalendarEvent(
"2",
"Design Review",
now.replace(hour=14, minute=0),
now.replace(hour=15, minute=0),
location="https://meet.google.com/abc",
)
evt_today_3 = CalendarEvent(
"3",
"1-on-1 Sync",
now.replace(hour=16, minute=0),
now.replace(hour=16, minute=30),
)
res_multi = format_idle_marquee(
now, [evt_today, evt_today_2, evt_today_3], lookahead_count=3
)
assert res_multi.startswith("10:30 AM - Standup - Room 2B")
assert "[THEN 2:00 PM] Design Review (Google Meet)" in res_multi
assert "[THEN 4:00 PM] 1-on-1 Sync" in res_multi
# 4. Next event tomorrow (starts directly with title, NOT 'Tomorrow')
evt_tomorrow = CalendarEvent(
"4",
"Quarterly Sync",
(now + timedelta(days=1)).replace(hour=9, minute=0),
(now + timedelta(days=1)).replace(hour=10, minute=0),
location="Auditorium",
)
res_tomorrow = format_idle_marquee(now, [evt_tomorrow], lookahead_count=6)
assert res_tomorrow.startswith("Quarterly Sync - Auditorium")
assert "tomorrow" not in res_tomorrow.lower()
# 5. Multi-day lookahead crossing into subsequent days
res_cross = format_idle_marquee(now, [evt_tomorrow, evt_today_3], lookahead_count=2)
assert res_cross.startswith("Quarterly Sync - Auditorium")
assert "[THEN" in res_cross
def test_resolve_glyph_tint_color() -> None:
assert resolve_glyph_tint_color("travel") == "#FDD663FF"
assert resolve_glyph_tint_color("video") == "#AECBFAFF"
assert resolve_glyph_tint_color("focus") == "#D7AEFBFF"
assert resolve_glyph_tint_color("coffee") == "#81C995FF"
assert resolve_glyph_tint_color("fitness") == "#81C995FF"
assert resolve_glyph_tint_color("celebrate") == "#F28B82FF"
assert resolve_glyph_tint_color("overtime") == "#F28B82FF"
assert resolve_glyph_tint_color("calendar") == "#AECBFAFF"
def test_no_queued_string_across_all_lifecycle_states() -> None:
now = datetime.now(timezone.utc)
evt1 = CalendarEvent(
"e1", "Event 1", now - timedelta(minutes=10), now + timedelta(minutes=20)
)
evt2 = CalendarEvent(
"e2", "Event 2", now + timedelta(minutes=30), now + timedelta(minutes=60)
)
evt3 = CalendarEvent(
"e3", "Event 3", now + timedelta(minutes=90), now + timedelta(minutes=120)
)
alert = AlertState(evt2, 5, now + timedelta(seconds=15))
states = [
evaluate_display(now, []),
evaluate_display(now, [evt2, evt3]),
evaluate_display(now, [evt1, evt2, evt3]),
render_aero_alert(now, alert, [evt2, evt3])[0],
render_aero_breather(now, evt2)[0],
render_aero_active(now, evt1, [evt2, evt3], alert)[0],
]
for state_elements in states:
for elem in state_elements:
if elem.get("type") == "text":
txt = elem.get("text", "")
assert "queued" not in txt.lower(), (
f"Found 'queued' in element {elem['id']}: {txt}"
)
def test_sanitize_display_text() -> None:
assert sanitize_display_text("🚌 Travel") == "Travel"
assert sanitize_display_text("😎 Decompress") == "Decompress"
assert sanitize_display_text("⚡ Tight Turn: Meeting") == "Tight Turn: Meeting"
assert (
sanitize_display_text("Sprint Planning \u00b7 Room 3B")
== "Sprint Planning - Room 3B"
)
assert (
sanitize_display_text("\u201cHello\u201d & \u2018World\u2019\u2026")
== "\"Hello\" & 'World'..."
)
assert sanitize_display_text("") == ""
def test_lookahead_count_validation() -> None:
"""Test that lookahead_count is constrained between 2 and 20."""
from pydantic import ValidationError
from app import AppConfig
# Valid values
assert AppConfig().lookahead_count == 8
assert AppConfig(lookahead_count=2).lookahead_count == 2
assert AppConfig(lookahead_count=8).lookahead_count == 8
assert AppConfig(lookahead_count=20).lookahead_count == 20
# Invalid values below 2
with pytest.raises(ValidationError):
AppConfig(lookahead_count=1)
with pytest.raises(ValidationError):
AppConfig(lookahead_count=0)
# Invalid values above 20
with pytest.raises(ValidationError):
AppConfig(lookahead_count=21)
def test_app_name_and_config_env_aliases(monkeypatch: pytest.MonkeyPatch) -> None:
"""Test APP identifier and environment variable alias fallback resolution."""
from app import APP, AppConfig
assert APP == "gcal-glance"
# Primary GCAL_GLANCE_ prefix
monkeypatch.setenv("GCAL_GLANCE_LOOKAHEAD_COUNT", "8")
monkeypatch.setenv("GCAL_GLANCE_HOST", "192.168.1.50")
cfg = AppConfig()
assert cfg.lookahead_count == 8
assert cfg.host == "192.168.1.50"
# Backward compatibility with GCAL_ prefix
monkeypatch.delenv("GCAL_GLANCE_LOOKAHEAD_COUNT", raising=False)
monkeypatch.delenv("GCAL_GLANCE_HOST", raising=False)
monkeypatch.setenv("GCAL_LOOKAHEAD_COUNT", "4")
monkeypatch.setenv("GCAL_HOST", "10.0.0.99")
cfg = AppConfig()
assert cfg.lookahead_count == 4
assert cfg.host == "10.0.0.99"
# Backward compatibility with CALSYNC_ prefix
monkeypatch.delenv("GCAL_LOOKAHEAD_COUNT", raising=False)
monkeypatch.delenv("GCAL_HOST", raising=False)
monkeypatch.setenv("CALSYNC_LOOKAHEAD_COUNT", "3")
monkeypatch.setenv("CALSYNC_HOST", "127.0.0.1")
cfg = AppConfig()
assert cfg.lookahead_count == 3
assert cfg.host == "127.0.0.1"
def test_build_peek_queue() -> None:
from app import build_peek_queue
now = datetime(2026, 8, 16, 10, 0, tzinfo=timezone.utc)
# 1. Empty events
assert build_peek_queue(now, [], lookahead_count=6) == []
# 2. Events today + events tomorrow + events next week
evt_today_1 = CalendarEvent(
"1", "Standup", now.replace(hour=10, minute=30), now.replace(hour=11, minute=0)
)
evt_today_2 = CalendarEvent(
"2", "Design", now.replace(hour=14, minute=0), now.replace(hour=15, minute=0)
)
evt_tmrw = CalendarEvent(
"3",
"1-on-1",
now.replace(day=17, hour=9, minute=0),
now.replace(day=17, hour=9, minute=30),
)
evt_future_1 = CalendarEvent(
"4",
"Review",
now.replace(day=18, hour=10, minute=0),
now.replace(day=18, hour=11, minute=0),
)
evt_future_2 = CalendarEvent(
"5",
"All Hands",
now.replace(day=19, hour=14, minute=0),
now.replace(day=19, hour=15, minute=0),
)
all_events = [evt_today_1, evt_today_2, evt_tmrw, evt_future_1, evt_future_2]
# With lookahead_count = 2 (today's 2 + next 2 future = 4)
q = build_peek_queue(now, all_events, lookahead_count=2)
assert len(q) == 4
assert [e.uid for e in q] == ["1", "2", "3", "4"]
# When today has NO events (only future events)
q_no_today = build_peek_queue(
now, [evt_tmrw, evt_future_1, evt_future_2], lookahead_count=2
)
assert len(q_no_today) == 2
assert [e.uid for e in q_no_today] == ["3", "4"]
def test_format_peek_bookmark_and_delta() -> None:
from app import format_peek_bookmark, format_relative_delta
now = datetime(2026, 8, 16, 10, 0, tzinfo=timezone.utc)
# Today's event
evt_today = CalendarEvent(
"1", "Standup", now.replace(hour=10, minute=30), now.replace(hour=11, minute=0)
)
assert format_peek_bookmark(now, evt_today, 1, 4) == "1/4"
assert format_relative_delta(now, evt_today) == "in 30m"
# Active LIVE event
evt_live = CalendarEvent(
"2", "Call", now.replace(hour=9, minute=30), now.replace(hour=10, minute=30)
)
assert format_relative_delta(now, evt_live) == "LIVE"
# Ended event (e.g. ended 15m ago vs ended 1h ago)
evt_ended_15m = CalendarEvent(
"3", "Breakfast", now.replace(hour=9, minute=15), now.replace(hour=9, minute=45)
)
assert format_relative_delta(now, evt_ended_15m) == "ended 15m"
evt_ended_1h = CalendarEvent(
"3b", "Breakfast", now.replace(hour=8, minute=0), now.replace(hour=9, minute=0)
)
assert format_relative_delta(now, evt_ended_1h) == "ended 1h"
# Tomorrow event
evt_tmrw = CalendarEvent(
"4",
"Sync",
now.replace(day=17, hour=9, minute=0),
now.replace(day=17, hour=10, minute=0),
)
assert format_peek_bookmark(now, evt_tmrw, 2, 4) == "TMRW 2/4"
# Within 7 days event (e.g. Wednesday 8/19)
evt_wed = CalendarEvent(
"5",
"Demo",
now.replace(day=19, hour=14, minute=0),
now.replace(day=19, hour=15, minute=0),
)
assert format_peek_bookmark(now, evt_wed, 3, 4) == "WED 3/4"
# Far future event (e.g. 10 days out)
evt_far = CalendarEvent(
"6",
"Offsite",
now.replace(day=26, hour=9, minute=0),
now.replace(day=26, hour=17, minute=0),
)
assert format_peek_bookmark(now, evt_far, 4, 4) == "WED 8/26 4/4"
def test_render_aero_peek() -> None:
from app import AgendaPeekState, render_aero_peek
now = datetime(2026, 8, 16, 10, 0, tzinfo=timezone.utc)
evt1 = CalendarEvent(
"1",
"Sprint Sync",
now.replace(hour=11, minute=0),
now.replace(hour=11, minute=30),
location="Room 3B",
)
evt2 = CalendarEvent(
"2",
"Design",
now.replace(hour=14, minute=0),
now.replace(hour=15, minute=0),
location="https://meet.google.com/abc",
)
peek = AgendaPeekState(
queue=[evt1, evt2],
selected_index=0,
expires_at=now + timedelta(seconds=6),
)
elements, extra = render_aero_peek(now, peek)
assert extra["priority"] == 50
# Invariant element IDs present
elem_ids = {e["id"] for e in elements}
assert "clock_txt" in elem_ids
assert "badge_bg" in elem_ids
assert "badge_txt" in elem_ids
assert "title_txt" in elem_ids
assert "radar_bg" in elem_ids
assert "stepper_0" in elem_ids
assert "stepper_1" in elem_ids
def test_decode_input_events() -> None:
from app import decode_input_events
# Construct mock protobuf frame for StateUpdate.input:
# State.updates (f=2,w=2) -> StateUpdate.input (f=11,w=2) -> button_event (f=1,w=2)
btn_payload = bytes([0x08, 0x00, 0x10, 0x00]) # BTN_OK, PRESS
input_ev = bytes([0x0A, len(btn_payload)]) + btn_payload
state_upd = bytes([0x5A, len(input_ev)]) + input_ev
frame = bytes([0x12, len(state_upd)]) + state_upd
events = decode_input_events(frame)
assert len(events) == 1
assert events[0] == ("button", (0, 0))
def test_apply_hardware_input_alert_dismissal_and_exit() -> None:
import app
from app import (
ACTION_PRESS,
BTN_BACK,
BTN_OK,
BTN_START,
AgendaPeekState,
AlertState,
apply_hardware_input,
)
now = datetime(2026, 8, 16, 10, 0, tzinfo=timezone.utc)
evt = CalendarEvent(
"1", "Standup", now + timedelta(minutes=5), now + timedelta(minutes=35)
)
# 1. Early alert dismissal on START button
app.active_alert = AlertState(
event=evt, checkpoint_minutes=5, expires_at=now + timedelta(seconds=15)
)
changed = apply_hardware_input(now, "button", (BTN_START, ACTION_PRESS), [evt])
assert changed is True
assert app.active_alert is None
# 2. Early alert dismissal on BACK button
app.active_alert = AlertState(
event=evt, checkpoint_minutes=5, expires_at=now + timedelta(seconds=15)
)
changed = apply_hardware_input(now, "button", (BTN_BACK, ACTION_PRESS), [evt])
assert changed is True
assert app.active_alert is None
# 3. Exit peek mode on BACK button
app.active_peek = AgendaPeekState(
queue=[evt], selected_index=0, expires_at=now + timedelta(seconds=6)
)
changed = apply_hardware_input(now, "button", (BTN_BACK, ACTION_PRESS), [evt])
assert changed is True
assert app.active_peek is None
# 4. Manual sync on OK button
app.manual_sync_flash_until = None
changed = apply_hardware_input(now, "button", (BTN_OK, ACTION_PRESS), [evt])
assert changed is True
assert app.manual_sync_flash_until is not None
assert app.manual_sync_flash_until > now
def test_apply_hardware_input_encoder_dial() -> None:
import app
from app import CalendarEvent, apply_hardware_input
now = datetime(2026, 8, 16, 10, 0, tzinfo=timezone.utc)
evt1 = CalendarEvent(
"1", "Standup", now.replace(hour=10, minute=30), now.replace(hour=11, minute=0)
)
evt2 = CalendarEvent(
"2", "Design", now.replace(hour=14, minute=0), now.replace(hour=15, minute=0)
)
events = [evt1, evt2]
app.active_peek = None
# Step forward (+1 CW) opens peek view at event 0
changed = apply_hardware_input(now, "encoder", 1, events)
assert changed is True
assert app.active_peek is not None
assert app.active_peek.selected_index == 0
# Step forward again (+1 CW) moves to event 1
apply_hardware_input(now, "encoder", 1, events)
assert app.active_peek.selected_index == 1
# Step backward (-1 CCW) moves back to event 0
apply_hardware_input(now, "encoder", -1, events)
assert app.active_peek.selected_index == 0
def test_top_led_halo_states() -> None:
import app
from app import (
LED_COLOR_ALERT,
LED_COLOR_MEETING,
LED_COLOR_SYNC_ACK,
AlertState,
CalendarEvent,
evaluate_display_with_hardware,
)
now = datetime(2026, 8, 16, 10, 0, tzinfo=timezone.utc)
# 1. Idle state: LED off (None)
app.active_alert = None
app.active_peek = None
app.manual_sync_flash_until = None
_, extra = evaluate_display_with_hardware(now, [])
assert extra["led_notification_color"] is None
# 2. Alert state: LED amber
evt_soon = CalendarEvent(
"1", "Standup", now + timedelta(minutes=5), now + timedelta(minutes=35)
)
app.active_alert = AlertState(
event=evt_soon, checkpoint_minutes=5, expires_at=now + timedelta(seconds=15)
)
_, extra = evaluate_display_with_hardware(now, [evt_soon])
assert extra["led_notification_color"] == LED_COLOR_ALERT
# 3. Active meeting state: LED blue
app.active_alert = None
evt_active = CalendarEvent(
"2", "Review", now - timedelta(minutes=10), now + timedelta(minutes=20)
)
_, extra = evaluate_display_with_hardware(now, [evt_active])
assert extra["led_notification_color"] == LED_COLOR_MEETING
# 4. Manual sync flash overrides LED color
app.manual_sync_flash_until = now + timedelta(seconds=0.5)
_, extra = evaluate_display_with_hardware(now, [])
assert extra["led_notification_color"] == LED_COLOR_SYNC_ACK
def test_sound_and_volume_config() -> None:
from pydantic import ValidationError
from app import AppConfig
# Default configuration: sound=True, volume=None
cfg = AppConfig()
assert cfg.sound is True
assert cfg.volume is None
# Custom configuration
cfg_custom = AppConfig(sound=False, volume=75)
assert cfg_custom.sound is False
assert cfg_custom.volume == 75
# Bounds validation for volume (0..100)
assert AppConfig(volume=0).volume == 0
assert AppConfig(volume=100).volume == 100
with pytest.raises(ValidationError):
AppConfig(volume=-1)
with pytest.raises(ValidationError):
AppConfig(volume=101)
async def test_play_sound_api_dispatch(mock_busy_bar_api: respx.MockRouter) -> None:
from app import STOCK_SOUND_EVENT_START, play_sound
play_route = mock_busy_bar_api.post("http://10.0.4.20/api/audio/play").respond(
json={"success": True}
)
vol_route = mock_busy_bar_api.post("http://10.0.4.20/api/audio/volume").respond(
json={"success": True}
)
async with httpx.AsyncClient() as client:
# Play sound without explicit volume
await play_sound(client, STOCK_SOUND_EVENT_START)
assert play_route.called
assert not vol_route.called
assert (
play_route.calls.last.request.headers["content-type"] == "application/json"
)
body = json.loads(play_route.calls.last.request.content)
assert body["stock_path"] == "calendar_event_starts"
# Play sound with explicit volume setting
await play_sound(client, STOCK_SOUND_EVENT_START, volume=80)
assert vol_route.called
vol_body = json.loads(vol_route.calls.last.request.content)
assert vol_body["volume"] == 80
def test_alert_triggers_queue_sound_chimes(monkeypatch: pytest.MonkeyPatch) -> None:
import app
from app import (
STOCK_SOUND_EVENT_START,
STOCK_SOUND_REMINDER,
CalendarEvent,
evaluate_display_with_hardware,
)
app._pending_audio_chimes.clear()
app.fired_checkpoints.clear()
app.active_alert = None
now = datetime(2026, 8, 16, 9, 45, tzinfo=timezone.utc)
evt = CalendarEvent(
"audio-evt-1",
"Design Review",
now + timedelta(minutes=15),
now + timedelta(minutes=45),
)
# 1. 15m warning alert -> triggers STOCK_SOUND_REMINDER
monkeypatch.setattr(app.get_config(), "sound", True)
evaluate_display_with_hardware(now, [evt])
assert app._pending_audio_chimes == [STOCK_SOUND_REMINDER]
app._pending_audio_chimes.clear()
# 2. 0m event start -> triggers STOCK_SOUND_EVENT_START
now_start = evt.start
evaluate_display_with_hardware(now_start, [evt])
assert app._pending_audio_chimes == [STOCK_SOUND_EVENT_START]
app._pending_audio_chimes.clear()
# 3. When sound is muted (sound=False), no chimes are queued
app.fired_checkpoints.clear()
app.active_alert = None
monkeypatch.setattr(app.get_config(), "sound", False)
evaluate_display_with_hardware(now, [evt])
assert app._pending_audio_chimes == []Source and Provenance
Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: e652e22c685e1727e342f2a32a747630d51248d6c6375d17255ad1c58c066931
Rate and Discuss
Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.





