Compare commits

..

19 Commits

Author SHA1 Message Date
9a10a6e10c Feat: notification queue — buffer rapid /notify calls, play sequentially
When multiple /notify calls arrive in quick succession, they now queue up
and display one at a time rather than clobbering each other. Each notification
plays for its full duration before the next is promoted.

- /notify returns `queued: true` and `notify_queue_size` when buffered
- Cleanup thread auto-advances the queue when the playing notification expires
- /clear on the playing notification promotes the next immediately
- /clear on a queued (not-yet-playing) notification removes it from the queue
- /clear-all also drains the queue
- Status response includes `notify_queue_size` for frontend awareness

Bump to v2.3.3. Update OpenAPI spec, README, TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:33:45 -06:00
9f48dec4fc Fix: deadlock in /clear endpoint — move write_status() outside events_lock
write_status() calls get_current_state() which re-acquires events_lock.
Calling it inside the `with events_lock:` block caused the same thread to
block forever (threading.Lock is not reentrant). Any /clear request would
hang the aggregator indefinitely, breaking detector recovery flow.

Bump to v2.3.2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:11:06 -06:00
2089a06512 Fix: move isReacting=false before updateDisplay call 2026-02-26 20:58:46 -06:00
dbba288d24 Fix: event TTL and version display - add default TTL for priority 1/2 events, fetch fresh state after version tap 2026-02-26 19:59:03 -06:00
aaae20281d Bump to v2.3.0: replace polling with SSE stream, fix detector imports
- Add GET /stream SSE endpoint to aggregator.py; state is pushed
  instantly on every change instead of fetched every 2s
- Replace setInterval polling in index.html with EventSource;
  onerror shows the ( ?.?) face and auto-reconnect is handled by
  the browser natively
- Fix ModuleNotFoundError in detectors: inject project root into
  PYTHONPATH when launching subprocesses from kao.py
- Update openapi.yaml, CLAUDE.md, README.md with /stream endpoint
- Remove completed SSE item from TODO.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:25:15 -06:00
9291066263 Add TODO.md and reframe CLAUDE.md around REST-push philosophy
Kao is a display, not a monitor — external systems push events via REST
rather than Kao polling things itself. Update CLAUDE.md to reflect this:
- New Design Philosophy section making the REST-first approach explicit
- Architecture diagram updated to show external systems as the push source
- Detectors section demoted to "Legacy Detectors" with a note to prefer push
- TODO.md added with planned REST API improvements (SSE, notify queue,
  sticky notifications, named presets, batch notify, /history endpoint)
  and display improvements (brightness curve, scrolling ticker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 17:42:21 -06:00
50e34b24c6 Bump to v2.2.0: fix warning/bubble sounds, add klaxon, refresh docs
- index.html: fix playWarningSound (440→550 Hz, louder), fix playBubbleSound
  (audible volumes/durations), add looping klaxon sound (sawtooth wah-wah),
  stopKlaxon() on tap and state clear, bump VERSION to v2.2.0
- kao_tui.py: add klaxon to SOUNDS list, drop notify duration 5→2s for
  faster iteration; also include improved post() error reporting
- CLAUDE.md: add kao_tui.py to file structure, fix personality table
  (remove ˙▿˙ row not in aggregator), add klaxon to sound list
- README.md: add klaxon to sound list, update counts
- openapi.yaml: bump version to 2.2.0, add klaxon to sound enum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 17:30:19 -06:00
a36fd7037a Add venv-wsl/ to .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 17:12:39 -06:00
92e6441218 Bump to v2.1.0: add kao_tui.py developer TUI and WSL venv
- Add kao_tui.py: Textual TUI with Sounds, Faces, Events, Controls tabs
- Add textual to requirements.txt
- Add venv activation instructions to README
- Add Developer TUI section to README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 17:12:05 -06:00
a074a42d40 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>
2026-02-20 16:36:23 -06:00
dd8bf6005b Bump to v1.5.0: deduplicate detectors, fix aggregator bugs, fix blocking I/O
- Extract shared send_event/clear_event into detectors/base.py, removing
  ~150 lines of duplication across all 6 detectors
- Fix default aggregator URL from port 5000 to 5100 in all detectors
- Standardize cpu.py and memory.py to use active_alerts set pattern
- Fix immediate emote rotation on startup (last_emote_change = time.time())
- Extract magic numbers to named constants in aggregator
- Protect write_status() with try/except OSError
- Fix notify event ID collision with monotonic counter
- Replace blocking stream_output() with background daemon threads in kao.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:17:17 -06:00
c3ceb74ce8 Bump to v1.4.0: tap-to-dismiss, docker restart detection, cleanup thread fix
Add /clear-all endpoint and wire it to the tap handler so tapping the
display dismisses active warnings/critical alerts. Fix docker detector
to track restart count deltas instead of relying on the transient
"restarting" state. Wrap cleanup thread in try/except so it can't die
silently and leave events stuck forever.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:54:00 -06:00
fa0c16609d And bumped Version 2026-02-06 11:10:37 -06:00
2c918565de Updated Celebrations and Silent Dying on Critical/Warning 2026-02-06 11:09:58 -06:00
94f29bf4f4 Fix /docs: add explicit route for openapi.yaml
Flask static folder wasn't serving .yaml files automatically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:36:42 -06:00
5e76ce9597 Add interactive API docs at /docs endpoint
Swagger UI served via CDN for browsing OpenAPI spec.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:33:21 -06:00
4262865520 Add OpenAPI 3.0 documentation for API
Documents all endpoints with request/response schemas and examples.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:28:50 -06:00
8ad86d1c6e Bump to v1.3.0, add commit guidelines to CLAUDE.md
- Version bump to v1.3.0
- Added instruction to bump version and update README on every commit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:02:47 -06:00
1ec67b4033 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>
2026-02-03 21:50:32 -06:00
18 changed files with 1423 additions and 431 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Virtual environment
venv/
venv-wsl/
# Python
__pycache__/

View File

@@ -2,27 +2,35 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Important:** When updating this file, always update `README.md` as well. The README is the main user-facing documentation for the project.
**Important:**
- When updating this file, always update `README.md` as well. The README is the main user-facing documentation for the project.
- On every commit, bump the version number in `index.html` (the `VERSION` constant) and update `README.md` with any relevant changes.
## Project Overview
Kao is a minimalist system status monitor designed for an old Pixel phone used as an ambient display. It uses ASCII "emotes" to represent system health instead of complex graphs.
Kao is a minimalist ambient display for an old Pixel phone. It uses ASCII "emotes" to represent system health instead of graphs or dashboards. External systems (Home Assistant, Uptime Kuma, scripts, etc.) push events and notifications to Kao via REST — Kao itself does not poll or monitor anything.
## Design Philosophy
**Kao is a display, not a monitor.** It should not duplicate work that other tools already do. The right pattern is: your existing monitoring stack detects a problem, then POSTs to Kao to show it. This keeps Kao simple and lets each tool do what it's good at.
The bundled `detectors/` scripts are legacy and exist for standalone use cases. New work should focus on making the REST API richer, not adding more detectors.
## Architecture
**Publisher/Subscriber model:**
**REST push model:**
```
┌─────────────┐ POST /event ┌─────────────┐ GET /status ┌─────────────┐
Detectors │ ──────────────────▶ │ Aggregator │ ◀────────────────── │ Emote-UI │
│ (sensors) │ │ (broker) │ │ (display) │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────────────┐ POST /event ┌─────────────┐ GET /status ┌─────────────┐
External systems │ ──────────────────▶ │ Aggregator │ ◀────────────────── │ Emote-UI │
│ (HA, scripts, etc) │ POST /notify │ (broker) │ │ (display) │
└─────────────────────┘ └─────────────┘ └─────────────┘
```
- **Aggregator** (`aggregator.py`) — Flask service managing the event queue and priority logic
- **Detectors** (`detectors/*.py`) — Independent scripts monitoring system metrics
- **Emote-UI** (`index.html`) — OLED-optimized web frontend
- **Sentry** (`kao.py`) — Unified entry point managing all processes
- **Detectors** (`detectors/*.py`) — Legacy standalone sensors; prefer REST push from existing tools
## Quick Start
@@ -54,7 +62,9 @@ Edit `config.json` to configure detectors:
}
```
## Detectors
## Legacy Detectors
These bundled scripts exist for standalone use but are not the preferred approach. Prefer pushing from Home Assistant, Uptime Kuma, or any tool that already monitors what you care about.
| Detector | Script | Required Env Vars |
|----------|--------|-------------------|
@@ -73,11 +83,38 @@ 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}` |
| `/clear-all` | POST | Clear all active events |
| `/notify` | POST | Queued notification — buffered if one is playing, auto-advances when it expires |
| `/sleep` | POST | Enter sleep mode |
| `/wake` | POST | Exit sleep mode |
| `/status` | GET | Current state JSON |
| `/stream` | GET | SSE stream — pushes state JSON on every change (used by the frontend) |
| `/status` | GET | Current state JSON (one-shot query) |
| `/events` | GET | List active events |
| `/docs` | GET | Interactive API documentation (Swagger UI) |
### `/notify` Endpoint
```json
{
"message": "Someone at the door",
"duration": 10,
"emote": "( °o°)",
"color": "#FF9900",
"animation": "popping",
"sound": "chime"
}
```
Rapid calls are buffered — each notification plays for its full `duration` before the next is shown. `/clear-all` also drains the queue.
| 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`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon`, `none` |
## Priority System
@@ -92,12 +129,11 @@ Lower number = higher priority. Events with a `ttl` auto-expire (heartbeat patte
## 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 |
|-------|-----------|------|
| `( ^_^)` | breathing | calm |
| `( ˙▿˙)` | floating | content |
| `(◕‿◕)` | bouncing | cheerful |
| `( ・ω・)` | swaying | curious |
| `( ˘▽˘)` | breathing | cozy |
@@ -148,11 +184,14 @@ Use in automations:
## File Structure
```
├── kao.py # Unified entry point
├── kao.py # Unified entry point
├── aggregator.py # Event broker/API server
├── index.html # OLED-optimized frontend
├── kao_tui.py # Developer TUI for testing sounds/events
├── config.json # Runtime configuration
├── openapi.yaml # API documentation (OpenAPI 3.0)
├── detectors/
│ ├── base.py
│ ├── disk_space.py
│ ├── cpu.py
│ ├── memory.py
@@ -160,5 +199,6 @@ Use in automations:
│ ├── network.py
│ └── docker.py
├── requirements.txt
├── TODO.md # Planned features and improvements
└── SPEC.md # Original project specification
```

101
README.md
View File

@@ -12,9 +12,11 @@ Turn an old phone (with its OLED screen) into a glanceable ambient display for y
- **OLED-optimized** — Pure black background, saves battery
- **Glanceable** — Know your server's status from across the room
- **Instant updates** — SSE stream pushes state changes the moment they happen
- **Extensible** — Add custom detectors for any metric
- **Personality** — Rotating expressions, celebration animations, sleep mode
- **Sound effects** — Optional audio cues for state changes (tap to enable)
- **Notification queue** — Rapid `/notify` bursts are buffered and played sequentially, never clobbering
- **Home Assistant ready** — Webhook endpoints for notifications and automation
## Quick Start
@@ -33,6 +35,23 @@ python kao.py
Open http://localhost:5100 on your phone (use Fully Kiosk Browser for best results).
## Activating the venv
Two venvs exist — one for Windows, one for WSL:
```bash
# WSL / Linux
source venv-wsl/bin/activate
# Windows (PowerShell)
.\venv\Scripts\Activate.ps1
# Windows (cmd)
.\venv\Scripts\activate.bat
```
Deactivate either with `deactivate`.
## Status Faces
| State | Emote | Meaning |
@@ -109,7 +128,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 +161,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,34 +192,65 @@ automation:
service: rest_command.kao_wake
```
Rapid `/notify` calls are queued automatically — each plays for its full `duration` before the next is shown. `/clear-all` drains the queue.
**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`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon`, `none`
## Developer TUI
`kao_tui.py` is a terminal UI for firing test events, faces, and sounds at a running Kao instance — no `curl` needed.
```bash
python kao_tui.py # connect to http://localhost:5100
python kao_tui.py http://192.168.1.x:5100 # custom URL
```
Four tabs:
- **Sounds** — fire any of the 18 sounds via `/notify`
- **Faces** — send any preset emote/animation combo via `/notify`
- **Events** — post Critical/Warning/Notify events (10s TTL) or clear all
- **Controls** — Sleep, Wake, Clear all
Navigate with `↑↓` or `Tab`, press `Enter` to fire, `Q` to quit. A toast confirms each action.
## API Reference
| Endpoint | Method | Description |
| --------- | ------ | ------------------------------------------------ |
| `/` | GET | Web UI |
| `/status` | GET | Current state as JSON |
| `/events` | GET | List all active events |
| `/event` | POST | Register an event |
| `/clear` | POST | Clear an event by ID |
| `/notify` | POST | Simple notification `{"message": "", "duration": 5}` |
| `/sleep` | POST | Enter sleep mode |
| `/wake` | POST | Exit sleep mode |
| Endpoint | Method | Description |
| ------------ | ------ | ----------------------------------------------------- |
| `/` | GET | Web UI |
| `/stream` | GET | SSE stream — pushes state JSON on every change |
| `/status` | GET | Current state as JSON (one-shot query) |
| `/events` | GET | List all active events |
| `/event` | POST | Register an event |
| `/clear` | POST | Clear an event by ID |
| `/clear-all` | POST | Clear all active events |
| `/notify` | POST | Queued notification `{"message": "", "duration": 5}` — buffered if one is playing |
| `/sleep` | POST | Enter sleep mode |
| `/wake` | POST | Exit sleep mode |
| `/docs` | GET | Interactive API documentation (Swagger UI) |
Full API documentation available at [/docs](http://localhost:5100/docs) or in [openapi.yaml](openapi.yaml).
## Personality
The emote has personality! In optimal state it:
- Rotates through happy faces every 5 minutes
- Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two
- Shows a stable face all day — set fresh each morning when `/wake` is called
- Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two on wake
- Celebrates `\(^o^)/` when recovering from warnings
- Each face has its own animation (floating, bouncing, swaying)
- Reacts when tapped `( °o°)` and shows version info
- Reacts when tapped `( °o°)`, shows version info, and dismisses active alerts
**Sound effects** (tap screen to enable, or use `?sound=on`):
- Warning: soft double-beep
- Critical: urgent descending tone
- Notify: gentle ping
- Recovery: happy ascending chirp
- 11 additional synthesized sounds for `/notify`: `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon` (alarm and klaxon loop until tapped)
## License

158
SPEC.md
View File

@@ -1,79 +1,79 @@
# SPEC.md: Project "Sentry-Emote"
## 1. Overview
**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.
**Target Device:** Android Pixel (accessed via Fully Kiosk Browser).
## 2. System Architecture
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.
- **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.
## 3. Data Specification
### 3.1 `status.json` (State Registry)
The Aggregator outputs this file every time the state changes.
```json
{
"current_state": "optimal",
"active_emote": "( ^_^)",
"color": "#00FF00",
"animation": "breathing",
"message": "All systems nominal",
"active_events": [
{
"id": "disk_check",
"priority": 4,
"message": "Disk 40% full"
}
],
"last_updated": "2026-02-02T17:30:00"
}
```
### 3.2 Priority Hierarchy
| Level | Name | Priority | Emote | Color | Logic |
| ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- |
| **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. |
| **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. |
| **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. |
| **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. |
## 4. Component Requirements
### 4.1 Aggregator (`aggregator.py`)
- **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals.
- **State Management:** Maintain a list of "Active Events."
- **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.
### 4.2 Emote-UI (`index.html`)
- **OLED Optimization:** Pure black background (`#000000`).
- **Glanceability:** Massive centered text for the emote.
- **Animations:** - `breathing`: Slow opacity/scale pulse.
- `shaking`: Rapid X-axis jitter for Critical.
- `popping`: Scale-up effect for Notifications.
- **Refresh:** Long-polling or `setInterval` every 2 seconds.
### 4.3 Extensibility (The Hook System)
- 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`
## 5. Implementation Roadmap
1. **Phase 1:** Build the `aggregator.py` with basic JSON output.
2. **Phase 2:** Build the OLED-friendly `index.html` frontend.
3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker).
4. **Phase 4:** Implement TTL for transient notifications.
# SPEC.md: Project "Sentry-Emote"
## 1. Overview
**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.
**Target Device:** Android Pixel (accessed via Fully Kiosk Browser).
## 2. System Architecture
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.
- **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.
## 3. Data Specification
### 3.1 `status.json` (State Registry)
The Aggregator outputs this file every time the state changes.
```json
{
"current_state": "optimal",
"active_emote": "( ^_^)",
"color": "#00FF00",
"animation": "breathing",
"message": "All systems nominal",
"active_events": [
{
"id": "disk_check",
"priority": 4,
"message": "Disk 40% full"
}
],
"last_updated": "2026-02-02T17:30:00"
}
```
### 3.2 Priority Hierarchy
| Level | Name | Priority | Emote | Color | Logic |
| ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- |
| **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. |
| **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. |
| **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. |
| **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. |
## 4. Component Requirements
### 4.1 Aggregator (`aggregator.py`)
- **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals.
- **State Management:** Maintain a list of "Active Events."
- **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.
### 4.2 Emote-UI (`index.html`)
- **OLED Optimization:** Pure black background (`#000000`).
- **Glanceability:** Massive centered text for the emote.
- **Animations:** - `breathing`: Slow opacity/scale pulse.
- `shaking`: Rapid X-axis jitter for Critical.
- `popping`: Scale-up effect for Notifications.
- **Refresh:** Long-polling or `setInterval` every 2 seconds.
### 4.3 Extensibility (The Hook System)
- 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`
## 5. Implementation Roadmap
1. **Phase 1:** Build the `aggregator.py` with basic JSON output.
2. **Phase 2:** Build the OLED-friendly `index.html` frontend.
3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker).
4. **Phase 4:** Implement TTL for transient notifications.

23
TODO.md Normal file
View File

@@ -0,0 +1,23 @@
# Kao — TODO
Feature ideas for future work, roughly in priority order.
## REST API improvements
- ~~**Notification queue**~~ — done in v2.3.3
- **Sticky notifications** — a `sticky: true` flag on `/notify` to keep a
notification visible until explicitly cleared via `/clear`, rather than TTL-expiring
- **Named presets** — define reusable notification profiles in `config.json`
(e.g. `"doorbell"` → specific emote/sound/color/duration) so callers can
POST `{"preset": "doorbell"}` without repeating fields every time
- **Batch `/notify`** — accept an array in a single POST so multiple
notifications can be queued atomically
- **`/history` endpoint** — a ring buffer of the last N state changes/events
received, for auditing what fired overnight without tailing logs
## Display / frontend
- **Brightness curve** — dim gradually after dark rather than hard-sleeping;
reduces OLED burn-in without losing glanceability
- **Scrolling ticker** — when multiple events are active, cycle through their
messages rather than only showing the top one

View File

@@ -3,14 +3,16 @@ Kao Aggregator
A lightweight event broker that manages priority-based system status.
"""
import collections
import json
import os
import queue
import random
import threading
import time
from datetime import datetime
from pathlib import Path
from flask import Flask, request, jsonify, send_from_directory
from flask import Flask, request, jsonify, send_from_directory, Response, stream_with_context
app = Flask(__name__, static_folder=".")
ROOT_DIR = Path(__file__).parent
@@ -18,12 +20,14 @@ ROOT_DIR = Path(__file__).parent
# Configuration
STATUS_FILE = Path(__file__).parent / "status.json"
DEFAULT_NOTIFY_TTL = 10 # Default TTL for Priority 3 (Notify) events
DEFAULT_EVENT_TTL = 60 # Default TTL for Priority 1/2 events without explicit TTL
CELEBRATION_DURATION = 5 # Seconds to show celebration after recovery
IDLE_EXPRESSION_CHANCE = 0.15 # Chance of a brief blink/wink on wake
DEFAULT_NOTIFY_DURATION = 5 # Default duration for /notify events
# Emote variations with paired animations
OPTIMAL_EMOTES = [
("( ^_^)", "breathing"), # calm, content
("( ˙▿˙)", "floating"), # content
("(◕‿◕)", "bouncing"), # cheerful
("( ・ω・)", "swaying"), # curious
("( ˘▽˘)", "breathing"), # cozy
@@ -53,10 +57,32 @@ celebrating_until = 0
blinking_until = 0
blink_emote = None
blink_animation = None
last_emote_change = 0
current_optimal_emote = OPTIMAL_EMOTES[0][0]
current_optimal_animation = OPTIMAL_EMOTES[0][1]
# Notify counter for unique IDs
_notify_counter = 0
# Notification queue: buffered /notify calls waiting to play (protected by events_lock)
notify_queue: collections.deque = collections.deque()
# SSE subscribers: one queue per connected client
_subscribers: set = set()
_subscribers_lock = threading.Lock()
def broadcast(state_json: str):
"""Push state JSON to all connected SSE clients."""
with _subscribers_lock:
dead = []
for q in _subscribers:
try:
q.put_nowait(state_json)
except queue.Full:
dead.append(q)
for q in dead:
_subscribers.discard(q)
# Sleep mode
is_sleeping = False
SLEEP_EMOTE = "( -_-)zzZ"
@@ -67,7 +93,7 @@ SLEEP_ANIMATION = "sleeping"
def get_current_state():
"""Determine current state based on active events."""
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
if is_sleeping:
@@ -83,13 +109,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,15 +130,27 @@ 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:
if priority == 4 and previous_priority < 3:
celebrating_until = now + CELEBRATION_DURATION
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
@@ -116,53 +159,78 @@ def get_current_state():
emote = blink_emote
animation = blink_animation
else:
# Rotate optimal emotes every 5 minutes
if now - last_emote_change > 300:
last_emote_change = now
current_optimal_emote, current_optimal_animation = random.choice(OPTIMAL_EMOTES)
# 15% chance of a brief blink/wink
if random.random() < 0.15:
blink_emote, blink_animation = random.choice(IDLE_EMOTES)
blinking_until = now + random.uniform(1, 2)
emote = current_optimal_emote
animation = current_optimal_animation
return {
result = {
"current_state": config["name"].lower(),
"active_emote": emote,
"color": color,
"animation": animation,
"message": config["name"] if priority == 4 else f"{config['name']} state active",
"active_events": sorted(events_list, key=lambda x: x["priority"]),
"notify_queue_size": len(notify_queue),
"last_updated": datetime.now().isoformat(timespec="seconds"),
}
if sound:
result["sound"] = sound
return result
def write_status():
"""Write current state to status.json."""
"""Write current state to status.json and push to SSE subscribers."""
state = get_current_state()
with open(STATUS_FILE, "w") as f:
json.dump(state, f, indent="\t")
try:
with open(STATUS_FILE, "w") as f:
json.dump(state, f, indent="\t")
except OSError as e:
print(f"[ERROR] Failed to write status file: {e}")
broadcast(json.dumps(state))
return state
def _promote_next_notification(now: float):
"""Promote the next queued notification to active. Must be called under events_lock."""
if notify_queue:
next_id, next_event = notify_queue.popleft()
duration = next_event.get("duration", DEFAULT_NOTIFY_DURATION)
active_events[next_id] = {
**next_event,
"timestamp": now,
"ttl": now + duration,
}
print(f"[QUEUE] Promoting next notification: {next_id} ({len(notify_queue)} remaining)")
def cleanup_expired_events():
"""Background thread to remove expired TTL events."""
while True:
time.sleep(1)
now = time.time()
expired = []
try:
time.sleep(1)
now = time.time()
expired = []
had_queue_event = False
with events_lock:
for eid, event in active_events.items():
if event.get("ttl") and now > event["ttl"]:
expired.append(eid)
with events_lock:
for eid, event in active_events.items():
if event.get("ttl") and now > event["ttl"]:
expired.append(eid)
for eid in expired:
del active_events[eid]
for eid in expired:
if active_events[eid].get("from_queue"):
had_queue_event = True
del active_events[eid]
if expired:
write_status()
# Auto-advance: promote next queued notification when the playing one expires
if had_queue_event:
_promote_next_notification(now)
if expired:
write_status()
except Exception as e:
print(f"[cleanup] Error: {e}")
@app.route("/event", methods=["POST"])
@@ -188,11 +256,13 @@ def post_event():
"timestamp": time.time(),
}
# Apply TTL if provided, or use default for Priority 3 (Notify)
# Apply TTL if provided, or use default based on priority
if "ttl" in data:
event["ttl"] = time.time() + int(data["ttl"])
elif priority == 3:
event["ttl"] = time.time() + DEFAULT_NOTIFY_TTL
else:
event["ttl"] = time.time() + DEFAULT_EVENT_TTL
with events_lock:
active_events[event_id] = event
@@ -201,6 +271,18 @@ def post_event():
return jsonify({"status": "ok", "current_state": state}), 200
@app.route("/clear-all", methods=["POST"])
def clear_all_events():
"""Clear all active events and the notification queue."""
with events_lock:
count = len(active_events)
active_events.clear()
notify_queue.clear()
state = write_status()
return jsonify({"status": "cleared", "count": count, "current_state": state}), 200
@app.route("/clear", methods=["POST"])
def clear_event():
"""
@@ -214,41 +296,91 @@ def clear_event():
event_id = str(data["id"])
found = False
with events_lock:
if event_id in active_events:
was_queue_playing = active_events[event_id].get("from_queue", False)
del active_events[event_id]
state = write_status()
return jsonify({"status": "cleared", "current_state": state}), 200
found = True
# If the playing queue notification was cleared, promote the next one
if was_queue_playing:
_promote_next_notification(time.time())
else:
return jsonify({"error": "Event not found"}), 404
# Check if it's waiting in the queue and remove it
new_queue = collections.deque(
(qid, qe) for qid, qe in notify_queue if qid != event_id
)
if len(new_queue) < len(notify_queue):
notify_queue.clear()
notify_queue.extend(new_queue)
found = True
if not found:
return jsonify({"error": "Event not found"}), 404
state = write_status()
return jsonify({"status": "cleared", "current_state": state}), 200
@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
}
Rapid calls are buffered and played sequentially.
"""
global _notify_counter
data = request.get_json(force=True) if request.data else {}
message = data.get("message", "")
duration = int(data.get("duration", 5))
duration = int(data.get("duration", DEFAULT_NOTIFY_DURATION))
# Generate unique ID to avoid conflicts
event_id = f"ha_notify_{int(time.time() * 1000)}"
_notify_counter += 1
event_id = f"notify_{int(time.time())}_{_notify_counter}"
event = {
"priority": 3, # Notify priority
"priority": 3,
"message": message,
"timestamp": time.time(),
"ttl": time.time() + duration,
"from_queue": True, # Tag so cleanup knows to auto-advance
"duration": duration, # Preserved for when promoted from queue
}
with events_lock:
active_events[event_id] = event
# Optional custom display properties
for key in ("emote", "color", "animation", "sound"):
if key in data:
event[key] = data[key]
state = write_status()
return jsonify({"status": "ok", "id": event_id, "current_state": state}), 200
play_now = False
with events_lock:
# Play immediately only if no queue notification is currently active or pending
queue_busy = any(e.get("from_queue") for e in active_events.values()) or len(notify_queue) > 0
if not queue_busy:
now = time.time()
active_events[event_id] = {**event, "timestamp": now, "ttl": now + duration}
play_now = True
else:
notify_queue.append((event_id, event))
print(f"[QUEUE] Buffered notification: {event_id} ({len(notify_queue)} in queue)")
if play_now:
state = write_status()
else:
state = get_current_state()
return jsonify({
"status": "ok",
"id": event_id,
"queued": not play_now,
"notify_queue_size": len(notify_queue),
"current_state": state,
}), 200
@app.route("/sleep", methods=["POST"])
@@ -263,8 +395,13 @@ def sleep_mode():
@app.route("/wake", methods=["POST"])
def wake_mode():
"""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
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()
return jsonify({"status": "awake", "current_state": state}), 200
@@ -275,6 +412,12 @@ def index():
return send_from_directory(ROOT_DIR, "index.html")
@app.route("/openapi.yaml")
def openapi_spec():
"""Serve OpenAPI specification."""
return send_from_directory(ROOT_DIR, "openapi.yaml", mimetype="text/yaml")
@app.route("/status", methods=["GET"])
def get_status():
"""Return current status as JSON."""
@@ -288,6 +431,67 @@ def list_events():
return jsonify({"events": dict(active_events)}), 200
@app.route("/stream")
def stream():
"""Server-Sent Events stream. Pushes state JSON on every change."""
q = queue.Queue(maxsize=10)
with _subscribers_lock:
_subscribers.add(q)
def generate():
try:
# Send current state immediately on connect
yield f"data: {json.dumps(get_current_state())}\n\n"
while True:
try:
data = q.get(timeout=30)
yield f"data: {data}\n\n"
except queue.Empty:
# Keepalive comment to prevent proxy timeouts
yield ": keepalive\n\n"
finally:
with _subscribers_lock:
_subscribers.discard(q)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disable nginx buffering if proxied
},
)
@app.route("/docs")
def docs():
"""Serve interactive API documentation via Swagger UI."""
return """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kao API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body { margin: 0; background: #fafafa; }
.swagger-ui .topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/openapi.yaml',
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: 'BaseLayout'
});
</script>
</body>
</html>"""
def main():
port = int(os.environ.get("PORT", 5100))

35
detectors/base.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Shared utilities for Kao detectors.
"""
import requests
DEFAULT_AGGREGATOR_URL = "http://localhost:5100"
def send_event(url, event_id, priority, message, check_interval):
"""Send an event to the aggregator with heartbeat TTL."""
ttl = check_interval * 2
try:
response = requests.post(
f"{url}/event",
json={"id": event_id, "priority": priority, "message": message, "ttl": ttl},
timeout=5,
)
print(f"[EVENT] {event_id}: {message} (priority {priority}, ttl {ttl}s) -> {response.status_code}")
except requests.RequestException as e:
print(f"[ERROR] Failed to send event: {e}")
def clear_event(url, event_id):
"""Clear an event from the aggregator."""
try:
response = requests.post(
f"{url}/clear",
json={"id": event_id},
timeout=5,
)
if response.status_code == 200:
print(f"[CLEAR] {event_id}")
except requests.RequestException as e:
print(f"[ERROR] Failed to clear event: {e}")

View File

@@ -3,7 +3,7 @@ CPU Usage Detector
Monitors CPU usage and reports to the aggregator when thresholds are exceeded.
Environment variables:
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5000)
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5100)
CHECK_INTERVAL - Seconds between checks (default: 30)
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
@@ -12,10 +12,11 @@ Environment variables:
import os
import time
import psutil
import requests
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
# Configuration from environment
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", "http://localhost:5000")
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", DEFAULT_AGGREGATOR_URL)
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 30))
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
@@ -23,34 +24,6 @@ THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
EVENT_ID = "cpu_usage"
def send_event(priority, message):
"""Send an event to the aggregator with heartbeat TTL."""
ttl = CHECK_INTERVAL * 2
try:
response = requests.post(
f"{AGGREGATOR_URL}/event",
json={"id": EVENT_ID, "priority": priority, "message": message, "ttl": ttl},
timeout=5
)
print(f"[EVENT] {message} (priority {priority}, ttl {ttl}s) -> {response.status_code}")
except requests.RequestException as e:
print(f"[ERROR] Failed to send event: {e}")
def clear_event():
"""Clear the event from the aggregator."""
try:
response = requests.post(
f"{AGGREGATOR_URL}/clear",
json={"id": EVENT_ID},
timeout=5
)
if response.status_code == 200:
print(f"[CLEAR] {EVENT_ID}")
except requests.RequestException as e:
print(f"[ERROR] Failed to clear event: {e}")
def main():
print(f"CPU Usage Detector started")
print(f" Aggregator: {AGGREGATOR_URL}")
@@ -58,23 +31,27 @@ def main():
print(f" Thresholds: Warning={THRESHOLD_WARNING}%, Critical={THRESHOLD_CRITICAL}%")
print()
alert_active = False
active_alerts = set()
while True:
# Get CPU usage over a 1-second sample
cpu_percent = psutil.cpu_percent(interval=1)
current_alerts = set()
if cpu_percent >= THRESHOLD_CRITICAL:
send_event(1, f"CPU at {cpu_percent:.0f}%")
alert_active = True
send_event(AGGREGATOR_URL, EVENT_ID, 1, f"CPU at {cpu_percent:.0f}%", CHECK_INTERVAL)
current_alerts.add(EVENT_ID)
elif cpu_percent >= THRESHOLD_WARNING:
send_event(2, f"CPU at {cpu_percent:.0f}%")
alert_active = True
send_event(AGGREGATOR_URL, EVENT_ID, 2, f"CPU at {cpu_percent:.0f}%", CHECK_INTERVAL)
current_alerts.add(EVENT_ID)
else:
print(f"[OK] CPU: {cpu_percent:.0f}%")
if alert_active:
clear_event()
alert_active = False
# Clear alerts that are no longer active
for eid in active_alerts - current_alerts:
clear_event(AGGREGATOR_URL, eid)
active_alerts = current_alerts
time.sleep(CHECK_INTERVAL - 1) # Account for 1s sample time

View File

@@ -3,7 +3,7 @@ Disk Space Detector
Monitors all drives and reports to the aggregator when thresholds are exceeded.
Environment variables:
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5000)
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5100)
CHECK_INTERVAL - Seconds between checks (default: 300)
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
@@ -12,10 +12,11 @@ Environment variables:
import os
import time
import shutil
import requests
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
# Configuration from environment
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", "http://localhost:5000")
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", DEFAULT_AGGREGATOR_URL)
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 300))
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
@@ -85,34 +86,6 @@ def check_disk(drive):
return None, None, None
def send_event(event_id, priority, message):
"""Send an event to the aggregator with heartbeat TTL."""
ttl = CHECK_INTERVAL * 2 # Event expires if not refreshed
try:
response = requests.post(
f"{AGGREGATOR_URL}/event",
json={"id": event_id, "priority": priority, "message": message, "ttl": ttl},
timeout=5
)
print(f"[EVENT] {event_id}: {message} (priority {priority}, ttl {ttl}s) -> {response.status_code}")
except requests.RequestException as e:
print(f"[ERROR] Failed to send event: {e}")
def clear_event(event_id):
"""Clear an event from the aggregator."""
try:
response = requests.post(
f"{AGGREGATOR_URL}/clear",
json={"id": event_id},
timeout=5
)
if response.status_code == 200:
print(f"[CLEAR] {event_id}")
except requests.RequestException as e:
print(f"[ERROR] Failed to clear event: {e}")
def main():
print(f"Disk Space Detector started")
print(f" Aggregator: {AGGREGATOR_URL}")
@@ -139,18 +112,18 @@ def main():
if percent >= THRESHOLD_CRITICAL:
message = f"{drive} at {percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)"
send_event(event_id, 1, message)
send_event(AGGREGATOR_URL, event_id, 1, message, CHECK_INTERVAL)
current_alerts.add(event_id)
elif percent >= THRESHOLD_WARNING:
message = f"{drive} at {percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)"
send_event(event_id, 2, message)
send_event(AGGREGATOR_URL, event_id, 2, message, CHECK_INTERVAL)
current_alerts.add(event_id)
else:
print(f"[OK] {drive}: {percent:.0f}%")
# Clear alerts that are no longer active
for event_id in active_alerts - current_alerts:
clear_event(event_id)
clear_event(AGGREGATOR_URL, event_id)
active_alerts = current_alerts

View File

@@ -3,7 +3,7 @@ Docker Container Health Detector
Monitors for containers stuck in restart loops or unhealthy states.
Environment variables:
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5000)
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5100)
CHECK_INTERVAL - Seconds between checks (default: 60)
RESTART_THRESHOLD - Number of restarts to consider a loop (default: 3)
CONTAINERS - Comma-separated container names to monitor (optional, monitors all if empty)
@@ -13,10 +13,11 @@ import json
import os
import subprocess
import time
import requests
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
# Configuration from environment
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", "http://localhost:5000")
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", DEFAULT_AGGREGATOR_URL)
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 60))
RESTART_THRESHOLD = int(os.environ.get("RESTART_THRESHOLD", 3))
CONTAINERS = os.environ.get("CONTAINERS", "")
@@ -70,34 +71,6 @@ def get_restart_count(container_name):
return 0
def send_event(event_id, priority, message):
"""Send an event to the aggregator with heartbeat TTL."""
ttl = CHECK_INTERVAL * 2
try:
response = requests.post(
f"{AGGREGATOR_URL}/event",
json={"id": event_id, "priority": priority, "message": message, "ttl": ttl},
timeout=5
)
print(f"[EVENT] {event_id}: {message} (priority {priority}, ttl {ttl}s) -> {response.status_code}")
except requests.RequestException as e:
print(f"[ERROR] Failed to send event: {e}")
def clear_event(event_id):
"""Clear an event from the aggregator."""
try:
response = requests.post(
f"{AGGREGATOR_URL}/clear",
json={"id": event_id},
timeout=5
)
if response.status_code == 200:
print(f"[CLEAR] {event_id}")
except requests.RequestException as e:
print(f"[ERROR] Failed to clear event: {e}")
def main():
# Parse container filter
filter_containers = None
@@ -115,6 +88,7 @@ def main():
print()
active_alerts = set()
last_restart_counts = {}
while True:
containers = get_container_status()
@@ -137,28 +111,36 @@ def main():
event_id = f"docker_{name.replace('/', '_')}"
# Check for restarting state
if state == "restarting":
# Check restart count for running/restarting containers
# The "restarting" state is too transient to catch reliably,
# so we track count increases between checks instead
if state in ("running", "restarting"):
restart_count = get_restart_count(name)
if restart_count >= RESTART_THRESHOLD:
send_event(event_id, 1, f"Container '{name}' restart loop ({restart_count}x)")
prev_count = last_restart_counts.get(name, restart_count)
new_restarts = restart_count - prev_count
last_restart_counts[name] = restart_count
if state == "restarting" or new_restarts >= RESTART_THRESHOLD:
send_event(AGGREGATOR_URL, event_id, 1, f"Container '{name}' restart loop ({restart_count}x)", CHECK_INTERVAL)
current_alerts.add(event_id)
elif new_restarts > 0:
send_event(AGGREGATOR_URL, event_id, 2, f"Container '{name}' restarting ({restart_count}x)", CHECK_INTERVAL)
current_alerts.add(event_id)
else:
send_event(event_id, 2, f"Container '{name}' restarting ({restart_count}x)")
current_alerts.add(event_id)
print(f"[OK] Container '{name}' is {state}")
# Check for exited/dead containers (warning)
elif state in ("exited", "dead"):
# Only alert if it exited abnormally (non-zero exit code in status)
if "Exited (0)" not in status:
send_event(event_id, 2, f"Container '{name}' {state}")
send_event(AGGREGATOR_URL, event_id, 2, f"Container '{name}' {state}", CHECK_INTERVAL)
current_alerts.add(event_id)
else:
print(f"[OK] Container '{name}' exited cleanly")
# Check for unhealthy containers
elif "unhealthy" in status.lower():
send_event(event_id, 2, f"Container '{name}' unhealthy")
send_event(AGGREGATOR_URL, event_id, 2, f"Container '{name}' unhealthy", CHECK_INTERVAL)
current_alerts.add(event_id)
else:
@@ -166,7 +148,7 @@ def main():
# Clear alerts for containers that are now healthy
for event_id in active_alerts - current_alerts:
clear_event(event_id)
clear_event(AGGREGATOR_URL, event_id)
active_alerts = current_alerts

View File

@@ -3,7 +3,7 @@ Memory Usage Detector
Monitors RAM usage and reports to the aggregator when thresholds are exceeded.
Environment variables:
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5000)
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5100)
CHECK_INTERVAL - Seconds between checks (default: 30)
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
@@ -12,10 +12,11 @@ Environment variables:
import os
import time
import psutil
import requests
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
# Configuration from environment
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", "http://localhost:5000")
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", DEFAULT_AGGREGATOR_URL)
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 30))
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
@@ -23,34 +24,6 @@ THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
EVENT_ID = "memory_usage"
def send_event(priority, message):
"""Send an event to the aggregator with heartbeat TTL."""
ttl = CHECK_INTERVAL * 2
try:
response = requests.post(
f"{AGGREGATOR_URL}/event",
json={"id": EVENT_ID, "priority": priority, "message": message, "ttl": ttl},
timeout=5
)
print(f"[EVENT] {message} (priority {priority}, ttl {ttl}s) -> {response.status_code}")
except requests.RequestException as e:
print(f"[ERROR] Failed to send event: {e}")
def clear_event():
"""Clear the event from the aggregator."""
try:
response = requests.post(
f"{AGGREGATOR_URL}/clear",
json={"id": EVENT_ID},
timeout=5
)
if response.status_code == 200:
print(f"[CLEAR] {EVENT_ID}")
except requests.RequestException as e:
print(f"[ERROR] Failed to clear event: {e}")
def main():
print(f"Memory Usage Detector started")
print(f" Aggregator: {AGGREGATOR_URL}")
@@ -58,25 +31,29 @@ def main():
print(f" Thresholds: Warning={THRESHOLD_WARNING}%, Critical={THRESHOLD_CRITICAL}%")
print()
alert_active = False
active_alerts = set()
while True:
mem = psutil.virtual_memory()
mem_percent = mem.percent
used_gb = mem.used / (1024 ** 3)
total_gb = mem.total / (1024 ** 3)
current_alerts = set()
if mem_percent >= THRESHOLD_CRITICAL:
send_event(1, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)")
alert_active = True
send_event(AGGREGATOR_URL, EVENT_ID, 1, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)", CHECK_INTERVAL)
current_alerts.add(EVENT_ID)
elif mem_percent >= THRESHOLD_WARNING:
send_event(2, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)")
alert_active = True
send_event(AGGREGATOR_URL, EVENT_ID, 2, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)", CHECK_INTERVAL)
current_alerts.add(EVENT_ID)
else:
print(f"[OK] Memory: {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)")
if alert_active:
clear_event()
alert_active = False
# Clear alerts that are no longer active
for eid in active_alerts - current_alerts:
clear_event(AGGREGATOR_URL, eid)
active_alerts = current_alerts
time.sleep(CHECK_INTERVAL)

View File

@@ -3,7 +3,7 @@ Network/Ping Detector
Monitors if hosts are reachable via ping.
Environment variables:
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5000)
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5100)
CHECK_INTERVAL - Seconds between checks (default: 60)
HOSTS - Comma-separated list of hosts to ping (required)
Example: "8.8.8.8,google.com,192.168.1.1"
@@ -15,10 +15,11 @@ import sys
import time
import platform
import subprocess
import requests
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
# Configuration from environment
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", "http://localhost:5000")
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", DEFAULT_AGGREGATOR_URL)
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 60))
HOSTS = os.environ.get("HOSTS", "")
TIMEOUT = int(os.environ.get("TIMEOUT", 5))
@@ -44,34 +45,6 @@ def ping(host):
return False
def send_event(event_id, priority, message):
"""Send an event to the aggregator with heartbeat TTL."""
ttl = CHECK_INTERVAL * 2
try:
response = requests.post(
f"{AGGREGATOR_URL}/event",
json={"id": event_id, "priority": priority, "message": message, "ttl": ttl},
timeout=5
)
print(f"[EVENT] {event_id}: {message} (priority {priority}, ttl {ttl}s) -> {response.status_code}")
except requests.RequestException as e:
print(f"[ERROR] Failed to send event: {e}")
def clear_event(event_id):
"""Clear the event from the aggregator."""
try:
response = requests.post(
f"{AGGREGATOR_URL}/clear",
json={"id": event_id},
timeout=5
)
if response.status_code == 200:
print(f"[CLEAR] {event_id}")
except requests.RequestException as e:
print(f"[ERROR] Failed to clear event: {e}")
def main():
if not HOSTS:
print("ERROR: HOSTS environment variable is required")
@@ -99,12 +72,12 @@ def main():
if ping(host):
print(f"[OK] Host '{host}' is reachable")
else:
send_event(event_id, 1, f"Host '{host}' is unreachable")
send_event(AGGREGATOR_URL, event_id, 1, f"Host '{host}' is unreachable", CHECK_INTERVAL)
current_alerts.add(event_id)
# Clear alerts for hosts that are now reachable
for event_id in active_alerts - current_alerts:
clear_event(event_id)
clear_event(AGGREGATOR_URL, event_id)
active_alerts = current_alerts

View File

@@ -3,7 +3,7 @@ Service Health Detector
Monitors if specific processes/services are running.
Environment variables:
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5000)
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5100)
CHECK_INTERVAL - Seconds between checks (default: 30)
SERVICES - Comma-separated list of process names to monitor (required)
Example: "nginx,postgres,redis"
@@ -13,10 +13,11 @@ import os
import sys
import time
import psutil
import requests
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
# Configuration from environment
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", "http://localhost:5000")
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", DEFAULT_AGGREGATOR_URL)
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 30))
SERVICES = os.environ.get("SERVICES", "")
@@ -37,34 +38,6 @@ def get_running_processes():
return running
def send_event(event_id, priority, message):
"""Send an event to the aggregator with heartbeat TTL."""
ttl = CHECK_INTERVAL * 2
try:
response = requests.post(
f"{AGGREGATOR_URL}/event",
json={"id": event_id, "priority": priority, "message": message, "ttl": ttl},
timeout=5
)
print(f"[EVENT] {event_id}: {message} (priority {priority}, ttl {ttl}s) -> {response.status_code}")
except requests.RequestException as e:
print(f"[ERROR] Failed to send event: {e}")
def clear_event(event_id):
"""Clear the event from the aggregator."""
try:
response = requests.post(
f"{AGGREGATOR_URL}/clear",
json={"id": event_id},
timeout=5
)
if response.status_code == 200:
print(f"[CLEAR] {event_id}")
except requests.RequestException as e:
print(f"[ERROR] Failed to clear event: {e}")
def main():
if not SERVICES:
print("ERROR: SERVICES environment variable is required")
@@ -90,14 +63,14 @@ def main():
event_id = f"service_{service}"
if service not in running:
send_event(event_id, 1, f"Service '{service}' is not running")
send_event(AGGREGATOR_URL, event_id, 1, f"Service '{service}' is not running", CHECK_INTERVAL)
current_alerts.add(event_id)
else:
print(f"[OK] Service '{service}' is running")
# Clear alerts for services that are now running
for event_id in active_alerts - current_alerts:
clear_event(event_id)
clear_event(AGGREGATOR_URL, event_id)
active_alerts = current_alerts

View File

@@ -216,8 +216,7 @@
<script>
const emoteEl = document.getElementById("emote");
const messageEl = document.getElementById("message");
const POLL_INTERVAL = 2000;
const VERSION = "v1.2.0";
const VERSION = "v2.3.3";
// Sound system
let audioCtx = null;
@@ -254,9 +253,9 @@
}
function playWarningSound() {
// Soft double-beep
playTone(440, 0.15);
setTimeout(() => playTone(440, 0.15), 180);
// Two-tone warning beep - differentiated pitches
playTone(440, 0.2, "sine", 0.18);
setTimeout(() => playTone(550, 0.2, "sine", 0.18), 220);
}
function playCriticalSound() {
@@ -282,14 +281,158 @@
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);
}
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.15, "sine", 0.10);
setTimeout(() => playTone(650, 0.12, "sine", 0.08), 100);
}
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;
}
}
// Klaxon — looping wah-wah horn, loops until manually stopped
let klaxonInterval = null;
function playKlaxonTick() {
playTone(500, 0.22, "sawtooth", 0.22);
setTimeout(() => playTone(380, 0.22, "sawtooth", 0.22), 230);
}
function startKlaxon() {
if (klaxonInterval) return;
playKlaxonTick();
klaxonInterval = setInterval(playKlaxonTick, 470);
}
function stopKlaxon() {
if (klaxonInterval) {
clearInterval(klaxonInterval);
klaxonInterval = null;
}
}
// Play sound by name
function playSoundByName(name) {
if (name === "alarm") { startAlarm(); return; }
if (name === "klaxon") { startKlaxon(); return; }
const sounds = {
chime: playChimeSound,
alert: playAlertSound,
warning: playWarningSound,
critical: playCriticalSound,
success: playSuccessSound,
notify: playNotifySound,
recovery: playRecoverySound,
doorbell: playDoorbellSound,
knock: playKnockSound,
ding: playDingSound,
blip: playBlipSound,
siren: playSirenSound,
tada: playTadaSound,
ping: playPingSound,
bubble: playBubbleSound,
fanfare: playFanfareSound,
};
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) {
stopAlarm(); // stop any looping sounds
stopKlaxon();
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,12 +447,14 @@
// Recovery - also check for celebration emote
playRecoverySound();
}
lastState = newState;
}
lastState = newState;
}
// Handle tap - enable sound and show reaction
document.body.addEventListener("click", () => {
stopAlarm(); // stop any looping sounds
stopKlaxon();
// Enable sound on first tap (browser autoplay policy)
if (!soundEnabled) {
soundEnabled = true;
@@ -324,23 +469,27 @@
const prevClass = emoteEl.className;
const prevMsg = messageEl.textContent;
// Clear active warning/critical events
if (lastData && lastData.active_events && lastData.active_events.length > 0) {
fetch("/clear-all", { method: "POST" });
}
// Surprised face!
emoteEl.textContent = "( °o°)";
emoteEl.className = "popping";
messageEl.textContent = `Kao ${VERSION}`;
playReactSound();
// Return to normal after 1.5s
setTimeout(() => {
if (lastData) {
updateDisplay(lastData);
} else {
emoteEl.textContent = prevEmote;
emoteEl.style.color = prevColor;
emoteEl.className = prevClass;
messageEl.textContent = prevMsg;
}
// Return to normal after 1.5s - fetch fresh state
setTimeout(async () => {
isReacting = false;
try {
const resp = await fetch("/status");
if (resp.ok) {
const freshData = await resp.json();
updateDisplay(freshData);
}
} catch (_) {}
}, 1500);
}
});
@@ -350,20 +499,21 @@
document.addEventListener("DOMContentLoaded", initAudio);
}
async function fetchStatus() {
try {
const response = await fetch("/status");
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
updateDisplay(data);
} catch (err) {
// Connection lost state
function connectStream() {
const es = new EventSource("/stream");
es.onmessage = (e) => {
try {
updateDisplay(JSON.parse(e.data));
} catch (_) {}
};
es.onerror = () => {
// Connection lost — EventSource will auto-reconnect
emoteEl.textContent = "( ?.?)";
emoteEl.style.color = "#888888";
emoteEl.className = "searching";
messageEl.style.color = "#888888";
messageEl.textContent = "";
}
};
}
function updateDisplay(data) {
@@ -373,7 +523,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;
@@ -390,9 +540,7 @@
}
}
// Initial fetch and start polling
fetchStatus();
setInterval(fetchStatus, POLL_INTERVAL);
connectStream();
</script>
</body>
</html>

23
kao.py
View File

@@ -11,6 +11,7 @@ import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
@@ -46,6 +47,8 @@ class KaoManager:
# Build environment
proc_env = os.environ.copy()
# Ensure the project root is on PYTHONPATH so detectors can import each other
proc_env["PYTHONPATH"] = str(self.base_dir) + os.pathsep + proc_env.get("PYTHONPATH", "")
if env:
proc_env.update(env)
@@ -59,11 +62,19 @@ class KaoManager:
universal_newlines=True,
)
print(f"[{name}] Started (PID {process.pid})")
# Read output in a background thread to avoid blocking the main loop
thread = threading.Thread(target=self._read_output, args=(name, process), daemon=True)
thread.start()
return process
except Exception as e:
print(f"[{name}] Failed to start: {e}")
return None
def _read_output(self, name, process):
"""Read and print output from a process in a background thread."""
for line in process.stdout:
print(f"[{name}] {line.rstrip()}")
def wait_for_aggregator(self, url, timeout=AGGREGATOR_STARTUP_TIMEOUT):
"""Wait for the aggregator to become available."""
print(f"[aggregator] Waiting for service at {url}...")
@@ -80,15 +91,6 @@ class KaoManager:
print(f"[aggregator] Timeout waiting for service")
return False
def stream_output(self, name, process):
"""Read and print output from a process (non-blocking)."""
if process.stdout:
while True:
line = process.stdout.readline()
if not line:
break
print(f"[{name}] {line.rstrip()}")
def get_aggregator_url(self):
"""Get aggregator URL from config port."""
port = self.config.get("port", 5100)
@@ -135,9 +137,6 @@ class KaoManager:
for name, info in list(self.processes.items()):
process = info["process"]
# Stream any available output
self.stream_output(name, process)
# Check if process has exited
if process.poll() is not None:
print(f"[{name}] Exited with code {process.returncode}, restarting in {RESTART_DELAY}s...")

132
kao_tui.py Normal file
View File

@@ -0,0 +1,132 @@
"""Kao TUI — developer test tool for firing events, faces, and sounds."""
import sys
import requests
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Footer, Header, ListView, ListItem, Label, TabbedContent, TabPane
BASE_URL = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:5100"
SOUNDS = [
"chime", "alert", "warning", "critical", "success", "notify",
"doorbell", "knock", "ding", "blip", "siren", "tada",
"ping", "bubble", "fanfare", "alarm", "klaxon", "none",
]
FACES = [
{"emote": "( ^_^)", "animation": "breathing", "color": "#00FF00", "desc": "calm"},
{"emote": "( ˙▿˙)", "animation": "floating", "color": "#00FF00", "desc": "content"},
{"emote": "(◕‿◕)", "animation": "bouncing", "color": "#00FF00", "desc": "cheerful"},
{"emote": "( ・ω・)", "animation": "swaying", "color": "#00FF00", "desc": "curious"},
{"emote": "( ˘▽˘)", "animation": "breathing", "color": "#00FF00", "desc": "cozy"},
{"emote": "( -_^)", "animation": "blink", "color": "#00FF00", "desc": "wink"},
{"emote": "( x_x)", "animation": "shaking", "color": "#FF0000", "desc": "critical"},
{"emote": "( o_o)", "animation": "breathing", "color": "#FFFF00", "desc": "warning"},
{"emote": "( 'o')", "animation": "popping", "color": "#0088FF", "desc": "notify"},
{"emote": r"\(^o^)/", "animation": "celebrating", "color": "#00FF00", "desc": "celebrate"},
{"emote": "( -_-)zzZ", "animation": "sleeping", "color": "#333333", "desc": "sleep"},
{"emote": "( ?.?)", "animation": "searching", "color": "#888888", "desc": "lost"},
]
EVENTS = [
{"label": "Critical (priority 1)", "id": "tui_1", "priority": 1, "message": "TUI Critical"},
{"label": "Warning (priority 2)", "id": "tui_2", "priority": 2, "message": "TUI Warning"},
{"label": "Notify (priority 3)", "id": "tui_3", "priority": 3, "message": "TUI Notify"},
{"label": "── Clear all ──", "id": None},
]
CONTROLS = [
{"label": "Sleep", "action": "sleep"},
{"label": "Wake", "action": "wake"},
{"label": "Clear all events", "action": "clear-all"},
]
def post(path: str, data: dict | None = None) -> tuple[bool, str]:
try:
resp = requests.post(f"{BASE_URL}/{path}", json=data or {}, timeout=5)
return resp.ok, "" if resp.ok else f"HTTP {resp.status_code}: {resp.text[:80]}"
except Exception as e:
return False, str(e)
class KaoTUI(App):
TITLE = f"Kao TUI — {BASE_URL}"
CSS = """
ListView {
height: 1fr;
border: none;
}
ListItem {
padding: 0 2;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
]
def compose(self) -> ComposeResult:
yield Header()
with TabbedContent("Sounds", "Faces", "Events", "Controls"):
with TabPane("Sounds", id="tab-sounds"):
with ListView(id="list-sounds"):
for name in SOUNDS:
yield ListItem(Label(name), id=f"sound-{name}")
with TabPane("Faces", id="tab-faces"):
with ListView(id="list-faces"):
for face in FACES:
label = f"{face['emote']} {face['animation']} {face['desc']}"
yield ListItem(Label(label), id=f"face-{face['desc']}")
with TabPane("Events", id="tab-events"):
with ListView(id="list-events"):
for ev in EVENTS:
yield ListItem(Label(ev["label"]), id=f"event-{ev['id'] or 'clearall'}")
with TabPane("Controls", id="tab-controls"):
with ListView(id="list-controls"):
for ctrl in CONTROLS:
yield ListItem(Label(ctrl["label"]), id=f"ctrl-{ctrl['action']}")
yield Footer()
def on_list_view_selected(self, event: ListView.Selected) -> None:
item_id = event.item.id or ""
if item_id.startswith("sound-"):
name = item_id[len("sound-"):]
ok, err = post("notify", {"message": f"sound: {name}", "sound": name, "duration": 2})
self.notify(f"{name} sent" if ok else f"{name} FAILED: {err}", severity="information" if ok else "error")
elif item_id.startswith("face-"):
desc = item_id[len("face-"):]
face = next((f for f in FACES if f["desc"] == desc), None)
if face:
ok, err = post("notify", {
"emote": face["emote"],
"animation": face["animation"],
"color": face["color"],
"message": f"face: {face['desc']}",
"duration": 2,
})
self.notify(f"{face['emote']} {face['desc']} sent" if ok else f"{face['desc']} FAILED: {err}", severity="information" if ok else "error")
elif item_id.startswith("event-"):
suffix = item_id[len("event-"):]
if suffix == "clearall":
ok, err = post("clear-all")
self.notify("clear-all sent" if ok else f"clear-all FAILED: {err}", severity="information" if ok else "error")
else:
ev = next((e for e in EVENTS if e["id"] == suffix), None)
if ev:
ok, err = post("event", {"id": ev["id"], "priority": ev["priority"], "message": ev["message"], "ttl": 10})
self.notify(f"{ev['label'].strip()} sent" if ok else f"{ev['label'].strip()} FAILED: {err}", severity="information" if ok else "error")
elif item_id.startswith("ctrl-"):
action = item_id[len("ctrl-"):]
ok, err = post(action)
self.notify(f"{action} sent" if ok else f"{action} FAILED: {err}", severity="information" if ok else "error")
if __name__ == "__main__":
KaoTUI().run()

481
openapi.yaml Normal file
View File

@@ -0,0 +1,481 @@
openapi: 3.0.3
info:
title: Kao API
description: |
Kao is a minimalist system status monitor that uses ASCII emotes to represent system health.
## Priority System
Events use a priority system where lower numbers indicate higher priority:
- **1 (Critical)**: System failure - red, shaking
- **2 (Warning)**: Degraded state - yellow, breathing
- **3 (Notify)**: Informational alert - blue, popping
- **4 (Optimal)**: All systems healthy - green, varies
## TTL/Heartbeat Pattern
Events can have a TTL (time-to-live) that auto-expires them. Detectors typically send
heartbeat events that expire if not refreshed, indicating loss of communication.
version: 2.3.0
license:
name: MIT
servers:
- url: http://192.168.2.114:5100
description: Local development server
paths:
/event:
post:
summary: Register an event
description: |
Register or update a system event. Events are identified by their ID and will
overwrite any existing event with the same ID. Use TTL for heartbeat-style
monitoring where absence of updates indicates a problem.
operationId: postEvent
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EventRequest"
examples:
critical:
summary: Critical event
value:
id: "disk_root"
priority: 1
message: "Root disk 98% full"
heartbeat:
summary: Heartbeat with TTL
value:
id: "cpu_monitor"
priority: 4
message: "CPU nominal"
ttl: 60
responses:
"200":
description: Event registered successfully
content:
application/json:
schema:
$ref: "#/components/schemas/EventResponse"
"400":
description: Invalid request
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/clear-all:
post:
summary: Clear all events
description: |
Clear all active events at once. Used by the frontend when the display
is tapped to dismiss warnings and critical alerts.
operationId: clearAllEvents
responses:
"200":
description: All events cleared
content:
application/json:
schema:
$ref: "#/components/schemas/ClearAllResponse"
/clear:
post:
summary: Clear an event
description: Remove an event by its ID. Use this when a condition has resolved.
operationId: clearEvent
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: string
description: Event identifier to clear
example: "disk_root"
responses:
"200":
description: Event cleared successfully
content:
application/json:
schema:
$ref: "#/components/schemas/ClearResponse"
"400":
description: Missing required field
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Event not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/notify:
post:
summary: Send a notification
description: |
Send a temporary notification with optional custom display properties.
Designed for Home Assistant integration. Notifications auto-expire after
the specified duration. Rapid calls are buffered and played sequentially —
each notification waits for the previous to expire before displaying.
operationId: notify
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/NotifyRequest"
examples:
simple:
summary: Simple notification
value:
message: "Doorbell rang"
duration: 10
custom:
summary: Custom notification
value:
message: "Welcome home!"
duration: 10
emote: "(-´)ゞ"
color: "#00FF88"
animation: "celebrating"
sound: "chime"
responses:
"200":
description: Notification sent
content:
application/json:
schema:
$ref: "#/components/schemas/NotifyResponse"
/sleep:
post:
summary: Enter sleep mode
description: |
Put Kao into sleep mode. The display will show a sleeping emote with
dimmed colors. All events are preserved but not displayed until wake.
operationId: sleep
responses:
"200":
description: Entered sleep mode
content:
application/json:
schema:
$ref: "#/components/schemas/SleepResponse"
/wake:
post:
summary: Exit sleep mode
description: Wake Kao from sleep mode and resume normal status display.
operationId: wake
responses:
"200":
description: Exited sleep mode
content:
application/json:
schema:
$ref: "#/components/schemas/WakeResponse"
/stream:
get:
summary: SSE stream of state updates
description: |
Server-Sent Events stream that pushes the current state as JSON whenever
it changes. The frontend connects here instead of polling `/status`.
Each event is a `data:` line containing a JSON-encoded Status object,
followed by a blank line. A `: keepalive` comment is sent every 30
seconds to prevent proxy timeouts. The current state is pushed
immediately on connection.
The browser's `EventSource` API handles automatic reconnection if the
connection drops.
operationId: getStream
responses:
"200":
description: Event stream
content:
text/event-stream:
schema:
type: string
description: |
Newline-delimited SSE events. `data:` lines contain a
JSON-encoded Status object. Lines beginning with `:` are
keepalive comments and can be ignored.
example: "data: {\"current_state\": \"optimal\", ...}\n\n"
/status:
get:
summary: Get current status
description: |
Returns the current display state including the active emote, color,
animation, and list of active events. Prefer `/stream` for live
displays; use this endpoint for one-shot queries.
operationId: getStatus
responses:
"200":
description: Current status
content:
application/json:
schema:
$ref: "#/components/schemas/Status"
/events:
get:
summary: List all active events
description: Returns all currently active events with their full details.
operationId: listEvents
responses:
"200":
description: List of active events
content:
application/json:
schema:
$ref: "#/components/schemas/EventList"
components:
schemas:
EventRequest:
type: object
required:
- id
- priority
properties:
id:
type: string
description: Unique identifier for this event
example: "cpu_high"
priority:
type: integer
minimum: 1
maximum: 4
description: |
Event priority (1=Critical, 2=Warning, 3=Notify, 4=Optimal)
example: 2
message:
type: string
description: Optional message to display
example: "CPU at 90%"
ttl:
type: integer
description: Time-to-live in seconds. Event auto-expires after this duration.
example: 60
EventResponse:
type: object
properties:
status:
type: string
example: "ok"
current_state:
$ref: "#/components/schemas/Status"
ClearAllResponse:
type: object
properties:
status:
type: string
example: "cleared"
count:
type: integer
description: Number of events that were cleared
example: 2
current_state:
$ref: "#/components/schemas/Status"
ClearResponse:
type: object
properties:
status:
type: string
example: "cleared"
current_state:
$ref: "#/components/schemas/Status"
NotifyRequest:
type: object
properties:
message:
type: string
description: Text to display below the emote
example: "Someone at the door"
duration:
type: integer
description: Seconds before auto-expire
default: 5
example: 10
emote:
type: string
description: Custom emote to display (overrides default)
example: "( °o°)"
color:
type: string
description: Custom color in hex format
pattern: "^#[0-9A-Fa-f]{6}$"
example: "#FF9900"
animation:
type: string
description: Animation style
enum:
- breathing
- shaking
- popping
- celebrating
- floating
- bouncing
- swaying
example: "popping"
sound:
type: string
description: Sound effect to play
enum:
- chime
- alert
- warning
- critical
- success
- notify
- doorbell
- knock
- ding
- blip
- siren
- tada
- ping
- bubble
- fanfare
- alarm
- klaxon
- none
example: "chime"
NotifyResponse:
type: object
properties:
status:
type: string
example: "ok"
id:
type: string
description: Generated event ID
example: "notify_1704067200_1"
queued:
type: boolean
description: True if the notification was buffered (another is currently playing)
example: false
notify_queue_size:
type: integer
description: Number of notifications now waiting in the queue
example: 0
current_state:
$ref: "#/components/schemas/Status"
SleepResponse:
type: object
properties:
status:
type: string
example: "sleeping"
current_state:
$ref: "#/components/schemas/Status"
WakeResponse:
type: object
properties:
status:
type: string
example: "awake"
current_state:
$ref: "#/components/schemas/Status"
Status:
type: object
properties:
current_state:
type: string
description: Current state name
enum:
- critical
- warning
- notify
- optimal
- sleeping
example: "optimal"
active_emote:
type: string
description: ASCII emote to display
example: "( ^_^)"
color:
type: string
description: Display color in hex
example: "#00FF00"
animation:
type: string
description: Current animation
example: "breathing"
message:
type: string
description: Status message
example: "Optimal"
sound:
type: string
description: Sound to play (only present when triggered)
example: "chime"
active_events:
type: array
items:
$ref: "#/components/schemas/ActiveEvent"
notify_queue_size:
type: integer
description: Number of notifications waiting in the queue
example: 0
last_updated:
type: string
format: date-time
description: ISO 8601 timestamp
example: "2024-01-01T12:00:00"
ActiveEvent:
type: object
properties:
id:
type: string
example: "cpu_monitor"
priority:
type: integer
example: 4
message:
type: string
example: "CPU nominal"
EventList:
type: object
properties:
events:
type: object
additionalProperties:
type: object
properties:
priority:
type: integer
message:
type: string
timestamp:
type: number
ttl:
type: number
Error:
type: object
properties:
error:
type: string
example: "Missing required fields: id, priority"

View File

@@ -1,14 +1,15 @@
blinker==1.9.0
certifi==2026.1.4
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
Flask==3.1.2
idna==3.11
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
psutil==7.2.2
requests==2.32.5
urllib3==2.6.3
Werkzeug==3.1.5
blinker==1.9.0
certifi==2026.1.4
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
Flask==3.1.2
idna==3.11
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
psutil==7.2.2
requests==2.32.5
urllib3==2.6.3
Werkzeug==3.1.5
textual