From 1ec67b40333fca357b558cb1412a314bb603a701 Mon Sep 17 00:00:00 2001 From: Spencer Grimes Date: Tue, 3 Feb 2026 21:50:32 -0600 Subject: [PATCH] Enhance /notify with custom emote, color, animation, sound - /notify now accepts optional: emote, color, animation, sound - Backend passes custom properties to status response - Frontend handles custom sounds (chime, alert, success, etc.) - Added new sound effects: chime, alert, success - Updated documentation with full notify options - Added HA automation examples for doorbell and timer Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 24 ++++++++++++++++++++- README.md | 31 ++++++++++++++++++++++++++- aggregator.py | 52 +++++++++++++++++++++++++++++++++++++++------ index.html | 59 ++++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 152 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8d4e37..9e3201b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,12 +73,34 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`, |----------|--------|-------------| | `/event` | POST | Register event: `{"id": "name", "priority": 1-4, "message": "optional", "ttl": seconds}` | | `/clear` | POST | Clear event: `{"id": "name"}` | -| `/notify` | POST | Simple notification: `{"message": "text", "duration": 5}` | +| `/notify` | POST | Notification with optional customization (see below) | | `/sleep` | POST | Enter sleep mode | | `/wake` | POST | Exit sleep mode | | `/status` | GET | Current state JSON | | `/events` | GET | List active events | +### `/notify` Endpoint + +```json +{ + "message": "Someone at the door", + "duration": 10, + "emote": "( °o°)", + "color": "#FF9900", + "animation": "popping", + "sound": "chime" +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `message` | No | Text to display below emote | +| `duration` | No | Seconds before auto-expire (default: 5) | +| `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` | + ## Priority System Lower number = higher priority. Events with a `ttl` auto-expire (heartbeat pattern). diff --git a/README.md b/README.md index 81ca12c..f233e73 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,15 @@ rest_command: url: "http://YOUR_SERVER:5100/notify" method: POST content_type: "application/json" - payload: '{"message": "{{ message }}", "duration": {{ duration | default(5) }}}' + payload: > + { + "message": "{{ message | default('') }}", + "duration": {{ duration | default(5) }}, + "emote": "{{ emote | default('') }}", + "color": "{{ color | default('') }}", + "animation": "{{ animation | default('') }}", + "sound": "{{ sound | default('') }}" + } kao_sleep: url: "http://YOUR_SERVER:5100/sleep" @@ -134,6 +142,21 @@ automation: data: message: "Someone at the door" duration: 10 + emote: "( °o°)" + color: "#FF9900" + sound: "chime" + + - alias: "Timer Complete" + trigger: + platform: event + event_type: timer.finished + action: + service: rest_command.kao_notify + data: + message: "Timer done!" + emote: "\\(^o^)/" + animation: "celebrating" + sound: "success" - alias: "Kao Sleep at Bedtime" trigger: @@ -150,6 +173,12 @@ automation: service: rest_command.kao_wake ``` +**Notify options:** +- `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` + ## API Reference | Endpoint | Method | Description | diff --git a/aggregator.py b/aggregator.py index 05ce60b..9a45745 100644 --- a/aggregator.py +++ b/aggregator.py @@ -83,13 +83,18 @@ def get_current_state(): now = time.time() + top_event = None with events_lock: if not active_events: priority = 4 events_list = [] else: - # Find highest priority (lowest number) + # Find highest priority (lowest number) and its event priority = min(e["priority"] for e in active_events.values()) + for eid, e in active_events.items(): + if e["priority"] == priority: + top_event = e + break events_list = [ {"id": eid, "priority": e["priority"], "message": e.get("message", "")} for eid, e in active_events.items() @@ -99,6 +104,18 @@ def get_current_state(): emote = config["emote"] animation = config["animation"] color = config["color"] + sound = None + + # Check for custom display properties from top event + if top_event: + if "emote" in top_event: + emote = top_event["emote"] + if "color" in top_event: + color = top_event["color"] + if "animation" in top_event: + animation = top_event["animation"] + if "sound" in top_event: + sound = top_event["sound"] # Check for recovery (was bad, now optimal) if priority == 4 and previous_priority < 4: @@ -106,8 +123,8 @@ def get_current_state(): previous_priority = priority - # Handle optimal state personality - if priority == 4: + # Handle optimal state personality (only if no custom overrides) + if priority == 4 and not top_event: if now < celebrating_until: # Celebration mode emote, animation = CELEBRATION_EMOTE @@ -127,7 +144,7 @@ def get_current_state(): emote = current_optimal_emote animation = current_optimal_animation - return { + result = { "current_state": config["name"].lower(), "active_emote": emote, "color": color, @@ -137,6 +154,11 @@ def get_current_state(): "last_updated": datetime.now().isoformat(timespec="seconds"), } + if sound: + result["sound"] = sound + + return result + def write_status(): """Write current state to status.json.""" @@ -226,9 +248,15 @@ def clear_event(): @app.route("/notify", methods=["POST"]) def notify(): """ - Simple notification endpoint for Home Assistant. - JSON: {"message": "text", "duration": 5} - Shows the Notify emote with message, auto-expires after duration. + Notification endpoint for Home Assistant. + JSON: { + "message": "text", + "duration": 5, + "emote": "( °o°)", # optional custom emote + "color": "#FF9900", # optional custom color + "animation": "popping", # optional: breathing, shaking, popping, celebrating, floating, bouncing, swaying + "sound": "chime" # optional: chime, alert, warning, critical, success, none + } """ data = request.get_json(force=True) if request.data else {} message = data.get("message", "") @@ -244,6 +272,16 @@ def notify(): "ttl": time.time() + duration, } + # Optional custom display properties + if "emote" in data: + event["emote"] = data["emote"] + if "color" in data: + event["color"] = data["color"] + if "animation" in data: + event["animation"] = data["animation"] + if "sound" in data: + event["sound"] = data["sound"] + with events_lock: active_events[event_id] = event diff --git a/index.html b/index.html index a21f851..346da7e 100644 --- a/index.html +++ b/index.html @@ -282,14 +282,63 @@ playTone(600, 0.08, "sine", 0.06); } - function handleStateChange(newState, newEmote) { + function playChimeSound() { + // Pleasant doorbell-like chime + playTone(659, 0.15); + setTimeout(() => playTone(784, 0.15), 150); + setTimeout(() => playTone(988, 0.2), 300); + } + + function playAlertSound() { + // Attention-getting alert + playTone(880, 0.1); + setTimeout(() => playTone(880, 0.1), 150); + setTimeout(() => playTone(880, 0.15), 300); + } + + function playSuccessSound() { + // Triumphant success fanfare + playTone(523, 0.1); + setTimeout(() => playTone(659, 0.1), 100); + setTimeout(() => playTone(784, 0.1), 200); + setTimeout(() => playTone(1047, 0.2), 300); + } + + // Play sound by name + function playSoundByName(name) { + const sounds = { + chime: playChimeSound, + alert: playAlertSound, + warning: playWarningSound, + critical: playCriticalSound, + success: playSuccessSound, + notify: playNotifySound, + recovery: playRecoverySound, + }; + if (sounds[name]) { + sounds[name](); + } + } + + // Track which custom sounds we've played to avoid repeats + let lastCustomSound = null; + + function handleStateChange(newState, newEmote, customSound) { + // Handle custom sound from notification + if (customSound && customSound !== "none" && customSound !== lastCustomSound) { + playSoundByName(customSound); + lastCustomSound = customSound; + } else if (!customSound) { + lastCustomSound = null; + } + if (!lastState) { lastState = newState; return; } - // State transitions that trigger sounds - if (newState !== lastState) { + // State transitions that trigger sounds (only if no custom sound) + if (newState !== lastState && !customSound) { if (newState === "critical") { playCriticalSound(); } else if (newState === "warning") { @@ -304,8 +353,8 @@ // Recovery - also check for celebration emote playRecoverySound(); } - lastState = newState; } + lastState = newState; } // Handle tap - enable sound and show reaction @@ -373,7 +422,7 @@ if (isReacting) return; // Check for state changes and play sounds - handleStateChange(data.current_state, data.active_emote); + handleStateChange(data.current_state, data.active_emote, data.sound); emoteEl.textContent = data.active_emote; emoteEl.style.color = data.color;