Compare commits
17 Commits
8ad86d1c6e
...
feature/no
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a10a6e10c | |||
| 9f48dec4fc | |||
| 2089a06512 | |||
| dbba288d24 | |||
| aaae20281d | |||
| 9291066263 | |||
| 50e34b24c6 | |||
| a36fd7037a | |||
| 92e6441218 | |||
| a074a42d40 | |||
| dd8bf6005b | |||
| c3ceb74ce8 | |||
| fa0c16609d | |||
| 2c918565de | |||
| 94f29bf4f4 | |||
| 5e76ce9597 | |||
| 4262865520 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Virtual environment
|
# Virtual environment
|
||||||
venv/
|
venv/
|
||||||
|
venv-wsl/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
44
CLAUDE.md
44
CLAUDE.md
@@ -8,23 +8,29 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## 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
|
## Architecture
|
||||||
|
|
||||||
**Publisher/Subscriber model:**
|
**REST push model:**
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ POST /event ┌─────────────┐ GET /status ┌─────────────┐
|
┌─────────────────────┐ POST /event ┌─────────────┐ GET /status ┌─────────────┐
|
||||||
│ Detectors │ ──────────────────▶ │ Aggregator │ ◀────────────────── │ Emote-UI │
|
│ External systems │ ──────────────────▶ │ Aggregator │ ◀────────────────── │ Emote-UI │
|
||||||
│ (sensors) │ │ (broker) │ │ (display) │
|
│ (HA, scripts, etc) │ POST /notify │ (broker) │ │ (display) │
|
||||||
└─────────────┘ └─────────────┘ └─────────────┘
|
└─────────────────────┘ └─────────────┘ └─────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Aggregator** (`aggregator.py`) — Flask service managing the event queue and priority logic
|
- **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
|
- **Emote-UI** (`index.html`) — OLED-optimized web frontend
|
||||||
- **Sentry** (`kao.py`) — Unified entry point managing all processes
|
- **Sentry** (`kao.py`) — Unified entry point managing all processes
|
||||||
|
- **Detectors** (`detectors/*.py`) — Legacy standalone sensors; prefer REST push from existing tools
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -56,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 |
|
| Detector | Script | Required Env Vars |
|
||||||
|----------|--------|-------------------|
|
|----------|--------|-------------------|
|
||||||
@@ -75,11 +83,14 @@ 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 | Notification with optional customization (see below) |
|
| `/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 |
|
| `/sleep` | POST | Enter sleep mode |
|
||||||
| `/wake` | POST | Exit 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 |
|
| `/events` | GET | List active events |
|
||||||
|
| `/docs` | GET | Interactive API documentation (Swagger UI) |
|
||||||
|
|
||||||
### `/notify` Endpoint
|
### `/notify` Endpoint
|
||||||
|
|
||||||
@@ -94,6 +105,8 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`,
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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 |
|
| Field | Required | Description |
|
||||||
|-------|----------|-------------|
|
|-------|----------|-------------|
|
||||||
| `message` | No | Text to display below emote |
|
| `message` | No | Text to display below emote |
|
||||||
@@ -101,7 +114,7 @@ All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`,
|
|||||||
| `emote` | No | Custom emote to display |
|
| `emote` | No | Custom emote to display |
|
||||||
| `color` | No | Custom color (hex, e.g., `#FF9900`) |
|
| `color` | No | Custom color (hex, e.g., `#FF9900`) |
|
||||||
| `animation` | No | One of: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` |
|
| `animation` | No | One of: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` |
|
||||||
| `sound` | No | One of: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `none` |
|
| `sound` | No | One of: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon`, `none` |
|
||||||
|
|
||||||
## Priority System
|
## Priority System
|
||||||
|
|
||||||
@@ -116,12 +129,11 @@ Lower number = higher priority. Events with a `ttl` auto-expire (heartbeat patte
|
|||||||
|
|
||||||
## Personality System
|
## 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 |
|
| Emote | Animation | Vibe |
|
||||||
|-------|-----------|------|
|
|-------|-----------|------|
|
||||||
| `( ^_^)` | breathing | calm |
|
| `( ^_^)` | breathing | calm |
|
||||||
| `( ˙▿˙)` | floating | content |
|
|
||||||
| `(◕‿◕)` | bouncing | cheerful |
|
| `(◕‿◕)` | bouncing | cheerful |
|
||||||
| `( ・ω・)` | swaying | curious |
|
| `( ・ω・)` | swaying | curious |
|
||||||
| `( ˘▽˘)` | breathing | cozy |
|
| `( ˘▽˘)` | breathing | cozy |
|
||||||
@@ -172,11 +184,14 @@ Use in automations:
|
|||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── kao.py # Unified entry point
|
├── kao.py # Unified entry point
|
||||||
├── aggregator.py # Event broker/API server
|
├── aggregator.py # Event broker/API server
|
||||||
├── index.html # OLED-optimized frontend
|
├── index.html # OLED-optimized frontend
|
||||||
|
├── kao_tui.py # Developer TUI for testing sounds/events
|
||||||
├── config.json # Runtime configuration
|
├── config.json # Runtime configuration
|
||||||
|
├── openapi.yaml # API documentation (OpenAPI 3.0)
|
||||||
├── detectors/
|
├── detectors/
|
||||||
|
│ ├── base.py
|
||||||
│ ├── disk_space.py
|
│ ├── disk_space.py
|
||||||
│ ├── cpu.py
|
│ ├── cpu.py
|
||||||
│ ├── memory.py
|
│ ├── memory.py
|
||||||
@@ -184,5 +199,6 @@ Use in automations:
|
|||||||
│ ├── network.py
|
│ ├── network.py
|
||||||
│ └── docker.py
|
│ └── docker.py
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
|
├── TODO.md # Planned features and improvements
|
||||||
└── SPEC.md # Original project specification
|
└── SPEC.md # Original project specification
|
||||||
```
|
```
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -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
|
- **OLED-optimized** — Pure black background, saves battery
|
||||||
- **Glanceable** — Know your server's status from across the room
|
- **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
|
- **Extensible** — Add custom detectors for any metric
|
||||||
- **Personality** — Rotating expressions, celebration animations, sleep mode
|
- **Personality** — Rotating expressions, celebration animations, sleep mode
|
||||||
- **Sound effects** — Optional audio cues for state changes (tap to enable)
|
- **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
|
- **Home Assistant ready** — Webhook endpoints for notifications and automation
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -33,6 +35,23 @@ python kao.py
|
|||||||
|
|
||||||
Open http://localhost:5100 on your phone (use Fully Kiosk Browser for best results).
|
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
|
## Status Faces
|
||||||
|
|
||||||
| State | Emote | Meaning |
|
| State | Emote | Meaning |
|
||||||
@@ -173,40 +192,65 @@ automation:
|
|||||||
service: rest_command.kao_wake
|
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:**
|
**Notify options:**
|
||||||
- `emote`: Any ASCII emote (e.g., `( °o°)`, `\\(^o^)/`)
|
- `emote`: Any ASCII emote (e.g., `( °o°)`, `\\(^o^)/`)
|
||||||
- `color`: Hex color (e.g., `#FF9900`)
|
- `color`: Hex color (e.g., `#FF9900`)
|
||||||
- `animation`: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying`
|
- `animation`: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying`
|
||||||
- `sound`: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `none`
|
- `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
|
## API Reference
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
| --------- | ------ | ------------------------------------------------ |
|
| ------------ | ------ | ----------------------------------------------------- |
|
||||||
| `/` | GET | Web UI |
|
| `/` | GET | Web UI |
|
||||||
| `/status` | GET | Current state as JSON |
|
| `/stream` | GET | SSE stream — pushes state JSON on every change |
|
||||||
| `/events` | GET | List all active events |
|
| `/status` | GET | Current state as JSON (one-shot query) |
|
||||||
| `/event` | POST | Register an event |
|
| `/events` | GET | List all active events |
|
||||||
| `/clear` | POST | Clear an event by ID |
|
| `/event` | POST | Register an event |
|
||||||
| `/notify` | POST | Simple notification `{"message": "", "duration": 5}` |
|
| `/clear` | POST | Clear an event by ID |
|
||||||
| `/sleep` | POST | Enter sleep mode |
|
| `/clear-all` | POST | Clear all active events |
|
||||||
| `/wake` | POST | Exit sleep mode |
|
| `/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
|
## Personality
|
||||||
|
|
||||||
The emote has personality! In optimal state it:
|
The emote has personality! In optimal state it:
|
||||||
|
|
||||||
- Rotates through happy faces every 5 minutes
|
- Shows a stable face all day — set fresh each morning when `/wake` is called
|
||||||
- Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two
|
- Occasionally winks `( -_^)` or blinks `( ᵕ.ᵕ)` for a second or two on wake
|
||||||
- Celebrates `\(^o^)/` when recovering from warnings
|
- Celebrates `\(^o^)/` when recovering from warnings
|
||||||
- Each face has its own animation (floating, bouncing, swaying)
|
- 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`):
|
**Sound effects** (tap screen to enable, or use `?sound=on`):
|
||||||
- Warning: soft double-beep
|
- Warning: soft double-beep
|
||||||
- Critical: urgent descending tone
|
- Critical: urgent descending tone
|
||||||
- Notify: gentle ping
|
- Notify: gentle ping
|
||||||
- Recovery: happy ascending chirp
|
- 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
|
## License
|
||||||
|
|
||||||
|
|||||||
158
SPEC.md
158
SPEC.md
@@ -1,79 +1,79 @@
|
|||||||
# SPEC.md: Project "Sentry-Emote"
|
# SPEC.md: Project "Sentry-Emote"
|
||||||
|
|
||||||
## 1. Overview
|
## 1. Overview
|
||||||
|
|
||||||
**Purpose:** Repurpose an old Pixel phone (OLED screen) as an ambient, glanceable system status monitor for a home server.
|
**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.
|
**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).
|
**Target Device:** Android Pixel (accessed via Fully Kiosk Browser).
|
||||||
|
|
||||||
## 2. System Architecture
|
## 2. System Architecture
|
||||||
|
|
||||||
The system follows a decoupled **Publisher/Subscriber** model to ensure extensibility.
|
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.
|
- **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.
|
- **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.
|
- **Emote-UI (The Subscriber):** A mobile-optimized web frontend that displays the current highest-priority emote.
|
||||||
|
|
||||||
## 3. Data Specification
|
## 3. Data Specification
|
||||||
|
|
||||||
### 3.1 `status.json` (State Registry)
|
### 3.1 `status.json` (State Registry)
|
||||||
|
|
||||||
The Aggregator outputs this file every time the state changes.
|
The Aggregator outputs this file every time the state changes.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"current_state": "optimal",
|
"current_state": "optimal",
|
||||||
"active_emote": "( ^_^)",
|
"active_emote": "( ^_^)",
|
||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"animation": "breathing",
|
"animation": "breathing",
|
||||||
"message": "All systems nominal",
|
"message": "All systems nominal",
|
||||||
"active_events": [
|
"active_events": [
|
||||||
{
|
{
|
||||||
"id": "disk_check",
|
"id": "disk_check",
|
||||||
"priority": 4,
|
"priority": 4,
|
||||||
"message": "Disk 40% full"
|
"message": "Disk 40% full"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_updated": "2026-02-02T17:30:00"
|
"last_updated": "2026-02-02T17:30:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Priority Hierarchy
|
### 3.2 Priority Hierarchy
|
||||||
|
|
||||||
| Level | Name | Priority | Emote | Color | Logic |
|
| Level | Name | Priority | Emote | Color | Logic |
|
||||||
| ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- |
|
| ----- | ------------ | --------- | -------- | ------ | ---------------------------------------- |
|
||||||
| **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. |
|
| **1** | **Critical** | Emergency | `( x_x)` | Red | Overrules all. Manual clear required. |
|
||||||
| **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. |
|
| **2** | **Warning** | Caution | `( o_o)` | Yellow | Overrules Optimal. Auto-clears if fixed. |
|
||||||
| **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. |
|
| **3** | **Notify** | Event | `( 'o')` | Blue | Transient. TTL (Time To Live) of 10s. |
|
||||||
| **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. |
|
| **4** | **Optimal** | Default | `( ^_^)` | Green | Active when no other events exist. |
|
||||||
|
|
||||||
## 4. Component Requirements
|
## 4. Component Requirements
|
||||||
|
|
||||||
### 4.1 Aggregator (`aggregator.py`)
|
### 4.1 Aggregator (`aggregator.py`)
|
||||||
|
|
||||||
- **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals.
|
- **Event Bus:** Accept HTTP POST requests or watch a specific file/directory for new event signals.
|
||||||
- **State Management:** Maintain a list of "Active Events."
|
- **State Management:** Maintain a list of "Active Events."
|
||||||
- **TTL Logic:** Automatically remove Priority 3 events after 10 seconds.
|
- **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.
|
- **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`)
|
### 4.2 Emote-UI (`index.html`)
|
||||||
|
|
||||||
- **OLED Optimization:** Pure black background (`#000000`).
|
- **OLED Optimization:** Pure black background (`#000000`).
|
||||||
- **Glanceability:** Massive centered text for the emote.
|
- **Glanceability:** Massive centered text for the emote.
|
||||||
- **Animations:** - `breathing`: Slow opacity/scale pulse.
|
- **Animations:** - `breathing`: Slow opacity/scale pulse.
|
||||||
- `shaking`: Rapid X-axis jitter for Critical.
|
- `shaking`: Rapid X-axis jitter for Critical.
|
||||||
- `popping`: Scale-up effect for Notifications.
|
- `popping`: Scale-up effect for Notifications.
|
||||||
|
|
||||||
- **Refresh:** Long-polling or `setInterval` every 2 seconds.
|
- **Refresh:** Long-polling or `setInterval` every 2 seconds.
|
||||||
|
|
||||||
### 4.3 Extensibility (The Hook System)
|
### 4.3 Extensibility (The Hook System)
|
||||||
|
|
||||||
- New detectors must be able to send an event to the Aggregator without modifying the core code.
|
- 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`
|
- Example Detector Hook: `curl -X POST -d '{"id":"ssh","priority":1}' http://localhost:5000/event`
|
||||||
|
|
||||||
## 5. Implementation Roadmap
|
## 5. Implementation Roadmap
|
||||||
|
|
||||||
1. **Phase 1:** Build the `aggregator.py` with basic JSON output.
|
1. **Phase 1:** Build the `aggregator.py` with basic JSON output.
|
||||||
2. **Phase 2:** Build the OLED-friendly `index.html` frontend.
|
2. **Phase 2:** Build the OLED-friendly `index.html` frontend.
|
||||||
3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker).
|
3. **Phase 3:** Create the first "Detector" (e.g., a simple disk space checker).
|
||||||
4. **Phase 4:** Implement TTL for transient notifications.
|
4. **Phase 4:** Implement TTL for transient notifications.
|
||||||
|
|||||||
23
TODO.md
Normal file
23
TODO.md
Normal 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
|
||||||
262
aggregator.py
262
aggregator.py
@@ -3,14 +3,16 @@ Kao Aggregator
|
|||||||
A lightweight event broker that manages priority-based system status.
|
A lightweight event broker that manages priority-based system status.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import collections
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import random
|
import random
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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=".")
|
app = Flask(__name__, static_folder=".")
|
||||||
ROOT_DIR = Path(__file__).parent
|
ROOT_DIR = Path(__file__).parent
|
||||||
@@ -18,12 +20,14 @@ ROOT_DIR = Path(__file__).parent
|
|||||||
# Configuration
|
# Configuration
|
||||||
STATUS_FILE = Path(__file__).parent / "status.json"
|
STATUS_FILE = Path(__file__).parent / "status.json"
|
||||||
DEFAULT_NOTIFY_TTL = 10 # Default TTL for Priority 3 (Notify) events
|
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
|
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
|
# Emote variations with paired animations
|
||||||
OPTIMAL_EMOTES = [
|
OPTIMAL_EMOTES = [
|
||||||
("( ^_^)", "breathing"), # calm, content
|
("( ^_^)", "breathing"), # calm, content
|
||||||
("( ˙▿˙)", "floating"), # content
|
|
||||||
("(◕‿◕)", "bouncing"), # cheerful
|
("(◕‿◕)", "bouncing"), # cheerful
|
||||||
("( ・ω・)", "swaying"), # curious
|
("( ・ω・)", "swaying"), # curious
|
||||||
("( ˘▽˘)", "breathing"), # cozy
|
("( ˘▽˘)", "breathing"), # cozy
|
||||||
@@ -53,10 +57,32 @@ celebrating_until = 0
|
|||||||
blinking_until = 0
|
blinking_until = 0
|
||||||
blink_emote = None
|
blink_emote = None
|
||||||
blink_animation = None
|
blink_animation = None
|
||||||
last_emote_change = 0
|
|
||||||
current_optimal_emote = OPTIMAL_EMOTES[0][0]
|
current_optimal_emote = OPTIMAL_EMOTES[0][0]
|
||||||
current_optimal_animation = OPTIMAL_EMOTES[0][1]
|
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
|
# Sleep mode
|
||||||
is_sleeping = False
|
is_sleeping = False
|
||||||
SLEEP_EMOTE = "( -_-)zzZ"
|
SLEEP_EMOTE = "( -_-)zzZ"
|
||||||
@@ -67,7 +93,7 @@ SLEEP_ANIMATION = "sleeping"
|
|||||||
def get_current_state():
|
def get_current_state():
|
||||||
"""Determine current state based on active events."""
|
"""Determine current state based on active events."""
|
||||||
global previous_priority, celebrating_until, blinking_until, blink_emote, blink_animation
|
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
|
# Sleep mode overrides everything
|
||||||
if is_sleeping:
|
if is_sleeping:
|
||||||
@@ -118,7 +144,7 @@ def get_current_state():
|
|||||||
sound = top_event["sound"]
|
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 < 3:
|
||||||
celebrating_until = now + CELEBRATION_DURATION
|
celebrating_until = now + CELEBRATION_DURATION
|
||||||
|
|
||||||
previous_priority = priority
|
previous_priority = priority
|
||||||
@@ -133,14 +159,6 @@ def get_current_state():
|
|||||||
emote = blink_emote
|
emote = blink_emote
|
||||||
animation = blink_animation
|
animation = blink_animation
|
||||||
else:
|
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
|
emote = current_optimal_emote
|
||||||
animation = current_optimal_animation
|
animation = current_optimal_animation
|
||||||
|
|
||||||
@@ -151,6 +169,7 @@ def get_current_state():
|
|||||||
"animation": animation,
|
"animation": animation,
|
||||||
"message": config["name"] if priority == 4 else f"{config['name']} state active",
|
"message": config["name"] if priority == 4 else f"{config['name']} state active",
|
||||||
"active_events": sorted(events_list, key=lambda x: x["priority"]),
|
"active_events": sorted(events_list, key=lambda x: x["priority"]),
|
||||||
|
"notify_queue_size": len(notify_queue),
|
||||||
"last_updated": datetime.now().isoformat(timespec="seconds"),
|
"last_updated": datetime.now().isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,30 +180,57 @@ def get_current_state():
|
|||||||
|
|
||||||
|
|
||||||
def write_status():
|
def write_status():
|
||||||
"""Write current state to status.json."""
|
"""Write current state to status.json and push to SSE subscribers."""
|
||||||
state = get_current_state()
|
state = get_current_state()
|
||||||
with open(STATUS_FILE, "w") as f:
|
try:
|
||||||
json.dump(state, f, indent="\t")
|
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
|
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():
|
def cleanup_expired_events():
|
||||||
"""Background thread to remove expired TTL events."""
|
"""Background thread to remove expired TTL events."""
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
try:
|
||||||
now = time.time()
|
time.sleep(1)
|
||||||
expired = []
|
now = time.time()
|
||||||
|
expired = []
|
||||||
|
had_queue_event = False
|
||||||
|
|
||||||
with events_lock:
|
with events_lock:
|
||||||
for eid, event in active_events.items():
|
for eid, event in active_events.items():
|
||||||
if event.get("ttl") and now > event["ttl"]:
|
if event.get("ttl") and now > event["ttl"]:
|
||||||
expired.append(eid)
|
expired.append(eid)
|
||||||
|
|
||||||
for eid in expired:
|
for eid in expired:
|
||||||
del active_events[eid]
|
if active_events[eid].get("from_queue"):
|
||||||
|
had_queue_event = True
|
||||||
|
del active_events[eid]
|
||||||
|
|
||||||
if expired:
|
# Auto-advance: promote next queued notification when the playing one expires
|
||||||
write_status()
|
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"])
|
@app.route("/event", methods=["POST"])
|
||||||
@@ -210,11 +256,13 @@ def post_event():
|
|||||||
"timestamp": time.time(),
|
"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:
|
if "ttl" in data:
|
||||||
event["ttl"] = time.time() + int(data["ttl"])
|
event["ttl"] = time.time() + int(data["ttl"])
|
||||||
elif priority == 3:
|
elif priority == 3:
|
||||||
event["ttl"] = time.time() + DEFAULT_NOTIFY_TTL
|
event["ttl"] = time.time() + DEFAULT_NOTIFY_TTL
|
||||||
|
else:
|
||||||
|
event["ttl"] = time.time() + DEFAULT_EVENT_TTL
|
||||||
|
|
||||||
with events_lock:
|
with events_lock:
|
||||||
active_events[event_id] = event
|
active_events[event_id] = event
|
||||||
@@ -223,6 +271,18 @@ def post_event():
|
|||||||
return jsonify({"status": "ok", "current_state": state}), 200
|
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"])
|
@app.route("/clear", methods=["POST"])
|
||||||
def clear_event():
|
def clear_event():
|
||||||
"""
|
"""
|
||||||
@@ -236,13 +296,30 @@ def clear_event():
|
|||||||
|
|
||||||
event_id = str(data["id"])
|
event_id = str(data["id"])
|
||||||
|
|
||||||
|
found = False
|
||||||
with events_lock:
|
with events_lock:
|
||||||
if event_id in active_events:
|
if event_id in active_events:
|
||||||
|
was_queue_playing = active_events[event_id].get("from_queue", False)
|
||||||
del active_events[event_id]
|
del active_events[event_id]
|
||||||
state = write_status()
|
found = True
|
||||||
return jsonify({"status": "cleared", "current_state": state}), 200
|
# If the playing queue notification was cleared, promote the next one
|
||||||
|
if was_queue_playing:
|
||||||
|
_promote_next_notification(time.time())
|
||||||
else:
|
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"])
|
@app.route("/notify", methods=["POST"])
|
||||||
@@ -257,36 +334,53 @@ def notify():
|
|||||||
"animation": "popping", # optional: breathing, shaking, popping, celebrating, floating, bouncing, swaying
|
"animation": "popping", # optional: breathing, shaking, popping, celebrating, floating, bouncing, swaying
|
||||||
"sound": "chime" # optional: chime, alert, warning, critical, success, none
|
"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 {}
|
data = request.get_json(force=True) if request.data else {}
|
||||||
message = data.get("message", "")
|
message = data.get("message", "")
|
||||||
duration = int(data.get("duration", 5))
|
duration = int(data.get("duration", DEFAULT_NOTIFY_DURATION))
|
||||||
|
|
||||||
# Generate unique ID to avoid conflicts
|
# 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 = {
|
event = {
|
||||||
"priority": 3, # Notify priority
|
"priority": 3,
|
||||||
"message": message,
|
"message": message,
|
||||||
"timestamp": time.time(),
|
"from_queue": True, # Tag so cleanup knows to auto-advance
|
||||||
"ttl": time.time() + duration,
|
"duration": duration, # Preserved for when promoted from queue
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional custom display properties
|
# Optional custom display properties
|
||||||
if "emote" in data:
|
for key in ("emote", "color", "animation", "sound"):
|
||||||
event["emote"] = data["emote"]
|
if key in data:
|
||||||
if "color" in data:
|
event[key] = data[key]
|
||||||
event["color"] = data["color"]
|
|
||||||
if "animation" in data:
|
|
||||||
event["animation"] = data["animation"]
|
|
||||||
if "sound" in data:
|
|
||||||
event["sound"] = data["sound"]
|
|
||||||
|
|
||||||
|
play_now = False
|
||||||
with events_lock:
|
with events_lock:
|
||||||
active_events[event_id] = event
|
# 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)")
|
||||||
|
|
||||||
state = write_status()
|
if play_now:
|
||||||
return jsonify({"status": "ok", "id": event_id, "current_state": state}), 200
|
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"])
|
@app.route("/sleep", methods=["POST"])
|
||||||
@@ -301,8 +395,13 @@ def sleep_mode():
|
|||||||
@app.route("/wake", methods=["POST"])
|
@app.route("/wake", methods=["POST"])
|
||||||
def wake_mode():
|
def wake_mode():
|
||||||
"""Exit sleep mode. For Home Assistant webhook."""
|
"""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
|
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()
|
state = write_status()
|
||||||
return jsonify({"status": "awake", "current_state": state}), 200
|
return jsonify({"status": "awake", "current_state": state}), 200
|
||||||
|
|
||||||
@@ -313,6 +412,12 @@ def index():
|
|||||||
return send_from_directory(ROOT_DIR, "index.html")
|
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"])
|
@app.route("/status", methods=["GET"])
|
||||||
def get_status():
|
def get_status():
|
||||||
"""Return current status as JSON."""
|
"""Return current status as JSON."""
|
||||||
@@ -326,6 +431,67 @@ def list_events():
|
|||||||
return jsonify({"events": dict(active_events)}), 200
|
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():
|
def main():
|
||||||
port = int(os.environ.get("PORT", 5100))
|
port = int(os.environ.get("PORT", 5100))
|
||||||
|
|
||||||
|
|||||||
35
detectors/base.py
Normal file
35
detectors/base.py
Normal 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}")
|
||||||
@@ -3,7 +3,7 @@ CPU Usage Detector
|
|||||||
Monitors CPU usage and reports to the aggregator when thresholds are exceeded.
|
Monitors CPU usage and reports to the aggregator when thresholds are exceeded.
|
||||||
|
|
||||||
Environment variables:
|
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)
|
CHECK_INTERVAL - Seconds between checks (default: 30)
|
||||||
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
|
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
|
||||||
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
|
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
|
||||||
@@ -12,10 +12,11 @@ Environment variables:
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import psutil
|
import psutil
|
||||||
import requests
|
|
||||||
|
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
|
||||||
|
|
||||||
# Configuration from environment
|
# 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))
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 30))
|
||||||
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
|
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
|
||||||
THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
|
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"
|
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():
|
def main():
|
||||||
print(f"CPU Usage Detector started")
|
print(f"CPU Usage Detector started")
|
||||||
print(f" Aggregator: {AGGREGATOR_URL}")
|
print(f" Aggregator: {AGGREGATOR_URL}")
|
||||||
@@ -58,23 +31,27 @@ def main():
|
|||||||
print(f" Thresholds: Warning={THRESHOLD_WARNING}%, Critical={THRESHOLD_CRITICAL}%")
|
print(f" Thresholds: Warning={THRESHOLD_WARNING}%, Critical={THRESHOLD_CRITICAL}%")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
alert_active = False
|
active_alerts = set()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Get CPU usage over a 1-second sample
|
# Get CPU usage over a 1-second sample
|
||||||
cpu_percent = psutil.cpu_percent(interval=1)
|
cpu_percent = psutil.cpu_percent(interval=1)
|
||||||
|
current_alerts = set()
|
||||||
|
|
||||||
if cpu_percent >= THRESHOLD_CRITICAL:
|
if cpu_percent >= THRESHOLD_CRITICAL:
|
||||||
send_event(1, f"CPU at {cpu_percent:.0f}%")
|
send_event(AGGREGATOR_URL, EVENT_ID, 1, f"CPU at {cpu_percent:.0f}%", CHECK_INTERVAL)
|
||||||
alert_active = True
|
current_alerts.add(EVENT_ID)
|
||||||
elif cpu_percent >= THRESHOLD_WARNING:
|
elif cpu_percent >= THRESHOLD_WARNING:
|
||||||
send_event(2, f"CPU at {cpu_percent:.0f}%")
|
send_event(AGGREGATOR_URL, EVENT_ID, 2, f"CPU at {cpu_percent:.0f}%", CHECK_INTERVAL)
|
||||||
alert_active = True
|
current_alerts.add(EVENT_ID)
|
||||||
else:
|
else:
|
||||||
print(f"[OK] CPU: {cpu_percent:.0f}%")
|
print(f"[OK] CPU: {cpu_percent:.0f}%")
|
||||||
if alert_active:
|
|
||||||
clear_event()
|
# Clear alerts that are no longer active
|
||||||
alert_active = False
|
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
|
time.sleep(CHECK_INTERVAL - 1) # Account for 1s sample time
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Disk Space Detector
|
|||||||
Monitors all drives and reports to the aggregator when thresholds are exceeded.
|
Monitors all drives and reports to the aggregator when thresholds are exceeded.
|
||||||
|
|
||||||
Environment variables:
|
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)
|
CHECK_INTERVAL - Seconds between checks (default: 300)
|
||||||
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
|
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
|
||||||
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
|
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
|
||||||
@@ -12,10 +12,11 @@ Environment variables:
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
import requests
|
|
||||||
|
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
|
||||||
|
|
||||||
# Configuration from environment
|
# 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))
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 300))
|
||||||
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
|
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
|
||||||
THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
|
THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
|
||||||
@@ -85,34 +86,6 @@ def check_disk(drive):
|
|||||||
return None, None, None
|
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():
|
def main():
|
||||||
print(f"Disk Space Detector started")
|
print(f"Disk Space Detector started")
|
||||||
print(f" Aggregator: {AGGREGATOR_URL}")
|
print(f" Aggregator: {AGGREGATOR_URL}")
|
||||||
@@ -139,18 +112,18 @@ def main():
|
|||||||
|
|
||||||
if percent >= THRESHOLD_CRITICAL:
|
if percent >= THRESHOLD_CRITICAL:
|
||||||
message = f"{drive} at {percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)"
|
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)
|
current_alerts.add(event_id)
|
||||||
elif percent >= THRESHOLD_WARNING:
|
elif percent >= THRESHOLD_WARNING:
|
||||||
message = f"{drive} at {percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)"
|
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)
|
current_alerts.add(event_id)
|
||||||
else:
|
else:
|
||||||
print(f"[OK] {drive}: {percent:.0f}%")
|
print(f"[OK] {drive}: {percent:.0f}%")
|
||||||
|
|
||||||
# Clear alerts that are no longer active
|
# Clear alerts that are no longer active
|
||||||
for event_id in active_alerts - current_alerts:
|
for event_id in active_alerts - current_alerts:
|
||||||
clear_event(event_id)
|
clear_event(AGGREGATOR_URL, event_id)
|
||||||
|
|
||||||
active_alerts = current_alerts
|
active_alerts = current_alerts
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Docker Container Health Detector
|
|||||||
Monitors for containers stuck in restart loops or unhealthy states.
|
Monitors for containers stuck in restart loops or unhealthy states.
|
||||||
|
|
||||||
Environment variables:
|
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)
|
CHECK_INTERVAL - Seconds between checks (default: 60)
|
||||||
RESTART_THRESHOLD - Number of restarts to consider a loop (default: 3)
|
RESTART_THRESHOLD - Number of restarts to consider a loop (default: 3)
|
||||||
CONTAINERS - Comma-separated container names to monitor (optional, monitors all if empty)
|
CONTAINERS - Comma-separated container names to monitor (optional, monitors all if empty)
|
||||||
@@ -13,10 +13,11 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import requests
|
|
||||||
|
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
|
||||||
|
|
||||||
# Configuration from environment
|
# 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))
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 60))
|
||||||
RESTART_THRESHOLD = int(os.environ.get("RESTART_THRESHOLD", 3))
|
RESTART_THRESHOLD = int(os.environ.get("RESTART_THRESHOLD", 3))
|
||||||
CONTAINERS = os.environ.get("CONTAINERS", "")
|
CONTAINERS = os.environ.get("CONTAINERS", "")
|
||||||
@@ -70,34 +71,6 @@ def get_restart_count(container_name):
|
|||||||
return 0
|
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():
|
def main():
|
||||||
# Parse container filter
|
# Parse container filter
|
||||||
filter_containers = None
|
filter_containers = None
|
||||||
@@ -115,6 +88,7 @@ def main():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
active_alerts = set()
|
active_alerts = set()
|
||||||
|
last_restart_counts = {}
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
containers = get_container_status()
|
containers = get_container_status()
|
||||||
@@ -137,28 +111,36 @@ def main():
|
|||||||
|
|
||||||
event_id = f"docker_{name.replace('/', '_')}"
|
event_id = f"docker_{name.replace('/', '_')}"
|
||||||
|
|
||||||
# Check for restarting state
|
# Check restart count for running/restarting containers
|
||||||
if state == "restarting":
|
# 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)
|
restart_count = get_restart_count(name)
|
||||||
if restart_count >= RESTART_THRESHOLD:
|
prev_count = last_restart_counts.get(name, restart_count)
|
||||||
send_event(event_id, 1, f"Container '{name}' restart loop ({restart_count}x)")
|
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)
|
current_alerts.add(event_id)
|
||||||
else:
|
else:
|
||||||
send_event(event_id, 2, f"Container '{name}' restarting ({restart_count}x)")
|
print(f"[OK] Container '{name}' is {state}")
|
||||||
current_alerts.add(event_id)
|
|
||||||
|
|
||||||
# Check for exited/dead containers (warning)
|
# Check for exited/dead containers (warning)
|
||||||
elif state in ("exited", "dead"):
|
elif state in ("exited", "dead"):
|
||||||
# Only alert if it exited abnormally (non-zero exit code in status)
|
# Only alert if it exited abnormally (non-zero exit code in status)
|
||||||
if "Exited (0)" not 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)
|
current_alerts.add(event_id)
|
||||||
else:
|
else:
|
||||||
print(f"[OK] Container '{name}' exited cleanly")
|
print(f"[OK] Container '{name}' exited cleanly")
|
||||||
|
|
||||||
# Check for unhealthy containers
|
# Check for unhealthy containers
|
||||||
elif "unhealthy" in status.lower():
|
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)
|
current_alerts.add(event_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -166,7 +148,7 @@ def main():
|
|||||||
|
|
||||||
# Clear alerts for containers that are now healthy
|
# Clear alerts for containers that are now healthy
|
||||||
for event_id in active_alerts - current_alerts:
|
for event_id in active_alerts - current_alerts:
|
||||||
clear_event(event_id)
|
clear_event(AGGREGATOR_URL, event_id)
|
||||||
|
|
||||||
active_alerts = current_alerts
|
active_alerts = current_alerts
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Memory Usage Detector
|
|||||||
Monitors RAM usage and reports to the aggregator when thresholds are exceeded.
|
Monitors RAM usage and reports to the aggregator when thresholds are exceeded.
|
||||||
|
|
||||||
Environment variables:
|
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)
|
CHECK_INTERVAL - Seconds between checks (default: 30)
|
||||||
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
|
THRESHOLD_CRITICAL - Percent usage for critical alert (default: 95)
|
||||||
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
|
THRESHOLD_WARNING - Percent usage for warning alert (default: 85)
|
||||||
@@ -12,10 +12,11 @@ Environment variables:
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import psutil
|
import psutil
|
||||||
import requests
|
|
||||||
|
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
|
||||||
|
|
||||||
# Configuration from environment
|
# 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))
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 30))
|
||||||
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
|
THRESHOLD_CRITICAL = int(os.environ.get("THRESHOLD_CRITICAL", 95))
|
||||||
THRESHOLD_WARNING = int(os.environ.get("THRESHOLD_WARNING", 85))
|
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"
|
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():
|
def main():
|
||||||
print(f"Memory Usage Detector started")
|
print(f"Memory Usage Detector started")
|
||||||
print(f" Aggregator: {AGGREGATOR_URL}")
|
print(f" Aggregator: {AGGREGATOR_URL}")
|
||||||
@@ -58,25 +31,29 @@ def main():
|
|||||||
print(f" Thresholds: Warning={THRESHOLD_WARNING}%, Critical={THRESHOLD_CRITICAL}%")
|
print(f" Thresholds: Warning={THRESHOLD_WARNING}%, Critical={THRESHOLD_CRITICAL}%")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
alert_active = False
|
active_alerts = set()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
mem_percent = mem.percent
|
mem_percent = mem.percent
|
||||||
used_gb = mem.used / (1024 ** 3)
|
used_gb = mem.used / (1024 ** 3)
|
||||||
total_gb = mem.total / (1024 ** 3)
|
total_gb = mem.total / (1024 ** 3)
|
||||||
|
current_alerts = set()
|
||||||
|
|
||||||
if mem_percent >= THRESHOLD_CRITICAL:
|
if mem_percent >= THRESHOLD_CRITICAL:
|
||||||
send_event(1, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)")
|
send_event(AGGREGATOR_URL, EVENT_ID, 1, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)", CHECK_INTERVAL)
|
||||||
alert_active = True
|
current_alerts.add(EVENT_ID)
|
||||||
elif mem_percent >= THRESHOLD_WARNING:
|
elif mem_percent >= THRESHOLD_WARNING:
|
||||||
send_event(2, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)")
|
send_event(AGGREGATOR_URL, EVENT_ID, 2, f"Memory at {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)", CHECK_INTERVAL)
|
||||||
alert_active = True
|
current_alerts.add(EVENT_ID)
|
||||||
else:
|
else:
|
||||||
print(f"[OK] Memory: {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)")
|
print(f"[OK] Memory: {mem_percent:.0f}% ({used_gb:.1f}/{total_gb:.1f} GB)")
|
||||||
if alert_active:
|
|
||||||
clear_event()
|
# Clear alerts that are no longer active
|
||||||
alert_active = False
|
for eid in active_alerts - current_alerts:
|
||||||
|
clear_event(AGGREGATOR_URL, eid)
|
||||||
|
|
||||||
|
active_alerts = current_alerts
|
||||||
|
|
||||||
time.sleep(CHECK_INTERVAL)
|
time.sleep(CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Network/Ping Detector
|
|||||||
Monitors if hosts are reachable via ping.
|
Monitors if hosts are reachable via ping.
|
||||||
|
|
||||||
Environment variables:
|
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)
|
CHECK_INTERVAL - Seconds between checks (default: 60)
|
||||||
HOSTS - Comma-separated list of hosts to ping (required)
|
HOSTS - Comma-separated list of hosts to ping (required)
|
||||||
Example: "8.8.8.8,google.com,192.168.1.1"
|
Example: "8.8.8.8,google.com,192.168.1.1"
|
||||||
@@ -15,10 +15,11 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
import requests
|
|
||||||
|
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
|
||||||
|
|
||||||
# Configuration from environment
|
# 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))
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 60))
|
||||||
HOSTS = os.environ.get("HOSTS", "")
|
HOSTS = os.environ.get("HOSTS", "")
|
||||||
TIMEOUT = int(os.environ.get("TIMEOUT", 5))
|
TIMEOUT = int(os.environ.get("TIMEOUT", 5))
|
||||||
@@ -44,34 +45,6 @@ def ping(host):
|
|||||||
return False
|
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():
|
def main():
|
||||||
if not HOSTS:
|
if not HOSTS:
|
||||||
print("ERROR: HOSTS environment variable is required")
|
print("ERROR: HOSTS environment variable is required")
|
||||||
@@ -99,12 +72,12 @@ def main():
|
|||||||
if ping(host):
|
if ping(host):
|
||||||
print(f"[OK] Host '{host}' is reachable")
|
print(f"[OK] Host '{host}' is reachable")
|
||||||
else:
|
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)
|
current_alerts.add(event_id)
|
||||||
|
|
||||||
# Clear alerts for hosts that are now reachable
|
# Clear alerts for hosts that are now reachable
|
||||||
for event_id in active_alerts - current_alerts:
|
for event_id in active_alerts - current_alerts:
|
||||||
clear_event(event_id)
|
clear_event(AGGREGATOR_URL, event_id)
|
||||||
|
|
||||||
active_alerts = current_alerts
|
active_alerts = current_alerts
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Service Health Detector
|
|||||||
Monitors if specific processes/services are running.
|
Monitors if specific processes/services are running.
|
||||||
|
|
||||||
Environment variables:
|
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)
|
CHECK_INTERVAL - Seconds between checks (default: 30)
|
||||||
SERVICES - Comma-separated list of process names to monitor (required)
|
SERVICES - Comma-separated list of process names to monitor (required)
|
||||||
Example: "nginx,postgres,redis"
|
Example: "nginx,postgres,redis"
|
||||||
@@ -13,10 +13,11 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import psutil
|
import psutil
|
||||||
import requests
|
|
||||||
|
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
|
||||||
|
|
||||||
# Configuration from environment
|
# 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))
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 30))
|
||||||
SERVICES = os.environ.get("SERVICES", "")
|
SERVICES = os.environ.get("SERVICES", "")
|
||||||
|
|
||||||
@@ -37,34 +38,6 @@ def get_running_processes():
|
|||||||
return running
|
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():
|
def main():
|
||||||
if not SERVICES:
|
if not SERVICES:
|
||||||
print("ERROR: SERVICES environment variable is required")
|
print("ERROR: SERVICES environment variable is required")
|
||||||
@@ -90,14 +63,14 @@ def main():
|
|||||||
event_id = f"service_{service}"
|
event_id = f"service_{service}"
|
||||||
|
|
||||||
if service not in running:
|
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)
|
current_alerts.add(event_id)
|
||||||
else:
|
else:
|
||||||
print(f"[OK] Service '{service}' is running")
|
print(f"[OK] Service '{service}' is running")
|
||||||
|
|
||||||
# Clear alerts for services that are now running
|
# Clear alerts for services that are now running
|
||||||
for event_id in active_alerts - current_alerts:
|
for event_id in active_alerts - current_alerts:
|
||||||
clear_event(event_id)
|
clear_event(AGGREGATOR_URL, event_id)
|
||||||
|
|
||||||
active_alerts = current_alerts
|
active_alerts = current_alerts
|
||||||
|
|
||||||
|
|||||||
155
index.html
155
index.html
@@ -216,8 +216,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const emoteEl = document.getElementById("emote");
|
const emoteEl = document.getElementById("emote");
|
||||||
const messageEl = document.getElementById("message");
|
const messageEl = document.getElementById("message");
|
||||||
const POLL_INTERVAL = 2000;
|
const VERSION = "v2.3.3";
|
||||||
const VERSION = "v1.3.0";
|
|
||||||
|
|
||||||
// Sound system
|
// Sound system
|
||||||
let audioCtx = null;
|
let audioCtx = null;
|
||||||
@@ -254,9 +253,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function playWarningSound() {
|
function playWarningSound() {
|
||||||
// Soft double-beep
|
// Two-tone warning beep - differentiated pitches
|
||||||
playTone(440, 0.15);
|
playTone(440, 0.2, "sine", 0.18);
|
||||||
setTimeout(() => playTone(440, 0.15), 180);
|
setTimeout(() => playTone(550, 0.2, "sine", 0.18), 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playCriticalSound() {
|
function playCriticalSound() {
|
||||||
@@ -304,8 +303,88 @@
|
|||||||
setTimeout(() => playTone(1047, 0.2), 300);
|
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
|
// Play sound by name
|
||||||
function playSoundByName(name) {
|
function playSoundByName(name) {
|
||||||
|
if (name === "alarm") { startAlarm(); return; }
|
||||||
|
if (name === "klaxon") { startKlaxon(); return; }
|
||||||
const sounds = {
|
const sounds = {
|
||||||
chime: playChimeSound,
|
chime: playChimeSound,
|
||||||
alert: playAlertSound,
|
alert: playAlertSound,
|
||||||
@@ -314,6 +393,15 @@
|
|||||||
success: playSuccessSound,
|
success: playSuccessSound,
|
||||||
notify: playNotifySound,
|
notify: playNotifySound,
|
||||||
recovery: playRecoverySound,
|
recovery: playRecoverySound,
|
||||||
|
doorbell: playDoorbellSound,
|
||||||
|
knock: playKnockSound,
|
||||||
|
ding: playDingSound,
|
||||||
|
blip: playBlipSound,
|
||||||
|
siren: playSirenSound,
|
||||||
|
tada: playTadaSound,
|
||||||
|
ping: playPingSound,
|
||||||
|
bubble: playBubbleSound,
|
||||||
|
fanfare: playFanfareSound,
|
||||||
};
|
};
|
||||||
if (sounds[name]) {
|
if (sounds[name]) {
|
||||||
sounds[name]();
|
sounds[name]();
|
||||||
@@ -325,10 +413,16 @@
|
|||||||
|
|
||||||
function handleStateChange(newState, newEmote, customSound) {
|
function handleStateChange(newState, newEmote, customSound) {
|
||||||
// Handle custom sound from notification
|
// Handle custom sound from notification
|
||||||
if (customSound && customSound !== "none" && customSound !== lastCustomSound) {
|
if (
|
||||||
|
customSound &&
|
||||||
|
customSound !== "none" &&
|
||||||
|
customSound !== lastCustomSound
|
||||||
|
) {
|
||||||
playSoundByName(customSound);
|
playSoundByName(customSound);
|
||||||
lastCustomSound = customSound;
|
lastCustomSound = customSound;
|
||||||
} else if (!customSound) {
|
} else if (!customSound) {
|
||||||
|
stopAlarm(); // stop any looping sounds
|
||||||
|
stopKlaxon();
|
||||||
lastCustomSound = null;
|
lastCustomSound = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +453,8 @@
|
|||||||
|
|
||||||
// Handle tap - enable sound and show reaction
|
// Handle tap - enable sound and show reaction
|
||||||
document.body.addEventListener("click", () => {
|
document.body.addEventListener("click", () => {
|
||||||
|
stopAlarm(); // stop any looping sounds
|
||||||
|
stopKlaxon();
|
||||||
// Enable sound on first tap (browser autoplay policy)
|
// Enable sound on first tap (browser autoplay policy)
|
||||||
if (!soundEnabled) {
|
if (!soundEnabled) {
|
||||||
soundEnabled = true;
|
soundEnabled = true;
|
||||||
@@ -373,23 +469,27 @@
|
|||||||
const prevClass = emoteEl.className;
|
const prevClass = emoteEl.className;
|
||||||
const prevMsg = messageEl.textContent;
|
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!
|
// Surprised face!
|
||||||
emoteEl.textContent = "( °o°)";
|
emoteEl.textContent = "( °o°)";
|
||||||
emoteEl.className = "popping";
|
emoteEl.className = "popping";
|
||||||
messageEl.textContent = `Kao ${VERSION}`;
|
messageEl.textContent = `Kao ${VERSION}`;
|
||||||
playReactSound();
|
playReactSound();
|
||||||
|
|
||||||
// Return to normal after 1.5s
|
// Return to normal after 1.5s - fetch fresh state
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
if (lastData) {
|
|
||||||
updateDisplay(lastData);
|
|
||||||
} else {
|
|
||||||
emoteEl.textContent = prevEmote;
|
|
||||||
emoteEl.style.color = prevColor;
|
|
||||||
emoteEl.className = prevClass;
|
|
||||||
messageEl.textContent = prevMsg;
|
|
||||||
}
|
|
||||||
isReacting = false;
|
isReacting = false;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/status");
|
||||||
|
if (resp.ok) {
|
||||||
|
const freshData = await resp.json();
|
||||||
|
updateDisplay(freshData);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -399,20 +499,21 @@
|
|||||||
document.addEventListener("DOMContentLoaded", initAudio);
|
document.addEventListener("DOMContentLoaded", initAudio);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStatus() {
|
function connectStream() {
|
||||||
try {
|
const es = new EventSource("/stream");
|
||||||
const response = await fetch("/status");
|
es.onmessage = (e) => {
|
||||||
if (!response.ok) throw new Error("Failed to fetch");
|
try {
|
||||||
const data = await response.json();
|
updateDisplay(JSON.parse(e.data));
|
||||||
updateDisplay(data);
|
} catch (_) {}
|
||||||
} catch (err) {
|
};
|
||||||
// Connection lost state
|
es.onerror = () => {
|
||||||
|
// Connection lost — EventSource will auto-reconnect
|
||||||
emoteEl.textContent = "( ?.?)";
|
emoteEl.textContent = "( ?.?)";
|
||||||
emoteEl.style.color = "#888888";
|
emoteEl.style.color = "#888888";
|
||||||
emoteEl.className = "searching";
|
emoteEl.className = "searching";
|
||||||
messageEl.style.color = "#888888";
|
messageEl.style.color = "#888888";
|
||||||
messageEl.textContent = "";
|
messageEl.textContent = "";
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDisplay(data) {
|
function updateDisplay(data) {
|
||||||
@@ -439,9 +540,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial fetch and start polling
|
connectStream();
|
||||||
fetchStatus();
|
|
||||||
setInterval(fetchStatus, POLL_INTERVAL);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
23
kao.py
23
kao.py
@@ -11,6 +11,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ class KaoManager:
|
|||||||
|
|
||||||
# Build environment
|
# Build environment
|
||||||
proc_env = os.environ.copy()
|
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:
|
if env:
|
||||||
proc_env.update(env)
|
proc_env.update(env)
|
||||||
|
|
||||||
@@ -59,11 +62,19 @@ class KaoManager:
|
|||||||
universal_newlines=True,
|
universal_newlines=True,
|
||||||
)
|
)
|
||||||
print(f"[{name}] Started (PID {process.pid})")
|
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
|
return process
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[{name}] Failed to start: {e}")
|
print(f"[{name}] Failed to start: {e}")
|
||||||
return None
|
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):
|
def wait_for_aggregator(self, url, timeout=AGGREGATOR_STARTUP_TIMEOUT):
|
||||||
"""Wait for the aggregator to become available."""
|
"""Wait for the aggregator to become available."""
|
||||||
print(f"[aggregator] Waiting for service at {url}...")
|
print(f"[aggregator] Waiting for service at {url}...")
|
||||||
@@ -80,15 +91,6 @@ class KaoManager:
|
|||||||
print(f"[aggregator] Timeout waiting for service")
|
print(f"[aggregator] Timeout waiting for service")
|
||||||
return False
|
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):
|
def get_aggregator_url(self):
|
||||||
"""Get aggregator URL from config port."""
|
"""Get aggregator URL from config port."""
|
||||||
port = self.config.get("port", 5100)
|
port = self.config.get("port", 5100)
|
||||||
@@ -135,9 +137,6 @@ class KaoManager:
|
|||||||
for name, info in list(self.processes.items()):
|
for name, info in list(self.processes.items()):
|
||||||
process = info["process"]
|
process = info["process"]
|
||||||
|
|
||||||
# Stream any available output
|
|
||||||
self.stream_output(name, process)
|
|
||||||
|
|
||||||
# Check if process has exited
|
# Check if process has exited
|
||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
print(f"[{name}] Exited with code {process.returncode}, restarting in {RESTART_DELAY}s...")
|
print(f"[{name}] Exited with code {process.returncode}, restarting in {RESTART_DELAY}s...")
|
||||||
|
|||||||
132
kao_tui.py
Normal file
132
kao_tui.py
Normal 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
481
openapi.yaml
Normal 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"
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
blinker==1.9.0
|
blinker==1.9.0
|
||||||
certifi==2026.1.4
|
certifi==2026.1.4
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
click==8.3.1
|
click==8.3.1
|
||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
Flask==3.1.2
|
Flask==3.1.2
|
||||||
idna==3.11
|
idna==3.11
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
MarkupSafe==3.0.3
|
MarkupSafe==3.0.3
|
||||||
psutil==7.2.2
|
psutil==7.2.2
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
urllib3==2.6.3
|
urllib3==2.6.3
|
||||||
Werkzeug==3.1.5
|
Werkzeug==3.1.5
|
||||||
|
textual
|
||||||
|
|||||||
Reference in New Issue
Block a user