Bump to v2.0.0: daily emote rotation + expanded sound library

- Emote face is now stable all day; /wake picks a fresh random face each
  morning instead of rotating every 5 minutes. Removes EMOTE_ROTATION_INTERVAL
  and last_emote_change entirely.
- Added 10 new synthesized sounds for /notify: doorbell, knock, ding, blip,
  siren, tada, ping, bubble, fanfare, and alarm (loops until tapped or TTL
  expires). stopAlarm() wired into tap handler and handleStateChange().
- openapi.yaml: new sound names added to enum.
- CLAUDE.md / README.md updated to reflect both changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 16:36:23 -06:00
parent dd8bf6005b
commit a074a42d40
7 changed files with 190 additions and 113 deletions

View File

@@ -103,7 +103,7 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`,
| `emote` | No | Custom emote to display | | `emote` | No | Custom emote to display |
| `color` | No | Custom color (hex, e.g., `#FF9900`) | | `color` | No | Custom color (hex, e.g., `#FF9900`) |
| `animation` | No | One of: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` | | `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 ## Priority System
@@ -118,7 +118,7 @@ Lower number = higher priority. Events with a `ttl` auto-expire (heartbeat patte
## Personality System ## 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 | | Emote | Animation | Vibe |
|-------|-----------|------| |-------|-----------|------|

View File

@@ -177,7 +177,7 @@ automation:
- `emote`: Any ASCII emote (e.g., `( °o°)`, `\\(^o^)/`) - `emote`: Any ASCII emote (e.g., `( °o°)`, `\\(^o^)/`)
- `color`: Hex color (e.g., `#FF9900`) - `color`: Hex color (e.g., `#FF9900`)
- `animation`: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` - `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 ## 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: The emote has personality! In optimal state it:
- Rotates through happy faces every 5 minutes - Shows a stable face all day — set fresh each morning when `/wake` is called
- Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two - Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two on wake
- Celebrates `\(^o^)/` when recovering from warnings - Celebrates `\(^o^)/` when recovering from warnings
- Each face has its own animation (floating, bouncing, swaying) - Each face has its own animation (floating, bouncing, swaying)
- Reacts when tapped `( °o°)`, shows version info, and dismisses active alerts - 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 - Critical: urgent descending tone
- Notify: gentle ping - Notify: gentle ping
- Recovery: happy ascending chirp - 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 ## License

158
SPEC.md
View File

@@ -1,79 +1,79 @@
# SPEC.md: Project "Sentry-Emote" # SPEC.md: Project "Sentry-Emote"
## 1. Overview ## 1. Overview
**Purpose:** Repurpose an old Pixel phone (OLED screen) as an ambient, glanceable system status monitor for a home server. **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. **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). **Target Device:** Android Pixel (accessed via Fully Kiosk Browser).
## 2. System Architecture ## 2. System Architecture
The system follows a decoupled **Publisher/Subscriber** model to ensure extensibility. 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. - **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. - **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. - **Emote-UI (The Subscriber):** A mobile-optimized web frontend that displays the current highest-priority emote.
## 3. Data Specification ## 3. Data Specification
### 3.1 `status.json` (State Registry) ### 3.1 `status.json` (State Registry)
The Aggregator outputs this file every time the state changes. The Aggregator outputs this file every time the state changes.
```json ```json
{ {
"current_state": "optimal", "current_state": "optimal",
"active_emote": "( ^_^)", "active_emote": "( ^_^)",
"color": "#00FF00", "color": "#00FF00",
"animation": "breathing", "animation": "breathing",
"message": "All systems nominal", "message": "All systems nominal",
"active_events": [ "active_events": [
{ {
"id": "disk_check", "id": "disk_check",
"priority": 4, "priority": 4,
"message": "Disk 40% full" "message": "Disk 40% full"
} }
], ],
"last_updated": "2026-02-02T17:30:00" "last_updated": "2026-02-02T17:30:00"
} }
``` ```
### 3.2 Priority Hierarchy ### 3.2 Priority Hierarchy
| Level | Name | Priority | Emote | Color | Logic | | Level | Name | Priority | Emote | Color | Logic |
| ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- | | ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- |
| **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. | | **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. |
| **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. | | **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. |
| **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. | | **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. |
| **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. | | **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. |
## 4. Component Requirements ## 4. Component Requirements
### 4.1 Aggregator (`aggregator.py`) ### 4.1 Aggregator (`aggregator.py`)
- **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals. - **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals.
- **State Management:** Maintain a list of "Active Events." - **State Management:** Maintain a list of "Active Events."
- **TTL Logic:** Automatically remove Priority 3 events after 10 seconds. - **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. - **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`) ### 4.2 Emote-UI (`index.html`)
- **OLED Optimization:** Pure black background (`#000000`). - **OLED Optimization:** Pure black background (`#000000`).
- **Glanceability:** Massive centered text for the emote. - **Glanceability:** Massive centered text for the emote.
- **Animations:** - `breathing`: Slow opacity/scale pulse. - **Animations:** - `breathing`: Slow opacity/scale pulse.
- `shaking`: Rapid X-axis jitter for Critical. - `shaking`: Rapid X-axis jitter for Critical.
- `popping`: Scale-up effect for Notifications. - `popping`: Scale-up effect for Notifications.
- **Refresh:** Long-polling or `setInterval` every 2 seconds. - **Refresh:** Long-polling or `setInterval` every 2 seconds.
### 4.3 Extensibility (The Hook System) ### 4.3 Extensibility (The Hook System)
- New detectors must be able to send an event to the Aggregator without modifying the core code. - 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` - Example Detector Hook: `curl -X POST -d '{"id":"ssh","priority":1}' http://localhost:5000/event`
## 5. Implementation Roadmap ## 5. Implementation Roadmap
1. **Phase 1:** Build the `aggregator.py` with basic JSON output. 1. **Phase 1:** Build the `aggregator.py` with basic JSON output.
2. **Phase 2:** Build the OLED-friendly `index.html` frontend. 2. **Phase 2:** Build the OLED-friendly `index.html` frontend.
3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker). 3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker).
4. **Phase 4:** Implement TTL for transient notifications. 4. **Phase 4:** Implement TTL for transient notifications.

View File

@@ -19,14 +19,12 @@ ROOT_DIR = Path(__file__).parent
STATUS_FILE = Path(__file__).parent / "status.json" STATUS_FILE = Path(__file__).parent / "status.json"
DEFAULT_NOTIFY_TTL = 10 # Default TTL for Priority 3 (Notify) events DEFAULT_NOTIFY_TTL = 10 # Default TTL for Priority 3 (Notify) events
CELEBRATION_DURATION = 5 # Seconds to show celebration after recovery 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 wake
IDLE_EXPRESSION_CHANCE = 0.15 # Chance of a brief blink/wink on rotation
DEFAULT_NOTIFY_DURATION = 5 # Default duration for /notify events DEFAULT_NOTIFY_DURATION = 5 # Default duration for /notify events
# Emote variations with paired animations # Emote variations with paired animations
OPTIMAL_EMOTES = [ OPTIMAL_EMOTES = [
("( ^_^)", "breathing"), # calm, content ("( ^_^)", "breathing"), # calm, content
("( ˙▿˙)", "floating"), # content
("(◕‿◕)", "bouncing"), # cheerful ("(◕‿◕)", "bouncing"), # cheerful
("( ・ω・)", "swaying"), # curious ("( ・ω・)", "swaying"), # curious
("( ˘▽˘)", "breathing"), # cozy ("( ˘▽˘)", "breathing"), # cozy
@@ -56,7 +54,6 @@ celebrating_until = 0
blinking_until = 0 blinking_until = 0
blink_emote = None blink_emote = None
blink_animation = None blink_animation = None
last_emote_change = time.time()
current_optimal_emote = OPTIMAL_EMOTES[0][0] current_optimal_emote = OPTIMAL_EMOTES[0][0]
current_optimal_animation = OPTIMAL_EMOTES[0][1] current_optimal_animation = OPTIMAL_EMOTES[0][1]
@@ -73,7 +70,7 @@ SLEEP_ANIMATION = "sleeping"
def get_current_state(): def get_current_state():
"""Determine current state based on active events.""" """Determine current state based on active events."""
global previous_priority, celebrating_until, blinking_until, blink_emote, blink_animation 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 # Sleep mode overrides everything
if is_sleeping: if is_sleeping:
@@ -139,14 +136,6 @@ def get_current_state():
emote = blink_emote emote = blink_emote
animation = blink_animation animation = blink_animation
else: 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 emote = current_optimal_emote
animation = current_optimal_animation animation = current_optimal_animation
@@ -326,8 +315,13 @@ def sleep_mode():
@app.route("/wake", methods=["POST"]) @app.route("/wake", methods=["POST"])
def wake_mode(): def wake_mode():
"""Exit sleep mode. For Home Assistant webhook.""" """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 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() state = write_status()
return jsonify({"status": "awake", "current_state": state}), 200 return jsonify({"status": "awake", "current_state": state}), 200

View File

@@ -217,7 +217,7 @@
const emoteEl = document.getElementById("emote"); const emoteEl = document.getElementById("emote");
const messageEl = document.getElementById("message"); const messageEl = document.getElementById("message");
const POLL_INTERVAL = 2000; const POLL_INTERVAL = 2000;
const VERSION = "v1.5.0"; const VERSION = "v2.0.0";
// Sound system // Sound system
let audioCtx = null; let audioCtx = null;
@@ -304,8 +304,69 @@
setTimeout(() => playTone(1047, 0.2), 300); 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 // Play sound by name
function playSoundByName(name) { function playSoundByName(name) {
if (name === "alarm") { startAlarm(); return; }
const sounds = { const sounds = {
chime: playChimeSound, chime: playChimeSound,
alert: playAlertSound, alert: playAlertSound,
@@ -314,6 +375,15 @@
success: playSuccessSound, success: playSuccessSound,
notify: playNotifySound, notify: playNotifySound,
recovery: playRecoverySound, recovery: playRecoverySound,
doorbell: playDoorbellSound,
knock: playKnockSound,
ding: playDingSound,
blip: playBlipSound,
siren: playSirenSound,
tada: playTadaSound,
ping: playPingSound,
bubble: playBubbleSound,
fanfare: playFanfareSound,
}; };
if (sounds[name]) { if (sounds[name]) {
sounds[name](); sounds[name]();
@@ -333,6 +403,7 @@
playSoundByName(customSound); playSoundByName(customSound);
lastCustomSound = customSound; lastCustomSound = customSound;
} else if (!customSound) { } else if (!customSound) {
stopAlarm(); // stop any looping alarm
lastCustomSound = null; lastCustomSound = null;
} }
@@ -363,6 +434,7 @@
// Handle tap - enable sound and show reaction // Handle tap - enable sound and show reaction
document.body.addEventListener("click", () => { document.body.addEventListener("click", () => {
stopAlarm(); // stop any looping alarm
// Enable sound on first tap (browser autoplay policy) // Enable sound on first tap (browser autoplay policy)
if (!soundEnabled) { if (!soundEnabled) {
soundEnabled = true; soundEnabled = true;

View File

@@ -313,6 +313,16 @@ components:
- critical - critical
- success - success
- notify - notify
- doorbell
- knock
- ding
- blip
- siren
- tada
- ping
- bubble
- fanfare
- alarm
- none - none
example: "chime" example: "chime"

View File

@@ -1,14 +1,14 @@
blinker==1.9.0 blinker==1.9.0
certifi==2026.1.4 certifi==2026.1.4
charset-normalizer==3.4.4 charset-normalizer==3.4.4
click==8.3.1 click==8.3.1
colorama==0.4.6 colorama==0.4.6
Flask==3.1.2 Flask==3.1.2
idna==3.11 idna==3.11
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
MarkupSafe==3.0.3 MarkupSafe==3.0.3
psutil==7.2.2 psutil==7.2.2
requests==2.32.5 requests==2.32.5
urllib3==2.6.3 urllib3==2.6.3
Werkzeug==3.1.5 Werkzeug==3.1.5