Compare commits
27 Commits
c6913c611d
...
feature/no
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a10a6e10c | |||
| 9f48dec4fc | |||
| 2089a06512 | |||
| dbba288d24 | |||
| aaae20281d | |||
| 9291066263 | |||
| 50e34b24c6 | |||
| a36fd7037a | |||
| 92e6441218 | |||
| a074a42d40 | |||
| dd8bf6005b | |||
| c3ceb74ce8 | |||
| fa0c16609d | |||
| 2c918565de | |||
| 94f29bf4f4 | |||
| 5e76ce9597 | |||
| 4262865520 | |||
| 8ad86d1c6e | |||
| 1ec67b4033 | |||
| 942cdad5b8 | |||
| cefbf21097 | |||
| d149580387 | |||
| 66c9790d2b | |||
| da6613ada3 | |||
| b99ac96ffa | |||
| e82151daa0 | |||
| 8d609db90e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Virtual environment
|
# Virtual environment
|
||||||
venv/
|
venv/
|
||||||
|
venv-wsl/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
110
CLAUDE.md
110
CLAUDE.md
@@ -2,25 +2,35 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- When updating this file, always update `README.md` as well. The README is the main user-facing documentation for the project.
|
||||||
|
- On every commit, bump the version number in `index.html` (the `VERSION` constant) and update `README.md` with any relevant changes.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
@@ -52,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 |
|
||||||
|----------|--------|-------------------|
|
|----------|--------|-------------------|
|
||||||
@@ -61,6 +73,7 @@ Edit `config.json` to configure detectors:
|
|||||||
| Memory | `detectors/memory.py` | — |
|
| Memory | `detectors/memory.py` | — |
|
||||||
| Service | `detectors/service.py` | `SERVICES` (comma-separated process names) |
|
| Service | `detectors/service.py` | `SERVICES` (comma-separated process names) |
|
||||||
| Network | `detectors/network.py` | `HOSTS` (comma-separated hostnames/IPs) |
|
| Network | `detectors/network.py` | `HOSTS` (comma-separated hostnames/IPs) |
|
||||||
|
| Docker | `detectors/docker.py` | `CONTAINERS` (optional, monitors all if empty) |
|
||||||
|
|
||||||
All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`, `THRESHOLD_CRITICAL`
|
All detectors support: `AGGREGATOR_URL`, `CHECK_INTERVAL`, `THRESHOLD_WARNING`, `THRESHOLD_CRITICAL`
|
||||||
|
|
||||||
@@ -70,10 +83,38 @@ 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"}` |
|
||||||
| `/sleep` | POST | Enter sleep mode (for Home Assistant) |
|
| `/clear-all` | POST | Clear all active events |
|
||||||
|
| `/notify` | POST | Queued notification — buffered if one is playing, auto-advances when it expires |
|
||||||
|
| `/sleep` | POST | Enter sleep mode |
|
||||||
| `/wake` | POST | Exit sleep mode |
|
| `/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
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Someone at the door",
|
||||||
|
"duration": 10,
|
||||||
|
"emote": "( °o°)",
|
||||||
|
"color": "#FF9900",
|
||||||
|
"animation": "popping",
|
||||||
|
"sound": "chime"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rapid calls are buffered — each notification plays for its full `duration` before the next is shown. `/clear-all` also drains the queue.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `message` | No | Text to display below emote |
|
||||||
|
| `duration` | No | Seconds before auto-expire (default: 5) |
|
||||||
|
| `emote` | No | Custom emote to display |
|
||||||
|
| `color` | No | Custom color (hex, e.g., `#FF9900`) |
|
||||||
|
| `animation` | No | One of: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying` |
|
||||||
|
| `sound` | No | One of: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon`, `none` |
|
||||||
|
|
||||||
## Priority System
|
## Priority System
|
||||||
|
|
||||||
@@ -88,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 | dreamy |
|
|
||||||
| `(◕‿◕)` | bouncing | cheerful |
|
| `(◕‿◕)` | bouncing | cheerful |
|
||||||
| `( ・ω・)` | swaying | curious |
|
| `( ・ω・)` | swaying | curious |
|
||||||
| `( ˘▽˘)` | breathing | cozy |
|
| `( ˘▽˘)` | breathing | cozy |
|
||||||
@@ -104,19 +144,61 @@ Additional states:
|
|||||||
- **Connection lost**: `( ?.?)` gray, searching animation
|
- **Connection lost**: `( ?.?)` gray, searching animation
|
||||||
- **Sleep mode**: `( -_-)zzZ` dim, very slow breathing
|
- **Sleep mode**: `( -_-)zzZ` dim, very slow breathing
|
||||||
|
|
||||||
|
## Home Assistant Integration
|
||||||
|
|
||||||
|
Add REST commands to `configuration.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rest_command:
|
||||||
|
kao_notify:
|
||||||
|
url: "http://kao-host:5100/notify"
|
||||||
|
method: POST
|
||||||
|
content_type: "application/json"
|
||||||
|
payload: '{"message": "{{ message }}", "duration": {{ duration | default(5) }}}'
|
||||||
|
|
||||||
|
kao_sleep:
|
||||||
|
url: "http://kao-host:5100/sleep"
|
||||||
|
method: POST
|
||||||
|
|
||||||
|
kao_wake:
|
||||||
|
url: "http://kao-host:5100/wake"
|
||||||
|
method: POST
|
||||||
|
```
|
||||||
|
|
||||||
|
Use in automations:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Doorbell notification
|
||||||
|
- service: rest_command.kao_notify
|
||||||
|
data:
|
||||||
|
message: "Someone at the door"
|
||||||
|
duration: 10
|
||||||
|
|
||||||
|
# Bedtime routine
|
||||||
|
- service: rest_command.kao_sleep
|
||||||
|
|
||||||
|
# Morning routine
|
||||||
|
- service: rest_command.kao_wake
|
||||||
|
```
|
||||||
|
|
||||||
## 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
|
||||||
│ ├── service.py
|
│ ├── service.py
|
||||||
│ └── network.py
|
│ ├── network.py
|
||||||
|
│ └── docker.py
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
|
├── TODO.md # Planned features and improvements
|
||||||
└── SPEC.md # Original project specification
|
└── SPEC.md # Original project specification
|
||||||
```
|
```
|
||||||
|
|||||||
160
README.md
160
README.md
@@ -8,18 +8,16 @@ A minimalist system status monitor that uses ASCII emotes to display server heal
|
|||||||
|
|
||||||
Turn an old phone (with its OLED screen) into a glanceable ambient display for your home server. Instead of graphs and numbers, see a happy face `( ^_^)` when things are good, and a worried face `( o_o)` when they're not.
|
Turn an old phone (with its OLED screen) into a glanceable ambient display for your home server. Instead of graphs and numbers, see a happy face `( ^_^)` when things are good, and a worried face `( o_o)` when they're not.
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- Figure out way to update installation in /opt/kao when git updates. Maybe run a git pull on service startup?
|
|
||||||
- Think about ways of implementing noises.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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
|
||||||
- **Home Assistant ready** — Webhook endpoints for automation
|
- **Sound effects** — Optional audio cues for state changes (tap to enable)
|
||||||
|
- **Notification queue** — Rapid `/notify` bursts are buffered and played sequentially, never clobbering
|
||||||
|
- **Home Assistant ready** — Webhook endpoints for notifications and automation
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -37,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 |
|
||||||
@@ -50,13 +65,14 @@ Open http://localhost:5100 on your phone (use Fully Kiosk Browser for best resul
|
|||||||
|
|
||||||
## Built-in Detectors
|
## Built-in Detectors
|
||||||
|
|
||||||
| Detector | Monitors |
|
| Detector | Monitors |
|
||||||
| -------------- | ----------------------------- |
|
| -------------- | ------------------------------------- |
|
||||||
| **disk_space** | Disk usage on all drives |
|
| **disk_space** | Disk usage on all drives |
|
||||||
| **cpu** | CPU utilization |
|
| **cpu** | CPU utilization |
|
||||||
| **memory** | RAM usage |
|
| **memory** | RAM usage |
|
||||||
| **service** | Whether processes are running |
|
| **service** | Whether processes are running |
|
||||||
| **network** | Host reachability (ping) |
|
| **network** | Host reachability (ping) |
|
||||||
|
| **docker** | Container health and restart loops |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -104,57 +120,137 @@ curl -X POST http://localhost:5100/clear \
|
|||||||
|
|
||||||
## Home Assistant Integration
|
## Home Assistant Integration
|
||||||
|
|
||||||
Add webhook commands to your Home Assistant config:
|
Add REST commands to your `configuration.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
rest_command:
|
rest_command:
|
||||||
sentry_sleep:
|
kao_notify:
|
||||||
|
url: "http://YOUR_SERVER:5100/notify"
|
||||||
|
method: POST
|
||||||
|
content_type: "application/json"
|
||||||
|
payload: >
|
||||||
|
{
|
||||||
|
"message": "{{ message | default('') }}",
|
||||||
|
"duration": {{ duration | default(5) }},
|
||||||
|
"emote": "{{ emote | default('') }}",
|
||||||
|
"color": "{{ color | default('') }}",
|
||||||
|
"animation": "{{ animation | default('') }}",
|
||||||
|
"sound": "{{ sound | default('') }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
kao_sleep:
|
||||||
url: "http://YOUR_SERVER:5100/sleep"
|
url: "http://YOUR_SERVER:5100/sleep"
|
||||||
method: POST
|
method: POST
|
||||||
sentry_wake:
|
|
||||||
|
kao_wake:
|
||||||
url: "http://YOUR_SERVER:5100/wake"
|
url: "http://YOUR_SERVER:5100/wake"
|
||||||
method: POST
|
method: POST
|
||||||
```
|
```
|
||||||
|
|
||||||
Trigger from automations:
|
Use in automations:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: "Sentry Sleep at Bedtime"
|
- alias: "Doorbell Notification"
|
||||||
|
trigger:
|
||||||
|
platform: state
|
||||||
|
entity_id: binary_sensor.doorbell
|
||||||
|
to: "on"
|
||||||
|
action:
|
||||||
|
service: rest_command.kao_notify
|
||||||
|
data:
|
||||||
|
message: "Someone at the door"
|
||||||
|
duration: 10
|
||||||
|
emote: "( °o°)"
|
||||||
|
color: "#FF9900"
|
||||||
|
sound: "chime"
|
||||||
|
|
||||||
|
- alias: "Timer Complete"
|
||||||
|
trigger:
|
||||||
|
platform: event
|
||||||
|
event_type: timer.finished
|
||||||
|
action:
|
||||||
|
service: rest_command.kao_notify
|
||||||
|
data:
|
||||||
|
message: "Timer done!"
|
||||||
|
emote: "\\(^o^)/"
|
||||||
|
animation: "celebrating"
|
||||||
|
sound: "success"
|
||||||
|
|
||||||
|
- alias: "Kao Sleep at Bedtime"
|
||||||
trigger:
|
trigger:
|
||||||
platform: time
|
platform: time
|
||||||
at: "23:00:00"
|
at: "23:00:00"
|
||||||
action:
|
action:
|
||||||
service: rest_command.sentry_sleep
|
service: rest_command.kao_sleep
|
||||||
|
|
||||||
- alias: "Sentry Wake in Morning"
|
- alias: "Kao Wake in Morning"
|
||||||
trigger:
|
trigger:
|
||||||
platform: time
|
platform: time
|
||||||
at: "07:00:00"
|
at: "07:00:00"
|
||||||
action:
|
action:
|
||||||
service: rest_command.sentry_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:**
|
||||||
|
- `emote`: Any ASCII emote (e.g., `( °o°)`, `\\(^o^)/`)
|
||||||
|
- `color`: Hex color (e.g., `#FF9900`)
|
||||||
|
- `animation`: `breathing`, `shaking`, `popping`, `celebrating`, `floating`, `bouncing`, `swaying`
|
||||||
|
- `sound`: `chime`, `alert`, `warning`, `critical`, `success`, `notify`, `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon`, `none`
|
||||||
|
|
||||||
|
## Developer TUI
|
||||||
|
|
||||||
|
`kao_tui.py` is a terminal UI for firing test events, faces, and sounds at a running Kao instance — no `curl` needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python kao_tui.py # connect to http://localhost:5100
|
||||||
|
python kao_tui.py http://192.168.1.x:5100 # custom URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Four tabs:
|
||||||
|
- **Sounds** — fire any of the 18 sounds via `/notify`
|
||||||
|
- **Faces** — send any preset emote/animation combo via `/notify`
|
||||||
|
- **Events** — post Critical/Warning/Notify events (10s TTL) or clear all
|
||||||
|
- **Controls** — Sleep, Wake, Clear all
|
||||||
|
|
||||||
|
Navigate with `↑↓` or `Tab`, press `Enter` to fire, `Q` to quit. A toast confirms each action.
|
||||||
|
|
||||||
## API Reference
|
## 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 |
|
||||||
| `/sleep` | POST | Enter sleep mode |
|
| `/clear` | POST | Clear an event by ID |
|
||||||
| `/wake` | POST | Exit sleep mode |
|
| `/clear-all` | POST | Clear all active events |
|
||||||
|
| `/notify` | POST | Queued notification `{"message": "", "duration": 5}` — buffered if one is playing |
|
||||||
|
| `/sleep` | POST | Enter sleep mode |
|
||||||
|
| `/wake` | POST | Exit sleep mode |
|
||||||
|
| `/docs` | GET | Interactive API documentation (Swagger UI) |
|
||||||
|
|
||||||
|
Full API documentation available at [/docs](http://localhost:5100/docs) or in [openapi.yaml](openapi.yaml).
|
||||||
|
|
||||||
## Personality
|
## 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 `( ᵕ.ᵕ)`
|
- 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°)`, shows version info, and dismisses active alerts
|
||||||
|
|
||||||
|
**Sound effects** (tap screen to enable, or use `?sound=on`):
|
||||||
|
- Warning: soft double-beep
|
||||||
|
- Critical: urgent descending tone
|
||||||
|
- Notify: gentle ping
|
||||||
|
- Recovery: happy ascending chirp
|
||||||
|
- 11 additional synthesized sounds for `/notify`: `doorbell`, `knock`, `ding`, `blip`, `siren`, `tada`, `ping`, `bubble`, `fanfare`, `alarm`, `klaxon` (alarm and klaxon loop until tapped)
|
||||||
|
|
||||||
## License
|
## 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
|
||||||
312
aggregator.py
312
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"), # dreamy
|
|
||||||
("(◕‿◕)", "bouncing"), # cheerful
|
("(◕‿◕)", "bouncing"), # cheerful
|
||||||
("( ・ω・)", "swaying"), # curious
|
("( ・ω・)", "swaying"), # curious
|
||||||
("( ˘▽˘)", "breathing"), # cozy
|
("( ˘▽˘)", "breathing"), # cozy
|
||||||
@@ -50,10 +54,35 @@ active_events = {} # id -> {priority, message, timestamp, ttl}
|
|||||||
# State tracking for personality
|
# State tracking for personality
|
||||||
previous_priority = 4
|
previous_priority = 4
|
||||||
celebrating_until = 0
|
celebrating_until = 0
|
||||||
last_emote_change = 0
|
blinking_until = 0
|
||||||
|
blink_emote = None
|
||||||
|
blink_animation = None
|
||||||
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"
|
||||||
@@ -63,7 +92,8 @@ 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, last_emote_change, current_optimal_emote, current_optimal_animation
|
global previous_priority, celebrating_until, blinking_until, blink_emote, blink_animation
|
||||||
|
global current_optimal_emote, current_optimal_animation
|
||||||
|
|
||||||
# Sleep mode overrides everything
|
# Sleep mode overrides everything
|
||||||
if is_sleeping:
|
if is_sleeping:
|
||||||
@@ -79,13 +109,18 @@ def get_current_state():
|
|||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
|
top_event = None
|
||||||
with events_lock:
|
with events_lock:
|
||||||
if not active_events:
|
if not active_events:
|
||||||
priority = 4
|
priority = 4
|
||||||
events_list = []
|
events_list = []
|
||||||
else:
|
else:
|
||||||
# Find highest priority (lowest number)
|
# Find highest priority (lowest number) and its event
|
||||||
priority = min(e["priority"] for e in active_events.values())
|
priority = min(e["priority"] for e in active_events.values())
|
||||||
|
for eid, e in active_events.items():
|
||||||
|
if e["priority"] == priority:
|
||||||
|
top_event = e
|
||||||
|
break
|
||||||
events_list = [
|
events_list = [
|
||||||
{"id": eid, "priority": e["priority"], "message": e.get("message", "")}
|
{"id": eid, "priority": e["priority"], "message": e.get("message", "")}
|
||||||
for eid, e in active_events.items()
|
for eid, e in active_events.items()
|
||||||
@@ -95,66 +130,107 @@ def get_current_state():
|
|||||||
emote = config["emote"]
|
emote = config["emote"]
|
||||||
animation = config["animation"]
|
animation = config["animation"]
|
||||||
color = config["color"]
|
color = config["color"]
|
||||||
|
sound = None
|
||||||
|
|
||||||
|
# Check for custom display properties from top event
|
||||||
|
if top_event:
|
||||||
|
if "emote" in top_event:
|
||||||
|
emote = top_event["emote"]
|
||||||
|
if "color" in top_event:
|
||||||
|
color = top_event["color"]
|
||||||
|
if "animation" in top_event:
|
||||||
|
animation = top_event["animation"]
|
||||||
|
if "sound" in top_event:
|
||||||
|
sound = top_event["sound"]
|
||||||
|
|
||||||
# Check for recovery (was bad, now optimal)
|
# Check for recovery (was bad, now optimal)
|
||||||
if priority == 4 and previous_priority < 4:
|
if priority == 4 and previous_priority < 3:
|
||||||
celebrating_until = now + CELEBRATION_DURATION
|
celebrating_until = now + CELEBRATION_DURATION
|
||||||
|
|
||||||
previous_priority = priority
|
previous_priority = priority
|
||||||
|
|
||||||
# Handle optimal state personality
|
# Handle optimal state personality (only if no custom overrides)
|
||||||
if priority == 4:
|
if priority == 4 and not top_event:
|
||||||
if now < celebrating_until:
|
if now < celebrating_until:
|
||||||
# Celebration mode
|
# Celebration mode
|
||||||
emote, animation = CELEBRATION_EMOTE
|
emote, animation = CELEBRATION_EMOTE
|
||||||
|
elif now < blinking_until:
|
||||||
|
# Brief blink/wink (1-2 seconds)
|
||||||
|
emote = blink_emote
|
||||||
|
animation = blink_animation
|
||||||
else:
|
else:
|
||||||
# Rotate optimal emotes every 5 minutes, occasional idle expression
|
|
||||||
if now - last_emote_change > 300:
|
|
||||||
last_emote_change = now
|
|
||||||
# 15% chance of an idle expression (wink/blink)
|
|
||||||
if random.random() < 0.15:
|
|
||||||
current_optimal_emote, current_optimal_animation = random.choice(IDLE_EMOTES)
|
|
||||||
else:
|
|
||||||
current_optimal_emote, current_optimal_animation = random.choice(OPTIMAL_EMOTES)
|
|
||||||
emote = current_optimal_emote
|
emote = current_optimal_emote
|
||||||
animation = current_optimal_animation
|
animation = current_optimal_animation
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"current_state": config["name"].lower(),
|
"current_state": config["name"].lower(),
|
||||||
"active_emote": emote,
|
"active_emote": emote,
|
||||||
"color": color,
|
"color": color,
|
||||||
"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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sound:
|
||||||
|
result["sound"] = sound
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def write_status():
|
def write_status():
|
||||||
"""Write current state to status.json."""
|
"""Write current state to status.json 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"])
|
||||||
@@ -180,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
|
||||||
@@ -193,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():
|
||||||
"""
|
"""
|
||||||
@@ -206,13 +296,91 @@ 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"])
|
||||||
|
def notify():
|
||||||
|
"""
|
||||||
|
Notification endpoint for Home Assistant.
|
||||||
|
JSON: {
|
||||||
|
"message": "text",
|
||||||
|
"duration": 5,
|
||||||
|
"emote": "( °o°)", # optional custom emote
|
||||||
|
"color": "#FF9900", # optional custom color
|
||||||
|
"animation": "popping", # optional: breathing, shaking, popping, celebrating, floating, bouncing, swaying
|
||||||
|
"sound": "chime" # optional: chime, alert, warning, critical, success, none
|
||||||
|
}
|
||||||
|
Rapid calls are buffered and played sequentially.
|
||||||
|
"""
|
||||||
|
global _notify_counter
|
||||||
|
data = request.get_json(force=True) if request.data else {}
|
||||||
|
message = data.get("message", "")
|
||||||
|
duration = int(data.get("duration", DEFAULT_NOTIFY_DURATION))
|
||||||
|
|
||||||
|
# Generate unique ID to avoid conflicts
|
||||||
|
_notify_counter += 1
|
||||||
|
event_id = f"notify_{int(time.time())}_{_notify_counter}"
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"priority": 3,
|
||||||
|
"message": message,
|
||||||
|
"from_queue": True, # Tag so cleanup knows to auto-advance
|
||||||
|
"duration": duration, # Preserved for when promoted from queue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional custom display properties
|
||||||
|
for key in ("emote", "color", "animation", "sound"):
|
||||||
|
if key in data:
|
||||||
|
event[key] = data[key]
|
||||||
|
|
||||||
|
play_now = False
|
||||||
|
with events_lock:
|
||||||
|
# Play immediately only if no queue notification is currently active or pending
|
||||||
|
queue_busy = any(e.get("from_queue") for e in active_events.values()) or len(notify_queue) > 0
|
||||||
|
if not queue_busy:
|
||||||
|
now = time.time()
|
||||||
|
active_events[event_id] = {**event, "timestamp": now, "ttl": now + duration}
|
||||||
|
play_now = True
|
||||||
|
else:
|
||||||
|
notify_queue.append((event_id, event))
|
||||||
|
print(f"[QUEUE] Buffered notification: {event_id} ({len(notify_queue)} in queue)")
|
||||||
|
|
||||||
|
if play_now:
|
||||||
|
state = write_status()
|
||||||
|
else:
|
||||||
|
state = get_current_state()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "ok",
|
||||||
|
"id": event_id,
|
||||||
|
"queued": not play_now,
|
||||||
|
"notify_queue_size": len(notify_queue),
|
||||||
|
"current_state": state,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sleep", methods=["POST"])
|
@app.route("/sleep", methods=["POST"])
|
||||||
@@ -227,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
|
||||||
|
|
||||||
@@ -239,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."""
|
||||||
@@ -252,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))
|
||||||
|
|
||||||
|
|||||||
14
config.json
14
config.json
@@ -45,13 +45,23 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "network",
|
"name": "network",
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"script": "detectors/network.py",
|
"script": "detectors/network.py",
|
||||||
"env": {
|
"env": {
|
||||||
"CHECK_INTERVAL": "60",
|
"CHECK_INTERVAL": "60",
|
||||||
"HOSTS": "8.8.8.8,google.com",
|
"HOSTS": "8.8.8.8,google.com",
|
||||||
"TIMEOUT": "5"
|
"TIMEOUT": "5"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "docker",
|
||||||
|
"enabled": true,
|
||||||
|
"script": "detectors/docker.py",
|
||||||
|
"env": {
|
||||||
|
"CHECK_INTERVAL": "60",
|
||||||
|
"RESTART_THRESHOLD": "3",
|
||||||
|
"CONTAINERS": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
160
detectors/docker.py
Normal file
160
detectors/docker.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
Docker Container Health Detector
|
||||||
|
Monitors for containers stuck in restart loops or unhealthy states.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
AGGREGATOR_URL - URL of the aggregator (default: http://localhost:5100)
|
||||||
|
CHECK_INTERVAL - Seconds between checks (default: 60)
|
||||||
|
RESTART_THRESHOLD - Number of restarts to consider a loop (default: 3)
|
||||||
|
CONTAINERS - Comma-separated container names to monitor (optional, monitors all if empty)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from detectors.base import DEFAULT_AGGREGATOR_URL, send_event, clear_event
|
||||||
|
|
||||||
|
# Configuration from environment
|
||||||
|
AGGREGATOR_URL = os.environ.get("AGGREGATOR_URL", DEFAULT_AGGREGATOR_URL)
|
||||||
|
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", 60))
|
||||||
|
RESTART_THRESHOLD = int(os.environ.get("RESTART_THRESHOLD", 3))
|
||||||
|
CONTAINERS = os.environ.get("CONTAINERS", "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_status():
|
||||||
|
"""Get status of all containers using docker CLI."""
|
||||||
|
try:
|
||||||
|
# Get container info as JSON
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "--format", "{{json .}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[ERROR] Docker command failed: {result.stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
containers = []
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
if line:
|
||||||
|
containers.append(json.loads(line))
|
||||||
|
|
||||||
|
return containers
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("[ERROR] Docker CLI not found")
|
||||||
|
return None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("[ERROR] Docker command timed out")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to get container status: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_restart_count(container_name):
|
||||||
|
"""Get restart count for a specific container."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "--format", "{{.RestartCount}}", container_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return int(result.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Parse container filter
|
||||||
|
filter_containers = None
|
||||||
|
if CONTAINERS:
|
||||||
|
filter_containers = set(s.strip().lower() for s in CONTAINERS.split(",") if s.strip())
|
||||||
|
|
||||||
|
print(f"Docker Container Detector started")
|
||||||
|
print(f" Aggregator: {AGGREGATOR_URL}")
|
||||||
|
print(f" Interval: {CHECK_INTERVAL}s")
|
||||||
|
print(f" Restart threshold: {RESTART_THRESHOLD}")
|
||||||
|
if filter_containers:
|
||||||
|
print(f" Monitoring: {', '.join(filter_containers)}")
|
||||||
|
else:
|
||||||
|
print(f" Monitoring: all containers")
|
||||||
|
print()
|
||||||
|
|
||||||
|
active_alerts = set()
|
||||||
|
last_restart_counts = {}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
containers = get_container_status()
|
||||||
|
|
||||||
|
if containers is None:
|
||||||
|
print("[WARN] Could not fetch container status, skipping check")
|
||||||
|
time.sleep(CHECK_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_alerts = set()
|
||||||
|
|
||||||
|
for container in containers:
|
||||||
|
name = container.get("Names", "unknown")
|
||||||
|
state = container.get("State", "").lower()
|
||||||
|
status = container.get("Status", "")
|
||||||
|
|
||||||
|
# Apply filter if specified
|
||||||
|
if filter_containers and name.lower() not in filter_containers:
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_id = f"docker_{name.replace('/', '_')}"
|
||||||
|
|
||||||
|
# Check restart count for running/restarting containers
|
||||||
|
# The "restarting" state is too transient to catch reliably,
|
||||||
|
# so we track count increases between checks instead
|
||||||
|
if state in ("running", "restarting"):
|
||||||
|
restart_count = get_restart_count(name)
|
||||||
|
prev_count = last_restart_counts.get(name, restart_count)
|
||||||
|
new_restarts = restart_count - prev_count
|
||||||
|
last_restart_counts[name] = restart_count
|
||||||
|
|
||||||
|
if state == "restarting" or new_restarts >= RESTART_THRESHOLD:
|
||||||
|
send_event(AGGREGATOR_URL, event_id, 1, f"Container '{name}' restart loop ({restart_count}x)", CHECK_INTERVAL)
|
||||||
|
current_alerts.add(event_id)
|
||||||
|
elif new_restarts > 0:
|
||||||
|
send_event(AGGREGATOR_URL, event_id, 2, f"Container '{name}' restarting ({restart_count}x)", CHECK_INTERVAL)
|
||||||
|
current_alerts.add(event_id)
|
||||||
|
else:
|
||||||
|
print(f"[OK] Container '{name}' is {state}")
|
||||||
|
|
||||||
|
# Check for exited/dead containers (warning)
|
||||||
|
elif state in ("exited", "dead"):
|
||||||
|
# Only alert if it exited abnormally (non-zero exit code in status)
|
||||||
|
if "Exited (0)" not in status:
|
||||||
|
send_event(AGGREGATOR_URL, event_id, 2, f"Container '{name}' {state}", CHECK_INTERVAL)
|
||||||
|
current_alerts.add(event_id)
|
||||||
|
else:
|
||||||
|
print(f"[OK] Container '{name}' exited cleanly")
|
||||||
|
|
||||||
|
# Check for unhealthy containers
|
||||||
|
elif "unhealthy" in status.lower():
|
||||||
|
send_event(AGGREGATOR_URL, event_id, 2, f"Container '{name}' unhealthy", CHECK_INTERVAL)
|
||||||
|
current_alerts.add(event_id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"[OK] Container '{name}' is {state}")
|
||||||
|
|
||||||
|
# Clear alerts for containers that are now healthy
|
||||||
|
for event_id in active_alerts - current_alerts:
|
||||||
|
clear_event(AGGREGATOR_URL, event_id)
|
||||||
|
|
||||||
|
active_alerts = current_alerts
|
||||||
|
|
||||||
|
print(f"[SLEEP] Next check in {CHECK_INTERVAL}s\n")
|
||||||
|
time.sleep(CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
723
index.html
723
index.html
@@ -1,239 +1,546 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
<meta
|
||||||
<title>Sentry-Emote</title>
|
name="viewport"
|
||||||
<style>
|
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
||||||
* {
|
/>
|
||||||
margin: 0;
|
<title>Kao</title>
|
||||||
padding: 0;
|
<style>
|
||||||
box-sizing: border-box;
|
* {
|
||||||
}
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #000000;
|
background: #000000;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#emote {
|
#emote {
|
||||||
font-size: 18vw;
|
font-size: 18vw;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: color 0.3s ease;
|
transition: color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#message {
|
#message {
|
||||||
font-size: 4vw;
|
font-size: 4vw;
|
||||||
margin-top: 2vh;
|
margin-top: 2vh;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Breathing animation - slow pulse */
|
/* Breathing animation - slow pulse */
|
||||||
.breathing {
|
.breathing {
|
||||||
animation: breathe 3s ease-in-out infinite;
|
animation: breathe 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes breathe {
|
@keyframes breathe {
|
||||||
0%, 100% {
|
0%,
|
||||||
opacity: 1;
|
100% {
|
||||||
transform: scale(1);
|
opacity: 1;
|
||||||
}
|
transform: scale(1);
|
||||||
50% {
|
}
|
||||||
opacity: 0.7;
|
50% {
|
||||||
transform: scale(0.98);
|
opacity: 0.7;
|
||||||
}
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Shaking animation - rapid jitter for Critical */
|
/* Shaking animation - rapid jitter for Critical */
|
||||||
.shaking {
|
.shaking {
|
||||||
animation: shake 0.15s linear infinite;
|
animation: shake 0.15s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0%, 100% { transform: translateX(0); }
|
0%,
|
||||||
25% { transform: translateX(-5px); }
|
100% {
|
||||||
75% { transform: translateX(5px); }
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Popping animation - scale up for Notifications */
|
/* Popping animation - scale up for Notifications */
|
||||||
.popping {
|
.popping {
|
||||||
animation: pop 1s ease-in-out infinite;
|
animation: pop 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pop {
|
@keyframes pop {
|
||||||
0%, 100% {
|
0%,
|
||||||
transform: scale(1);
|
100% {
|
||||||
}
|
transform: scale(1);
|
||||||
50% {
|
}
|
||||||
transform: scale(1.08);
|
50% {
|
||||||
}
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Celebrating animation - bounce and wiggle */
|
/* Celebrating animation - bounce and wiggle */
|
||||||
.celebrating {
|
.celebrating {
|
||||||
animation: celebrate 0.5s ease-in-out infinite;
|
animation: celebrate 0.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes celebrate {
|
@keyframes celebrate {
|
||||||
0%, 100% {
|
0%,
|
||||||
transform: translateY(0) rotate(0deg);
|
100% {
|
||||||
}
|
transform: translateY(0) rotate(0deg);
|
||||||
25% {
|
}
|
||||||
transform: translateY(-10px) rotate(-5deg);
|
25% {
|
||||||
}
|
transform: translateY(-10px) rotate(-5deg);
|
||||||
75% {
|
}
|
||||||
transform: translateY(-10px) rotate(5deg);
|
75% {
|
||||||
}
|
transform: translateY(-10px) rotate(5deg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Floating animation - gentle drift */
|
/* Floating animation - gentle drift */
|
||||||
.floating {
|
.floating {
|
||||||
animation: float 4s ease-in-out infinite;
|
animation: float 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% {
|
0%,
|
||||||
transform: translateY(0);
|
100% {
|
||||||
}
|
transform: translateY(0);
|
||||||
50% {
|
}
|
||||||
transform: translateY(-8px);
|
50% {
|
||||||
}
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Bouncing animation - playful hop */
|
/* Bouncing animation - playful hop */
|
||||||
.bouncing {
|
.bouncing {
|
||||||
animation: bounce 1s ease-in-out infinite;
|
animation: bounce 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bounce {
|
@keyframes bounce {
|
||||||
0%, 100% {
|
0%,
|
||||||
transform: translateY(0);
|
100% {
|
||||||
}
|
transform: translateY(0);
|
||||||
50% {
|
}
|
||||||
transform: translateY(-12px);
|
50% {
|
||||||
}
|
transform: translateY(-12px);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Swaying animation - curious side-to-side */
|
/* Swaying animation - curious side-to-side */
|
||||||
.swaying {
|
.swaying {
|
||||||
animation: sway 3s ease-in-out infinite;
|
animation: sway 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sway {
|
@keyframes sway {
|
||||||
0%, 100% {
|
0%,
|
||||||
transform: rotate(0deg);
|
100% {
|
||||||
}
|
transform: rotate(0deg);
|
||||||
25% {
|
}
|
||||||
transform: rotate(-3deg);
|
25% {
|
||||||
}
|
transform: rotate(-3deg);
|
||||||
75% {
|
}
|
||||||
transform: rotate(3deg);
|
75% {
|
||||||
}
|
transform: rotate(3deg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Blink animation - quick fade for winks */
|
/* Blink animation - quick fade for winks */
|
||||||
.blink {
|
.blink {
|
||||||
animation: blink 0.3s ease-in-out 1;
|
animation: blink 0.3s ease-in-out 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 100% {
|
0%,
|
||||||
opacity: 1;
|
100% {
|
||||||
}
|
opacity: 1;
|
||||||
50% {
|
}
|
||||||
opacity: 0.3;
|
50% {
|
||||||
}
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Searching animation - looking around for connection */
|
/* Searching animation - looking around for connection */
|
||||||
.searching {
|
.searching {
|
||||||
animation: search 2s ease-in-out infinite;
|
animation: search 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes search {
|
@keyframes search {
|
||||||
0%, 100% {
|
0%,
|
||||||
transform: translateX(0);
|
100% {
|
||||||
opacity: 0.6;
|
transform: translateX(0);
|
||||||
}
|
opacity: 0.6;
|
||||||
25% {
|
}
|
||||||
transform: translateX(-10px);
|
25% {
|
||||||
opacity: 0.8;
|
transform: translateX(-10px);
|
||||||
}
|
opacity: 0.8;
|
||||||
75% {
|
}
|
||||||
transform: translateX(10px);
|
75% {
|
||||||
opacity: 0.8;
|
transform: translateX(10px);
|
||||||
}
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Sleeping animation - very slow, subtle breathing */
|
/* Sleeping animation - very slow, subtle breathing */
|
||||||
.sleeping {
|
.sleeping {
|
||||||
animation: sleep 6s ease-in-out infinite;
|
animation: sleep 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sleep {
|
@keyframes sleep {
|
||||||
0%, 100% {
|
0%,
|
||||||
opacity: 0.4;
|
100% {
|
||||||
transform: scale(1);
|
opacity: 0.4;
|
||||||
}
|
transform: scale(1);
|
||||||
50% {
|
}
|
||||||
opacity: 0.2;
|
50% {
|
||||||
transform: scale(0.98);
|
opacity: 0.2;
|
||||||
}
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
</style>
|
}
|
||||||
</head>
|
</style>
|
||||||
<body>
|
</head>
|
||||||
<div id="emote" class="breathing">( ^_^)</div>
|
<body>
|
||||||
<div id="message">Loading...</div>
|
<div id="emote" class="breathing">( ^_^)</div>
|
||||||
|
<div id="message">Loading...</div>
|
||||||
|
|
||||||
<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";
|
||||||
|
|
||||||
async function fetchStatus() {
|
// Sound system
|
||||||
try {
|
let audioCtx = null;
|
||||||
const response = await fetch('/status');
|
let soundEnabled =
|
||||||
if (!response.ok) throw new Error('Failed to fetch');
|
new URLSearchParams(window.location.search).get("sound") === "on";
|
||||||
const data = await response.json();
|
let lastState = null;
|
||||||
updateDisplay(data);
|
let lastData = null;
|
||||||
} catch (err) {
|
let isReacting = false;
|
||||||
// Connection lost state
|
|
||||||
emoteEl.textContent = '( ?.?)';
|
|
||||||
emoteEl.style.color = '#888888';
|
|
||||||
emoteEl.className = 'searching';
|
|
||||||
messageEl.style.color = '#888888';
|
|
||||||
messageEl.textContent = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDisplay(data) {
|
function initAudio() {
|
||||||
emoteEl.textContent = data.active_emote;
|
if (!audioCtx) {
|
||||||
emoteEl.style.color = data.color;
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
messageEl.style.color = data.color;
|
}
|
||||||
|
if (audioCtx.state === "suspended") {
|
||||||
|
audioCtx.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only show message when there's something to report
|
function playTone(frequency, duration, type = "sine", volume = 0.1) {
|
||||||
const topEvent = data.active_events && data.active_events[0];
|
if (!soundEnabled || !audioCtx) return;
|
||||||
messageEl.textContent = (topEvent && topEvent.message) || '';
|
const osc = audioCtx.createOscillator();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.value = frequency;
|
||||||
|
gain.gain.value = volume;
|
||||||
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
0.001,
|
||||||
|
audioCtx.currentTime + duration,
|
||||||
|
);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(audioCtx.destination);
|
||||||
|
osc.start();
|
||||||
|
osc.stop(audioCtx.currentTime + duration);
|
||||||
|
}
|
||||||
|
|
||||||
// Update animation class
|
function playWarningSound() {
|
||||||
emoteEl.className = '';
|
// Two-tone warning beep - differentiated pitches
|
||||||
if (data.animation) {
|
playTone(440, 0.2, "sine", 0.18);
|
||||||
emoteEl.classList.add(data.animation);
|
setTimeout(() => playTone(550, 0.2, "sine", 0.18), 220);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Initial fetch and start polling
|
function playCriticalSound() {
|
||||||
fetchStatus();
|
// Urgent descending tone
|
||||||
setInterval(fetchStatus, POLL_INTERVAL);
|
playTone(600, 0.2);
|
||||||
</script>
|
setTimeout(() => playTone(400, 0.3), 220);
|
||||||
</body>
|
}
|
||||||
|
|
||||||
|
function playNotifySound() {
|
||||||
|
// Gentle ping
|
||||||
|
playTone(880, 0.1, "sine", 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playRecoverySound() {
|
||||||
|
// Happy ascending chirp
|
||||||
|
playTone(523, 0.1);
|
||||||
|
setTimeout(() => playTone(659, 0.1), 100);
|
||||||
|
setTimeout(() => playTone(784, 0.15), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playReactSound() {
|
||||||
|
// Cute surprised chirp
|
||||||
|
playTone(600, 0.08, "sine", 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playChimeSound() {
|
||||||
|
// Pleasant doorbell-like chime
|
||||||
|
playTone(659, 0.15);
|
||||||
|
setTimeout(() => playTone(784, 0.15), 150);
|
||||||
|
setTimeout(() => playTone(988, 0.2), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAlertSound() {
|
||||||
|
// Attention-getting alert
|
||||||
|
playTone(880, 0.1);
|
||||||
|
setTimeout(() => playTone(880, 0.1), 150);
|
||||||
|
setTimeout(() => playTone(880, 0.15), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSuccessSound() {
|
||||||
|
// Triumphant success fanfare
|
||||||
|
playTone(523, 0.1);
|
||||||
|
setTimeout(() => playTone(659, 0.1), 100);
|
||||||
|
setTimeout(() => playTone(784, 0.1), 200);
|
||||||
|
setTimeout(() => playTone(1047, 0.2), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playDoorbellSound() {
|
||||||
|
playTone(880, 0.3, "sine", 0.12);
|
||||||
|
setTimeout(() => playTone(659, 0.4, "sine", 0.12), 350);
|
||||||
|
}
|
||||||
|
function playKnockSound() {
|
||||||
|
playTone(200, 0.08, "square", 0.15);
|
||||||
|
setTimeout(() => playTone(200, 0.08, "square", 0.15), 150);
|
||||||
|
setTimeout(() => playTone(200, 0.08, "square", 0.15), 300);
|
||||||
|
}
|
||||||
|
function playDingSound() {
|
||||||
|
playTone(1046, 0.4, "sine", 0.1);
|
||||||
|
}
|
||||||
|
function playBlipSound() {
|
||||||
|
playTone(1200, 0.05, "sine", 0.07);
|
||||||
|
}
|
||||||
|
function playSirenSound() {
|
||||||
|
playTone(880, 0.15, "sawtooth", 0.12);
|
||||||
|
setTimeout(() => playTone(660, 0.15, "sawtooth", 0.12), 180);
|
||||||
|
setTimeout(() => playTone(880, 0.15, "sawtooth", 0.12), 360);
|
||||||
|
setTimeout(() => playTone(660, 0.15, "sawtooth", 0.12), 540);
|
||||||
|
}
|
||||||
|
function playTadaSound() {
|
||||||
|
playTone(392, 0.08, "sine", 0.1);
|
||||||
|
setTimeout(() => playTone(392, 0.08, "sine", 0.1), 100);
|
||||||
|
setTimeout(() => playTone(784, 0.4, "sine", 0.13), 220);
|
||||||
|
}
|
||||||
|
function playPingSound() {
|
||||||
|
playTone(1047, 0.15, "sine", 0.1);
|
||||||
|
}
|
||||||
|
function playBubbleSound() {
|
||||||
|
playTone(400, 0.15, "sine", 0.10);
|
||||||
|
setTimeout(() => playTone(650, 0.12, "sine", 0.08), 100);
|
||||||
|
}
|
||||||
|
function playFanfareSound() {
|
||||||
|
playTone(523, 0.1, "sine", 0.1);
|
||||||
|
setTimeout(() => playTone(659, 0.1, "sine", 0.1), 120);
|
||||||
|
setTimeout(() => playTone(784, 0.1, "sine", 0.1), 240);
|
||||||
|
setTimeout(() => playTone(1047, 0.15, "sine", 0.12), 360);
|
||||||
|
setTimeout(() => playTone(784, 0.08, "sine", 0.1), 520);
|
||||||
|
setTimeout(() => playTone(1047, 0.3, "sine", 0.15), 620);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alarm — loops until manually stopped
|
||||||
|
let alarmInterval = null;
|
||||||
|
function playAlarmTick() {
|
||||||
|
playTone(880, 0.15, "square", 0.2);
|
||||||
|
setTimeout(() => playTone(660, 0.15, "square", 0.2), 200);
|
||||||
|
}
|
||||||
|
function startAlarm() {
|
||||||
|
if (alarmInterval) return;
|
||||||
|
playAlarmTick();
|
||||||
|
alarmInterval = setInterval(playAlarmTick, 500);
|
||||||
|
}
|
||||||
|
function stopAlarm() {
|
||||||
|
if (alarmInterval) {
|
||||||
|
clearInterval(alarmInterval);
|
||||||
|
alarmInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klaxon — looping wah-wah horn, loops until manually stopped
|
||||||
|
let klaxonInterval = null;
|
||||||
|
function playKlaxonTick() {
|
||||||
|
playTone(500, 0.22, "sawtooth", 0.22);
|
||||||
|
setTimeout(() => playTone(380, 0.22, "sawtooth", 0.22), 230);
|
||||||
|
}
|
||||||
|
function startKlaxon() {
|
||||||
|
if (klaxonInterval) return;
|
||||||
|
playKlaxonTick();
|
||||||
|
klaxonInterval = setInterval(playKlaxonTick, 470);
|
||||||
|
}
|
||||||
|
function stopKlaxon() {
|
||||||
|
if (klaxonInterval) {
|
||||||
|
clearInterval(klaxonInterval);
|
||||||
|
klaxonInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play sound by name
|
||||||
|
function playSoundByName(name) {
|
||||||
|
if (name === "alarm") { startAlarm(); return; }
|
||||||
|
if (name === "klaxon") { startKlaxon(); return; }
|
||||||
|
const sounds = {
|
||||||
|
chime: playChimeSound,
|
||||||
|
alert: playAlertSound,
|
||||||
|
warning: playWarningSound,
|
||||||
|
critical: playCriticalSound,
|
||||||
|
success: playSuccessSound,
|
||||||
|
notify: playNotifySound,
|
||||||
|
recovery: playRecoverySound,
|
||||||
|
doorbell: playDoorbellSound,
|
||||||
|
knock: playKnockSound,
|
||||||
|
ding: playDingSound,
|
||||||
|
blip: playBlipSound,
|
||||||
|
siren: playSirenSound,
|
||||||
|
tada: playTadaSound,
|
||||||
|
ping: playPingSound,
|
||||||
|
bubble: playBubbleSound,
|
||||||
|
fanfare: playFanfareSound,
|
||||||
|
};
|
||||||
|
if (sounds[name]) {
|
||||||
|
sounds[name]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which custom sounds we've played to avoid repeats
|
||||||
|
let lastCustomSound = null;
|
||||||
|
|
||||||
|
function handleStateChange(newState, newEmote, customSound) {
|
||||||
|
// Handle custom sound from notification
|
||||||
|
if (
|
||||||
|
customSound &&
|
||||||
|
customSound !== "none" &&
|
||||||
|
customSound !== lastCustomSound
|
||||||
|
) {
|
||||||
|
playSoundByName(customSound);
|
||||||
|
lastCustomSound = customSound;
|
||||||
|
} else if (!customSound) {
|
||||||
|
stopAlarm(); // stop any looping sounds
|
||||||
|
stopKlaxon();
|
||||||
|
lastCustomSound = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastState) {
|
||||||
|
lastState = newState;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State transitions that trigger sounds (only if no custom sound)
|
||||||
|
if (newState !== lastState && !customSound) {
|
||||||
|
if (newState === "critical") {
|
||||||
|
playCriticalSound();
|
||||||
|
} else if (newState === "warning") {
|
||||||
|
playWarningSound();
|
||||||
|
} else if (newState === "notify") {
|
||||||
|
playNotifySound();
|
||||||
|
} else if (
|
||||||
|
newState === "optimal" &&
|
||||||
|
lastState !== "optimal" &&
|
||||||
|
lastState !== "sleeping"
|
||||||
|
) {
|
||||||
|
// Recovery - also check for celebration emote
|
||||||
|
playRecoverySound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastState = newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tap - enable sound and show reaction
|
||||||
|
document.body.addEventListener("click", () => {
|
||||||
|
stopAlarm(); // stop any looping sounds
|
||||||
|
stopKlaxon();
|
||||||
|
// Enable sound on first tap (browser autoplay policy)
|
||||||
|
if (!soundEnabled) {
|
||||||
|
soundEnabled = true;
|
||||||
|
initAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show surprised reaction and version
|
||||||
|
if (!isReacting) {
|
||||||
|
isReacting = true;
|
||||||
|
const prevEmote = emoteEl.textContent;
|
||||||
|
const prevColor = emoteEl.style.color;
|
||||||
|
const prevClass = emoteEl.className;
|
||||||
|
const prevMsg = messageEl.textContent;
|
||||||
|
|
||||||
|
// Clear active warning/critical events
|
||||||
|
if (lastData && lastData.active_events && lastData.active_events.length > 0) {
|
||||||
|
fetch("/clear-all", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surprised face!
|
||||||
|
emoteEl.textContent = "( °o°)";
|
||||||
|
emoteEl.className = "popping";
|
||||||
|
messageEl.textContent = `Kao ${VERSION}`;
|
||||||
|
playReactSound();
|
||||||
|
|
||||||
|
// Return to normal after 1.5s - fetch fresh state
|
||||||
|
setTimeout(async () => {
|
||||||
|
isReacting = false;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/status");
|
||||||
|
if (resp.ok) {
|
||||||
|
const freshData = await resp.json();
|
||||||
|
updateDisplay(freshData);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also init if ?sound=on
|
||||||
|
if (soundEnabled) {
|
||||||
|
document.addEventListener("DOMContentLoaded", initAudio);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectStream() {
|
||||||
|
const es = new EventSource("/stream");
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
updateDisplay(JSON.parse(e.data));
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
// Connection lost — EventSource will auto-reconnect
|
||||||
|
emoteEl.textContent = "( ?.?)";
|
||||||
|
emoteEl.style.color = "#888888";
|
||||||
|
emoteEl.className = "searching";
|
||||||
|
messageEl.style.color = "#888888";
|
||||||
|
messageEl.textContent = "";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisplay(data) {
|
||||||
|
lastData = data;
|
||||||
|
|
||||||
|
// Don't update display while showing reaction
|
||||||
|
if (isReacting) return;
|
||||||
|
|
||||||
|
// Check for state changes and play sounds
|
||||||
|
handleStateChange(data.current_state, data.active_emote, data.sound);
|
||||||
|
|
||||||
|
emoteEl.textContent = data.active_emote;
|
||||||
|
emoteEl.style.color = data.color;
|
||||||
|
messageEl.style.color = data.color;
|
||||||
|
|
||||||
|
// Only show message when there's something to report
|
||||||
|
const topEvent = data.active_events && data.active_events[0];
|
||||||
|
messageEl.textContent = (topEvent && topEvent.message) || "";
|
||||||
|
|
||||||
|
// Update animation class
|
||||||
|
emoteEl.className = "";
|
||||||
|
if (data.animation) {
|
||||||
|
emoteEl.classList.add(data.animation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectStream();
|
||||||
|
</script>
|
||||||
|
</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