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}` |
|
| `/event` | POST | Register event: `{"id": "name", "priority": 1-4, "message": "optional", "ttl": seconds}` |
|
||||||
| `/clear` | POST | Clear event: `{"id": "name"}` |
|
| `/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 |
|
| `/sleep` | POST | Enter sleep mode |
|
||||||
| `/wake` | POST | Exit sleep mode |
|
| `/wake` | POST | Exit sleep mode |
|
||||||
| `/status` | GET | Current state JSON |
|
| `/status` | GET | Current state JSON |
|
||||||
| `/events` | GET | List active events |
|
| `/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
|
## Priority System
|
||||||
|
|
||||||
Lower number = higher priority. Events with a `ttl` auto-expire (heartbeat pattern).
|
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"
|
url: "http://YOUR_SERVER:5100/notify"
|
||||||
method: POST
|
method: POST
|
||||||
content_type: "application/json"
|
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:
|
kao_sleep:
|
||||||
url: "http://YOUR_SERVER:5100/sleep"
|
url: "http://YOUR_SERVER:5100/sleep"
|
||||||
@@ -134,6 +142,21 @@ automation:
|
|||||||
data:
|
data:
|
||||||
message: "Someone at the door"
|
message: "Someone at the door"
|
||||||
duration: 10
|
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"
|
- alias: "Kao Sleep at Bedtime"
|
||||||
trigger:
|
trigger:
|
||||||
@@ -150,6 +173,12 @@ automation:
|
|||||||
service: rest_command.kao_wake
|
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
|
## API Reference
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|
|||||||
@@ -83,13 +83,18 @@ def get_current_state():
|
|||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
|
top_event = None
|
||||||
with events_lock:
|
with events_lock:
|
||||||
if not active_events:
|
if not active_events:
|
||||||
priority = 4
|
priority = 4
|
||||||
events_list = []
|
events_list = []
|
||||||
else:
|
else:
|
||||||
# Find highest priority (lowest number)
|
# Find highest priority (lowest number) and its event
|
||||||
priority = min(e["priority"] for e in active_events.values())
|
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 = [
|
events_list = [
|
||||||
{"id": eid, "priority": e["priority"], "message": e.get("message", "")}
|
{"id": eid, "priority": e["priority"], "message": e.get("message", "")}
|
||||||
for eid, e in active_events.items()
|
for eid, e in active_events.items()
|
||||||
@@ -99,6 +104,18 @@ def get_current_state():
|
|||||||
emote = config["emote"]
|
emote = config["emote"]
|
||||||
animation = config["animation"]
|
animation = config["animation"]
|
||||||
color = config["color"]
|
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)
|
# Check for recovery (was bad, now optimal)
|
||||||
if priority == 4 and previous_priority < 4:
|
if priority == 4 and previous_priority < 4:
|
||||||
@@ -106,8 +123,8 @@ def get_current_state():
|
|||||||
|
|
||||||
previous_priority = priority
|
previous_priority = priority
|
||||||
|
|
||||||
# Handle optimal state personality
|
# Handle optimal state personality (only if no custom overrides)
|
||||||
if priority == 4:
|
if priority == 4 and not top_event:
|
||||||
if now < celebrating_until:
|
if now < celebrating_until:
|
||||||
# Celebration mode
|
# Celebration mode
|
||||||
emote, animation = CELEBRATION_EMOTE
|
emote, animation = CELEBRATION_EMOTE
|
||||||
@@ -127,7 +144,7 @@ def get_current_state():
|
|||||||
emote = current_optimal_emote
|
emote = current_optimal_emote
|
||||||
animation = current_optimal_animation
|
animation = current_optimal_animation
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"current_state": config["name"].lower(),
|
"current_state": config["name"].lower(),
|
||||||
"active_emote": emote,
|
"active_emote": emote,
|
||||||
"color": color,
|
"color": color,
|
||||||
@@ -137,6 +154,11 @@ def get_current_state():
|
|||||||
"last_updated": datetime.now().isoformat(timespec="seconds"),
|
"last_updated": datetime.now().isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sound:
|
||||||
|
result["sound"] = sound
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def write_status():
|
def write_status():
|
||||||
"""Write current state to status.json."""
|
"""Write current state to status.json."""
|
||||||
@@ -226,9 +248,15 @@ def clear_event():
|
|||||||
@app.route("/notify", methods=["POST"])
|
@app.route("/notify", methods=["POST"])
|
||||||
def notify():
|
def notify():
|
||||||
"""
|
"""
|
||||||
Simple notification endpoint for Home Assistant.
|
Notification endpoint for Home Assistant.
|
||||||
JSON: {"message": "text", "duration": 5}
|
JSON: {
|
||||||
Shows the Notify emote with message, auto-expires after duration.
|
"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 {}
|
data = request.get_json(force=True) if request.data else {}
|
||||||
message = data.get("message", "")
|
message = data.get("message", "")
|
||||||
@@ -244,6 +272,16 @@ def notify():
|
|||||||
"ttl": time.time() + duration,
|
"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:
|
with events_lock:
|
||||||
active_events[event_id] = event
|
active_events[event_id] = event
|
||||||
|
|
||||||
|
|||||||
59
index.html
59
index.html
@@ -282,14 +282,63 @@
|
|||||||
playTone(600, 0.08, "sine", 0.06);
|
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) {
|
if (!lastState) {
|
||||||
lastState = newState;
|
lastState = newState;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// State transitions that trigger sounds
|
// State transitions that trigger sounds (only if no custom sound)
|
||||||
if (newState !== lastState) {
|
if (newState !== lastState && !customSound) {
|
||||||
if (newState === "critical") {
|
if (newState === "critical") {
|
||||||
playCriticalSound();
|
playCriticalSound();
|
||||||
} else if (newState === "warning") {
|
} else if (newState === "warning") {
|
||||||
@@ -304,8 +353,8 @@
|
|||||||
// Recovery - also check for celebration emote
|
// Recovery - also check for celebration emote
|
||||||
playRecoverySound();
|
playRecoverySound();
|
||||||
}
|
}
|
||||||
lastState = newState;
|
|
||||||
}
|
}
|
||||||
|
lastState = newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tap - enable sound and show reaction
|
// Handle tap - enable sound and show reaction
|
||||||
@@ -373,7 +422,7 @@
|
|||||||
if (isReacting) return;
|
if (isReacting) return;
|
||||||
|
|
||||||
// Check for state changes and play sounds
|
// 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.textContent = data.active_emote;
|
||||||
emoteEl.style.color = data.color;
|
emoteEl.style.color = data.color;
|
||||||
|
|||||||
Reference in New Issue
Block a user