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 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 21:50:32 -06:00
parent 942cdad5b8
commit 1ec67b4033
4 changed files with 152 additions and 14 deletions

View File

@@ -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).

View File

@@ -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 |

View File

@@ -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

View File

@@ -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;