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:
@@ -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 |
|
||||||
|-------|-----------|------|
|
|-------|-----------|------|
|
||||||
|
|||||||
@@ -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
158
SPEC.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
74
index.html
74
index.html
@@ -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;
|
||||||
|
|||||||
10
openapi.yaml
10
openapi.yaml
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user