diff --git a/.env.example b/.env.example index 72d30a8..8cebba3 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,56 @@ -# Bearer token for API authentication -# Leave this blank or commented out on first run - a secure token will be auto-generated -# and saved to .env. Only set this if you want to use a specific token. -# BEARER_TOKEN= +# Bearer token for API authentication (REQUIRED) +# Generate a secure token with: python3 -c "import secrets; print(secrets.token_hex(32))" +# Then paste it here: +BEARER_TOKEN= # Log file path (defaults to temp_monitor.log in current directory) # Can be absolute or relative path LOG_FILE=temp_monitor.log +# ===== CLOUDFLARED TUNNEL ===== +# Cloudflare Tunnel token from Zero Trust dashboard +# Used by docker-compose cloudflared service +CLOUDFLARED_TOKEN= + +# ===== WEBHOOK CONFIGURATION ===== +# Slack incoming webhook URL for alerts and notifications (optional - required for webhook features) +# Get this from: https://api.slack.com/messaging/webhooks +SLACK_WEBHOOK_URL= + +# Enable or disable webhook notifications (default: true) +WEBHOOK_ENABLED=true + +# Webhook retry configuration +WEBHOOK_RETRY_COUNT=3 +WEBHOOK_RETRY_DELAY=5 +WEBHOOK_TIMEOUT=10 + +# ===== ALERT THRESHOLDS ===== +# Temperature thresholds in Celsius (set to empty to disable) +# Default: 15°C (59°F) min, 27°C (80.6°F) max +ALERT_TEMP_MIN_C=15.0 +ALERT_TEMP_MAX_C=27.0 + +# Humidity thresholds in percentage (set to empty to disable) +# Default: 30% min, 70% max +ALERT_HUMIDITY_MIN=30.0 +ALERT_HUMIDITY_MAX=70.0 + +# ===== PERIODIC STATUS UPDATES ===== +# Enable periodic status updates via webhook (default: false) +# Set to 'true' to receive regular status reports at specified intervals +STATUS_UPDATE_ENABLED=false + +# Interval for status updates in seconds (default: 3600 = 1 hour) +# Common values: +# - 1800 = 30 minutes +# - 3600 = 1 hour (recommended) +# - 7200 = 2 hours +# - 14400 = 4 hours +# - 86400 = 24 hours (daily) +# Note: Cannot be less than 60 seconds (sampling interval) +STATUS_UPDATE_INTERVAL=3600 + +# Send status update immediately on startup (default: false) +# Useful for confirming service is running after deployment +STATUS_UPDATE_ON_STARTUP=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fe9e72b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,133 @@ +name: CI + +on: + workflow_dispatch: + inputs: + deploy_ref: + description: "Branch, tag, or SHA to deploy" + required: false + default: "" + environment: + description: "Deployment environment (for testing from feature branches)" + required: false + type: choice + options: + - production + - testing + default: "testing" + push: + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + release: + types: [published] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ["3.9"] + env: + BEARER_TOKEN: test_token_ci + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.deploy_ref || github.ref }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements.txt + + - name: Install dependencies (exclude Sense HAT) + run: | + python -m pip install --upgrade pip + grep -v '^sense-hat' requirements.txt | grep -v '^#' | grep -v '^$' | xargs pip install + + - name: Run tests + run: | + python test_webhook_api.py + python test_webhook.py + python test_periodic_updates.py + python test_api_models.py + + deploy: + runs-on: self-hosted + needs: tests + timeout-minutes: 20 + # SECURITY: Only deploy from trusted sources (requires write access to repo) + # - Manual trigger (workflow_dispatch) - requires write access + # - Release published - requires write access + # - Push to main/master - requires write access + # NEVER runs on pull_request events (untrusted forks could execute malicious code) + if: | + github.event_name == 'workflow_dispatch' || + github.event_name == 'release' || + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) + environment: + name: production + url: http://raspberrypi.local:8080 + steps: + - name: Display deployment info + run: | + echo "Event: ${{ github.event_name }}" + echo "Ref: ${{ github.ref }}" + echo "Deploy ref: ${{ github.event.inputs.deploy_ref || github.ref }}" + echo "Environment: ${{ github.event.inputs.environment || 'production' }}" + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.deploy_ref || github.ref }} + + - name: Create .env from GitHub Secrets + run: | + # Validate required secrets + missing="" + for var in BEARER_TOKEN SLACK_WEBHOOK_URL; do + if [ -z "${!var}" ]; then + missing="$missing $var" + fi + done + if [ -n "$missing" ]; then + echo "Missing required secrets:$missing" + exit 1 + fi + + # Create .env file with all configuration + cat > .env <` +- 401 (missing header) vs 403 (invalid token) distinction +- Swagger UI accessible without auth for API documentation + +**Webhook Reliability** +- Alert cooldown prevents spam (5 minutes between same alert type) +- Exponential backoff: delay = initial_delay × 2^(attempt_number) +- Configurable retry count (1-10) and timeout (5-120 seconds) +- Thread-safe alert tracking via locks + +## Development Commands + +### Running the Application + +```bash +# Install dependencies +pip install -r requirements.txt + +# Set up environment (copy example) +cp .env.example .env +# Edit .env to add BEARER_TOKEN and other settings + +# Run directly (requires Sense HAT hardware or mock) +python temp_monitor.py + +# Run with Docker Compose (includes ARM build support) +docker compose build +docker compose up -d +``` + +### Testing + +```bash +# Run API endpoint tests +python test_webhook_api.py + +# Run webhook service tests +python test_webhook.py + +# Run periodic update tests +python test_periodic_updates.py +``` + +### Docker Deployment + +```bash +# Build image +docker build -t temp-monitor . + +# Run container with hardware access +docker run -d \ + --name temp-monitor \ + --privileged \ + -p 8080:8080 \ + -v $(pwd)/logs:/app/logs \ + -v $(pwd)/.env:/app/.env \ + -v /sys:/sys:ro \ + --device /dev/i2c-1:/dev/i2c-1 \ + temp-monitor +``` + +### Systemd Service Setup + +Create `/etc/systemd/system/temp_monitor.service`: +```ini +[Unit] +Description=Temperature Monitor Service +After=network.target + +[Service] +User=yourusername +WorkingDirectory=/path/to/temp_monitor +ExecStart=/path/to/venv/bin/python3 temp_monitor.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Then enable: `sudo systemctl enable temp_monitor.service && sudo systemctl start temp_monitor.service` + +## Testing Strategy + +Tests use `unittest.mock` to mock the `sense_hat` module (unavailable on non-RPi systems). Key test patterns: + +```python +# Mock sense_hat before importing temp_monitor +sys.modules['sense_hat'] = MagicMock() +from temp_monitor import app, webhook_service + +# Use test client with Bearer token +self.client.get('/api/temp', headers={'Authorization': f'Bearer {token}'}) +``` + +Critical areas to test: +1. Webhook config creation when `webhook_service` is `None` (AttributeError bug fix) +2. Threshold validation (cross-field min/max relationships) +3. Alert cooldown preventing duplicate alerts +4. Exponential backoff retry logic + +## Common Issues & Solutions + +**Sense HAT Detection** +- Ensure I2C is enabled: `sudo raspi-config` → Interface Options → I2C +- Verify with: `i2cdetect -y 1` + +**Temperature Calibration** +- Adjust `factor` in `get_compensated_temperature()` (line 191) based on actual readings +- CPU heat affects accuracy; hardware compensation attempts to correct this + +**Webhook Failures** +- Check Slack webhook URL format: `https://hooks.slack.com/services/...` +- Verify network connectivity: `curl -X POST ` +- Monitor logs for retry attempts and final failures + +**API Authentication** +- Generate token: `python3 -c "import secrets; print(secrets.token_hex(32))"` +- Always include `Authorization: Bearer ` header +- Bearer token is case-sensitive + +## Dependencies + +- **Flask 2.3.3** - Web framework +- **Flask-RESTX 1.3.0+** - REST API with OpenAPI/Swagger documentation +- **sense-hat 2.6.0** - Sense HAT hardware library +- **python-dotenv 1.0.0** - Environment variable management +- **requests 2.31.0** - HTTP client for webhooks +- **waitress 2.1.2+** - Production WSGI server +- **psutil 5.9.0+** - System metrics (optional, enhances /metrics endpoint) + +## File Structure + +- `temp_monitor.py` - Main application (~800 lines) +- `webhook_service.py` - Webhook/alert logic (~410 lines) +- `api_models.py` - Flask-RESTX models and validation (~170 lines) +- `wsgi.py` - Production WSGI entry point (waitress) +- `sense_hat.py` - Mock/compatibility layer for Sense HAT +- `test_webhook_api.py` - Integration tests for API endpoints +- `test_webhook.py` - Unit tests for webhook service +- `test_periodic_updates.py` - Tests for periodic status updates +- `test_api_models.py` - Unit tests for API model validation +- `Dockerfile` - ARM-compatible build (Python 3.9) +- `docker-compose.yml` - Production-ready compose configuration +- `requirements.txt` - Python dependencies +- `.env.example` - Environment template +- `static/` - Web assets (favicon, logo) diff --git a/CLAUDE.md b/CLAUDE.md index 958450a..1d1eac6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,500 +1,236 @@ -# CLAUDE.md - AI Assistant Guide for Temperature Monitor Project +# CLAUDE.md -## Project Overview - -This is a **Server Room Temperature Monitor** built on Raspberry Pi with Sense HAT hardware. It's a Flask-based web application that monitors environmental conditions (temperature and humidity) and provides both a web dashboard and secure REST API endpoints. - -**Primary Purpose:** Real-time monitoring of server room environmental conditions with hardware sensor integration and remote access capabilities. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -**Target Hardware:** Raspberry Pi Zero 2 W with Sense HAT add-on board - ---- +## Project Overview -## Codebase Structure +**Server Room Temperature Monitor** - A lightweight environmental monitoring system running on a Raspberry Pi 4 with Sense HAT that provides: +- Real-time temperature and humidity monitoring with hardware compensation for CPU heat +- Web dashboard (auto-refreshes every 60 seconds) +- REST API with Bearer token authentication +- Slack webhook notifications for temperature/humidity alerts +- Periodic status updates +- LED matrix display showing current temperature + +## Architecture Overview + +### Core Layers + +**Flask Application (temp_monitor.py)** +- Main entry point that initializes Flask app with Flask-RESTX for API documentation +- Manages sensor reading loop in a background thread (`update_sensor_data()`) +- Implements routes for web dashboard (`/`) and API endpoints (`/api/temp`, `/api/raw`, `/api/verify-token`) +- Bearer token authentication via `@require_token` decorator on protected endpoints + +**Webhook Service (webhook_service.py)** +- `WebhookService` class: Handles outbound Slack webhook communication +- `WebhookConfig` dataclass: Configuration for webhook endpoint (URL, retry logic, timeout) +- `AlertThresholds` dataclass: Temperature/humidity thresholds that trigger alerts +- Features: Alert cooldown (5-min between same alert type), exponential backoff retry logic, thread-safe operations with locks +- Methods: `check_and_alert()` (threshold checking), `send_status_update()` (periodic reports), `send_slack_message()` (generic Slack formatting) + +**API Models (api_models.py)** +- Flask-RESTX namespace (`webhooks_ns`) defining OpenAPI/Swagger models +- Input models with validation constraints (e.g., retry_count 1-10, timeout 5-120 seconds) +- Output models for responses +- Validation functions: `validate_webhook_config()` and `validate_thresholds()` (cross-field validation) + +**Sensor Data Processing** +- `get_compensated_temperature()`: Takes 10 readings (5 from humidity + 5 from pressure sensors), filters outliers, applies CPU heat compensation (factor: 0.7) and -4°F correction +- `get_humidity()`: Takes 3 readings, filters outliers, applies +4% correction +- `get_cpu_temperature()`: Reads from `/sys/class/thermal/thermal_zone0/temp` + +### API Endpoints Structure + +**Public Routes:** +- `GET /` - Web dashboard (HTML) +- `GET /docs` - Swagger UI +- `GET /health` - Health check endpoint for monitoring/load balancers +- `GET /metrics` - System and application metrics (requires psutil for system stats) + +**Protected Routes (require Bearer token):** +- `GET /api/temp` - Current temperature/humidity data +- `GET /api/raw` - Raw sensor readings for debugging +- `GET /api/verify-token` - Token validation check +- `GET /api/webhook/config` - Get webhook configuration +- `PUT /api/webhook/config` - Update webhook config and thresholds (with validation) +- `POST /api/webhook/test` - Send test webhook +- `POST /api/webhook/enable` - Enable webhooks +- `POST /api/webhook/disable` - Disable webhooks -``` -temp_monitor/ -├── temp_monitor.py # Main Flask application (367 lines) -├── generate_token.py # Token generation utility (56 lines) -├── requirements.txt # Python dependencies -├── .env.example # Example environment variables -├── .env # Environment variables (gitignored) -├── .gitignore # Git ignore rules -├── README.md # User-facing documentation -├── CLAUDE.md # AI assistant guide -├── static/ # Web assets served by Flask -│ ├── My-img8bit-1com-Effect.gif # Logo displayed on dashboard -│ └── favicon.ico # Favicon for web interface -├── My-img8bit-1com-Effect.gif # Legacy logo copy (not used by Flask static route) -└── temp-favicon.ico # Legacy favicon copy (not used by Flask static route) -``` +### Configuration -### Core Files - -#### `temp_monitor.py` (Main Application) -- **Lines 1-14:** Imports and environment variable loading -- **Lines 16-34:** Logging configuration with directory validation -- **Lines 36-50:** Flask app setup and global variables -- **Lines 52-70:** Bearer token initialization from environment -- **Lines 72-90:** `require_token()` decorator for API authentication -- **Lines 92-107:** Image/asset loading with base64 encoding and favicon validation -- **Lines 109-117:** `get_cpu_temperature()` - reads from `/sys/class/thermal/thermal_zone0/temp` -- **Lines 119-149:** `get_compensated_temperature()` - temperature reading with CPU heat compensation -- **Lines 151-165:** `get_humidity()` - humidity sensor reading with averaging -- **Lines 167-192:** `update_sensor_data()` - background thread for continuous monitoring -- **Lines 194-280:** `index()` - web dashboard route with HTML template -- **Lines 282-289:** `favicon()` - favicon serving endpoint with fallback handling -- **Lines 291-301:** `api_temp()` - protected API endpoint for temperature data -- **Lines 303-315:** `api_raw()` - protected debugging endpoint for raw sensor data -- **Lines 317-345:** `generate_new_token()` - API endpoint to regenerate bearer tokens -- **Lines 347-355:** `verify_token()` - token validation endpoint -- **Lines 357-367:** Main execution block - starts sensor thread and Flask server - -#### `generate_token.py` (Token Management) -- Standalone utility script to generate secure bearer tokens -- Uses `secrets.token_hex(32)` for cryptographically secure random tokens -- Manages `.env` file updates while preserving other environment variables -- Can be run independently or called via API - ---- - -## Key Technical Concepts - -### 1. Temperature Compensation Algorithm -**Location:** `temp_monitor.py:119-149` - -The Sense HAT sensor is affected by CPU heat due to proximity on the board. Compensation formula: -```python -comp_temp = raw_temp - ((cpu_temp - raw_temp) * factor) -``` -- **factor:** 0.7 (calibration constant, may need adjustment per hardware) -- **Averaging:** Takes 5 readings from both humidity and pressure sensors -- **Outlier removal:** Removes highest and lowest values before averaging -- **Graceful degradation:** Uses raw temperature if CPU temp unavailable - -### 2. Sensor Data Collection -**Location:** `temp_monitor.py:167-192` - -Background thread pattern: -- Runs continuously in daemon thread -- 60-second sampling interval (configurable via `sampling_interval`) -- Updates global variables: `current_temp`, `current_humidity`, `last_updated` -- Displays temperature on LED matrix via `sense.show_message()` -- Logs all readings to file with CPU temperature when available - -### 3. Bearer Token Authentication -**Location:** `temp_monitor.py:52-90` - -Security implementation: -- Uses decorator pattern (`@require_token`) to protect API endpoints -- Requires `Authorization: Bearer ` header -- Token stored in `.env` file and loaded via `python-dotenv` -- Auto-generates token if `.env` missing (shown on console) -- Returns 401 for missing auth, 403 for invalid token - -### 4. Environment Variable Configuration -**Location:** `temp_monitor.py:16-34, 94, 105` - -Configuration via environment variables: -- **LOG_FILE:** Path for temperature/humidity log file (defaults to `temp_monitor.log`) -- **Static assets:** Served from the `static/` directory bundled with the app; replace the files there to customize images. -- Supports both absolute and relative paths for log files -- Log directory is auto-created if it doesn't exist - -### 5. Web Dashboard Auto-Refresh -**Location:** `temp_monitor.py:204` (meta refresh tag) - -```html - -``` -- Client-side refresh every 60 seconds -- No JavaScript required -- Ensures users always see current data - ---- - -## Development Workflows - -### Local Development Setup - -1. **Hardware Requirements:** - - Must have Sense HAT hardware attached for full functionality - - Without hardware, app will fail at initialization (line 25-29) - -2. **Environment Setup:** - ```bash - # Install system dependencies (Raspberry Pi OS) - sudo apt-get update - sudo apt-get install -y python3-pip python3-sense-hat - - # Create virtual environment - python3 -m venv venv - source venv/bin/activate - - # Install Python dependencies - pip install -r requirements.txt - ``` - -3. **Configuration:** - - Copy `.env.example` to `.env` and customize as needed: - ```bash - cp .env.example .env - ``` - - Generate bearer token: `python generate_token.py` (or manually set in `.env`) - - Update environment variables in `.env`: - - `LOG_FILE`: Path to log file - - `BEARER_TOKEN`: API authentication token (auto-generated if omitted) - - Static assets are located in `static/`; replace those files directly if you want custom branding - -4. **Running Locally:** - ```bash - python temp_monitor.py - ``` - - Server runs on `0.0.0.0:8080` - - Web dashboard: `http://localhost:8080` - - API: `http://localhost:8080/api/temp` (requires auth header) - -### Testing API Endpoints +Environment variables (from `.env`): +- `LOG_FILE` - Path to log file (default: `temp_monitor.log`) +- `BEARER_TOKEN` - Required for API access (generated with `python3 -c "import secrets; print(secrets.token_hex(32))"`) +- `SLACK_WEBHOOK_URL` - Slack webhook URL (enables webhook service) +- `WEBHOOK_ENABLED` - Enable/disable webhook notifications (default: true) +- `WEBHOOK_RETRY_COUNT` - Retry attempts (default: 3) +- `WEBHOOK_RETRY_DELAY` - Initial retry delay in seconds (default: 5) +- `WEBHOOK_TIMEOUT` - Request timeout (default: 10) +- `ALERT_TEMP_MIN_C`, `ALERT_TEMP_MAX_C`, `ALERT_HUMIDITY_MIN`, `ALERT_HUMIDITY_MAX` - Thresholds +- `STATUS_UPDATE_ENABLED` - Enable periodic status updates (default: false) +- `STATUS_UPDATE_INTERVAL` - Status update frequency in seconds (default: 3600) +- `STATUS_UPDATE_ON_STARTUP` - Send status update on startup (default: false) + +## Key Design Patterns + +**Thread Safety** +- Global state (`current_temp`, `current_humidity`) is read-only from thread perspective +- `WebhookService` uses `threading.Lock()` for concurrent access to alert tracking and config +- Background thread runs sensor loop with 60-second sampling interval + +**Sensor Data Quality** +- Multiple readings with outlier filtering (removes min/max) +- CPU heat compensation formula to correct for SoC temperature affecting sensor +- Sensor readings are cached and accessed by multiple endpoints + +**API Security** +- Bearer token required for all non-public endpoints +- Token format validation: `Authorization: Bearer ` +- 401 (missing header) vs 403 (invalid token) distinction +- Swagger UI accessible without auth for API documentation + +**Webhook Reliability** +- Alert cooldown prevents spam (5 minutes between same alert type) +- Exponential backoff: delay = initial_delay × 2^(attempt_number) +- Configurable retry count (1-10) and timeout (5-120 seconds) +- Thread-safe alert tracking via locks + +## Development Commands + +### Running the Application ```bash -# Get token from .env -TOKEN=$(grep BEARER_TOKEN .env | cut -d= -f2) - -# Test temperature endpoint -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/temp +# Install dependencies +pip install -r requirements.txt -# Test raw data endpoint (debugging) -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/raw +# Set up environment (copy example) +cp .env.example .env +# Edit .env to add BEARER_TOKEN and other settings -# Verify token -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/verify-token +# Run directly (requires Sense HAT hardware or mock) +python temp_monitor.py -# Generate new token -curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/generate-token +# Run with Docker Compose (includes ARM build support) +docker compose build +docker compose up -d ``` -### Git Workflow +### Testing -Based on recent commits: -- Feature branches follow pattern: `claude/claude-md-*` or user-specific prefixes -- Pull request workflow for all changes -- Commit message format: `: ` (e.g., `feat: add bearer token authentication`) -- Recent PR topics: bug fixes, API schema, authentication features - -**Current Branch:** `claude/claude-md-mih5ygkdlylf5q31-01SRHr3eBKXwcgtdd89ks7mj` - -### Deployment as Systemd Service - -The README documents systemd service setup: -- Service file: `/etc/systemd/system/temp_monitor.service` -- Runs as non-root user -- Auto-restart on failure (10 second delay) -- Starts after network is available - ---- - -## Key Conventions & Patterns - -### Code Style -- **Logging:** All significant events logged via `logging` module -- **Error Handling:** Try-except blocks with logging for hardware operations -- **Threading:** Daemon threads for background tasks -- **Global State:** Global variables for sensor data (thread-safe due to GIL) +```bash +# Run API endpoint tests +python test_webhook_api.py -### API Response Format -All API endpoints return JSON with consistent structure: +# Run webhook service tests +python test_webhook.py -```json -{ - "temperature_c": 23.5, - "temperature_f": 74.3, - "humidity": 45.2, - "timestamp": "2023-09-19 14:23:45" -} +# Run periodic update tests +python test_periodic_updates.py ``` -### Security Practices -- Bearer tokens are 64-character hex strings (32 bytes) -- `.env` file is gitignored (never commit tokens) -- API endpoints are protected by default (use `@require_token`) -- Web dashboard (`/`) is public (no authentication) -- Favicon route is public - -### Configuration via Environment Variables - -**Path variables (configured in `.env`):** -- `LOG_FILE` - Log file path (defaults to `temp_monitor.log`) -- `BEARER_TOKEN` - API authentication token (auto-generated if missing) - -**Static assets:** -- Served from the `static/` directory; replace `static/My-img8bit-1com-Effect.gif` or `static/favicon.ico` to customize branding. - -**Hardcoded constants (code-level configuration):** -- `temp_monitor.py:50` - Sampling interval: 60 seconds -- `temp_monitor.py:143` - Temperature compensation factor: 0.7 -- `temp_monitor.py:367` - Flask port: 8080 - -**File validation and safety:** -- Log directory is auto-created if missing (lines 20-25) -- All file operations wrapped in try-except blocks +### Docker Deployment ---- - -## Common Tasks for AI Assistants - -### Adding a New API Endpoint - -1. Define route with `@app.route('/api/new-endpoint')` -2. Add `@require_token` decorator if authentication needed -3. Return JSON using `jsonify()` -4. Log access attempts -5. Update README with endpoint documentation - -### Modifying Temperature Compensation - -1. Edit `get_compensated_temperature()` function (line 119) -2. Adjust `factor` variable (currently 0.7, line 143) -3. Consider hardware-specific calibration -4. Test with physical hardware for accuracy -5. Update comments explaining calibration methodology - -### Changing Sampling Interval - -1. Modify `sampling_interval` global variable (line 50) -2. Update web dashboard meta refresh (line 204) to match -3. Consider LED display frequency impact -4. Update README documentation - -### Adding Configuration Options - -1. Add variable to global configuration (top of file) -2. Load from environment with `os.getenv('VARIABLE_NAME', default)` -3. Validate and document in `.env.example` -4. Update CLAUDE.md with configuration section -5. Ensure backwards compatibility with defaults - -### Bug Fixes Related to Hardware - -**Common issues:** -- **Missing CPU temp:** Gracefully handled (returns None), see line 116-117 -- **Sense HAT not detected:** App fails at startup with clear error message (lines 37-42) -- **Outlier filtering:** Requires at least 3 readings, see line 132 -- **Missing logo image:** Logs error but app continues, see lines 100-102 -- **Missing favicon:** Logs warning at startup, returns 404 on request (lines 106-107, 286-289) - -**Recent bug fix example:** -- Commit 909e636: "Handle missing CPU temperature gracefully" -- Shows pattern: add None checks, provide fallback behavior, log errors - ---- - -## Dependencies & Requirements - -### Python Dependencies (requirements.txt) -- `flask==2.3.3` - Web framework -- `sense-hat==2.6.0` - Sense HAT hardware interface -- `python-dotenv==1.0.0` - Environment variable management +```bash +# Build image +docker build -t temp-monitor . + +# Run container with hardware access +docker run -d \ + --name temp-monitor \ + --privileged \ + -p 8080:8080 \ + -v $(pwd)/logs:/app/logs \ + -v $(pwd)/.env:/app/.env \ + -v /sys:/sys:ro \ + --device /dev/i2c-1:/dev/i2c-1 \ + temp-monitor +``` -### System Dependencies -- `python3-sense-hat` - System package for Sense HAT drivers -- Raspberry Pi OS (Raspbian) recommended -- I2C must be enabled for Sense HAT communication +### Systemd Service Setup -### Hardware Dependencies -- Raspberry Pi (any model, Zero 2 W tested) -- Sense HAT add-on board (8x8 LED matrix, multiple sensors) -- Power supply adequate for Pi + Sense HAT +Create `/etc/systemd/system/temp_monitor.service`: +```ini +[Unit] +Description=Temperature Monitor Service +After=network.target ---- +[Service] +User=yourusername +WorkingDirectory=/path/to/temp_monitor +ExecStart=/path/to/venv/bin/python3 temp_monitor.py +Restart=always +RestartSec=10 -## Security Considerations +[Install] +WantedBy=multi-user.target +``` -### Current Security Model -- **API endpoints:** Protected with bearer token authentication -- **Web dashboard:** Public access (no authentication required) -- **Token generation:** Requires existing valid token to generate new one -- **Token storage:** File-based (`.env`), not in database +Then enable: `sudo systemctl enable temp_monitor.service && sudo systemctl start temp_monitor.service` -### Potential Security Improvements for AI to Consider -- Add rate limiting to prevent brute force token attempts -- Implement token expiration/rotation policy -- Add HTTPS support (currently HTTP only) -- Consider adding authentication to web dashboard for public deployments -- Implement audit logging for security events +## Testing Strategy ---- +Tests use `unittest.mock` to mock the `sense_hat` module (unavailable on non-RPi systems). Key test patterns: -## Testing Strategy +```python +# Mock sense_hat before importing temp_monitor +sys.modules['sense_hat'] = MagicMock() +from temp_monitor import app, webhook_service -### Manual Testing -1. **Hardware verification:** Check Sense HAT LED display shows temperature -2. **Web dashboard:** Access via browser, verify auto-refresh works -3. **API endpoints:** Test with curl commands (see Testing API Endpoints section) -4. **Error conditions:** Test without Sense HAT, with invalid tokens, etc. - -### No Automated Tests Currently -- No test suite exists in repository -- Consider adding pytest-based tests for: - - Temperature compensation calculations - - Token validation logic - - API endpoint responses - - Error handling scenarios - ---- - -## Troubleshooting Guide for AI Assistants - -### Application Won't Start -- **Check:** Sense HAT hardware connection -- **Check:** I2C enabled via `sudo raspi-config` -- **Check:** Python dependencies installed -- **Check:** Correct Python version (3.7+) - -### Inaccurate Temperature Readings -- **Adjust:** Compensation factor in `get_compensated_temperature()` (line 127) -- **Check:** CPU temperature sensor accessible -- **Consider:** Enclosure affecting airflow -- **Verify:** Sense HAT firmly seated on GPIO pins - -### API Authentication Failures -- **Check:** `.env` file exists and contains BEARER_TOKEN -- **Verify:** Token format in Authorization header: `Bearer ` -- **Check:** Token matches exactly (case-sensitive) -- **Review:** Logs at `/home/fakebizprez/temp_monitor.log` for details - -### Web Dashboard Not Updating -- **Check:** Background sensor thread is running -- **Verify:** No exceptions in logs -- **Check:** Browser cache (hard refresh with Ctrl+F5) -- **Test:** API endpoint directly to verify data is being collected - ---- - -## Recent Changes & History - -### Latest Changes (Current Sprint) - -1. **Environment Variables Configuration** (6e1f06f) - - Replaced hardcoded paths with environment variables for logs - - Static assets now live in the `static/` directory instead of configurable paths - - Supports both absolute and relative paths for log configuration - -2. **Log File Path Validation** (43e866d) - - Added automatic creation of log directory if missing - - Proper error handling for directory creation failures - - Clear error messages if logging cannot be initialized - -3. **Static Asset Validation** (001e0a5) - - Added existence check for favicon file at startup - - Logs warning if favicon is missing - - Gracefully handles missing favicon without crashing (returns 404) - -4. **Security Enhancement** (0a6b4ff) - - Updated `.env.example` with instructions not to hardcode BEARER_TOKEN - - Token auto-generation is now the recommended approach - -5. **Development Infrastructure** (05dcd8d) - - Added Python cache files to `.gitignore` - - Includes `__pycache__/`, `*.pyc`, `*.pyo`, `*.pyd` - -### Evolution Pattern -- Started as simple temperature monitor -- Added API endpoints for programmatic access -- Enhanced security with bearer token authentication -- Ongoing refinement of error handling and edge cases -- Recent focus: Configuration management and file validation - ---- - -## Best Practices for AI Assistants - -### When Making Changes - -1. **Always read files before editing** - Never assume structure -2. **Update README.md** when adding features or changing APIs -3. **Add logging statements** for significant operations -4. **Handle hardware failures gracefully** - Sense HAT may not always be available -5. **Test with actual hardware** when possible -6. **Update this CLAUDE.md** when making architectural changes -7. **Follow existing code style** - spacing, naming conventions, etc. -8. **Consider deployment context** - This runs on Raspberry Pi, not cloud servers - -### Code Review Checklist - -- [ ] Hardware errors handled with try-except -- [ ] Logging added for new operations -- [ ] API endpoints have `@require_token` decorator (unless intentionally public) -- [ ] JSON responses use `jsonify()` -- [ ] Documentation updated (README.md, docstrings) -- [ ] Hardcoded paths reviewed (should use config/env vars) -- [ ] Thread safety considered for global variables -- [ ] Error messages are informative - -### Don't Do These Things - -- ❌ Remove hardware error handling (app must be resilient) -- ❌ Commit `.env` file (contains secrets) -- ❌ Make web dashboard require authentication without discussing (design decision) -- ❌ Change core temperature compensation without calibration data -- ❌ Add heavy dependencies (runs on resource-constrained Raspberry Pi Zero) -- ❌ Remove logging statements (critical for debugging headless deployments) -- ❌ Use blocking operations in sensor thread (would freeze monitoring) - ---- - -## Future Enhancement Ideas - -Areas where improvements could be made: - -1. **Database Integration:** Store historical data for trending analysis -2. **Alerting:** Email/SMS notifications for out-of-range conditions -3. **Graphing:** Historical charts in web dashboard -4. **Multi-sensor Support:** Monitor multiple rooms with multiple devices -5. **HTTPS Support:** SSL/TLS for secure remote access -6. **Docker Support:** Containerization for easier deployment -7. **Automated Testing:** Unit and integration test suite for critical functions -8. **Web Dashboard Auth:** Optional authentication for public deployments -9. **API Versioning:** `/api/v1/temp` for future compatibility -10. **Rate Limiting:** Implement rate limiting on API endpoints -11. **Token Expiration:** Add token expiration and rotation policies - ---- - -## Quick Reference - -### File Locations -- Main app: `temp_monitor.py` -- Token utility: `generate_token.py` -- Dependencies: `requirements.txt` -- Config: `.env` (not in git) -- Docs: `README.md`, `CLAUDE.md` - -### Important Functions -- `get_compensated_temperature()` - Core temp reading logic -- `update_sensor_data()` - Background monitoring loop -- `require_token()` - Authentication decorator - -### API Endpoints -- `GET /` - Web dashboard (public) -- `GET /api/temp` - Current readings (protected) -- `GET /api/raw` - Raw sensor data (protected) -- `GET /api/verify-token` - Token validation (protected) -- `POST /api/generate-token` - Generate new token (protected) +# Use test client with Bearer token +self.client.get('/api/temp', headers={'Authorization': f'Bearer {token}'}) +``` -### Configuration -- Port: 8080 -- Sampling: 60 seconds -- Compensation factor: 0.7 -- Token length: 64 hex chars - ---- - -*This document is maintained for AI assistants working with the Temperature Monitor codebase. Last updated: 2025-11-27* - -### Documentation Updates in This Version -- Updated all line numbers to reflect current codebase structure -- Added Environment Variable Configuration section -- Documented recent changes including path configuration, file validation, and security improvements -- Clarified configuration approach (environment variables instead of hardcoded values) -- Added new subsection "Adding Configuration Options" for common tasks -- Enhanced troubleshooting section with file validation issues +Critical areas to test: +1. Webhook config creation when `webhook_service` is `None` (AttributeError bug fix) +2. Threshold validation (cross-field min/max relationships) +3. Alert cooldown preventing duplicate alerts +4. Exponential backoff retry logic + +## Common Issues & Solutions + +**Sense HAT Detection** +- Ensure I2C is enabled: `sudo raspi-config` → Interface Options → I2C +- Verify with: `i2cdetect -y 1` + +**Temperature Calibration** +- Adjust `factor` in `get_compensated_temperature()` (line 191) based on actual readings +- CPU heat affects accuracy; hardware compensation attempts to correct this + +**Webhook Failures** +- Check Slack webhook URL format: `https://hooks.slack.com/services/...` +- Verify network connectivity: `curl -X POST ` +- Monitor logs for retry attempts and final failures + +**API Authentication** +- Generate token: `python3 -c "import secrets; print(secrets.token_hex(32))"` +- Always include `Authorization: Bearer ` header +- Bearer token is case-sensitive + +## Dependencies + +- **Flask 2.3.3** - Web framework +- **Flask-RESTX 1.3.0+** - REST API with OpenAPI/Swagger documentation +- **sense-hat 2.6.0** - Sense HAT hardware library +- **python-dotenv 1.0.0** - Environment variable management +- **requests 2.31.0** - HTTP client for webhooks +- **waitress 2.1.2+** - Production WSGI server +- **psutil 5.9.0+** - System metrics (optional, enhances /metrics endpoint) + +## File Structure + +- `temp_monitor.py` - Main application (~800 lines) +- `webhook_service.py` - Webhook/alert logic (~410 lines) +- `api_models.py` - Flask-RESTX models and validation (~170 lines) +- `wsgi.py` - Production WSGI entry point (waitress) +- `sense_hat.py` - Mock/compatibility layer for Sense HAT +- `test_webhook_api.py` - Integration tests for API endpoints +- `test_webhook.py` - Unit tests for webhook service +- `test_periodic_updates.py` - Tests for periodic status updates +- `test_api_models.py` - Unit tests for API model validation +- `Dockerfile` - ARM-compatible build (Python 3.9) +- `docker-compose.yml` - Production-ready compose configuration +- `requirements.txt` - Python dependencies +- `.env.example` - Environment template +- `static/` - Web assets (favicon, logo) diff --git a/Dockerfile b/Dockerfile index 5f945de..1c423da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy application files -COPY temp_monitor.py generate_token.py ./ +COPY temp_monitor.py webhook_service.py sense_hat.py api_models.py wsgi.py ./ COPY static ./static # Create directories for volumes @@ -35,4 +35,9 @@ RUN mkdir -p /app/logs /app/static # Expose the Flask port EXPOSE 8080 -CMD ["python", "temp_monitor.py"] +# Health check for monitoring and load balancers +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8080/health', timeout=5)" || exit 1 + +# Use Waitress for production deployment +CMD ["waitress-serve", "--host=0.0.0.0", "--port=8080", "--threads=1", "--channel-timeout=120", "--call", "wsgi:app"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc8edfe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 linehaul.ai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 78cb882..3acb753 100644 --- a/README.md +++ b/README.md @@ -1,360 +1,295 @@ -# Server Room Temp Monitor +# Server Room Temperature Monitor - -A lightweight environmental monitoring system for server rooms or any space where temperature and humidity tracking is critical. Built on a Raspberry Pi Zero 2 W with a Sense HAT. +A lightweight environmental monitoring system for server rooms built on Raspberry Pi 4 with Sense HAT. Features real-time monitoring, REST API, Slack webhook alerts, and production-ready deployment options. ![image](https://github.com/user-attachments/assets/c96b3e96-c6e6-415d-afc3-7bb13eb406ee) +## Table of Contents + +- [Features](#features) +- [Hardware Requirements](#hardware-requirements) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [API Reference](#api-reference) +- [Webhook Notifications](#webhook-notifications) +- [Deployment](#deployment) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) ## Features -- **Real-time Temperature Monitoring**: Measures ambient temperature with hardware compensation for CPU heat -- **Humidity Tracking**: Monitors relative humidity percentage -- **Web Dashboard**: Clean, responsive web interface automatically refreshes every 60 seconds -- **API Endpoints**: JSON data access for integration with other monitoring systems -- **LED Display**: Shows current temperature on the Sense HAT LED matrix -- **Logging**: Records all measurements to a log file +- **Real-time Monitoring**: Temperature with CPU heat compensation, humidity tracking +- **Web Dashboard**: Auto-refreshing interface at port 8080 +- **REST API**: JSON endpoints with Bearer token authentication +- **Swagger Documentation**: Interactive API docs at `/docs` +- **Slack Webhooks**: Threshold-based alerts with configurable cooldowns +- **Periodic Status Updates**: Scheduled status reports via webhook +- **LED Display**: Current temperature on Sense HAT matrix +- **Production Ready**: Waitress WSGI server, health checks, metrics endpoint +- **Docker Support**: Pre-configured docker-compose for easy deployment ## Hardware Requirements -- Raspberry Pi (Zero 2 W or other model) +- Raspberry Pi 4 (2GB+ RAM recommended) - Sense HAT add-on board -- Power supply +- 5V/3A USB-C power supply - (Optional) Case for the Raspberry Pi -## Installation +## Quick Start -### Prerequisites +### 1. Clone and Install ```bash -# Install required system packages -sudo apt-get update - -sudo apt-get install -y python3-pip python3-sense-hat - - - -# Create a virtual environment (optional but recommended) +git clone https://github.com/yourusername/temp_monitor.git +cd temp_monitor python3 -m venv venv - - - source venv/bin/activate - - - - - -# Install Python dependencies -pip install flask +pip install -r requirements.txt ``` -### Setup - -1. Clone this repository: - ```bash - git clone https://github.com/yourusername/temp_monitor.git - cd temp_monitor +### 2. Configure Environment +```bash +cp .env.example .env +# Generate a bearer token (required) +python3 -c "import secrets; print(secrets.token_hex(32))" +# Add the output to .env as BEARER_TOKEN= +``` +### 3. Run +```bash +# Development +python temp_monitor.py - ``` - -2. Configure environment variables: - Copy `.env.example` to `.env` and customize paths as needed: - ```bash - cp .env.example .env - ``` - - Edit `.env` to set your paths: - ``` - # Log file path (absolute or relative) - LOG_FILE=/home/yourusername/temp_monitor.log - ``` - - Static assets (logo and favicon) are served from the repository's `static/` directory by default. Replace the files there if you want to customize the images. - -3. Generate a bearer token: - ```bash - python generate_token.py - ``` - This will create a secure token and save it to `.env`. +# Production (with Waitress) +./start_production.sh -4. Set up as a service (for automatic startup): - Create a systemd service file: - ```bash - sudo nano /etc/systemd/system/temp_monitor.service - ``` - - Add the following content: - ``` - [Unit] - Description=Temperature Monitor Service - After=network.target +# Docker +docker compose up -d +``` - [Service] - User=yourusername - WorkingDirectory=/home/yourusername/temp_monitor - ExecStart=/home/yourusername/temp_monitor/venv/bin/python3 temp_monitor.py - Restart=always - RestartSec=10 +Access the dashboard at `http://[raspberry-pi-ip]:8080` - [Install] - WantedBy=multi-user.target - ``` +## Configuration - Enable and start the service: - ```bash - sudo systemctl enable temp_monitor.service - sudo systemctl start temp_monitor.service - ``` +All configuration is done via environment variables in `.env`. Copy `.env.example` to get started. -## Docker Deployment +### Core Settings -The application can be deployed as a Docker container, making it easier to manage dependencies and deployment. +| Variable | Default | Description | +|----------|---------|-------------| +| `BEARER_TOKEN` | (required) | API authentication token | +| `LOG_FILE` | `temp_monitor.log` | Log file path | +| `CLOUDFLARED_TOKEN` | (none) | Cloudflare Tunnel token for docker-compose `cloudflared` service | -### Prerequisites +### Webhook Settings -- Docker and Docker Compose installed on your Raspberry Pi -- Raspberry Pi with ARM architecture (armv7l or aarch64) -- Sense HAT hardware properly connected +| Variable | Default | Description | +|----------|---------|-------------| +| `SLACK_WEBHOOK_URL` | (optional) | Slack incoming webhook URL for alerts | +| `WEBHOOK_ENABLED` | `true` | Enable/disable notifications | +| `WEBHOOK_RETRY_COUNT` | `3` | Retry attempts (1-10) | +| `WEBHOOK_RETRY_DELAY` | `5` | Initial retry delay in seconds | +| `WEBHOOK_TIMEOUT` | `10` | Request timeout in seconds | -### Preparing for Docker Deployment +### Alert Thresholds -1. **Create a logs directory:** - ```bash - mkdir -p logs - ``` +| Variable | Default | Description | +|----------|---------|-------------| +| `ALERT_TEMP_MIN_C` | `15.0` | Low temperature alert (Celsius) | +| `ALERT_TEMP_MAX_C` | `27.0` | High temperature alert (Celsius) | +| `ALERT_HUMIDITY_MIN` | `30.0` | Low humidity alert (%) | +| `ALERT_HUMIDITY_MAX` | `70.0` | High humidity alert (%) | -2. **(Optional) Replace static assets:** - The container serves images from the built-in `static/` directory. If you want to override them, replace the files in `stat -ic/` before building the image or mount your own `static/` directory at runtime. +### Periodic Status Updates -3. **Create a .env file:** - ```bash - cp .env.example .env - ``` +| Variable | Default | Description | +|----------|---------|-------------| +| `STATUS_UPDATE_ENABLED` | `false` | Enable periodic status reports | +| `STATUS_UPDATE_INTERVAL` | `3600` | Interval in seconds (min: 60) | +| `STATUS_UPDATE_ON_STARTUP` | `false` | Send status on startup | - The bearer token will be auto-generated on first run, or you can generate it manually (see below). +## API Reference -### Building and Running with Docker Compose +### Public Endpoints (No Authentication) -1. **Build the Docker image:** - ```bash - docker-compose build - ``` +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/` | GET | Web dashboard | +| `/docs` | GET | Swagger UI documentation | +| `/health` | GET | Health check for load balancers | +| `/metrics` | GET | Application and system metrics | -2. **Start the container:** - ```bash - docker-compose up -d - ``` +### Protected Endpoints (Bearer Token Required) -3. **View logs:** - ```bash - docker-compose logs -f - ``` +Include header: `Authorization: Bearer YOUR_TOKEN` -4. **Stop the container:** - ```bash - docker-compose down - ``` - -### Generating Bearer Token in Container - -To generate or regenerate the bearer token inside the container: +#### Sensor Data -```bash -docker-compose exec temp-monitor python generate_token.py -``` +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/temp` | GET | Current temperature and humidity | +| `/api/raw` | GET | Raw sensor data for debugging | +| `/api/verify-token` | GET | Validate authentication token | -The token will be saved to the `.env` file in your project directory (which is mounted as a volume). +#### Webhook Management -### Building Docker Image Manually +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/webhook/config` | GET | Get current webhook configuration | +| `/api/webhook/config` | PUT | Update webhook config and thresholds | +| `/api/webhook/test` | POST | Send a test webhook message | +| `/api/webhook/enable` | POST | Enable webhook notifications | +| `/api/webhook/disable` | POST | Disable webhook notifications | -If you prefer to build and run without docker-compose: +### Example Requests ```bash -# Build the image -docker build -t temp-monitor . - -# Run the container -docker run -d \ - --name temp-monitor \ - --privileged \ - -p 8080:8080 \ - -v $(pwd)/logs:/app/logs \ - -v $(pwd)/static:/app/static:ro \ - -v $(pwd)/.env:/app/.env \ - -v /sys:/sys:ro \ - --device /dev/i2c-1:/dev/i2c-1 \ - -e LOG_FILE=/app/logs/temp_monitor.log \ - temp-monitor -``` - -### Important Docker Notes - -- **Privileged Mode:** The container requires privileged mode to access the I2C interface and hardware sensors on the Sense HAT -- **ARM Architecture:** This application is designed for ARM-based Raspberry Pi. The Python base image will automatically use the appropriate ARM variant -- **Device Access:** The container needs access to `/dev/i2c-1` for Sense HAT communication and `/sys` (read-only) for CPU temperature readings -- **Persistent Data:** Logs and the `.env` file are stored in mounted volumes, so they persist across container restarts -- **Auto-restart:** The docker-compose configuration includes `restart: unless-stopped` to automatically restart the container if it crashes or after system reboot - -## Usage - -### Web Dashboard - -Access the web dashboard by navigating to: -``` -http://[raspberry-pi-ip-address]:8080 -``` - -The dashboard will automatically refresh every 60 seconds. - -### API Endpoints - -#### Temperature and Humidity Data -``` -GET http://[raspberry-pi-ip-address]:8080/api/temp -``` +# Get temperature data +curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/temp -Returns: -```json +# Response: { "temperature_c": 23.5, "temperature_f": 74.3, "humidity": 45.2, - "timestamp": "2023-09-19 14:23:45" + "timestamp": "2024-01-15 14:23:45" } -``` -#### Raw Sensor Data (for debugging) -``` -GET http://[raspberry-pi-ip-address]:8080/api/raw -``` +# Health check (no auth needed) +curl http://localhost:8080/health -Returns: -```json +# Response: { - "cpu_temperature": 54.2, - "raw_temperature": 32.6, - "compensated_temperature": 23.5, - "humidity": 45.2, - "timestamp": "2023-09-19 14:23:45" + "status": "healthy", + "uptime_seconds": 12345, + "sensor_thread_alive": true, + "timestamp": 1705329825.123 } -``` -## Temperature Compensation - -The system compensates for the effect of CPU heat on temperature readings using a formula: +# Update webhook thresholds +curl -X PUT http://localhost:8080/api/webhook/config \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "thresholds": { + "temp_min_c": 18.0, + "temp_max_c": 25.0 + } + }' ``` -compensated_temp = raw_temp - ((cpu_temp - raw_temp) * factor) -``` -Where `factor` is a calibration value (default 0.7) that may need adjustment based on your specific hardware configuration and enclosure. - -## Customization - -### Sampling Interval - -To change how often temperature readings are updated, modify the `sampling_interval` variable (in seconds): - -```python -sampling_interval = 60 # seconds between temperature updates -``` - -### Web Interface - -The web interface uses an embedded HTML template with CSS. You can customize the appearance by modifying the HTML template in the `index()` function. - -## Configuration - -The application uses environment variables for configuration. Create a `.env` file (copy from `.env.example`) with these settings: -- **LOG_FILE**: Path to the log file (defaults to `temp_monitor.log`) -- **BEARER_TOKEN**: API authentication token (auto-generated if not provided) -- **Static assets**: Images are served from the `static/` directory. Replace `static/My-img8bit-1com-Effect.gif` or `static/f -avicon.ico` if you need custom artwork. +## Webhook Notifications -All paths can be absolute or relative. The application will create the log directory if it doesn't exist. +When configured with a Slack webhook URL, the system sends alerts when readings exceed thresholds. -## Troubleshooting +### Alert Types -- **Sense HAT not detected**: Ensure the HAT is properly connected and that I2C is enabled (use `sudo raspi-config`) -- **Web interface not accessible**: Check that port 8080 is not blocked by a firewall -- **Inaccurate temperature**: Adjust the compensation factor in the `get_compensated_temperature()` function -- **Favicon not displaying**: Verify `static/favicon.ico` exists and is being served -- **Log file creation fails**: Ensure the directory specified in `LOG_FILE` exists or that the user has permission to create it +- **Temperature High**: Triggered when temp > `ALERT_TEMP_MAX_C` +- **Temperature Low**: Triggered when temp < `ALERT_TEMP_MIN_C` +- **Humidity High**: Triggered when humidity > `ALERT_HUMIDITY_MAX` +- **Humidity Low**: Triggered when humidity < `ALERT_HUMIDITY_MIN` -## License +### Features -[MIT License](LICENSE) - -## Contributing +- **Alert Cooldown**: 5-minute cooldown between same alert type (prevents spam) +- **Exponential Backoff**: Retries with increasing delays on failure +- **URL Masking**: Webhook URLs are masked in API responses and logs for security -Contributions are welcome! Please feel free to submit a Pull Request. +### Getting a Slack Webhook URL -# Temperature Monitor API with Bearer Token Authentication +1. Go to [Slack API](https://api.slack.com/messaging/webhooks) +2. Create a new app or use an existing one +3. Enable Incoming Webhooks +4. Create a webhook for your channel +5. Copy the URL to `SLACK_WEBHOOK_URL` in `.env` -This application monitors temperature and humidity using a Raspberry Pi with Sense HAT and provides a web interface and API endpoints to access the data. +## Deployment -## API Security +### Docker (Recommended) -The API endpoints are protected with Bearer Token authentication. You need to include a valid token in the `Authorization` header to access the API. +```bash +# Create logs directory and configure +mkdir -p logs +cp .env.example .env +# Edit .env with your settings -## Getting Started +# Build and run +docker compose up -d -1. Install the required dependencies: - ```bash - pip install -r requirements.txt - ``` +# View logs +docker compose logs -f -2. Configure your environment (see Setup section above for details) +# Stop +docker compose down +``` -3. Start the application: - ```bash - python temp_monitor.py - ``` +**Note**: Requires privileged mode for I2C/hardware access. -## Using the API +**Cloudflare Tunnel (Optional):** To enable the bundled Cloudflare Tunnel: +1. Add `CLOUDFLARED_TOKEN` to `.env` +2. Start with the cloudflare profile: `docker compose --profile cloudflare up -d` +3. In Cloudflare Zero Trust UI, point the tunnel service at `http://temp-monitor:8080` -To access the API endpoints, include the bearer token in the `Authorization` header: +### Systemd Service ```bash -curl -H "Authorization: Bearer YOUR_TOKEN_HERE" http://your-server:8080/api/temp +sudo cp deployment/systemd/temp-monitor.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable temp-monitor.service +sudo systemctl start temp-monitor.service ``` -### Available Endpoints - -- `/api/temp` - Get current temperature and humidity data -- `/api/raw` - Get raw temperature data (including CPU temperature) -- `/api/verify-token` - Verify if your token is valid -- `/api/generate-token` - Generate a new token (requires existing valid token) - -## Regenerating Tokens +If you are using Docker Compose, use `deployment/systemd/temp-monitor-compose.service` instead (update the `WorkingDirectory` and `User`). -You can regenerate the token in two ways: +### Production Configuration -1. Using the script: - ``` - python generate_token.py - ``` +- **Memory Limit**: 512MB (configurable) +- **Server**: Waitress WSGI, single worker/thread +- **Health Checks**: Every 30 seconds via `/health` +- **Auto-restart**: On failure with 10-second delay -2. Using the API (requires existing valid token): - ``` - curl -X POST -H "Authorization: Bearer YOUR_CURRENT_TOKEN" http://your-server:8080/api/generate-token - ``` +For detailed production deployment, see [docs/PI4_DEPLOYMENT.md](docs/PI4_DEPLOYMENT.md). -## Security Notes +## Temperature Compensation -- Keep your bearer token secure and don't share it publicly -- The token is stored in the `.env` file, which should be kept private -- Consider regenerating the token periodically for enhanced security +The Sense HAT is affected by CPU heat. The system compensates using: +``` +compensated_temp = raw_temp - ((cpu_temp - raw_temp) * factor) +``` +The default factor is `0.7`. Adjust in `temp_monitor.py` if readings seem inaccurate. +## Troubleshooting +| Issue | Solution | +|-------|----------| +| Sense HAT not detected | Enable I2C via `sudo raspi-config`, check connection | +| Port 8080 blocked | Check firewall: `sudo ufw allow 8080` | +| Inaccurate temperature | Adjust compensation factor in code | +| Webhook failures | Check URL, network connectivity, view logs | +| API returns 401/403 | Verify Bearer token in request header | +| Service won't start | Check logs: `journalctl -u temp-monitor -f` | + +## Dependencies + +| Package | Version | Description | +|---------|---------|-------------| +| Flask | 2.3.3 | Web framework | +| Flask-RESTX | 1.3.0+ | REST API with Swagger | +| sense-hat | 2.6.0 | Sense HAT library | +| python-dotenv | 1.0.0 | Environment management | +| requests | 2.31.0 | HTTP client for webhooks | +| waitress | 2.1.2+ | Production WSGI server | +| psutil | 5.9.0+ | System metrics (optional) | +## Contributing +Contributions are welcome! Please feel free to submit a Pull Request. +## License +[MIT License](LICENSE) diff --git a/api_models.py b/api_models.py new file mode 100644 index 0000000..b14c457 --- /dev/null +++ b/api_models.py @@ -0,0 +1,166 @@ +""" +Flask-RESTX API Models for Temperature Monitor + +Defines request/response models with validation for webhook configuration endpoints. +Provides automatic OpenAPI/Swagger documentation generation. +""" + +from flask_restx import Namespace, fields + +# Create namespace for webhook endpoints +webhooks_ns = Namespace('webhooks', description='Webhook configuration and management') + +# Webhook configuration model with validation +# Note: url is not required for partial updates when webhook service already exists +webhook_config_input = webhooks_ns.model('WebhookConfigInput', { + 'url': fields.String( + required=False, + description='Slack webhook URL (required when creating new webhook config)', + example='https://hooks.slack.com/services/...' + ), + 'enabled': fields.Boolean( + default=True, + description='Enable/disable webhook notifications' + ), + 'retry_count': fields.Integer( + default=3, + min=1, + max=10, + description='Number of retry attempts (1-10)' + ), + 'retry_delay': fields.Integer( + default=5, + min=1, + max=60, + description='Initial retry delay in seconds (1-60)' + ), + 'timeout': fields.Integer( + default=10, + min=5, + max=120, + description='Request timeout in seconds (5-120)' + ) +}) + +# Alert thresholds model +alert_thresholds_input = webhooks_ns.model('AlertThresholdsInput', { + 'temp_min_c': fields.Float( + description='Minimum temperature threshold in Celsius (-50 to 100)', + min=-50, + max=100, + example=15.0 + ), + 'temp_max_c': fields.Float( + description='Maximum temperature threshold in Celsius (-50 to 100)', + min=-50, + max=100, + example=27.0 + ), + 'humidity_min': fields.Float( + description='Minimum humidity threshold percentage (0-100)', + min=0, + max=100, + example=30.0 + ), + 'humidity_max': fields.Float( + description='Maximum humidity threshold percentage (0-100)', + min=0, + max=100, + example=70.0 + ) +}) + +# Combined config update request model +webhook_config_update = webhooks_ns.model('WebhookConfigUpdate', { + 'webhook': fields.Nested(webhook_config_input, description='Webhook settings'), + 'thresholds': fields.Nested(alert_thresholds_input, description='Alert thresholds') +}) + +# Response models - separate from input models for flexibility +webhook_config_output = webhooks_ns.model('WebhookConfigOutput', { + 'url': fields.String(description='Webhook URL (masked - scheme and host only for security)'), + 'enabled': fields.Boolean(description='Webhook enabled status'), + 'retry_count': fields.Integer(description='Number of retry attempts'), + 'retry_delay': fields.Integer(description='Retry delay in seconds'), + 'timeout': fields.Integer(description='Request timeout in seconds') +}) + +alert_thresholds_output = webhooks_ns.model('AlertThresholdsOutput', { + 'temp_min_c': fields.Float(description='Minimum temperature threshold in Celsius'), + 'temp_max_c': fields.Float(description='Maximum temperature threshold in Celsius'), + 'humidity_min': fields.Float(description='Minimum humidity threshold percentage'), + 'humidity_max': fields.Float(description='Maximum humidity threshold percentage') +}) + +webhook_config_response = webhooks_ns.model('WebhookConfigResponse', { + 'webhook': fields.Nested(webhook_config_output), + 'thresholds': fields.Nested(alert_thresholds_output) +}) + +error_response = webhooks_ns.model('ErrorResponse', { + 'error': fields.String(description='Error message'), + 'details': fields.String(description='Additional error details') +}) + +success_response = webhooks_ns.model('SuccessResponse', { + 'message': fields.String(description='Success message'), + 'config': fields.Nested(webhook_config_response, description='Updated configuration') +}) + +# Simple message response for enable/disable endpoints +message_response = webhooks_ns.model('MessageResponse', { + 'message': fields.String(description='Response message'), + 'enabled': fields.Boolean(description='Current enabled status') +}) + +# Test webhook response +test_response = webhooks_ns.model('TestResponse', { + 'message': fields.String(description='Test result message'), + 'timestamp': fields.String(description='Timestamp of the test') +}) + + +def validate_webhook_config(webhook: dict) -> tuple: + """ + Validate webhook configuration field ranges. + + Args: + webhook: Dictionary with webhook config values + + Returns: + Tuple of (is_valid: bool, error_message: str) + """ + if 'retry_count' in webhook and webhook['retry_count'] is not None: + if not (1 <= webhook['retry_count'] <= 10): + return False, 'retry_count must be between 1 and 10' + + if 'retry_delay' in webhook and webhook['retry_delay'] is not None: + if not (1 <= webhook['retry_delay'] <= 60): + return False, 'retry_delay must be between 1 and 60 seconds' + + if 'timeout' in webhook and webhook['timeout'] is not None: + if not (5 <= webhook['timeout'] <= 120): + return False, 'timeout must be between 5 and 120 seconds' + + return True, '' + + +def validate_thresholds(thresholds: dict) -> tuple: + """ + Validate threshold relationships (cross-field validation). + + Args: + thresholds: Dictionary with threshold values + + Returns: + Tuple of (is_valid: bool, error_message: str) + """ + if thresholds.get('temp_min_c') is not None and thresholds.get('temp_max_c') is not None: + if thresholds['temp_min_c'] >= thresholds['temp_max_c']: + return False, 'temp_min_c must be less than temp_max_c' + + if thresholds.get('humidity_min') is not None and thresholds.get('humidity_max') is not None: + if thresholds['humidity_min'] >= thresholds['humidity_max']: + return False, 'humidity_min must be less than humidity_max' + + return True, '' diff --git a/deployment/systemd/temp-monitor-compose.service b/deployment/systemd/temp-monitor-compose.service new file mode 100644 index 0000000..a094f53 --- /dev/null +++ b/deployment/systemd/temp-monitor-compose.service @@ -0,0 +1,17 @@ +[Unit] +Description=Temperature Monitor (Docker Compose) +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +User=pi +Group=pi +WorkingDirectory=/home/pi/temp_monitor +ExecStart=/usr/bin/docker compose up -d --remove-orphans +ExecStop=/usr/bin/docker compose down +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target diff --git a/deployment/systemd/temp-monitor.service b/deployment/systemd/temp-monitor.service new file mode 100644 index 0000000..513bb14 --- /dev/null +++ b/deployment/systemd/temp-monitor.service @@ -0,0 +1,51 @@ +[Unit] +Description=Temperature Monitor Service for Pi 4 +Documentation=https://github.com/your-repo/temp_monitor +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=pi +Group=pi +WorkingDirectory=/home/pi/temp_monitor + +# Environment variables +Environment="PRODUCTION_MODE=true" +Environment="LOG_FILE=/var/log/temp-monitor/temp_monitor.log" + +# Service startup command using Waitress +ExecStart=/home/pi/temp_monitor/venv/bin/waitress-serve \ + --host=0.0.0.0 \ + --port=8080 \ + --threads=1 \ + --channel-timeout=120 \ + --connection-limit=50 \ + --call wsgi:app + +# Restart policy +Restart=always +RestartSec=10 +StartLimitInterval=300s +StartLimitBurst=5 + +# Resource limits for Pi 4 +MemoryLimit=512M +MemoryMax=600M + +# Output handling +StandardOutput=journal +StandardError=journal +SyslogIdentifier=temp-monitor + +# Security settings +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/var/log/temp-monitor /home/pi/temp_monitor + +# Capabilities needed for I2C/Sense HAT access +AmbientCapabilities=CAP_SYS_RAWIO + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml index b215fbc..5a36d5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,3 +14,22 @@ services: restart: unless-stopped environment: - LOG_FILE=/app/logs/temp_monitor.log + # Resource limits for Pi 4 (single-process deployment) + mem_limit: 512m + mem_reservation: 256m + # Health check for monitoring (checks /health endpoint every 30s) + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=5)\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + cloudflared: + image: cloudflare/cloudflared:latest + command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN} + depends_on: + - temp-monitor + restart: unless-stopped + profiles: + - cloudflare diff --git a/docs/PI4_DEPLOYMENT.md b/docs/PI4_DEPLOYMENT.md new file mode 100644 index 0000000..d0414bb --- /dev/null +++ b/docs/PI4_DEPLOYMENT.md @@ -0,0 +1,419 @@ +# Raspberry Pi 4 Production Deployment Guide + +This guide covers optimized deployment of the Temperature Monitor application on Raspberry Pi 4 for production environments. + +## Hardware Requirements + +### Raspberry Pi 4 Specifications +- **CPU:** ARM Cortex-A72, 4 cores @ 1.5GHz +- **RAM:** 2GB, 4GB, or 8GB (2GB minimum recommended) +- **Storage:** microSD card 16GB+ +- **Power:** 5V/3A USB-C power supply +- **OS:** Raspberry Pi OS Bullseye or later + +### Sense HAT Requirements +- Raspberry Pi Sense HAT board +- I2C interface enabled +- GPIO pins accessible + +## Baseline Memory Footprint + +**To be measured during testing:** +- [ ] Idle application memory (Flask + sensor thread) +- [ ] Memory with active API requests +- [ ] Memory after 24-hour continuous operation +- [ ] Peak memory under load + +**Expected baseline (before testing):** +- Flask app + sensor thread: ~50-80 MB +- With Waitress server: ~100-120 MB +- System + app total: ~250-300 MB + +## Deployment Options + +### Option 1: Docker Deployment (Recommended) + +#### Prerequisites +```bash +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker pi +``` + +#### Deployment +```bash +cd /path/to/temp_monitor +docker compose up -d + +# Optional: Enable Cloudflare Tunnel +# 1. Add CLOUDFLARED_TOKEN to .env +# 2. Start with cloudflare profile: +docker compose --profile cloudflare up -d +``` + +**Note:** When using Cloudflare Tunnel, configure the service in Cloudflare Zero Trust as `http://temp-monitor:8080`. + +#### Monitoring +```bash +# View logs +docker compose logs -f temp-monitor + +# Check health +curl http://localhost:8080/health + +# View metrics +curl http://localhost:8080/metrics +``` + +#### Stop Service +```bash +docker compose down +``` + +### Option 2: Systemd Service Deployment + +#### Prerequisites +```bash +# Create log directory +sudo mkdir -p /var/log/temp-monitor +sudo chown pi:pi /var/log/temp-monitor + +# Install dependencies +cd /path/to/temp_monitor +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +#### Installation +```bash +# Copy service file +sudo cp deployment/systemd/temp-monitor.service /etc/systemd/system/ + +# Enable service +sudo systemctl daemon-reload +sudo systemctl enable temp-monitor.service +sudo systemctl start temp-monitor.service + +# Check status +sudo systemctl status temp-monitor.service + +# View logs +sudo journalctl -u temp-monitor.service -f +``` + +> **Note:** If you are using Docker Compose, use `deployment/systemd/temp-monitor-compose.service` instead (update the `WorkingDirectory` and `User`). + +#### Useful Commands +```bash +# Start/stop service +sudo systemctl start temp-monitor.service +sudo systemctl stop temp-monitor.service +sudo systemctl restart temp-monitor.service + +# View status +sudo systemctl status temp-monitor.service + +# View recent logs +sudo journalctl -u temp-monitor.service -n 50 + +# Follow logs +sudo journalctl -u temp-monitor.service -f +``` + +### Option 3: Direct Python Deployment + +For development and testing only. + +```bash +# Setup +cd /path/to/temp_monitor +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run in development mode +python temp_monitor.py + +# Or run production mode +./start_production.sh +``` + +## Configuration + +### Environment Variables + +Create or update `.env` file in the application directory: + +```bash +# Logging +LOG_FILE=/var/log/temp-monitor/temp_monitor.log + +# Cloudflare Tunnel (optional, Docker) +# Configure the tunnel in Cloudflare Zero Trust with service http://temp-monitor:8080 +CLOUDFLARED_TOKEN=your-cloudflare-tunnel-token + +# Webhook Configuration +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +WEBHOOK_ENABLED=true +WEBHOOK_RETRY_COUNT=3 +WEBHOOK_RETRY_DELAY=5 +WEBHOOK_TIMEOUT=10 + +# Alert Thresholds +ALERT_TEMP_MIN_C=15.0 +ALERT_TEMP_MAX_C=27.0 +ALERT_HUMIDITY_MIN=30.0 +ALERT_HUMIDITY_MAX=70.0 + +# Periodic Status Updates (optional) +STATUS_UPDATE_ENABLED=false +STATUS_UPDATE_INTERVAL=3600 + +# API Security +BEARER_TOKEN=your-secure-token-here +``` + +### Docker Compose Configuration + +The `docker-compose.yml` includes: +- Memory limits: 512MB (hard limit) / 256MB (reservation) +- CPU restrictions: No limit (uses available cores) +- Health checks: Every 30 seconds +- Automatic restart policy + +### Systemd Service Configuration + +The `deployment/systemd/temp-monitor.service` includes: +- Memory limits: 512MB (hard limit: 600MB) +- Restart policy: Always, with 10-second delays +- Security settings: ProtectSystem, NoNewPrivileges, ProtectHome (read-only) +- I2C access: CAP_SYS_RAWIO capability + +## Monitoring and Health Checks + +### Health Endpoint +```bash +curl http://localhost:8080/health +``` + +Response: +```json +{ + "status": "healthy", + "uptime_seconds": 12345, + "sensor_thread_alive": true, + "timestamp": 1234567890.123 +} +``` + +### Metrics Endpoint +```bash +curl http://localhost:8080/metrics +``` + +Response includes: +- Application metrics (request count, alerts sent, uptime) +- Hardware metrics (CPU temperature) +- System metrics (CPU %, memory usage, threads) + +### Log Monitoring + +#### Docker +```bash +docker compose logs -f temp-monitor +``` + +#### Systemd +```bash +sudo journalctl -u temp-monitor.service -f + +# Filter by level +sudo journalctl -u temp-monitor.service -p err -f +``` + +#### File-based (if using LOG_FILE) +```bash +tail -f /var/log/temp-monitor/temp_monitor.log +``` + +## Performance Tuning + +### Single-Process Configuration +The application is configured for single-process deployment: +- **Workers:** 1 +- **Threads per worker:** 1 +- **Connection limit:** 50 concurrent connections +- **Request timeout:** 120 seconds + +This configuration is optimized for Pi 4's limited resources while maintaining reliability. + +### Memory Management + +#### Monitoring Memory Usage +```bash +# Check current memory +curl http://localhost:8080/metrics | python -m json.tool | grep -A 10 '"system"' + +# Monitor over time +watch -n 5 'curl -s http://localhost:8080/metrics | python -m json.tool | grep memory' +``` + +#### Memory Limits +- **Container/Process limit:** 512MB +- **Alert threshold:** 400MB +- **Restart threshold:** 512MB (enforced by systemd/Docker) + +#### Detecting Memory Leaks +Monitor the `/metrics` endpoint over a 24-hour period. If `memory_mb` shows continuous growth, investigate: +1. Check sensor thread logs for errors +2. Review webhook service for stuck connections +3. Check Flask request handling for unfinished requests + +### I2C Performance +The application communicates with Sense HAT via I2C. Performance factors: +- I2C clock speed: 100kHz (standard) +- Sampling interval: 60 seconds (configurable) +- Temperature compensation: Calculated locally + +## Troubleshooting + +### Service Won't Start + +**Check logs:** +```bash +# Docker +docker compose logs temp-monitor + +# Systemd +sudo journalctl -u temp-monitor.service -n 50 +``` + +**Common issues:** +1. Permission denied on `/dev/i2c-1` + - Solution: `sudo usermod -a -G i2c pi` (then logout/login) +2. Port 8080 already in use + - Solution: Change port in config or stop conflicting service +3. Sense HAT not detected + - Solution: Check I2C enabled (`sudo raspi-config`) and Sense HAT connected + +### High Memory Usage + +**Investigation steps:** +1. Check current memory: `curl http://localhost:8080/metrics` +2. Look for memory leak pattern in metrics over time +3. Check logs for repeated errors +4. Monitor webhook service for hung connections + +**Solutions:** +1. Restart service: `sudo systemctl restart temp-monitor.service` +2. Increase memory threshold in code +3. Check webhook URL is responding + +### Health Check Failing + +**Quick test:** +```bash +curl -v http://localhost:8080/health +``` + +**If returns 500:** +1. Check logs for errors +2. Verify sensor thread is running +3. Check available disk space for logs + +### Webhook Delivery Issues + +**Test webhook endpoint:** +```bash +curl -X POST http://localhost:8080/api/webhook/test \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://your-webhook-url", "enabled": true}' +``` + +**Check webhook configuration:** +```bash +curl http://localhost:8080/api/webhook/config \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## Backup and Recovery + +### Backup Configuration +```bash +# Backup .env file +sudo cp /home/pi/temp_monitor/.env /home/pi/temp_monitor/.env.backup + +# Backup logs +sudo tar -czf temp-monitor-logs-$(date +%Y%m%d).tar.gz /var/log/temp-monitor/ +``` + +### Restore Configuration +```bash +sudo cp /home/pi/temp_monitor/.env.backup /home/pi/temp_monitor/.env +sudo systemctl restart temp-monitor.service +``` + +## Updates and Maintenance + +### Update Application Code +```bash +cd /path/to/temp_monitor +git pull origin main +source venv/bin/activate +pip install -r requirements.txt + +# Restart service +sudo systemctl restart temp-monitor.service +``` + +### Log Rotation (Systemd) + +Logs are automatically managed by journald. View retention: +```bash +sudo journalctl --vacuum-time=30d # Keep 30 days +``` + +### Log Rotation (File-based) + +Create `/etc/logrotate.d/temp-monitor`: +``` +/var/log/temp-monitor/*.log { + daily + rotate 7 + compress + delaycompress + notifempty + create 0644 pi pi + sharedscripts +} +``` + +## Performance Testing Results + +**To be completed after deployment:** + +| Metric | Target | Actual | +|--------|--------|--------| +| Idle memory | <150MB | --- | +| Peak memory | <400MB | --- | +| API response time | <100ms | --- | +| Sensor update latency | <1s | --- | +| Uptime without restart | 7 days | --- | + +## Additional Resources + +- [Raspberry Pi 4 Documentation](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html) +- [Sense HAT Documentation](https://github.com/RPi-Distro/Adafruit-Raspberry-Pi-Python-Code) +- [Waitress Documentation](https://docs.pylonsproject.org/projects/waitress/) +- [Flask Documentation](https://flask.palletsprojects.com/) +- [Docker for Raspberry Pi](https://docs.docker.com/engine/install/raspberry-pi-os/) + +## Support + +For issues or questions: +1. Check logs first: `sudo journalctl -u temp-monitor.service -f` +2. Review this guide's troubleshooting section +3. Check `/health` and `/metrics` endpoints +4. Review application logs at `LOG_FILE` location diff --git a/generate_token.py b/generate_token.py deleted file mode 100644 index 8ea8b85..0000000 --- a/generate_token.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -""" -Token Generator for Temperature Monitor API - -This script generates a new secure bearer token and saves it to the .env file. -Run this script when you need to reset or create a new API token. -""" - -import secrets -import os -import sys - -def generate_token(): - """Generate a new bearer token and save it to .env file""" - # Generate a secure random token - new_token = secrets.token_hex(32) # 64 character hex string - - # Check if .env file exists - env_exists = os.path.isfile('.env') - - try: - # Read existing .env content if it exists - env_content = [] - if env_exists: - with open('.env', 'r') as env_file: - env_content = env_file.readlines() - - # Update or add the BEARER_TOKEN line - token_line_found = False - for i, line in enumerate(env_content): - if line.startswith('BEARER_TOKEN='): - env_content[i] = f'BEARER_TOKEN={new_token}\n' - token_line_found = True - break - - if not token_line_found: - env_content.append(f'BEARER_TOKEN={new_token}\n') - - # Write back to .env file - with open('.env', 'w') as env_file: - env_file.writelines(env_content) - - print(f"New bearer token generated successfully: {new_token}") - print("Token has been saved to .env file") - print("\nTo use this token with curl:") - print(f'curl -H "Authorization: Bearer {new_token}" http://your-server:8080/api/temp') - - return True - except Exception as e: - print(f"Error: Failed to save token to .env file: {e}", file=sys.stderr) - print(f"\nYour generated token is: {new_token}") - print("Please manually add this to your .env file as: BEARER_TOKEN=") - return False - -if __name__ == "__main__": - generate_token() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8bd6e41..53bba4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ flask==2.3.3 +flask-restx>=1.3.0 sense-hat==2.6.0 -python-dotenv==1.0.0 \ No newline at end of file +python-dotenv==1.0.0 +requests==2.31.0 +waitress>=2.1.2 +psutil>=5.9.0 \ No newline at end of file diff --git a/start_production.sh b/start_production.sh new file mode 100644 index 0000000..7b664c4 --- /dev/null +++ b/start_production.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Production startup script for Raspberry Pi 4 +# This script starts the temperature monitor service using Waitress +# for production-grade deployment. + +set -e + +export PRODUCTION_MODE=true + +# Check if running on Raspberry Pi +if [ -f /proc/device-tree/model ]; then + MODEL=$(cat /proc/device-tree/model) + echo "Running on: $MODEL" +fi + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Check if virtual environment exists +if [ ! -d "venv" ]; then + echo "Error: Virtual environment not found at ./venv" + echo "Please create one with: python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt" + exit 1 +fi + +# Activate virtual environment +echo "Activating virtual environment..." +source venv/bin/activate + +# Verify required modules are installed +echo "Checking dependencies..." +python -c "import waitress, psutil, flask" || { + echo "Error: Required packages not installed" + echo "Run: pip install -r requirements.txt" + exit 1 +} + +# Create logs directory if it doesn't exist +mkdir -p logs + +echo "Starting Temperature Monitor in production mode..." +echo "Server will be available at http://localhost:8080" +echo "Health endpoint: http://localhost:8080/health" +echo "Metrics endpoint: http://localhost:8080/metrics" +echo "API documentation: http://localhost:8080/docs" +echo "" +echo "Press Ctrl+C to stop" +echo "" + +# Start server with Waitress +waitress-serve \ + --host=0.0.0.0 \ + --port=8080 \ + --threads=1 \ + --channel-timeout=120 \ + --connection-limit=50 \ + --call wsgi:app diff --git a/temp_monitor.log b/temp_monitor.log index 61915d4..dd2b024 100644 --- a/temp_monitor.log +++ b/temp_monitor.log @@ -2,3 +2,30 @@ 2025-11-28 05:02:16,375 - INFO - Saved bearer token to .env file 2025-11-28 05:02:16,378 - INFO - Starting temperature monitor service 2025-11-28 05:02:16,379 - ERROR - Failed to get CPU temperature: [Errno 2] No such file or directory: '/sys/class/thermal/thermal_zone0/temp' +2026-01-01 05:17:07,566 - INFO - Webhook service not configured (no SLACK_WEBHOOK_URL) +2026-01-01 05:17:07,567 - ERROR - BEARER_TOKEN not set in environment. API endpoints will not work. +2026-01-01 05:17:07,574 - WARNING - API access attempt without valid Authorization header from 127.0.0.1 +2026-01-01 05:17:07,575 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:17:07,576 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:17:07,576 - INFO - Alert thresholds updated: {'temp_min_c': 10.0, 'temp_max_c': 30.0, 'humidity_min': 20.0, 'humidity_max': 80.0} +2026-01-01 05:17:07,577 - WARNING - API access attempt with invalid token from 127.0.0.1 +2026-01-01 05:17:07,577 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:17:07,578 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:39:38,334 - INFO - Webhook service not configured (no SLACK_WEBHOOK_URL) +2026-01-01 05:39:38,334 - ERROR - BEARER_TOKEN not set in environment. API endpoints will not work. +2026-01-01 05:39:38,343 - WARNING - API access attempt without valid Authorization header from 127.0.0.1 +2026-01-01 05:39:38,343 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:39:38,344 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:39:38,345 - INFO - Alert thresholds updated: {'temp_min_c': 10.0, 'temp_max_c': 30.0, 'humidity_min': 20.0, 'humidity_max': 80.0} +2026-01-01 05:39:38,346 - WARNING - API access attempt with invalid token from 127.0.0.1 +2026-01-01 05:39:38,346 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:39:38,347 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:42:03,704 - INFO - Webhook service not configured (no SLACK_WEBHOOK_URL) +2026-01-01 05:42:03,704 - ERROR - BEARER_TOKEN not set in environment. API endpoints will not work. +2026-01-01 05:42:03,709 - WARNING - API access attempt without valid Authorization header from 127.0.0.1 +2026-01-01 05:42:03,714 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:42:03,715 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:42:03,715 - INFO - Alert thresholds updated: {'temp_min_c': 10.0, 'temp_max_c': 30.0, 'humidity_min': 20.0, 'humidity_max': 80.0} +2026-01-01 05:42:03,716 - WARNING - API access attempt with invalid token from 127.0.0.1 +2026-01-01 05:42:03,716 - INFO - Webhook configuration updated: https://hooks.slack.com +2026-01-01 05:42:03,717 - INFO - Webhook configuration updated: https://hooks.slack.com diff --git a/temp_monitor.py b/temp_monitor.py index 7f19920..e45ba5a 100644 --- a/temp_monitor.py +++ b/temp_monitor.py @@ -1,13 +1,26 @@ from sense_hat import SenseHat from flask import Flask, jsonify, render_template_string, request, abort +from flask_restx import Api, Resource import time import logging import threading import statistics import os -import secrets import functools +import signal +from urllib.parse import urlparse from dotenv import load_dotenv +from webhook_service import WebhookService, WebhookConfig, AlertThresholds +from api_models import ( + webhooks_ns, webhook_config_update, webhook_config_response, + error_response, success_response, message_response, test_response, + validate_thresholds, validate_webhook_config +) + +try: + import psutil +except ImportError: + psutil = None # Load environment variables from .env file load_dotenv() @@ -42,31 +55,134 @@ app = Flask(__name__) +# Initialize Flask-RESTX API with Swagger documentation +api = Api( + app, + version='1.0', + title='Temperature Monitor API', + description='Server room environmental monitoring API with webhook notifications', + doc='/docs', + authorizations={ + 'bearer': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization', + 'description': 'Bearer token authentication. Format: "Bearer "' + } + } + # Note: security='bearer' removed to allow public Swagger UI access at /docs + # Individual endpoints are protected via @webhooks_ns.doc(security='bearer') decorators +) + +# Register the webhooks namespace +api.add_namespace(webhooks_ns, path='/api/webhook') + # Global variables to store sensor data current_temp = 0 current_humidity = 0 last_updated = "Never" sampling_interval = 60 # seconds between temperature updates -# Get bearer token from environment or generate a new one if not present +# Metrics tracking for production deployment +app_start_time = time.time() +request_counter = 0 +webhook_alert_counter = 0 +sensor_thread = None # Will be initialized when started + +# Periodic status update configuration +status_update_enabled = os.getenv('STATUS_UPDATE_ENABLED', 'false').lower() == 'true' +status_update_interval = int(os.getenv('STATUS_UPDATE_INTERVAL', '3600')) +last_status_update = None # Track time of last status update + +# Validate status update interval (must be >= sampling_interval) +if status_update_enabled and status_update_interval < sampling_interval: + logging.warning( + f"STATUS_UPDATE_INTERVAL ({status_update_interval}s) is less than " + f"sampling_interval ({sampling_interval}s). Using sampling_interval as minimum." + ) + status_update_interval = sampling_interval + +# Initialize webhook service +webhook_service = None +slack_webhook_url = os.getenv('SLACK_WEBHOOK_URL') +if slack_webhook_url: + webhook_config = WebhookConfig( + url=slack_webhook_url, + enabled=os.getenv('WEBHOOK_ENABLED', 'true').lower() == 'true', + retry_count=int(os.getenv('WEBHOOK_RETRY_COUNT', '3')), + retry_delay=int(os.getenv('WEBHOOK_RETRY_DELAY', '5')), + timeout=int(os.getenv('WEBHOOK_TIMEOUT', '10')) + ) + + alert_thresholds = AlertThresholds( + temp_min_c=float(os.getenv('ALERT_TEMP_MIN_C', '15.0')) if os.getenv('ALERT_TEMP_MIN_C') else None, + temp_max_c=float(os.getenv('ALERT_TEMP_MAX_C', '27.0')) if os.getenv('ALERT_TEMP_MAX_C') else None, + humidity_min=float(os.getenv('ALERT_HUMIDITY_MIN', '30.0')) if os.getenv('ALERT_HUMIDITY_MIN') else None, + humidity_max=float(os.getenv('ALERT_HUMIDITY_MAX', '70.0')) if os.getenv('ALERT_HUMIDITY_MAX') else None + ) + + webhook_service = WebhookService(webhook_config, alert_thresholds) + logging.info("Webhook service initialized") +else: + logging.info("Webhook service not configured (no SLACK_WEBHOOK_URL)") + +# Initialize status update timer +if status_update_enabled and webhook_service: + if os.getenv('STATUS_UPDATE_ON_STARTUP', 'false').lower() == 'true': + last_status_update = None # Will trigger immediately on first loop + logging.info("Periodic status updates enabled (will send on startup)") + else: + last_status_update = time.time() # Start timer from now + logging.info(f"Periodic status updates enabled (interval: {status_update_interval}s)") +elif status_update_enabled and not webhook_service: + logging.warning("STATUS_UPDATE_ENABLED is true but webhook service not configured") + +def generate_error_id(): + """Generate a correlation ID for error tracking in logs and responses""" + timestamp = int(time.time() * 1000) +import random + suffix = format(random.randint(0, 65535), '04x') + return f"{timestamp}_{suffix}" + + +# Get bearer token from environment (required) BEARER_TOKEN = os.getenv('BEARER_TOKEN') if not BEARER_TOKEN: - # Generate a new token if one doesn't exist - BEARER_TOKEN = secrets.token_hex(32) # 64 character hex string - logging.info("Generated new bearer token") - - # Save the token to .env file + logging.critical("BEARER_TOKEN not set in environment. Exiting.") + print("ERROR: BEARER_TOKEN environment variable is required.") + print("Generate a token with: python3 -c \"import secrets; print(secrets.token_hex(32))\"") + print("Then add it to your .env file: BEARER_TOKEN=") + import sys + sys.exit(1) +else: + logging.info("Bearer token loaded from environment") + +def mask_webhook_url(url): + """ + Mask webhook URL by returning only scheme and host for security. + + This prevents sensitive path components and tokens from being exposed + in API responses and logs, while still showing which service is configured. + + Args: + url: Full webhook URL or None + + Returns: + Masked URL in format 'scheme://host' or None if input is None/empty + """ + if not url: + return None + try: - with open('.env', 'w') as env_file: - env_file.write(f"BEARER_TOKEN={BEARER_TOKEN}\n") - logging.info("Saved bearer token to .env file") - print(f"New bearer token generated and saved to .env file: {BEARER_TOKEN}") + parsed = urlparse(url) + if parsed.scheme and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}" + else: + # Malformed URL - return generic placeholder + return "" except Exception as e: - logging.error(f"Failed to save bearer token to .env file: {e}") - print(f"WARNING: Generated bearer token but failed to save to .env file: {e}") - print(f"Please manually add this token to your .env file: BEARER_TOKEN={BEARER_TOKEN}") -else: - logging.info("Using bearer token from .env file") + logging.warning(f"Error masking webhook URL: {e}") + return "" def require_token(f): """Decorator to require bearer token authentication for API endpoints""" @@ -161,24 +277,65 @@ def get_humidity(): def update_sensor_data(): """Background thread function to update sensor data periodically""" global current_temp, current_humidity, last_updated - + while True: try: current_temp = get_compensated_temperature() current_humidity = get_humidity() last_updated = time.strftime("%Y-%m-%d %H:%M:%S") - + cpu_temp_val = get_cpu_temperature() cpu_temp_display = f"{cpu_temp_val}°C" if cpu_temp_val is not None else "N/A" logging.info( f"Temperature: {current_temp}°C, Humidity: {current_humidity}%, CPU Temp: {cpu_temp_display}" ) - + + # Check thresholds and send alerts via webhook + if webhook_service: + try: + alerts_sent = webhook_service.check_and_alert( + current_temp, current_humidity, last_updated + ) + if alerts_sent: + increment_alert_counter() + logging.info(f"Webhook alerts sent: {list(alerts_sent.keys())}") + except Exception as webhook_error: + logging.error(f"Error sending webhook alert: {webhook_error}") + + # Send periodic status updates if enabled + if status_update_enabled and webhook_service: + global last_status_update + current_time = time.time() + + # Check if it's time for a status update + should_send_update = ( + last_status_update is None or # First update or startup update + (current_time - last_status_update) >= status_update_interval + ) + + if should_send_update: + try: + cpu_temp = get_cpu_temperature() + success = webhook_service.send_status_update( + current_temp, current_humidity, cpu_temp, last_updated + ) + + if success: + logging.info("Periodic status update sent successfully") + else: + logging.warning("Periodic status update failed, will retry at next interval") + + except Exception as update_error: + logging.error(f"Error sending periodic status update: {update_error}") + finally: + # Always update timestamp to prevent retry storms + last_status_update = current_time + # Display temperature on Sense HAT LED matrix temp_f = round((current_temp * 9/5) + 32, 1) message = f"Temp: {temp_f}F" sense.show_message(message) - + # Sleep for the specified interval time.sleep(sampling_interval) except Exception as e: @@ -254,7 +411,7 @@ def index():
Last updated: {{ last_updated }}
- Monitoring device: Raspberry Pi Zero 2 W with Sense HAT
+ Monitoring device: Raspberry Pi 4 with Sense HAT
@@ -295,36 +452,6 @@ def api_raw(): 'timestamp': last_updated }) -# Add a token generation endpoint (protected by existing token) -@app.route('/api/generate-token', methods=['POST']) -@require_token -def generate_new_token(): - """Generate a new bearer token (requires existing token to access)""" - global BEARER_TOKEN - - # Generate new token - new_token = secrets.token_hex(32) - - # Save to .env file - try: - with open('.env', 'w') as env_file: - env_file.write(f"BEARER_TOKEN={new_token}\n") - - # Update the global token - BEARER_TOKEN = new_token - logging.info("Generated and saved new bearer token") - - return jsonify({ - 'message': 'New bearer token generated successfully', - 'token': new_token - }) - except Exception as e: - logging.error(f"Failed to save new bearer token: {e}") - return jsonify({ - 'error': 'Failed to save new token', - 'details': str(e) - }), 500 - # Add an endpoint to check if token is valid @app.route('/api/verify-token', methods=['GET']) @require_token @@ -335,14 +462,350 @@ def verify_token(): 'message': 'Token is valid' }) -if __name__ == '__main__': - # Start the background thread to update sensor data - logging.info("Starting temperature monitor service") +# Webhook management endpoints using Flask-RESTX +@webhooks_ns.route('/config') +class WebhookConfigResource(Resource): + """Webhook configuration management""" + + @webhooks_ns.doc(security='bearer') + @webhooks_ns.marshal_with(webhook_config_response) + @webhooks_ns.response(200, 'Success', webhook_config_response) + @require_token + def get(self): + """Get current webhook configuration""" + if not webhook_service or not webhook_service.webhook_config: + return { + 'webhook': { + 'url': None, + 'enabled': False, + 'retry_count': 3, + 'retry_delay': 5, + 'timeout': 10 + }, + 'thresholds': { + 'temp_min_c': None, + 'temp_max_c': None, + 'humidity_min': None, + 'humidity_max': None + } + } + + config = webhook_service.webhook_config + thresholds = webhook_service.alert_thresholds + + return { + 'webhook': { + 'url': mask_webhook_url(config.url), + 'enabled': config.enabled, + 'retry_count': config.retry_count, + 'retry_delay': config.retry_delay, + 'timeout': config.timeout + }, + 'thresholds': { + 'temp_min_c': thresholds.temp_min_c, + 'temp_max_c': thresholds.temp_max_c, + 'humidity_min': thresholds.humidity_min, + 'humidity_max': thresholds.humidity_max + } + } + + @webhooks_ns.doc(security='bearer') + @webhooks_ns.expect(webhook_config_update) + @webhooks_ns.marshal_with(success_response) + @webhooks_ns.response(400, 'Validation Error', error_response) + @webhooks_ns.response(500, 'Server Error', error_response) + @require_token + def put(self): + """Update webhook configuration with validation""" + global webhook_service + + data = webhooks_ns.payload + + # Validate webhook config field ranges + if 'webhook' in data and data['webhook']: + is_valid, error_msg = validate_webhook_config(data['webhook']) + if not is_valid: + webhooks_ns.abort(400, error_msg) + + # Cross-field validation for thresholds + if 'thresholds' in data and data['thresholds']: + is_valid, error_msg = validate_thresholds(data['thresholds']) + if not is_valid: + webhooks_ns.abort(400, error_msg) + + # Validate URL is provided when no existing URL to fall back to + if 'webhook' in data and data['webhook']: + webhook_data = data['webhook'] + has_existing_url = ( + webhook_service and + webhook_service.webhook_config and + webhook_service.webhook_config.url + ) + if not has_existing_url and 'url' not in webhook_data: + webhooks_ns.abort(400, 'URL required when no existing webhook config') + + try: + # Update webhook config if provided + if 'webhook' in data and data['webhook']: + webhook_data = data['webhook'] + + # If webhook service doesn't exist, create it + if not webhook_service: + webhook_service = WebhookService() + + existing_config = webhook_service.webhook_config if webhook_service else None + config = WebhookConfig( + url=webhook_data.get('url', existing_config.url if existing_config else ''), + enabled=webhook_data.get('enabled', existing_config.enabled if existing_config else True), + retry_count=webhook_data.get('retry_count', existing_config.retry_count if existing_config else 3), + retry_delay=webhook_data.get('retry_delay', existing_config.retry_delay if existing_config else 5), + timeout=webhook_data.get('timeout', existing_config.timeout if existing_config else 10) + ) + webhook_service.set_webhook_config(config) + + # Update thresholds if provided + if 'thresholds' in data and data['thresholds']: + threshold_data = data['thresholds'] + thresholds = AlertThresholds( + temp_min_c=threshold_data.get('temp_min_c'), + temp_max_c=threshold_data.get('temp_max_c'), + humidity_min=threshold_data.get('humidity_min'), + humidity_max=threshold_data.get('humidity_max') + ) + + if not webhook_service: + webhook_service = WebhookService(alert_thresholds=thresholds) + else: + webhook_service.set_alert_thresholds(thresholds) + + return { + 'message': 'Webhook configuration updated successfully', + 'config': { + 'webhook': { + 'url': mask_webhook_url(webhook_service.webhook_config.url) if webhook_service and webhook_service.webhook_config else None, + 'enabled': webhook_service.webhook_config.enabled if webhook_service and webhook_service.webhook_config else False, + 'retry_count': webhook_service.webhook_config.retry_count if webhook_service and webhook_service.webhook_config else 3, + 'retry_delay': webhook_service.webhook_config.retry_delay if webhook_service and webhook_service.webhook_config else 5, + 'timeout': webhook_service.webhook_config.timeout if webhook_service and webhook_service.webhook_config else 10 + }, + 'thresholds': { + 'temp_min_c': webhook_service.alert_thresholds.temp_min_c if webhook_service else None, + 'temp_max_c': webhook_service.alert_thresholds.temp_max_c if webhook_service else None, + 'humidity_min': webhook_service.alert_thresholds.humidity_min if webhook_service else None, + 'humidity_max': webhook_service.alert_thresholds.humidity_max if webhook_service else None + } + } + } + + except Exception as e: + error_id = generate_error_id() + logging.exception(f"Error updating webhook config [error_id: {error_id}]") + return {'error': 'Failed to update webhook configuration', 'error_id': error_id}, 500 + + +@webhooks_ns.route('/test') +class WebhookTestResource(Resource): + """Test webhook functionality""" + + @webhooks_ns.doc(security='bearer') + @webhooks_ns.marshal_with(test_response) + @webhooks_ns.response(400, 'Webhook not configured', error_response) + @webhooks_ns.response(500, 'Server Error', error_response) + @require_token + def post(self): + """Send a test webhook message""" + if not webhook_service or not webhook_service.webhook_config: + webhooks_ns.abort(400, 'Webhook not configured') + + try: + cpu_temp = get_cpu_temperature() + success = webhook_service.send_status_update( + current_temp, + current_humidity, + cpu_temp, + last_updated + ) + + if success: + return { + 'message': 'Test webhook sent successfully', + 'timestamp': last_updated + } + else: + webhooks_ns.abort(500, 'Failed to send test webhook') + + except Exception as e: + error_id = generate_error_id() + logging.exception(f"Error sending test webhook [error_id: {error_id}]") + webhooks_ns.abort(500, 'Failed to send test webhook') + + +@webhooks_ns.route('/enable') +class WebhookEnableResource(Resource): + """Enable webhook notifications""" + + @webhooks_ns.doc(security='bearer') + @webhooks_ns.marshal_with(message_response) + @webhooks_ns.response(400, 'Webhook not configured', error_response) + @require_token + def post(self): + """Enable webhook notifications""" + if not webhook_service or not webhook_service.webhook_config: + webhooks_ns.abort(400, 'Webhook not configured') + + webhook_service.webhook_config.enabled = True + logging.info("Webhook notifications enabled") + + return { + 'message': 'Webhook notifications enabled', + 'enabled': True + } + + +@webhooks_ns.route('/disable') +class WebhookDisableResource(Resource): + """Disable webhook notifications""" + + @webhooks_ns.doc(security='bearer') + @webhooks_ns.marshal_with(message_response) + @webhooks_ns.response(400, 'Webhook not configured', error_response) + @require_token + def post(self): + """Disable webhook notifications""" + if not webhook_service or not webhook_service.webhook_config: + webhooks_ns.abort(400, 'Webhook not configured') + + webhook_service.webhook_config.enabled = False + logging.info("Webhook notifications disabled") + + return { + 'message': 'Webhook notifications disabled', + 'enabled': False + } + + +# Production Deployment Endpoints +# ============================================================================ + +@app.route('/health') +def health(): + """Health check endpoint for monitoring and load balancers""" + try: + sensor_alive = sensor_thread is not None and sensor_thread.is_alive() + return jsonify({ + 'status': 'healthy', + 'uptime_seconds': time.time() - app_start_time, + 'sensor_thread_alive': sensor_alive, + 'timestamp': time.time() + }), 200 + except Exception as e: + error_id = generate_error_id() + logging.exception(f"Health check error [error_id: {error_id}]") + return jsonify({'status': 'error', 'error_id': error_id}), 500 + + +@app.route('/metrics') +def metrics(): + """System and application metrics for Pi 4 monitoring""" + try: + metrics_data = { + 'application': { + 'total_requests': request_counter, + 'webhook_alerts_sent': webhook_alert_counter, + 'uptime_seconds': time.time() - app_start_time, + 'last_sensor_update': last_updated, + 'current_temp_c': current_temp, + 'current_humidity_percent': current_humidity, + 'sensor_thread_alive': sensor_thread is not None and sensor_thread.is_alive() + }, + 'hardware': { + 'cpu_temp_c': get_cpu_temperature() + } + } + + # Add system metrics if psutil is available + if psutil: + try: + process = psutil.Process() + metrics_data['system'] = { + 'cpu_percent': psutil.cpu_percent(interval=0.1), + 'memory_mb': process.memory_info().rss / 1024 / 1024, + 'memory_percent': process.memory_percent(), + 'threads': process.num_threads(), + 'file_descriptors': process.num_fds() if hasattr(process, 'num_fds') else 'N/A' + } + except Exception as psutil_error: + logging.exception("Error collecting system metrics") + metrics_data['system'] = {'error': 'Unable to collect system metrics'} + else: + metrics_data['system'] = {'error': 'psutil not available'} + + return jsonify(metrics_data), 200 + except Exception as e: + error_id = generate_error_id() + logging.exception(f"Metrics endpoint error [error_id: {error_id}]") + return jsonify({'error': 'Unable to retrieve metrics', 'error_id': error_id}), 500 + + +def start_sensor_thread(): + """ + Start the background sensor thread. + + Returns: + threading.Thread: The started sensor thread + + Raises: + RuntimeError: If sensor thread fails to start + """ + global sensor_thread + + if sensor_thread is not None and sensor_thread.is_alive(): + logging.warning("Sensor thread is already running, skipping restart") + return sensor_thread + + logging.info("Starting temperature monitor sensor thread") sensor_thread = threading.Thread(target=update_sensor_data, daemon=True) sensor_thread.start() - + # Give the thread a moment to get initial readings time.sleep(2) - - # Start the Flask web server - app.run(host='0.0.0.0', port=8080) + + if not sensor_thread.is_alive(): + raise RuntimeError("Sensor thread failed to start") + + logging.info("Sensor thread started successfully") + return sensor_thread + + +def increment_request_counter(): + """Middleware-like function to track requests""" + global request_counter + with threading.Lock(): + request_counter += 1 + + +def increment_alert_counter(): + """Increment webhook alert counter""" + global webhook_alert_counter + with threading.Lock(): + webhook_alert_counter += 1 + + +# Add request counter tracking +@app.before_request +def before_request(): + """Track incoming requests for metrics""" + increment_request_counter() + + +if __name__ == '__main__': + try: + # Start the background sensor thread + start_sensor_thread() + + # Start the Flask web server in development mode + logging.info("Starting Flask development server on 0.0.0.0:8080") + app.run(host='0.0.0.0', port=8080) + except Exception as e: + logging.error(f"Failed to start service: {e}") + raise diff --git a/test_api_models.py b/test_api_models.py new file mode 100644 index 0000000..eda1468 --- /dev/null +++ b/test_api_models.py @@ -0,0 +1,197 @@ +""" +Unit tests for api_models validation functions. + +Tests validate_webhook_config() and validate_thresholds() functions +that perform server-side validation beyond Flask-RESTX model constraints. +""" + +import unittest +from api_models import validate_webhook_config, validate_thresholds + + +class TestValidateWebhookConfig(unittest.TestCase): + """Tests for validate_webhook_config function.""" + + def test_valid_config_all_fields(self): + """Valid config with all fields in range returns True.""" + config = {'retry_count': 5, 'retry_delay': 30, 'timeout': 60} + is_valid, error = validate_webhook_config(config) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_config_minimum_values(self): + """Valid config with minimum allowed values.""" + config = {'retry_count': 1, 'retry_delay': 1, 'timeout': 5} + is_valid, error = validate_webhook_config(config) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_config_maximum_values(self): + """Valid config with maximum allowed values.""" + config = {'retry_count': 10, 'retry_delay': 60, 'timeout': 120} + is_valid, error = validate_webhook_config(config) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_config_empty(self): + """Empty config is valid (all fields optional).""" + config = {} + is_valid, error = validate_webhook_config(config) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_config_none_values(self): + """Config with None values is valid (skipped during validation).""" + config = {'retry_count': None, 'retry_delay': None, 'timeout': None} + is_valid, error = validate_webhook_config(config) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_invalid_retry_count_too_low(self): + """retry_count below 1 is invalid.""" + config = {'retry_count': 0} + is_valid, error = validate_webhook_config(config) + self.assertFalse(is_valid) + self.assertIn('retry_count', error) + + def test_invalid_retry_count_too_high(self): + """retry_count above 10 is invalid.""" + config = {'retry_count': 11} + is_valid, error = validate_webhook_config(config) + self.assertFalse(is_valid) + self.assertIn('retry_count', error) + + def test_invalid_retry_delay_too_low(self): + """retry_delay below 1 is invalid.""" + config = {'retry_delay': 0} + is_valid, error = validate_webhook_config(config) + self.assertFalse(is_valid) + self.assertIn('retry_delay', error) + + def test_invalid_retry_delay_too_high(self): + """retry_delay above 60 is invalid.""" + config = {'retry_delay': 61} + is_valid, error = validate_webhook_config(config) + self.assertFalse(is_valid) + self.assertIn('retry_delay', error) + + def test_invalid_timeout_too_low(self): + """timeout below 5 is invalid.""" + config = {'timeout': 4} + is_valid, error = validate_webhook_config(config) + self.assertFalse(is_valid) + self.assertIn('timeout', error) + + def test_invalid_timeout_too_high(self): + """timeout above 120 is invalid.""" + config = {'timeout': 121} + is_valid, error = validate_webhook_config(config) + self.assertFalse(is_valid) + self.assertIn('timeout', error) + + +class TestValidateThresholds(unittest.TestCase): + """Tests for validate_thresholds function.""" + + def test_valid_thresholds_all_fields(self): + """Valid thresholds with all fields properly ordered.""" + thresholds = { + 'temp_min_c': 15.0, + 'temp_max_c': 27.0, + 'humidity_min': 30.0, + 'humidity_max': 70.0 + } + is_valid, error = validate_thresholds(thresholds) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_thresholds_empty(self): + """Empty thresholds is valid (all fields optional).""" + thresholds = {} + is_valid, error = validate_thresholds(thresholds) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_thresholds_none_values(self): + """Thresholds with None values are valid (skipped).""" + thresholds = { + 'temp_min_c': None, + 'temp_max_c': None, + 'humidity_min': None, + 'humidity_max': None + } + is_valid, error = validate_thresholds(thresholds) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_thresholds_only_temp(self): + """Valid when only temperature thresholds provided.""" + thresholds = {'temp_min_c': 10.0, 'temp_max_c': 30.0} + is_valid, error = validate_thresholds(thresholds) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_thresholds_only_humidity(self): + """Valid when only humidity thresholds provided.""" + thresholds = {'humidity_min': 20.0, 'humidity_max': 80.0} + is_valid, error = validate_thresholds(thresholds) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_thresholds_partial_pairs(self): + """Valid when only one of a pair is provided.""" + thresholds = {'temp_min_c': 10.0, 'humidity_max': 80.0} + is_valid, error = validate_thresholds(thresholds) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_invalid_temp_min_equals_max(self): + """temp_min_c equal to temp_max_c is invalid.""" + thresholds = {'temp_min_c': 20.0, 'temp_max_c': 20.0} + is_valid, error = validate_thresholds(thresholds) + self.assertFalse(is_valid) + self.assertIn('temp_min_c', error) + + def test_invalid_temp_min_greater_than_max(self): + """temp_min_c greater than temp_max_c is invalid.""" + thresholds = {'temp_min_c': 30.0, 'temp_max_c': 20.0} + is_valid, error = validate_thresholds(thresholds) + self.assertFalse(is_valid) + self.assertIn('temp_min_c', error) + + def test_invalid_humidity_min_equals_max(self): + """humidity_min equal to humidity_max is invalid.""" + thresholds = {'humidity_min': 50.0, 'humidity_max': 50.0} + is_valid, error = validate_thresholds(thresholds) + self.assertFalse(is_valid) + self.assertIn('humidity_min', error) + + def test_invalid_humidity_min_greater_than_max(self): + """humidity_min greater than humidity_max is invalid.""" + thresholds = {'humidity_min': 80.0, 'humidity_max': 30.0} + is_valid, error = validate_thresholds(thresholds) + self.assertFalse(is_valid) + self.assertIn('humidity_min', error) + + def test_valid_thresholds_with_negative_temps(self): + """Valid with negative temperature values (e.g., freezer monitoring).""" + thresholds = {'temp_min_c': -30.0, 'temp_max_c': -10.0} + is_valid, error = validate_thresholds(thresholds) + self.assertTrue(is_valid) + self.assertEqual(error, '') + + def test_valid_temp_invalid_humidity(self): + """Valid temp thresholds but invalid humidity still fails.""" + thresholds = { + 'temp_min_c': 15.0, + 'temp_max_c': 27.0, + 'humidity_min': 80.0, + 'humidity_max': 30.0 + } + is_valid, error = validate_thresholds(thresholds) + self.assertFalse(is_valid) + self.assertIn('humidity_min', error) + + +if __name__ == '__main__': + unittest.main() diff --git a/test_periodic_updates.py b/test_periodic_updates.py new file mode 100644 index 0000000..730eea9 --- /dev/null +++ b/test_periodic_updates.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Test script for periodic status update functionality + +This script validates the configuration loading and timing logic +for periodic status updates without requiring the full Flask app or hardware. +""" + +import os +import sys +import time + + +def test_configuration_loading(): + """Test that periodic update configuration is loaded correctly""" + print("Testing configuration loading...") + + # Test 1: Default disabled + os.environ.pop('STATUS_UPDATE_ENABLED', None) + os.environ.pop('STATUS_UPDATE_INTERVAL', None) + os.environ.pop('STATUS_UPDATE_ON_STARTUP', None) + + status_update_enabled = os.getenv('STATUS_UPDATE_ENABLED', 'false').lower() == 'true' + status_update_interval = int(os.getenv('STATUS_UPDATE_INTERVAL', '3600')) + + assert status_update_enabled == False, "Default should be disabled" + assert status_update_interval == 3600, "Default interval should be 3600" + print("✓ Default configuration correct (disabled, 3600s interval)") + + # Test 2: Enabled with custom interval + os.environ['STATUS_UPDATE_ENABLED'] = 'true' + os.environ['STATUS_UPDATE_INTERVAL'] = '1800' + + status_update_enabled = os.getenv('STATUS_UPDATE_ENABLED', 'false').lower() == 'true' + status_update_interval = int(os.getenv('STATUS_UPDATE_INTERVAL', '3600')) + + assert status_update_enabled == True, "Should be enabled" + assert status_update_interval == 1800, "Interval should be 1800" + print("✓ Custom configuration loaded correctly (enabled, 1800s interval)") + + # Test 3: Startup update flag + os.environ['STATUS_UPDATE_ON_STARTUP'] = 'true' + send_on_startup = os.getenv('STATUS_UPDATE_ON_STARTUP', 'false').lower() == 'true' + assert send_on_startup == True, "Startup update should be enabled" + print("✓ Startup update flag loaded correctly") + + # Cleanup + os.environ.pop('STATUS_UPDATE_ENABLED', None) + os.environ.pop('STATUS_UPDATE_INTERVAL', None) + os.environ.pop('STATUS_UPDATE_ON_STARTUP', None) + + print("\n✅ Configuration loading tests passed\n") + + +def test_timing_logic(): + """Test the periodic update timing logic""" + print("Testing timing logic...") + + # Simulate the timing logic from temp_monitor.py + status_update_interval = 120 # 2 minutes for testing + sampling_interval = 60 # 60 seconds + + # Test 1: Interval validation (minimum enforcement) + test_interval = 30 # Less than sampling_interval + if test_interval < sampling_interval: + test_interval = sampling_interval + assert test_interval == 60, "Interval should be enforced to minimum" + print("✓ Minimum interval enforcement works") + + # Test 2: First update trigger (last_status_update = None) + last_status_update = None + current_time = time.time() + + should_send_update = ( + last_status_update is None or + (current_time - last_status_update) >= status_update_interval + ) + assert should_send_update == True, "Should trigger on first update" + print("✓ First update triggers correctly (last_status_update = None)") + + # Test 3: Update after interval elapsed + last_status_update = current_time - 125 # 125 seconds ago + should_send_update = ( + last_status_update is None or + (current_time - last_status_update) >= status_update_interval + ) + assert should_send_update == True, "Should trigger after interval elapsed" + print("✓ Update triggers after interval elapses (125s > 120s)") + + # Test 4: No update before interval + last_status_update = current_time - 60 # 60 seconds ago + should_send_update = ( + last_status_update is None or + (current_time - last_status_update) >= status_update_interval + ) + assert should_send_update == False, "Should not trigger before interval" + print("✓ Update blocked before interval elapses (60s < 120s)") + + # Test 5: Exact interval boundary + last_status_update = current_time - 120 # Exactly 120 seconds ago + should_send_update = ( + last_status_update is None or + (current_time - last_status_update) >= status_update_interval + ) + assert should_send_update == True, "Should trigger at exact interval" + print("✓ Update triggers at exact interval boundary (120s >= 120s)") + + print("\n✅ Timing logic tests passed\n") + + +def test_startup_behavior(): + """Test startup update behavior""" + print("Testing startup update behavior...") + + # Test 1: Startup update enabled + last_status_update_startup = None # Set to None for immediate trigger + should_send_on_first_loop = (last_status_update_startup is None) + assert should_send_on_first_loop == True, "Should send on first loop when enabled" + print("✓ Startup update enabled: triggers on first loop") + + # Test 2: Startup update disabled + last_status_update_normal = time.time() # Set to now, starts timer + should_send_on_first_loop = (last_status_update_normal is None) + assert should_send_on_first_loop == False, "Should wait for interval when disabled" + print("✓ Startup update disabled: waits for interval") + + print("\n✅ Startup behavior tests passed\n") + + +def test_independence_from_alerts(): + """Test that status updates are independent from alerts""" + print("Testing independence from alert system...") + + # Simulate both systems running + class MockAlertSystem: + def __init__(self): + self.last_alert_time = {'temp_high': time.time() - 60} # Alert sent 60s ago + self.alert_cooldown = 300 # 5 minutes + + def can_send_alert(self, alert_type): + """Check if alert can be sent (simulates cooldown)""" + last_time = self.last_alert_time.get(alert_type) + if last_time is None: + return True + elapsed = time.time() - last_time + return elapsed >= self.alert_cooldown + + # Create mock alert system + alert_system = MockAlertSystem() + + # Status update timing (independent) + status_update_interval = 120 + last_status_update = time.time() - 125 # 125 seconds ago + current_time = time.time() + + # Check if status update should send + should_send_status = (current_time - last_status_update) >= status_update_interval + assert should_send_status == True, "Status update should trigger" + + # Check if alert can send (should be blocked by cooldown) + can_send_alert = alert_system.can_send_alert('temp_high') + assert can_send_alert == False, "Alert should be blocked by cooldown" + + print("✓ Status update triggers independently of alert cooldown") + print("✓ Status update: ready to send (125s elapsed)") + print("✓ Alert: blocked by cooldown (60s < 300s)") + + print("\n✅ Independence tests passed\n") + + +def test_configuration_examples(): + """Test common configuration examples""" + print("Testing common configuration examples...") + + examples = [ + {"name": "Hourly updates", "interval": 3600}, + {"name": "30-minute updates", "interval": 1800}, + {"name": "Every 2 hours", "interval": 7200}, + {"name": "Every 4 hours", "interval": 14400}, + {"name": "Daily updates", "interval": 86400}, + ] + + sampling_interval = 60 + + for example in examples: + interval = example["interval"] + name = example["name"] + + # Validate interval + if interval < sampling_interval: + interval = sampling_interval + + # Calculate how many sensor cycles per update + cycles = interval // sampling_interval + + print(f"✓ {name}: {interval}s ({cycles} sensor cycles)") + + print("\n✅ Configuration examples validated\n") + + +def main(): + """Run all tests""" + print("=" * 60) + print("Periodic Status Update Test Suite") + print("=" * 60) + print() + + try: + test_configuration_loading() + test_timing_logic() + test_startup_behavior() + test_independence_from_alerts() + test_configuration_examples() + + print("=" * 60) + print("✅ ALL TESTS PASSED") + print("=" * 60) + print() + print("Next steps:") + print("1. Add configuration to .env:") + print(" STATUS_UPDATE_ENABLED=true") + print(" STATUS_UPDATE_INTERVAL=120 # 2 minutes for testing") + print(" STATUS_UPDATE_ON_STARTUP=true") + print() + print("2. Run temp_monitor.py and check logs:") + print(" tail -f temp_monitor.log | grep 'Periodic status update'") + print() + print("3. For production, set interval to 3600 (1 hour)") + print() + + return 0 + + except AssertionError as e: + print(f"\n❌ TEST FAILED: {e}") + return 1 + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_webhook.py b/test_webhook.py new file mode 100644 index 0000000..4ddbcec --- /dev/null +++ b/test_webhook.py @@ -0,0 +1,604 @@ +f#!/usr/bin/env python3 +""" +Test script for webhook functionality + +This script tests the webhook service without requiring the full Flask app or hardware. +Uses unittest.mock to capture payloads and verify Slack message structure. +""" + +import sys +import unittest +from unittest.mock import patch, MagicMock +from webhook_service import WebhookService, WebhookConfig, AlertThresholds + + +class TestSlackFormatting(unittest.TestCase): + """Test Slack message formatting and payload structure""" + + def setUp(self): + """Set up test fixtures""" + self.config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=True + ) + self.service = WebhookService(webhook_config=self.config) + + @patch.object(WebhookService, '_send_webhook') + def test_basic_message_payload_structure(self, mock_send): + """Test basic message creates correct payload structure""" + mock_send.return_value = True + + result = self.service.send_slack_message( + text="Test message", + color="good" + ) + + self.assertTrue(result) + mock_send.assert_called_once() + + payload = mock_send.call_args[0][0] + + # Verify top-level structure + self.assertIn("attachments", payload) + self.assertEqual(len(payload["attachments"]), 1) + + attachment = payload["attachments"][0] + + # Verify attachment fields + self.assertEqual(attachment["text"], "Test message") + self.assertEqual(attachment["color"], "good") + self.assertIn("ts", attachment) + self.assertIsInstance(attachment["ts"], int) + + # No fields for basic message + self.assertNotIn("fields", attachment) + + @patch.object(WebhookService, '_send_webhook') + def test_message_with_custom_color(self, mock_send): + """Test message with different color values""" + mock_send.return_value = True + + for color in ["warning", "danger", "#FF5733"]: + self.service.send_slack_message(text="Test", color=color) + payload = mock_send.call_args[0][0] + self.assertEqual(payload["attachments"][0]["color"], color) + + @patch.object(WebhookService, '_send_webhook') + def test_message_with_fields(self, mock_send): + """Test message with fields includes correct structure""" + mock_send.return_value = True + + fields = [ + {"title": "Field 1", "value": "Value 1", "short": True}, + {"title": "Field 2", "value": "Value 2", "short": False} + ] + + self.service.send_slack_message( + text="Message with fields", + color="good", + fields=fields + ) + + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + self.assertIn("fields", attachment) + self.assertEqual(len(attachment["fields"]), 2) + self.assertEqual(attachment["fields"][0]["title"], "Field 1") + self.assertEqual(attachment["fields"][0]["value"], "Value 1") + self.assertTrue(attachment["fields"][0]["short"]) + self.assertEqual(attachment["fields"][1]["title"], "Field 2") + self.assertFalse(attachment["fields"][1]["short"]) + + +class TestAlertPayloads(unittest.TestCase): + """Test alert message payloads""" + + def setUp(self): + """Set up test fixtures with thresholds""" + self.config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=True + ) + self.thresholds = AlertThresholds( + temp_min_c=15.0, + temp_max_c=27.0, + humidity_min=30.0, + humidity_max=70.0 + ) + self.service = WebhookService( + webhook_config=self.config, + alert_thresholds=self.thresholds + ) + + def _reset_cooldown(self): + """Helper to reset alert cooldown""" + with self.service._lock: + self.service.last_alert_time.clear() + + @patch.object(WebhookService, '_send_webhook') + def test_temp_high_alert_payload(self, mock_send): + """Test high temperature alert has correct payload structure""" + mock_send.return_value = True + self._reset_cooldown() + + alerts = self.service.check_and_alert(30.0, 50.0, "2025-12-30 12:00:00") + + self.assertIn('temp_high', alerts) + mock_send.assert_called_once() + + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + # Verify text and color + self.assertIn("Temperature Alert: HIGH", attachment["text"]) + self.assertEqual(attachment["color"], "danger") + + # Verify fields structure and content + fields = attachment["fields"] + self.assertEqual(len(fields), 3) + + # Field 0: Current Temperature + self.assertEqual(fields[0]["title"], "Current Temperature") + self.assertIn("30", fields[0]["value"]) + self.assertIn("86", fields[0]["value"]) # 30°C = 86°F + self.assertTrue(fields[0]["short"]) + + # Field 1: Threshold + self.assertEqual(fields[1]["title"], "Threshold") + self.assertIn("27", fields[1]["value"]) + self.assertTrue(fields[1]["short"]) + + # Field 2: Timestamp + self.assertEqual(fields[2]["title"], "Timestamp") + self.assertEqual(fields[2]["value"], "2025-12-30 12:00:00") + self.assertFalse(fields[2]["short"]) + + @patch.object(WebhookService, '_send_webhook') + def test_temp_low_alert_payload(self, mock_send): + """Test low temperature alert has correct payload structure""" + mock_send.return_value = True + self._reset_cooldown() + + alerts = self.service.check_and_alert(10.0, 50.0, "2025-12-30 12:00:00") + + self.assertIn('temp_low', alerts) + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + self.assertIn("Temperature Alert: LOW", attachment["text"]) + self.assertEqual(attachment["color"], "warning") + self.assertEqual(len(attachment["fields"]), 3) + + @patch.object(WebhookService, '_send_webhook') + def test_humidity_high_alert_payload(self, mock_send): + """Test high humidity alert has correct payload structure""" + mock_send.return_value = True + self._reset_cooldown() + + alerts = self.service.check_and_alert(22.0, 75.0, "2025-12-30 12:00:00") + + self.assertIn('humidity_high', alerts) + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + self.assertIn("Humidity Alert: HIGH", attachment["text"]) + self.assertEqual(attachment["color"], "warning") + + fields = attachment["fields"] + self.assertEqual(fields[0]["title"], "Current Humidity") + self.assertEqual(fields[0]["value"], "75.0%") + self.assertEqual(fields[1]["title"], "Threshold") + self.assertEqual(fields[1]["value"], "70.0%") + + @patch.object(WebhookService, '_send_webhook') + def test_humidity_low_alert_payload(self, mock_send): + """Test low humidity alert has correct payload structure""" + mock_send.return_value = True + self._reset_cooldown() + + alerts = self.service.check_and_alert(22.0, 25.0, "2025-12-30 12:00:00") + + self.assertIn('humidity_low', alerts) + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + self.assertIn("Humidity Alert: LOW", attachment["text"]) + self.assertEqual(attachment["color"], "warning") + fields = attachment["fields"] + self.assertEqual(fields[0]["title"], "Current Humidity") + self.assertEqual(fields[0]["value"], "25.0%") + self.assertEqual(fields[1]["title"], "Threshold") + self.assertEqual(fields[1]["value"], "30.0%") + + @patch.object(WebhookService, '_send_webhook') + def test_normal_readings_no_alert(self, mock_send): + """Test normal readings do not trigger any alerts""" + mock_send.return_value = True + self._reset_cooldown() + + alerts = self.service.check_and_alert(22.0, 50.0, "2025-12-30 12:00:00") + + self.assertEqual(len(alerts), 0) + mock_send.assert_not_called() + + +class TestStatusUpdatePayload(unittest.TestCase): + """Test status update message payloads""" + + def setUp(self): + """Set up test fixtures""" + self.config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=True + ) + self.service = WebhookService(webhook_config=self.config) + + @patch.object(WebhookService, '_send_webhook') + def test_status_update_payload_structure(self, mock_send): + """Test status update has correct payload structure""" + mock_send.return_value = True + + result = self.service.send_status_update( + temperature_c=22.5, + humidity=55.0, + cpu_temp=45.0, + timestamp="2025-12-30 12:00:00" + ) + + self.assertTrue(result) + mock_send.assert_called_once() + + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + # Verify text and color + self.assertIn("Server Room Status Update", attachment["text"]) + self.assertEqual(attachment["color"], "good") + + # Verify fields order and content + fields = attachment["fields"] + self.assertEqual(len(fields), 4) + + # Field order: Temperature, Humidity, CPU Temperature, Last Updated + self.assertEqual(fields[0]["title"], "Temperature") + self.assertIn("22.5", fields[0]["value"]) + self.assertIn("72.5", fields[0]["value"]) # 22.5°C = 72.5°F + self.assertTrue(fields[0]["short"]) + + self.assertEqual(fields[1]["title"], "Humidity") + self.assertEqual(fields[1]["value"], "55.0%") + self.assertTrue(fields[1]["short"]) + + self.assertEqual(fields[2]["title"], "CPU Temperature") + self.assertEqual(fields[2]["value"], "45.0°C") + self.assertTrue(fields[2]["short"]) + + self.assertEqual(fields[3]["title"], "Last Updated") + self.assertEqual(fields[3]["value"], "2025-12-30 12:00:00") + self.assertFalse(fields[3]["short"]) + + @patch.object(WebhookService, '_send_webhook') + def test_status_update_without_cpu_temp(self, mock_send): + """Test status update without CPU temperature""" + mock_send.return_value = True + + self.service.send_status_update( + temperature_c=22.5, + humidity=55.0, + cpu_temp=None, + timestamp="2025-12-30 12:00:00" + ) + + payload = mock_send.call_args[0][0] + fields = payload["attachments"][0]["fields"] + + # Only 3 fields when CPU temp is None + self.assertEqual(len(fields), 3) + field_titles = [f["title"] for f in fields] + self.assertNotIn("CPU Temperature", field_titles) + self.assertIn("Temperature", field_titles) + self.assertIn("Humidity", field_titles) + self.assertIn("Last Updated", field_titles) + + +class TestSystemEventPayloads(unittest.TestCase): + """Test system event message payloads""" + + def setUp(self): + """Set up test fixtures""" + self.config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=True + ) + self.service = WebhookService(webhook_config=self.config) + + @patch.object(WebhookService, '_send_webhook') + def test_startup_event_payload(self, mock_send): + """Test startup event has correct icon and color""" + mock_send.return_value = True + + self.service.send_system_event( + event_type="startup", + message="Service started successfully", + severity="info" + ) + + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + self.assertIn("STARTUP", attachment["text"]) + self.assertIn("Service started successfully", attachment["text"]) + self.assertEqual(attachment["color"], "good") + + # Verify timestamp field + fields = attachment["fields"] + self.assertEqual(len(fields), 1) + self.assertEqual(fields[0]["title"], "Timestamp") + + @patch.object(WebhookService, '_send_webhook') + def test_shutdown_event_payload(self, mock_send): + """Test shutdown event has correct icon""" + mock_send.return_value = True + + self.service.send_system_event( + event_type="shutdown", + message="Service stopping", + severity="info" + ) + + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + self.assertIn("SHUTDOWN", attachment["text"]) + + @patch.object(WebhookService, '_send_webhook') + def test_error_event_payload(self, mock_send): + """Test error event has danger color""" + mock_send.return_value = True + + self.service.send_system_event( + event_type="error", + message="Critical failure", + severity="error" + ) + + payload = mock_send.call_args[0][0] + attachment = payload["attachments"][0] + + self.assertIn("ERROR", attachment["text"]) + self.assertEqual(attachment["color"], "danger") + + @patch.object(WebhookService, '_send_webhook') + def test_warning_severity_color(self, mock_send): + """Test warning severity maps to warning color""" + mock_send.return_value = True + + self.service.send_system_event( + event_type="info", + message="Warning message", + severity="warning" + ) + + payload = mock_send.call_args[0][0] + self.assertEqual(payload["attachments"][0]["color"], "warning") + + +class TestWebhookDisabled(unittest.TestCase): + """Test that send is not invoked when webhook is disabled""" + + @patch('webhook_service.requests.post') + def test_send_not_called_when_disabled(self, mock_post): + """Verify requests.post is NOT called when enabled=False""" + config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=False + ) + service = WebhookService(webhook_config=config) + + result = service.send_slack_message(text="Should not send") + + self.assertFalse(result) + mock_post.assert_not_called() + + @patch('webhook_service.requests.post') + def test_status_update_not_sent_when_disabled(self, mock_post): + """Verify status update does not send when disabled""" + config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=False + ) + service = WebhookService(webhook_config=config) + + result = service.send_status_update(22.0, 50.0, 40.0, "2025-12-30 12:00:00") + + self.assertFalse(result) + mock_post.assert_not_called() + + @patch('webhook_service.requests.post') + def test_system_event_not_sent_when_disabled(self, mock_post): + """Verify system event does not send when disabled""" + config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=False + ) + service = WebhookService(webhook_config=config) + + result = service.send_system_event("startup", "Test", "info") + + self.assertFalse(result) + mock_post.assert_not_called() + + @patch('webhook_service.requests.post') + def test_alerts_not_sent_when_disabled(self, mock_post): + """Verify alerts do not send when disabled""" + config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=False + ) + thresholds = AlertThresholds(temp_max_c=25.0) + service = WebhookService(webhook_config=config, alert_thresholds=thresholds) + + # Trigger a high temp alert + alerts = service.check_and_alert(30.0, 50.0, "2025-12-30 12:00:00") + + # Alert detected but not sent + self.assertIn('temp_high', alerts) + self.assertFalse(alerts['temp_high']) + mock_post.assert_not_called() + + +class TestThresholdDetection(unittest.TestCase): + """Test threshold detection logic""" + + def setUp(self): + """Set up test fixtures""" + self.config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=False # Disable actual sends + ) + self.thresholds = AlertThresholds( + temp_min_c=15.0, + temp_max_c=27.0, + humidity_min=30.0, + humidity_max=70.0 + ) + self.service = WebhookService( + webhook_config=self.config, + alert_thresholds=self.thresholds + ) + + def _reset_cooldown(self): + """Helper to reset alert cooldown""" + with self.service._lock: + self.service.last_alert_time.clear() + + def test_normal_readings_no_alerts(self): + """Normal readings should not trigger alerts""" + alerts = self.service.check_and_alert(22.0, 50.0, "2025-12-30 12:00:00") + self.assertEqual(len(alerts), 0) + + def test_high_temperature_triggers(self): + """High temperature should trigger temp_high alert""" + self._reset_cooldown() + alerts = self.service.check_and_alert(30.0, 50.0, "2025-12-30 12:00:00") + self.assertIn('temp_high', alerts) + + def test_low_temperature_triggers(self): + """Low temperature should trigger temp_low alert""" + self._reset_cooldown() + alerts = self.service.check_and_alert(10.0, 50.0, "2025-12-30 12:00:00") + self.assertIn('temp_low', alerts) + + def test_high_humidity_triggers(self): + """High humidity should trigger humidity_high alert""" + self._reset_cooldown() + alerts = self.service.check_and_alert(22.0, 75.0, "2025-12-30 12:00:00") + self.assertIn('humidity_high', alerts) + + def test_low_humidity_triggers(self): + """Low humidity should trigger humidity_low alert""" + self._reset_cooldown() + alerts = self.service.check_and_alert(22.0, 25.0, "2025-12-30 12:00:00") + self.assertIn('humidity_low', alerts) + + +class TestCooldownLogic(unittest.TestCase): + """Test alert cooldown logic""" + + def setUp(self): + """Set up test fixtures""" + self.config = WebhookConfig( + url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + enabled=False + ) + self.thresholds = AlertThresholds(temp_max_c=25.0) + self.service = WebhookService( + webhook_config=self.config, + alert_thresholds=self.thresholds + ) + + def test_first_alert_allowed(self): + """First alert should be allowed""" + can_send = self.service._can_send_alert('test_alert') + self.assertTrue(can_send) + + def test_cooldown_blocks_immediate_retry(self): + """Immediate retry should be blocked by cooldown""" + self.service._mark_alert_sent('test_alert') + can_send = self.service._can_send_alert('test_alert') + self.assertFalse(can_send) + + def test_different_alert_types_independent(self): + """Different alert types should be independent""" + self.service._mark_alert_sent('test_alert') + can_send = self.service._can_send_alert('different_alert') + self.assertTrue(can_send) + + +class TestConfiguration(unittest.TestCase): + """Test configuration management""" + + def test_default_config_values(self): + """Default configuration values should be correct""" + config = WebhookConfig(url="https://test.url") + self.assertTrue(config.enabled) + self.assertEqual(config.retry_count, 3) + self.assertEqual(config.retry_delay, 5) + self.assertEqual(config.timeout, 10) + + def test_custom_config_values(self): + """Custom configuration values should be applied""" + config = WebhookConfig( + url="https://test.url", + enabled=False, + retry_count=5, + retry_delay=10, + timeout=30 + ) + self.assertFalse(config.enabled) + self.assertEqual(config.retry_count, 5) + self.assertEqual(config.retry_delay, 10) + self.assertEqual(config.timeout, 30) + + def test_disabled_thresholds_dont_trigger(self): + """Disabled thresholds (None) should not trigger alerts""" + thresholds = AlertThresholds( + temp_min_c=None, + temp_max_c=30.0, + humidity_min=None, + humidity_max=80.0 + ) + service = WebhookService(alert_thresholds=thresholds) + + alerts = service.check_and_alert(10.0, 25.0, "2025-12-30 12:00:00") + + self.assertNotIn('temp_low', alerts) + self.assertNotIn('humidity_low', alerts) + + +def main(): + """Run all tests using unittest""" + # Create a test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add all test classes + suite.addTests(loader.loadTestsFromTestCase(TestSlackFormatting)) + suite.addTests(loader.loadTestsFromTestCase(TestAlertPayloads)) + suite.addTests(loader.loadTestsFromTestCase(TestStatusUpdatePayload)) + suite.addTests(loader.loadTestsFromTestCase(TestSystemEventPayloads)) + suite.addTests(loader.loadTestsFromTestCase(TestWebhookDisabled)) + suite.addTests(loader.loadTestsFromTestCase(TestThresholdDetection)) + suite.addTests(loader.loadTestsFromTestCase(TestCooldownLogic)) + suite.addTests(loader.loadTestsFromTestCase(TestConfiguration)) + + # Run with verbosity + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_webhook_api.py b/test_webhook_api.py new file mode 100644 index 0000000..b21b7e2 --- /dev/null +++ b/test_webhook_api.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +""" +Integration tests for Flask-RESTX Webhook API endpoints + +Tests the REST API endpoints that manage webhook configuration, +focusing on the bug fix for AttributeError when creating new webhook service. +""" + +import sys +import os +import json +import unittest +from unittest.mock import Mock, patch, MagicMock + +# Mock the sense_hat module before importing temp_monitor +sys.modules['sense_hat'] = MagicMock() + +# Now import after mocking +from temp_monitor import app, webhook_service +from webhook_service import WebhookService, WebhookConfig, AlertThresholds + + +class TestWebhookAPIEndpoints(unittest.TestCase): + """Test Flask-RESTX webhook API endpoints""" + + def setUp(self): + """Set up test client and test data""" + self.app = app + self.app.config['TESTING'] = True + self.client = self.app.test_client() + + # Get bearer token from environment + self.token = os.getenv('BEARER_TOKEN', 'test_token_12345') + self.auth_header = {'Authorization': f'Bearer {self.token}'} + + # Save original webhook_service state + self.original_webhook_service = webhook_service + + def tearDown(self): + """Clean up after tests""" + # Restore original webhook_service + import temp_monitor + temp_monitor.webhook_service = self.original_webhook_service + + def test_create_webhook_config_new_service(self): + """Test creating webhook config when webhook_service doesn't exist + + This is the critical test for the bug fix at line 495. + When webhook_service is None, creating a new config should work without AttributeError. + """ + # Ensure webhook_service is None to simulate the bug scenario + import temp_monitor + temp_monitor.webhook_service = None + + payload = { + 'webhook': { + 'url': 'https://hooks.slack.com/services/TEST/NEW/CONFIG', + 'enabled': True, + 'retry_count': 5, + 'retry_delay': 10, + 'timeout': 15 + } + } + + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json', + headers=self.auth_header + ) + + # Should succeed without AttributeError + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertIn('message', data) + + # Verify webhook_service was created + self.assertIsNotNone(temp_monitor.webhook_service) + self.assertEqual(temp_monitor.webhook_service.webhook_config.url, + 'https://hooks.slack.com/services/TEST/NEW/CONFIG') + self.assertEqual(temp_monitor.webhook_service.webhook_config.retry_count, 5) + + def test_create_webhook_config_missing_url(self): + """Test that creating webhook config without URL returns 400 error""" + import temp_monitor + temp_monitor.webhook_service = None + + payload = { + 'webhook': { + 'enabled': True + # URL is missing - should trigger validation error + } + } + + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json', + headers=self.auth_header + ) + + # Should fail with 400 Bad Request + self.assertEqual(response.status_code, 400) + data = json.loads(response.data) + self.assertIn('message', data) + self.assertIn('URL required', data['message']) + + def test_update_existing_webhook_config(self): + """Test updating webhook config when service already exists""" + # Create an existing webhook service + import temp_monitor + existing_config = WebhookConfig( + url='https://hooks.slack.com/services/EXISTING', + enabled=True + ) + temp_monitor.webhook_service = WebhookService(webhook_config=existing_config) + + payload = { + 'webhook': { + 'enabled': False, # Just update enabled, don't change URL + 'retry_count': 7 + } + } + + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json', + headers=self.auth_header + ) + + self.assertEqual(response.status_code, 200) + + # Verify config was updated + self.assertFalse(temp_monitor.webhook_service.webhook_config.enabled) + self.assertEqual(temp_monitor.webhook_service.webhook_config.retry_count, 7) + + def test_get_webhook_config_exists(self): + """Test getting webhook config when it exists""" + import temp_monitor + config = WebhookConfig(url='https://hooks.slack.com/test') + thresholds = AlertThresholds(temp_min_c=15.0, temp_max_c=27.0) + temp_monitor.webhook_service = WebhookService( + webhook_config=config, + alert_thresholds=thresholds + ) + + response = self.client.get( + '/api/webhook/config', + headers=self.auth_header + ) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + + self.assertIn('webhook', data) + # URL should be masked (scheme + host only) + self.assertEqual(data['webhook']['url'], 'https://hooks.slack.com') + + self.assertIn('thresholds', data) + self.assertEqual(data['thresholds']['temp_min_c'], 15.0) + self.assertEqual(data['thresholds']['temp_max_c'], 27.0) + + def test_get_webhook_config_not_exists(self): + """Test getting webhook config when service doesn't exist""" + import temp_monitor + temp_monitor.webhook_service = None + + response = self.client.get( + '/api/webhook/config', + headers=self.auth_header + ) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + + # Should return default values + self.assertIn('webhook', data) + self.assertIsNone(data['webhook']['url']) + self.assertFalse(data['webhook']['enabled']) + + def test_create_webhook_with_thresholds(self): + """Test creating webhook config with alert thresholds""" + import temp_monitor + temp_monitor.webhook_service = None + + payload = { + 'webhook': { + 'url': 'https://hooks.slack.com/services/TEST', + 'enabled': True + }, + 'thresholds': { + 'temp_min_c': 10.0, + 'temp_max_c': 30.0, + 'humidity_min': 20.0, + 'humidity_max': 80.0 + } + } + + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json', + headers=self.auth_header + ) + + self.assertEqual(response.status_code, 200) + + # Verify both webhook and thresholds were set + self.assertIsNotNone(temp_monitor.webhook_service) + self.assertIsNotNone(temp_monitor.webhook_service.webhook_config) + self.assertIsNotNone(temp_monitor.webhook_service.alert_thresholds) + + self.assertEqual(temp_monitor.webhook_service.alert_thresholds.temp_min_c, 10.0) + self.assertEqual(temp_monitor.webhook_service.alert_thresholds.temp_max_c, 30.0) + + def test_invalid_thresholds_validation(self): + """Test that invalid thresholds (min >= max) return 400 error""" + import temp_monitor + temp_monitor.webhook_service = None + + payload = { + 'webhook': { + 'url': 'https://hooks.slack.com/services/TEST' + }, + 'thresholds': { + 'temp_min_c': 30.0, # Min > Max - invalid! + 'temp_max_c': 20.0 + } + } + + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json', + headers=self.auth_header + ) + + # Should fail with 400 Bad Request + self.assertEqual(response.status_code, 400) + data = json.loads(response.data) + self.assertIn('message', data) + self.assertIn('temp_min_c must be less than temp_max_c', data['message']) + + def test_authentication_required(self): + """Test that API endpoints require authentication""" + payload = { + 'webhook': { + 'url': 'https://hooks.slack.com/test' + } + } + + # Request without auth header + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json' + ) + + # Should fail with 401 Unauthorized + self.assertEqual(response.status_code, 401) + + def test_invalid_token(self): + """Test that invalid bearer token is rejected""" + payload = { + 'webhook': { + 'url': 'https://hooks.slack.com/test' + } + } + + # Request with invalid token + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json', + headers={'Authorization': 'Bearer invalid_token_xyz'} + ) + + # Should fail with 403 Forbidden + self.assertEqual(response.status_code, 403) + + def test_webhook_url_masking(self): + """Test that webhook URLs are masked in API responses for security + + Verifies that full webhook URLs (which may contain sensitive tokens) + are not exposed in GET/PUT responses. Only scheme + host should be returned. + """ + import temp_monitor + + # Test with Slack webhook URL (contains sensitive tokens in path) + slack_url = 'https://hooks.slack.com/services/T12345/B67890/ABCDEFGHIJKLMNOP' + config = WebhookConfig(url=slack_url, enabled=True) + temp_monitor.webhook_service = WebhookService(webhook_config=config) + + # Test GET endpoint returns masked URL + response = self.client.get( + '/api/webhook/config', + headers=self.auth_header + ) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + + # Verify that full URL is NOT returned + self.assertNotEqual(data['webhook']['url'], slack_url) + # Verify that masked URL shows only scheme + host + self.assertEqual(data['webhook']['url'], 'https://hooks.slack.com') + # Verify tokens are NOT exposed + self.assertNotIn('T12345', data['webhook']['url']) + self.assertNotIn('B67890', data['webhook']['url']) + self.assertNotIn('ABCDEFGHIJKLMNOP', data['webhook']['url']) + + # Test PUT endpoint also returns masked URL + payload = { + 'webhook': { + 'enabled': False # Just disable, don't change URL + } + } + + response = self.client.put( + '/api/webhook/config', + data=json.dumps(payload), + content_type='application/json', + headers=self.auth_header + ) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + + # Verify masked URL in PUT response as well + self.assertEqual(data['config']['webhook']['url'], 'https://hooks.slack.com') + self.assertNotIn('T12345', data['config']['webhook']['url']) + + +def main(): + """Run all tests""" + print("=" * 70) + print("Flask-RESTX Webhook API Integration Tests") + print("=" * 70) + print() + + # Run tests + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(TestWebhookAPIEndpoints) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + print() + print("=" * 70) + if result.wasSuccessful(): + print("✅ ALL API TESTS PASSED") + else: + print("❌ SOME TESTS FAILED") + print("=" * 70) + + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/thoughts/tasks/issue-24-pydantic-validation/2025-12-31-research.md b/thoughts/tasks/issue-24-pydantic-validation/2025-12-31-research.md new file mode 100644 index 0000000..c9bdbc0 --- /dev/null +++ b/thoughts/tasks/issue-24-pydantic-validation/2025-12-31-research.md @@ -0,0 +1,643 @@ +--- +date: 2025-12-31T23:09:13Z +researcher: Claude Code +git_commit: aaec873487e0beff2c7b850a7c204112331b013f +branch: refactor/webhooks-tokens +repository: temp_monitor +topic: "Issue #24 - Pydantic for Data Validation" +tags: [research, codebase, validation, pydantic, dataclasses, webhook, flask] +status: complete +last_updated: 2025-12-31 +last_updated_by: Claude Code +--- + +# Research: Issue #24 - Pydantic for Data Validation + +**Date**: 2025-12-31T23:09:13Z +**Researcher**: Claude Code +**Git Commit**: aaec873487e0beff2c7b850a7c204112331b013f +**Branch**: refactor/webhooks-tokens +**Repository**: temp_monitor + +## Research Question + +Review GitHub Issue #24 which recommends using Pydantic for data validation. Document the current state of data validation in the codebase, the dataclass structures that would be affected, and the existing validation patterns. + +## Summary + +The codebase currently uses Python `@dataclass` decorators for structured data (`WebhookConfig`, `AlertThresholds`) with no runtime validation. API endpoints accept JSON data via `request.get_json()` with minimal existence checks. The issue proposes migrating to Pydantic to address three critical validation gaps: +- Issue #9: Missing numeric input validation in webhook config endpoint +- Issue #11: Unvalidated WebhookConfig construction +- Issue #12: Unvalidated AlertThresholds (no min < max check) + +Additionally, a Flask-RESTX skill is available in the project that provides an alternative approach using Flask-RESTX models for request validation and OpenAPI documentation. + +--- + +## Detailed Findings + +### 1. Current Dataclass Definitions + +#### WebhookConfig (`webhook_service.py:17-24`) + +```python +@dataclass +class WebhookConfig: + """Configuration for a webhook endpoint""" + url: str + enabled: bool = True + retry_count: int = 3 + retry_delay: int = 5 # seconds + timeout: int = 10 # seconds +``` + +**Current State:** +- No `__post_init__` method exists +- No URL format validation +- No range validation for numeric fields +- `url` can be an empty string +- `retry_count` can be negative +- `timeout` can be zero or negative + +#### AlertThresholds (`webhook_service.py:27-33`) + +```python +@dataclass +class AlertThresholds: + """Temperature and humidity thresholds for alerts""" + temp_min_c: Optional[float] = 15.0 # 59°F + temp_max_c: Optional[float] = 27.0 # 80.6°F + humidity_min: Optional[float] = 30.0 + humidity_max: Optional[float] = 70.0 +``` + +**Current State:** +- No `__post_init__` method exists +- No validation that `temp_min_c < temp_max_c` +- No validation that `humidity_min < humidity_max` +- Temperature values can be any float (including impossible values like -1000 C) +- Humidity can be negative or exceed 100% + +--- + +### 2. API Endpoint Validation Patterns + +#### PUT /api/webhook/config (`temp_monitor.py:416-483`) + +**Current validation:** +```python +data = request.get_json() +if not data: + return jsonify({'error': 'No data provided'}), 400 + +if 'webhook' in data: + webhook_data = data['webhook'] + if not webhook_service: + if 'url' not in webhook_data: + return jsonify({'error': 'URL required to create webhook config'}), 400 +``` + +**What is NOT validated:** +- URL format (empty string accepted) +- `enabled` type (any truthy/falsy value accepted) +- `retry_count` range (negative numbers accepted) +- `retry_delay` range (negative numbers accepted) +- `timeout` range (zero or negative accepted) +- Threshold value ranges +- `temp_min_c < temp_max_c` constraint + +#### Dataclass Instantiation (`temp_monitor.py:438-455`) + +```python +config = WebhookConfig( + url=webhook_data.get('url', webhook_service.webhook_config.url if webhook_service.webhook_config else ''), + enabled=webhook_data.get('enabled', True), + retry_count=webhook_data.get('retry_count', 3), + retry_delay=webhook_data.get('retry_delay', 5), + timeout=webhook_data.get('timeout', 10) +) + +thresholds = AlertThresholds( + temp_min_c=threshold_data.get('temp_min_c'), + temp_max_c=threshold_data.get('temp_max_c'), + humidity_min=threshold_data.get('humidity_min'), + humidity_max=threshold_data.get('humidity_max') +) +``` + +**Pattern:** Uses `dict.get()` with defaults, no type checking or range validation. + +--- + +### 3. Existing Validation Patterns in Codebase + +#### Pattern A: None/Existence Checks +- `temp_monitor.py:423-424`: Check if JSON data exists +- `temp_monitor.py:432-434`: Check if required key exists +- `webhook_service.py:69-71`: Check if webhook_config exists and is enabled + +#### Pattern B: Type Conversion from Environment Variables +- `temp_monitor.py:71-73`: `int(os.getenv('WEBHOOK_RETRY_COUNT', '3'))` +- `temp_monitor.py:77-80`: Conditional float conversion with None fallback + +#### Pattern C: Single Business Rule Validation +- `temp_monitor.py:56-62`: Validates `status_update_interval >= sampling_interval` + - Only validation that enforces a business rule + - Auto-corrects invalid values with logging + +#### Pattern D: Value Capping +- `temp_monitor.py:193-194`: Caps humidity at 100% + - Silent adjustment without warning + +#### Pattern E: Try/Except Error Handling +- `temp_monitor.py:478-483`: Catches all exceptions, returns 500 with details + +--- + +### 4. Type Annotations Usage + +**webhook_service.py:** +- Imports `Optional, Dict, Any, List` from `typing` module +- All function signatures have type hints +- Used for documentation only, no runtime enforcement + +**temp_monitor.py:** +- No type annotations on functions +- No type hints on variables + +--- + +### 5. GitHub Issue #24 Summary + +**Issue Title:** RECOMMENDATION: Use Pydantic for Data Validation + +**Labels:** bug, documentation, enhancement + +**Author:** fakebizprez + +**Created:** 2025-12-31T23:00:59Z + +**State:** OPEN + +**Key Points from Issue:** + +1. **Three Critical Validation Issues Identified:** + - Issue #9: Missing numeric input validation (`temp_monitor.py:438-455`) + - Issue #11: Unvalidated WebhookConfig (`webhook_service.py:18-24`) + - Issue #12: Unvalidated AlertThresholds (`webhook_service.py:28-33`) + +2. **Proposed Pydantic Models:** + +```python +# WebhookConfig replacement +class WebhookConfigRequest(BaseModel): + url: str + enabled: bool = True + retry_count: int = Field(ge=1, le=10, description="1-10 retries") + retry_delay: int = Field(ge=1, le=60, description="1-60 seconds") + timeout: int = Field(ge=5, le=120, description="5-120 seconds") + +# AlertThresholds replacement +class AlertThresholds(BaseModel): + temp_min_c: Optional[float] = None + temp_max_c: Optional[float] = None + humidity_min: Optional[float] = None + humidity_max: Optional[float] = None + + @validator('temp_max_c') + def min_less_than_max(cls, v, values): + if v and 'temp_min_c' in values and values['temp_min_c']: + if values['temp_min_c'] >= v: + raise ValueError('temp_min must be < temp_max') + return v +``` + +3. **Benefits Listed:** + - Automatic request validation + - Type-safe configuration objects + - Clear error messages + - Zero boilerplate code + - Built-in bounds checking + - Reusable validation models + +4. **Raspberry Pi Compatibility:** + - Pydantic has ARM wheels + - Pure Python, no C compilation + - ~5MB added, ~200KB runtime overhead + +5. **Implementation Options:** + - Option A: Add Pydantic in current PR (recommended) + - Option B: Add Pydantic in follow-up PR + +--- + +### 6. Alternative: Flask-RESTX Approach + +The project has a Flask-RESTX skill (`.claude/skills/flask-restx-webhooks`) that provides an alternative validation approach: + +**Flask-RESTX Model Definition:** +```python +from flask_restx import Namespace, fields + +webhooks_ns = Namespace('webhooks', description='Webhook operations') + +webhook_config_model = webhooks_ns.model('WebhookConfig', { + 'url': fields.String(required=True, description='Webhook URL'), + 'enabled': fields.Boolean(default=True, description='Enable webhook'), + 'retry_count': fields.Integer(min=1, max=10, description='Retry attempts'), + 'retry_delay': fields.Integer(min=1, max=60, description='Retry delay seconds'), + 'timeout': fields.Integer(min=5, max=120, description='Request timeout') +}) +``` + +**Benefits of Flask-RESTX:** +- Automatic Swagger/OpenAPI documentation generation +- Request validation through `@expect` decorator +- Response marshalling with field definitions +- Already integrated with Flask ecosystem +- No additional dependency for validation (uses existing Flask-RESTX) + +**Trade-offs vs Pydantic:** + +| Aspect | Pydantic | Flask-RESTX | +|--------|----------|-------------| +| Validation | Built-in with Field() | Via model fields | +| Swagger/OpenAPI | Requires integration | Built-in | +| Dataclass replacement | Direct replacement | Separate models | +| Cross-validator | @validator decorator | Custom implementation | +| Type safety | Strong with Python typing | Less strict | +| Internal use | Can use same models | Need separate models | + +--- + +### 7. Dataclass Instantiation Locations + +**WebhookConfig created at:** +- `temp_monitor.py:68-74`: From environment variables at startup +- `temp_monitor.py:438-444`: From API request JSON + +**AlertThresholds created at:** +- `temp_monitor.py:76-81`: From environment variables at startup +- `temp_monitor.py:450-455`: From API request JSON +- `webhook_service.py:42`: Default instantiation in `__init__` + +--- + +## Code References + +- `webhook_service.py:17-24` - WebhookConfig dataclass definition +- `webhook_service.py:27-33` - AlertThresholds dataclass definition +- `webhook_service.py:39-42` - WebhookService.__init__ with default AlertThresholds +- `webhook_service.py:47-51` - set_webhook_config setter method +- `webhook_service.py:53-57` - set_alert_thresholds setter method +- `temp_monitor.py:68-81` - Dataclass instantiation from environment variables +- `temp_monitor.py:416-483` - PUT /api/webhook/config endpoint +- `temp_monitor.py:438-444` - WebhookConfig instantiation from JSON +- `temp_monitor.py:450-455` - AlertThresholds instantiation from JSON +- `temp_monitor.py:56-62` - Only business rule validation in codebase + +## Architecture Documentation + +### Current Validation Architecture + +``` +Request JSON + | + v +request.get_json() + | + v +if not data: return 400 <-- Only existence check + | + v +dict.get() with defaults <-- No type/range validation + | + v +@dataclass instantiation <-- No __post_init__ validation + | + v +WebhookService methods +``` + +### Proposed Pydantic Architecture (from Issue #24) + +``` +Request JSON + | + v +request.get_json() + | + v +PydanticModel(**data) <-- Automatic validation + | - Type checking + +--> ValidationError - Range checking (Field()) + | return 400 - Cross-field validation (@validator) + | + v +Validated model instance + | + v +WebhookService methods +``` + +## Related Research + +- GitHub Issue #23 (referenced in Issue #24) - Related webhook PR +- GitHub Issues #9, #11, #12 - Original validation issues + +## Open Questions + +1. **Flask-RESTX vs Pydantic:** Should both be used together (Flask-RESTX for API docs, Pydantic for internal validation) or pick one approach? + +2. **Migration Strategy:** If adopting Pydantic, should existing dataclasses be replaced entirely or wrapped? + +3. **Error Response Format:** What error response format should validation failures return? Pydantic's default or custom? + +4. **Environment Variable Validation:** Should Pydantic also validate environment variable parsing at startup? + +5. **Backwards Compatibility:** Will API consumers need to handle new validation error responses? + +--- + +## Follow-up Research: Flask-RESTX Implementation Plan + +**Added:** 2025-12-31T23:15:00Z + +Based on the decision to use Flask-RESTX instead of Pydantic, here is the detailed implementation plan. + +### Why Flask-RESTX Over Pydantic + +1. **Built-in OpenAPI/Swagger** - Automatic API documentation at `/docs` +2. **No additional dependency** - Just add `flask-restx` to requirements.txt +3. **Flask ecosystem integration** - Works naturally with existing Flask patterns +4. **Request validation via decorators** - Clean `@expect` pattern +5. **Response marshalling** - Consistent API response formatting + +### Required Changes + +#### 1. Add Dependency + +**File:** `requirements.txt` +``` +flask-restx>=1.3.0 +``` + +#### 2. Create API Models Module + +**New file:** `api_models.py` + +```python +from flask_restx import Namespace, fields + +# Create namespace for webhook endpoints +webhooks_ns = Namespace('webhooks', description='Webhook configuration and management') + +# Webhook configuration model with validation +webhook_config_model = webhooks_ns.model('WebhookConfig', { + 'url': fields.String( + required=True, + description='Slack webhook URL', + example='https://hooks.slack.com/services/...' + ), + 'enabled': fields.Boolean( + default=True, + description='Enable/disable webhook notifications' + ), + 'retry_count': fields.Integer( + default=3, + min=1, + max=10, + description='Number of retry attempts (1-10)' + ), + 'retry_delay': fields.Integer( + default=5, + min=1, + max=60, + description='Initial retry delay in seconds (1-60)' + ), + 'timeout': fields.Integer( + default=10, + min=5, + max=120, + description='Request timeout in seconds (5-120)' + ) +}) + +# Alert thresholds model +alert_thresholds_model = webhooks_ns.model('AlertThresholds', { + 'temp_min_c': fields.Float( + description='Minimum temperature threshold in Celsius', + min=-50, + max=100, + example=15.0 + ), + 'temp_max_c': fields.Float( + description='Maximum temperature threshold in Celsius', + min=-50, + max=100, + example=27.0 + ), + 'humidity_min': fields.Float( + description='Minimum humidity threshold percentage', + min=0, + max=100, + example=30.0 + ), + 'humidity_max': fields.Float( + description='Maximum humidity threshold percentage', + min=0, + max=100, + example=70.0 + ) +}) + +# Combined config update request model +webhook_config_update_model = webhooks_ns.model('WebhookConfigUpdate', { + 'webhook': fields.Nested(webhook_config_model, description='Webhook settings'), + 'thresholds': fields.Nested(alert_thresholds_model, description='Alert thresholds') +}) + +# Response models +webhook_config_response = webhooks_ns.model('WebhookConfigResponse', { + 'webhook': fields.Nested(webhook_config_model), + 'thresholds': fields.Nested(alert_thresholds_model) +}) + +error_response = webhooks_ns.model('ErrorResponse', { + 'error': fields.String(description='Error message'), + 'details': fields.String(description='Additional error details') +}) + +success_response = webhooks_ns.model('SuccessResponse', { + 'message': fields.String(description='Success message'), + 'config': fields.Nested(webhook_config_response, description='Updated configuration') +}) +``` + +#### 3. Initialize Flask-RESTX API + +**File:** `temp_monitor.py` - Add after Flask app initialization + +```python +from flask_restx import Api + +api = Api( + app, + version='1.0', + title='Temperature Monitor API', + description='Server room environmental monitoring API', + doc='/docs', # Swagger UI endpoint + authorizations={ + 'bearer': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization', + 'description': 'Bearer token authentication. Format: "Bearer "' + } + }, + security='bearer' +) +``` + +#### 4. Migrate PUT /api/webhook/config Endpoint + +**Current location:** `temp_monitor.py:416-483` + +**New implementation using Flask-RESTX:** + +```python +from flask_restx import Resource +from api_models import webhooks_ns, webhook_config_update_model, success_response, error_response + +api.add_namespace(webhooks_ns, path='/api/webhook') + +@webhooks_ns.route('/config') +class WebhookConfigResource(Resource): + @webhooks_ns.doc(security='bearer') + @webhooks_ns.marshal_with(webhook_config_response) + @require_token + def get(self): + """Get current webhook configuration""" + if not webhook_service or not webhook_service.webhook_config: + return {'enabled': False, 'message': 'Webhook not configured'}, 200 + + config = webhook_service.webhook_config + thresholds = webhook_service.alert_thresholds + + return { + 'webhook': { + 'url': config.url, + 'enabled': config.enabled, + 'retry_count': config.retry_count, + 'retry_delay': config.retry_delay, + 'timeout': config.timeout + }, + 'thresholds': { + 'temp_min_c': thresholds.temp_min_c, + 'temp_max_c': thresholds.temp_max_c, + 'humidity_min': thresholds.humidity_min, + 'humidity_max': thresholds.humidity_max + } + } + + @webhooks_ns.doc(security='bearer') + @webhooks_ns.expect(webhook_config_update_model, validate=True) + @webhooks_ns.marshal_with(success_response) + @webhooks_ns.response(400, 'Validation Error', error_response) + @webhooks_ns.response(500, 'Server Error', error_response) + @require_token + def put(self): + """Update webhook configuration""" + global webhook_service + data = webhooks_ns.payload # Already validated by @expect + + # Custom cross-field validation for thresholds + if 'thresholds' in data: + thresholds = data['thresholds'] + if (thresholds.get('temp_min_c') is not None and + thresholds.get('temp_max_c') is not None and + thresholds['temp_min_c'] >= thresholds['temp_max_c']): + return {'error': 'temp_min_c must be less than temp_max_c'}, 400 + + if (thresholds.get('humidity_min') is not None and + thresholds.get('humidity_max') is not None and + thresholds['humidity_min'] >= thresholds['humidity_max']): + return {'error': 'humidity_min must be less than humidity_max'}, 400 + + # Process validated data (existing logic) + # ... +``` + +### Validation Coverage + +| Issue | Validation Required | Flask-RESTX Solution | +|-------|--------------------|-----------------------| +| #9 | Numeric bounds for retry_count, timeout | `fields.Integer(min=1, max=10)` | +| #11 | URL required, valid config values | `required=True`, field constraints | +| #12 | temp_min < temp_max | Custom validator in endpoint | + +### Cross-Field Validation + +Flask-RESTX doesn't have built-in cross-field validators like Pydantic's `@validator`. Implement in endpoint: + +```python +def validate_thresholds(thresholds: dict) -> tuple[bool, str]: + """Validate threshold relationships""" + if thresholds.get('temp_min_c') and thresholds.get('temp_max_c'): + if thresholds['temp_min_c'] >= thresholds['temp_max_c']: + return False, 'temp_min_c must be less than temp_max_c' + + if thresholds.get('humidity_min') and thresholds.get('humidity_max'): + if thresholds['humidity_min'] >= thresholds['humidity_max']: + return False, 'humidity_min must be less than humidity_max' + + return True, '' +``` + +### Migration Steps + +1. **Add flask-restx to requirements.txt** +2. **Create api_models.py with model definitions** +3. **Initialize Api in temp_monitor.py** +4. **Migrate webhook endpoints to Resource classes** +5. **Keep existing dataclasses for internal use** (WebhookConfig, AlertThresholds) +6. **Add validation logic in endpoints** +7. **Test Swagger UI at /docs** + +### Endpoints After Migration + +| Endpoint | Method | Flask-RESTX Resource | +|----------|--------|----------------------| +| `/api/webhook/config` | GET | WebhookConfigResource.get() | +| `/api/webhook/config` | PUT | WebhookConfigResource.put() | +| `/api/webhook/test` | POST | WebhookTestResource.post() | +| `/api/webhook/enable` | POST | WebhookEnableResource.post() | +| `/api/webhook/disable` | POST | WebhookDisableResource.post() | + +### Swagger Documentation Access + +After implementation, Swagger UI will be available at: +- **Swagger UI:** `http://localhost:8080/docs` +- **OpenAPI JSON:** `http://localhost:8080/swagger.json` + +### Keeping Existing Dataclasses + +The `WebhookConfig` and `AlertThresholds` dataclasses in `webhook_service.py` should remain unchanged. They serve as internal data structures. Flask-RESTX models handle API request/response validation, while dataclasses handle internal state. + +``` +API Request --> Flask-RESTX Model (validation) --> Dataclass (internal storage) +``` + +### Error Response Format + +Flask-RESTX validation errors return: +```json +{ + "message": "Input payload validation failed", + "errors": { + "webhook.retry_count": "Value must be between 1 and 10" + } +} +``` + +This is more structured than the current generic error responses. diff --git a/webhook_service.py b/webhook_service.py new file mode 100644 index 0000000..f531cb1 --- /dev/null +++ b/webhook_service.py @@ -0,0 +1,413 @@ +""" +Webhook Service for Temperature Monitor + +Handles outbound webhooks to Slack for temperature/humidity alerts and status updates. +""" + +import requests +import logging +import time +import json +from typing import Optional, Dict, Any, List +from dataclasses import dataclass, asdict +from datetime import datetime +import threading +from urllib.parse import urlparse + + +@dataclass +class WebhookConfig: + """Configuration for a webhook endpoint""" + url: str + enabled: bool = True + retry_count: int = 3 + retry_delay: int = 5 # seconds + timeout: int = 10 # seconds + + +@dataclass +class AlertThresholds: + """Temperature and humidity thresholds for alerts""" + temp_min_c: Optional[float] = 15.0 # 59°F + temp_max_c: Optional[float] = 27.0 # 80.6°F + humidity_min: Optional[float] = 30.0 + humidity_max: Optional[float] = 70.0 + + +class WebhookService: + """Service for managing and sending webhooks""" + + def __init__(self, webhook_config: Optional[WebhookConfig] = None, + alert_thresholds: Optional[AlertThresholds] = None): + self.webhook_config = webhook_config + self.alert_thresholds = alert_thresholds or AlertThresholds() + self.last_alert_time = {} # Track last alert per type to avoid spam + self.alert_cooldown = 300 # 5 minutes between same alert type + self._lock = threading.Lock() + + def _mask_url(self, url: str) -> str: + """ + Mask webhook URL by returning only scheme and host for security. + + This prevents sensitive path components and tokens from being exposed in logs. + + Args: + url: Full webhook URL + + Returns: + Masked URL in format 'scheme://host' or '' if malformed + """ + try: + parsed = urlparse(url) + if parsed.scheme and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}" + else: + return "" + except Exception as e: + logging.warning(f"Error masking webhook URL: {e}") + return "" + + def set_webhook_config(self, config: WebhookConfig): + """Update webhook configuration""" + with self._lock: + self.webhook_config = config + logging.info(f"Webhook configuration updated: {self._mask_url(config.url)}") + + def set_alert_thresholds(self, thresholds: AlertThresholds): + """Update alert thresholds""" + with self._lock: + self.alert_thresholds = thresholds + logging.info(f"Alert thresholds updated: {asdict(thresholds)}") + + def _send_webhook(self, payload: Dict[str, Any]) -> bool: + """ + Send webhook with retry logic + + Args: + payload: Dictionary to send as JSON + + Returns: + True if successful, False otherwise + """ + if not self.webhook_config or not self.webhook_config.enabled: + logging.debug("Webhook not configured or disabled, skipping send") + return False + + url = self.webhook_config.url + + for attempt in range(self.webhook_config.retry_count): + try: + response = requests.post( + url, + json=payload, + timeout=self.webhook_config.timeout, + headers={'Content-Type': 'application/json'} + ) + + if response.status_code == 200: + logging.info(f"Webhook sent successfully to {self._mask_url(url)}") + return True + else: + logging.warning( + f"Webhook failed with status {response.status_code}: {response.text}" + ) + + except requests.exceptions.Timeout: + logging.error(f"Webhook timeout (attempt {attempt + 1}/{self.webhook_config.retry_count})") + except requests.exceptions.RequestException as e: + logging.error(f"Webhook request failed (attempt {attempt + 1}/{self.webhook_config.retry_count}): {e}") + + # Wait before retry (exponential backoff) + if attempt < self.webhook_config.retry_count - 1: + delay = self.webhook_config.retry_delay * (2 ** attempt) + time.sleep(delay) + + logging.error(f"Webhook failed after {self.webhook_config.retry_count} attempts") + return False + + def _can_send_alert(self, alert_type: str) -> bool: + """ + Check if enough time has passed since last alert of this type + + Args: + alert_type: Type of alert (e.g., 'temp_high', 'humidity_low') + + Returns: + True if alert can be sent, False if in cooldown period + """ + with self._lock: + last_time = self.last_alert_time.get(alert_type) + if last_time is None: + return True + + elapsed = time.time() - last_time + return elapsed >= self.alert_cooldown + + def _mark_alert_sent(self, alert_type: str): + """Record that an alert was sent""" + with self._lock: + self.last_alert_time[alert_type] = time.time() + + def send_slack_message(self, text: str, color: str = "good", + fields: Optional[List[Dict[str, str]]] = None) -> bool: + """ + Send a formatted Slack message + + Args: + text: Main message text + color: Message color (good, warning, danger, or hex color) + fields: Optional list of field dictionaries with 'title' and 'value' + + Returns: + True if successful, False otherwise + """ + attachment = { + "color": color, + "text": text, + "ts": int(time.time()) + } + + if fields: + attachment["fields"] = fields + + payload = { + "attachments": [attachment] + } + + return self._send_webhook(payload) + + def check_and_alert(self, temperature_c: float, humidity: float, + timestamp: str) -> Dict[str, bool]: + """ + Check sensor readings against thresholds and send alerts if needed + + Args: + temperature_c: Current temperature in Celsius + humidity: Current humidity percentage + timestamp: Timestamp of reading + + Returns: + Dictionary with alert types as keys and success status as values + """ + alerts_sent = {} + + # Check temperature high + if (self.alert_thresholds.temp_max_c is not None and + temperature_c > self.alert_thresholds.temp_max_c): + + if self._can_send_alert('temp_high'): + temp_f = round((temperature_c * 9/5) + 32, 1) + max_f = round((self.alert_thresholds.temp_max_c * 9/5) + 32, 1) + + success = self.send_slack_message( + text=f"🔥 *Temperature Alert: HIGH*", + color="danger", + fields=[ + { + "title": "Current Temperature", + "value": f"{temperature_c}°C ({temp_f}°F)", + "short": True + }, + { + "title": "Threshold", + "value": f"{self.alert_thresholds.temp_max_c}°C ({max_f}°F)", + "short": True + }, + { + "title": "Timestamp", + "value": timestamp, + "short": False + } + ] + ) + + if success: + self._mark_alert_sent('temp_high') + alerts_sent['temp_high'] = success + + # Check temperature low + if (self.alert_thresholds.temp_min_c is not None and + temperature_c < self.alert_thresholds.temp_min_c): + + if self._can_send_alert('temp_low'): + temp_f = round((temperature_c * 9/5) + 32, 1) + min_f = round((self.alert_thresholds.temp_min_c * 9/5) + 32, 1) + + success = self.send_slack_message( + text=f"❄️ *Temperature Alert: LOW*", + color="warning", + fields=[ + { + "title": "Current Temperature", + "value": f"{temperature_c}°C ({temp_f}°F)", + "short": True + }, + { + "title": "Threshold", + "value": f"{self.alert_thresholds.temp_min_c}°C ({min_f}°F)", + "short": True + }, + { + "title": "Timestamp", + "value": timestamp, + "short": False + } + ] + ) + + if success: + self._mark_alert_sent('temp_low') + alerts_sent['temp_low'] = success + + # Check humidity high + if (self.alert_thresholds.humidity_max is not None and + humidity > self.alert_thresholds.humidity_max): + + if self._can_send_alert('humidity_high'): + success = self.send_slack_message( + text=f"💧 *Humidity Alert: HIGH*", + color="warning", + fields=[ + { + "title": "Current Humidity", + "value": f"{humidity}%", + "short": True + }, + { + "title": "Threshold", + "value": f"{self.alert_thresholds.humidity_max}%", + "short": True + }, + { + "title": "Timestamp", + "value": timestamp, + "short": False + } + ] + ) + + if success: + self._mark_alert_sent('humidity_high') + alerts_sent['humidity_high'] = success + + # Check humidity low + if (self.alert_thresholds.humidity_min is not None and + humidity < self.alert_thresholds.humidity_min): + + if self._can_send_alert('humidity_low'): + success = self.send_slack_message( + text=f"🏜️ *Humidity Alert: LOW*", + color="warning", + fields=[ + { + "title": "Current Humidity", + "value": f"{humidity}%", + "short": True + }, + { + "title": "Threshold", + "value": f"{self.alert_thresholds.humidity_min}%", + "short": True + }, + { + "title": "Timestamp", + "value": timestamp, + "short": False + } + ] + ) + + if success: + self._mark_alert_sent('humidity_low') + alerts_sent['humidity_low'] = success + + return alerts_sent + + def send_status_update(self, temperature_c: float, humidity: float, + cpu_temp: Optional[float], timestamp: str) -> bool: + """ + Send a status update with current readings + + Args: + temperature_c: Current temperature in Celsius + humidity: Current humidity percentage + cpu_temp: CPU temperature if available + timestamp: Timestamp of reading + + Returns: + True if successful, False otherwise + """ + temp_f = round((temperature_c * 9/5) + 32, 1) + + fields = [ + { + "title": "Temperature", + "value": f"{temperature_c}°C ({temp_f}°F)", + "short": True + }, + { + "title": "Humidity", + "value": f"{humidity}%", + "short": True + } + ] + + if cpu_temp is not None: + fields.append({ + "title": "CPU Temperature", + "value": f"{cpu_temp}°C", + "short": True + }) + + fields.append({ + "title": "Last Updated", + "value": timestamp, + "short": False + }) + + return self.send_slack_message( + text="📊 *Server Room Status Update*", + color="good", + fields=fields + ) + + def send_system_event(self, event_type: str, message: str, + severity: str = "info") -> bool: + """ + Send a system event notification + + Args: + event_type: Type of event (startup, shutdown, error, etc.) + message: Event message + severity: Severity level (info, warning, error) + + Returns: + True if successful, False otherwise + """ + color_map = { + "info": "good", + "warning": "warning", + "error": "danger" + } + + icon_map = { + "startup": "🚀", + "shutdown": "🛑", + "error": "⚠️", + "info": "ℹ️" + } + + icon = icon_map.get(event_type, "📢") + color = color_map.get(severity, "good") + + return self.send_slack_message( + text=f"{icon} *System Event: {event_type.upper()}*\n{message}", + color=color, + fields=[ + { + "title": "Timestamp", + "value": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "short": False + } + ] + ) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..a30f656 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,35 @@ +""" +WSGI entry point for production deployment on Raspberry Pi 4. + +This module provides the Flask application and sensor thread initialization +for use with Waitress or other WSGI servers. + +Usage: + waitress-serve --host=0.0.0.0 --port=8080 --threads=1 --call wsgi:app + +Or in docker-compose.yml: + CMD ["waitress-serve", "--host=0.0.0.0", "--port=8080", "--threads=1", "--call", "wsgi:app"] +""" + +import logging +import time +from temp_monitor import app, start_sensor_thread + +# Configure logging +logger = logging.getLogger(__name__) + +# Start background sensor thread when this module is imported +try: + logger.info("Initializing sensor thread for production deployment...") + sensor_thread = start_sensor_thread() + + # Give the thread a moment to get initial readings + time.sleep(2) + + logger.info("Sensor thread started successfully") +except Exception as e: + logger.error(f"Failed to start sensor thread: {e}") + raise + +# Export the Flask app for Waitress +__all__ = ['app']