diff --git a/CLAUDE.md b/CLAUDE.md index e15dcb6..8830928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`, | `emote` | No | Custom emote to display | | `color` | No | Custom color (hex, e.g., `#FF9900`) | | `animation` | No | One of: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` | -| `sound` | No | One of: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `none` | +| `sound` | No | One of: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `none` | ## Priority System @@ -118,7 +118,7 @@ Lower number = higher priority. Events with a `ttl` auto-expire (heartbeat patte ## Personality System -The optimal state cycles through emotes with paired animations every 5 minutes: +The optimal state face is set once per day on `/wake` (random pick). Each morning a fresh emote is chosen: | Emote | Animation | Vibe | |-------|-----------|------| diff --git a/README.md b/README.md index c179bd8..3914cce 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ automation: - `emote`: Any ASCII emote (e.g., `( °o°)`, `\\(^o^)/`) - `color`: Hex color (e.g., `#FF9900`) - `animation`: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` -- `sound`: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `none` +- `sound`: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `none` ## API Reference @@ -200,8 +200,8 @@ Full API documentation available at [/docs](http://localhost:5100/docs) or in [o The emote has personality! In optimal state it: -- Rotates through happy faces every 5 minutes -- Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two +- Shows a stable face all day — set fresh each morning when `/wake` is called +- Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two on wake - Celebrates `\(^o^)/` when recovering from warnings - Each face has its own animation (floating, bouncing, swaying) - Reacts when tapped `( °o°)`, shows version info, and dismisses active alerts @@ -211,6 +211,7 @@ The emote has personality! In optimal state it: - Critical: urgent descending tone - Notify: gentle ping - Recovery: happy ascending chirp +- 10 additional synthesized sounds for `/notify`: `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm` (alarm loops until tapped) ## License diff --git a/SPEC.md b/SPEC.md index 45f37bf..9545e92 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,79 +1,79 @@ -# SPEC.md: Project "Sentry-Emote" - -## 1. Overview - -**Purpose:** Repurpose an old Pixel phone (OLED screen) as an ambient, glanceable system status monitor for a home server. -**Design Philosophy:** Minimalist, binary-state, and high-signal. Use an "Emote" (ASCII/Emoji) to represent system health instead of complex graphs. -**Target Device:** Android Pixel (accessed via Fully Kiosk Browser). - -## 2. System Architecture - -The system follows a decoupled **Publisher/Subscriber** model to ensure extensibility. - -- **Aggregator (The Broker):** A central Python service running on the server. It manages the event queue and generates the state. -- **Detectors (The Publishers):** Independent scripts (Python, Bash, etc.) that monitor specific system metrics and "hook" into the Aggregator. -- **Emote-UI (The Subscriber):** A mobile-optimized web frontend that displays the current highest-priority emote. - -## 3. Data Specification - -### 3.1 `status.json` (State Registry) - -The Aggregator outputs this file every time the state changes. - -```json -{ - "current_state": "optimal", - "active_emote": "( ^_^)", - "color": "#00FF00", - "animation": "breathing", - "message": "All systems nominal", - "active_events": [ - { - "id": "disk_check", - "priority": 4, - "message": "Disk 40% full" - } - ], - "last_updated": "2026-02-02T17:30:00" -} -``` - -### 3.2 Priority Hierarchy - -| Level | Name | Priority | Emote | Color | Logic | -| ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- | -| **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. | -| **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. | -| **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. | -| **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. | - -## 4. Component Requirements - -### 4.1 Aggregator (`aggregator.py`) - -- **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals. -- **State Management:** Maintain a list of "Active Events." -- **TTL Logic:** Automatically remove Priority 3 events after 10 seconds. -- **Deduplication:** If multiple events exist, always select the one with the lowest priority number for the `active_emote` field. - -### 4.2 Emote-UI (`index.html`) - -- **OLED Optimization:** Pure black background (`#000000`). -- **Glanceability:** Massive centered text for the emote. -- **Animations:** - `breathing`: Slow opacity/scale pulse. -- `shaking`: Rapid X-axis jitter for Critical. -- `popping`: Scale-up effect for Notifications. - -- **Refresh:** Long-polling or `setInterval` every 2 seconds. - -### 4.3 Extensibility (The Hook System) - -- New detectors must be able to send an event to the Aggregator without modifying the core code. -- Example Detector Hook: `curl -X POST -d '{"id":"ssh","priority":1}' http://localhost:5000/event` - -## 5. Implementation Roadmap - -1. **Phase 1:** Build the `aggregator.py` with basic JSON output. -2. **Phase 2:** Build the OLED-friendly `index.html` frontend. -3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker). -4. **Phase 4:** Implement TTL for transient notifications. +# SPEC.md: Project "Sentry-Emote" + +## 1. Overview + +**Purpose:** Repurpose an old Pixel phone (OLED screen) as an ambient, glanceable system status monitor for a home server. +**Design Philosophy:** Minimalist, binary-state, and high-signal. Use an "Emote" (ASCII/Emoji) to represent system health instead of complex graphs. +**Target Device:** Android Pixel (accessed via Fully Kiosk Browser). + +## 2. System Architecture + +The system follows a decoupled **Publisher/Subscriber** model to ensure extensibility. + +- **Aggregator (The Broker):** A central Python service running on the server. It manages the event queue and generates the state. +- **Detectors (The Publishers):** Independent scripts (Python, Bash, etc.) that monitor specific system metrics and "hook" into the Aggregator. +- **Emote-UI (The Subscriber):** A mobile-optimized web frontend that displays the current highest-priority emote. + +## 3. Data Specification + +### 3.1 `status.json` (State Registry) + +The Aggregator outputs this file every time the state changes. + +```json +{ + "current_state": "optimal", + "active_emote": "( ^_^)", + "color": "#00FF00", + "animation": "breathing", + "message": "All systems nominal", + "active_events": [ + { + "id": "disk_check", + "priority": 4, + "message": "Disk 40% full" + } + ], + "last_updated": "2026-02-02T17:30:00" +} +``` + +### 3.2 Priority Hierarchy + +| Level | Name | Priority | Emote | Color | Logic | +| ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- | +| **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. | +| **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. | +| **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. | +| **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. | + +## 4. Component Requirements + +### 4.1 Aggregator (`aggregator.py`) + +- **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals. +- **State Management:** Maintain a list of "Active Events." +- **TTL Logic:** Automatically remove Priority 3 events after 10 seconds. +- **Deduplication:** If multiple events exist, always select the one with the lowest priority number for the `active_emote` field. + +### 4.2 Emote-UI (`index.html`) + +- **OLED Optimization:** Pure black background (`#000000`). +- **Glanceability:** Massive centered text for the emote. +- **Animations:** - `breathing`: Slow opacity/scale pulse. +- `shaking`: Rapid X-axis jitter for Critical. +- `popping`: Scale-up effect for Notifications. + +- **Refresh:** Long-polling or `setInterval` every 2 seconds. + +### 4.3 Extensibility (The Hook System) + +- New detectors must be able to send an event to the Aggregator without modifying the core code. +- Example Detector Hook: `curl -X POST -d '{"id":"ssh","priority":1}' http://localhost:5000/event` + +## 5. Implementation Roadmap + +1. **Phase 1:** Build the `aggregator.py` with basic JSON output. +2. **Phase 2:** Build the OLED-friendly `index.html` frontend. +3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker). +4. **Phase 4:** Implement TTL for transient notifications. diff --git a/aggregator.py b/aggregator.py index 2c992fd..86c9281 100644 --- a/aggregator.py +++ b/aggregator.py @@ -19,14 +19,12 @@ ROOT_DIR = Path(__file__).parent STATUS_FILE = Path(__file__).parent / "status.json" DEFAULT_NOTIFY_TTL = 10 # Default TTL for Priority 3 (Notify) events CELEBRATION_DURATION = 5 # Seconds to show celebration after recovery -EMOTE_ROTATION_INTERVAL = 300 # Seconds between emote rotations -IDLE_EXPRESSION_CHANCE = 0.15 # Chance of a brief blink/wink on rotation +IDLE_EXPRESSION_CHANCE = 0.15 # Chance of a brief blink/wink on wake DEFAULT_NOTIFY_DURATION = 5 # Default duration for /notify events # Emote variations with paired animations OPTIMAL_EMOTES = [ ("( ^_^)", "breathing"), # calm, content - ("( ˙▿˙)", "floating"), # content ("(◕‿◕)", "bouncing"), # cheerful ("( ・ω・)", "swaying"), # curious ("( ˘▽˘)", "breathing"), # cozy @@ -56,7 +54,6 @@ celebrating_until = 0 blinking_until = 0 blink_emote = None blink_animation = None -last_emote_change = time.time() current_optimal_emote = OPTIMAL_EMOTES[0][0] current_optimal_animation = OPTIMAL_EMOTES[0][1] @@ -73,7 +70,7 @@ SLEEP_ANIMATION = "sleeping" def get_current_state(): """Determine current state based on active events.""" global previous_priority, celebrating_until, blinking_until, blink_emote, blink_animation - global last_emote_change, current_optimal_emote, current_optimal_animation + global current_optimal_emote, current_optimal_animation # Sleep mode overrides everything if is_sleeping: @@ -139,14 +136,6 @@ def get_current_state(): emote = blink_emote animation = blink_animation else: - # Rotate optimal emotes every 5 minutes - if now - last_emote_change > EMOTE_ROTATION_INTERVAL: - last_emote_change = now - current_optimal_emote, current_optimal_animation = random.choice(OPTIMAL_EMOTES) - # Brief blink/wink chance on rotation - if random.random() < IDLE_EXPRESSION_CHANCE: - blink_emote, blink_animation = random.choice(IDLE_EMOTES) - blinking_until = now + random.uniform(1, 2) emote = current_optimal_emote animation = current_optimal_animation @@ -326,8 +315,13 @@ def sleep_mode(): @app.route("/wake", methods=["POST"]) def wake_mode(): """Exit sleep mode. For Home Assistant webhook.""" - global is_sleeping + global is_sleeping, current_optimal_emote, current_optimal_animation + global blink_emote, blink_animation, blinking_until is_sleeping = False + current_optimal_emote, current_optimal_animation = random.choice(OPTIMAL_EMOTES) + if random.random() < IDLE_EXPRESSION_CHANCE: + blink_emote, blink_animation = random.choice(IDLE_EMOTES) + blinking_until = time.time() + random.uniform(1, 2) state = write_status() return jsonify({"status": "awake", "current_state": state}), 200 diff --git a/index.html b/index.html index 7084b0e..6a88f15 100644 --- a/index.html +++ b/index.html @@ -217,7 +217,7 @@ const emoteEl = document.getElementById("emote"); const messageEl = document.getElementById("message"); const POLL_INTERVAL = 2000; - const VERSION = "v1.5.0"; + const VERSION = "v2.0.0"; // Sound system let audioCtx = null; @@ -304,8 +304,69 @@ setTimeout(() => playTone(1047, 0.2), 300); } + function playDoorbellSound() { + playTone(880, 0.3, "sine", 0.12); + setTimeout(() => playTone(659, 0.4, "sine", 0.12), 350); + } + function playKnockSound() { + playTone(200, 0.08, "square", 0.15); + setTimeout(() => playTone(200, 0.08, "square", 0.15), 150); + setTimeout(() => playTone(200, 0.08, "square", 0.15), 300); + } + function playDingSound() { + playTone(1046, 0.4, "sine", 0.1); + } + function playBlipSound() { + playTone(1200, 0.05, "sine", 0.07); + } + function playSirenSound() { + playTone(880, 0.15, "sawtooth", 0.12); + setTimeout(() => playTone(660, 0.15, "sawtooth", 0.12), 180); + setTimeout(() => playTone(880, 0.15, "sawtooth", 0.12), 360); + setTimeout(() => playTone(660, 0.15, "sawtooth", 0.12), 540); + } + function playTadaSound() { + playTone(392, 0.08, "sine", 0.1); + setTimeout(() => playTone(392, 0.08, "sine", 0.1), 100); + setTimeout(() => playTone(784, 0.4, "sine", 0.13), 220); + } + function playPingSound() { + playTone(1047, 0.15, "sine", 0.1); + } + function playBubbleSound() { + playTone(400, 0.06, "sine", 0.06); + setTimeout(() => playTone(600, 0.04, "sine", 0.04), 40); + } + function playFanfareSound() { + playTone(523, 0.1, "sine", 0.1); + setTimeout(() => playTone(659, 0.1, "sine", 0.1), 120); + setTimeout(() => playTone(784, 0.1, "sine", 0.1), 240); + setTimeout(() => playTone(1047, 0.15, "sine", 0.12), 360); + setTimeout(() => playTone(784, 0.08, "sine", 0.1), 520); + setTimeout(() => playTone(1047, 0.3, "sine", 0.15), 620); + } + + // Alarm — loops until manually stopped + let alarmInterval = null; + function playAlarmTick() { + playTone(880, 0.15, "square", 0.2); + setTimeout(() => playTone(660, 0.15, "square", 0.2), 200); + } + function startAlarm() { + if (alarmInterval) return; + playAlarmTick(); + alarmInterval = setInterval(playAlarmTick, 500); + } + function stopAlarm() { + if (alarmInterval) { + clearInterval(alarmInterval); + alarmInterval = null; + } + } + // Play sound by name function playSoundByName(name) { + if (name === "alarm") { startAlarm(); return; } const sounds = { chime: playChimeSound, alert: playAlertSound, @@ -314,6 +375,15 @@ success: playSuccessSound, notify: playNotifySound, recovery: playRecoverySound, + doorbell: playDoorbellSound, + knock: playKnockSound, + ding: playDingSound, + blip: playBlipSound, + siren: playSirenSound, + tada: playTadaSound, + ping: playPingSound, + bubble: playBubbleSound, + fanfare: playFanfareSound, }; if (sounds[name]) { sounds[name](); @@ -333,6 +403,7 @@ playSoundByName(customSound); lastCustomSound = customSound; } else if (!customSound) { + stopAlarm(); // stop any looping alarm lastCustomSound = null; } @@ -363,6 +434,7 @@ // Handle tap - enable sound and show reaction document.body.addEventListener("click", () => { + stopAlarm(); // stop any looping alarm // Enable sound on first tap (browser autoplay policy) if (!soundEnabled) { soundEnabled = true; diff --git a/openapi.yaml b/openapi.yaml index 2849b22..2426eba 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -313,6 +313,16 @@ components: - critical - success - notify + - doorbell + - knock + - ding + - blip + - siren + - tada + - ping + - bubble + - fanfare + - alarm - none example: "chime" diff --git a/requirements.txt b/requirements.txt index f8c52ee..ebbc292 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ -blinker==1.9.0 -certifi==2026.1.4 -charset-normalizer==3.4.4 -click==8.3.1 -colorama==0.4.6 -Flask==3.1.2 -idna==3.11 -itsdangerous==2.2.0 -Jinja2==3.1.6 -MarkupSafe==3.0.3 -psutil==7.2.2 -requests==2.32.5 -urllib3==2.6.3 -Werkzeug==3.1.5 +blinker==1.9.0 +certifi==2026.1.4 +charset-normalizer==3.4.4 +click==8.3.1 +colorama==0.4.6 +Flask==3.1.2 +idna==3.11 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +psutil==7.2.2 +requests==2.32.5 +urllib3==2.6.3 +Werkzeug==3.1.5