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:
24
CLAUDE.md
24
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).
|
||||
|
||||
31
README.md
31
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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
59
index.html
59
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;
|
||||
|
||||
Reference in New Issue
Block a user