Add initial configuration files and monitoring script for ESP32 integration
This commit is contained in:
11
.env.sample
Normal file
11
.env.sample
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# ESP32 Configuration
|
||||||
|
ESP_IP=192.168.1.100
|
||||||
|
ESP_API_KEY=YOUR_ENCRYPTION_KEY_HERE
|
||||||
|
|
||||||
|
# Uptime Kuma Configuration (API Key required)
|
||||||
|
# Get your API key from: Settings → API Keys in Uptime Kuma
|
||||||
|
KUMA_URL=https://status.example.com
|
||||||
|
KUMA_KEY=uk1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Polling Configuration
|
||||||
|
POLL_INTERVAL=30
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
heart-listener:
|
||||||
|
image: python:3.14-slim
|
||||||
|
container_name: uptime-heart-listener
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
command: >
|
||||||
|
bash -c "pip install -r requirements.txt && python listen.py"
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
network_mode: host
|
||||||
255
listen.py
Normal file
255
listen.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Uptime Kuma to ESP32 LED Status Monitor
|
||||||
|
Polls Uptime Kuma API and controls ESP32 LEDs based on monitor status.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from pythonkuma import UptimeKuma
|
||||||
|
from aioesphomeapi import APIClient, APIConnectionError, ReconnectLogic
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
ESP_IP = os.getenv('ESP_IP')
|
||||||
|
ESP_API_KEY = os.getenv('ESP_API_KEY', '')
|
||||||
|
KUMA_URL = os.getenv('KUMA_URL')
|
||||||
|
KUMA_KEY = os.getenv('KUMA_KEY')
|
||||||
|
POLL_INTERVAL = int(os.getenv('POLL_INTERVAL', '30'))
|
||||||
|
|
||||||
|
|
||||||
|
class UptimeKumaMonitor:
|
||||||
|
"""Handles communication with Uptime Kuma API"""
|
||||||
|
|
||||||
|
def __init__(self, session: aiohttp.ClientSession, base_url: str, api_key: str):
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.api_key = api_key
|
||||||
|
self.kuma = UptimeKuma(session, base_url, api_key)
|
||||||
|
|
||||||
|
async def get_all_monitors_status(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if all monitors are UP.
|
||||||
|
Returns True if all monitors are UP, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get all monitors using metrics() - returns dict of monitor_id: UptimeKumaMonitor
|
||||||
|
monitors = await self.kuma.metrics()
|
||||||
|
|
||||||
|
if not monitors:
|
||||||
|
logger.warning("No monitors found in Uptime Kuma")
|
||||||
|
return False
|
||||||
|
|
||||||
|
all_up = True
|
||||||
|
monitor_count = 0
|
||||||
|
down_monitors = []
|
||||||
|
|
||||||
|
for monitor_id, monitor in monitors.items():
|
||||||
|
monitor_count += 1
|
||||||
|
# MonitorStatus.UP has value 1
|
||||||
|
# Check monitor_status attribute (it's an enum)
|
||||||
|
if monitor.monitor_status.value != 1:
|
||||||
|
all_up = False
|
||||||
|
down_monitors.append(monitor.monitor_name or f"Monitor {monitor_id}")
|
||||||
|
|
||||||
|
if down_monitors:
|
||||||
|
logger.warning(f"Monitors DOWN: {', '.join(down_monitors)}")
|
||||||
|
|
||||||
|
logger.info(f"Checked {monitor_count} monitors - All UP: {all_up}")
|
||||||
|
return all_up
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch Uptime Kuma status: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ESP32Controller:
|
||||||
|
"""Handles communication with ESP32 via ESPHome API"""
|
||||||
|
|
||||||
|
def __init__(self, host: str, encryption_key: str = ''):
|
||||||
|
self.host = host
|
||||||
|
self.encryption_key = encryption_key
|
||||||
|
self.client: Optional[APIClient] = None
|
||||||
|
self.reconnect_logic: Optional[ReconnectLogic] = None
|
||||||
|
self.current_state = None # Track current LED state to minimize traffic
|
||||||
|
self.light_keys = {} # Map light IDs to entity keys
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Connect to ESP32"""
|
||||||
|
try:
|
||||||
|
self.client = APIClient(
|
||||||
|
address=self.host,
|
||||||
|
port=6053,
|
||||||
|
password='',
|
||||||
|
noise_psk=self.encryption_key if self.encryption_key else None
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.client.connect(login=True)
|
||||||
|
logger.info(f"Connected to ESP32 at {self.host}")
|
||||||
|
|
||||||
|
# Get entity list to map light IDs to keys
|
||||||
|
entities, services = await self.client.list_entities_services()
|
||||||
|
for entity in entities:
|
||||||
|
if hasattr(entity, 'object_id') and hasattr(entity, 'key'):
|
||||||
|
self.light_keys[entity.object_id] = entity.key
|
||||||
|
|
||||||
|
logger.info(f"Found lights: {list(self.light_keys.keys())}")
|
||||||
|
|
||||||
|
# Set up reconnection logic
|
||||||
|
async def on_disconnect():
|
||||||
|
logger.warning("Disconnected from ESP32")
|
||||||
|
|
||||||
|
async def on_connect():
|
||||||
|
logger.info("Reconnected to ESP32")
|
||||||
|
|
||||||
|
self.reconnect_logic = ReconnectLogic(
|
||||||
|
client=self.client,
|
||||||
|
on_disconnect=on_disconnect,
|
||||||
|
on_connect=on_connect,
|
||||||
|
name=self.host
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except (APIConnectionError, Exception) as e:
|
||||||
|
logger.error(f"Failed to connect to ESP32: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_all_monitors_up(self):
|
||||||
|
"""Set green LED with breathing effect (all monitors UP)"""
|
||||||
|
if self.current_state == 'all_up':
|
||||||
|
return # Already in correct state
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
logger.error("ESP32 client not connected")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Turn off red LED (sanitized name: rote_led)
|
||||||
|
if 'rote_led' in self.light_keys:
|
||||||
|
self.client.light_command(key=self.light_keys['rote_led'], state=False)
|
||||||
|
|
||||||
|
# Turn on green LED with breathing effect (sanitized name: gr_ne_led)
|
||||||
|
if 'gr_ne_led' in self.light_keys:
|
||||||
|
self.client.light_command(
|
||||||
|
key=self.light_keys['gr_ne_led'],
|
||||||
|
state=True,
|
||||||
|
brightness=1.0,
|
||||||
|
effect='Breathing'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.current_state = 'all_up'
|
||||||
|
logger.info("✓ All monitors UP - Green LED breathing")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set all_up state: {e}")
|
||||||
|
|
||||||
|
async def set_monitors_down(self):
|
||||||
|
"""Set red LED with fast heartbeat effect (monitors DOWN)"""
|
||||||
|
if self.current_state == 'monitors_down':
|
||||||
|
return # Already in correct state
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
logger.error("ESP32 client not connected")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Turn off green LED (sanitized name: gr_ne_led)
|
||||||
|
if 'gr_ne_led' in self.light_keys:
|
||||||
|
self.client.light_command(key=self.light_keys['gr_ne_led'], state=False)
|
||||||
|
|
||||||
|
# Turn on red LED with fast heartbeat effect (sanitized name: rote_led)
|
||||||
|
if 'rote_led' in self.light_keys:
|
||||||
|
self.client.light_command(
|
||||||
|
key=self.light_keys['rote_led'],
|
||||||
|
state=True,
|
||||||
|
brightness=1.0,
|
||||||
|
effect='Fast Heartbeat'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.current_state = 'monitors_down'
|
||||||
|
logger.warning("✗ Monitors DOWN - Red LED fast heartbeat")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set monitors_down state: {e}")
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Disconnect from ESP32"""
|
||||||
|
if self.client:
|
||||||
|
await self.client.disconnect()
|
||||||
|
logger.info("Disconnected from ESP32")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main monitoring loop"""
|
||||||
|
|
||||||
|
# Validate configuration
|
||||||
|
if not ESP_IP:
|
||||||
|
logger.error("ESP_IP not set in environment")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not KUMA_URL:
|
||||||
|
logger.error("KUMA_URL not set in environment")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not KUMA_KEY:
|
||||||
|
logger.error("KUMA_KEY not set in environment")
|
||||||
|
logger.error("Get your API key from Uptime Kuma: Settings → API Keys")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initialize ESP32 controller
|
||||||
|
esp = ESP32Controller(ESP_IP, ESP_API_KEY)
|
||||||
|
|
||||||
|
# Connect to ESP32
|
||||||
|
logger.info(f"Connecting to ESP32 at {ESP_IP}...")
|
||||||
|
if not await esp.connect():
|
||||||
|
logger.error("Failed to connect to ESP32. Retrying in 10 seconds...")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Starting monitoring loop (interval: {POLL_INTERVAL}s)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# Initialize Uptime Kuma monitor
|
||||||
|
kuma = UptimeKumaMonitor(session, KUMA_URL, KUMA_KEY)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Check Uptime Kuma status
|
||||||
|
all_up = await kuma.get_all_monitors_status()
|
||||||
|
|
||||||
|
# Update ESP32 LEDs based on status
|
||||||
|
if all_up:
|
||||||
|
await esp.set_all_monitors_up()
|
||||||
|
else:
|
||||||
|
await esp.set_monitors_down()
|
||||||
|
|
||||||
|
# Wait before next check
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error: {e}")
|
||||||
|
finally:
|
||||||
|
await esp.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Stopped by user")
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pythonkuma
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
aioesphomeapi>=23.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
@@ -32,6 +32,11 @@ wifi:
|
|||||||
password: "fallback_password"
|
password: "fallback_password"
|
||||||
|
|
||||||
captive_portal:
|
captive_portal:
|
||||||
|
|
||||||
|
# Time component for scheduling
|
||||||
|
time:
|
||||||
|
- platform: sntp
|
||||||
|
id: sntp_time
|
||||||
|
|
||||||
output:
|
output:
|
||||||
- platform: ledc
|
- platform: ledc
|
||||||
@@ -49,6 +54,23 @@ light:
|
|||||||
restore_mode: ALWAYS_OFF
|
restore_mode: ALWAYS_OFF
|
||||||
on_turn_on:
|
on_turn_on:
|
||||||
- light.turn_off: led_green
|
- light.turn_off: led_green
|
||||||
|
effects:
|
||||||
|
- strobe:
|
||||||
|
name: "Fast Heartbeat"
|
||||||
|
colors:
|
||||||
|
- state: true
|
||||||
|
brightness: 100%
|
||||||
|
duration: 200ms
|
||||||
|
- state: false
|
||||||
|
duration: 200ms
|
||||||
|
- strobe:
|
||||||
|
name: "Slow Pulse"
|
||||||
|
colors:
|
||||||
|
- state: true
|
||||||
|
brightness: 100%
|
||||||
|
duration: 1s
|
||||||
|
- state: false
|
||||||
|
duration: 1s
|
||||||
|
|
||||||
- platform: monochromatic
|
- platform: monochromatic
|
||||||
name: "Grüne LED"
|
name: "Grüne LED"
|
||||||
@@ -57,3 +79,55 @@ light:
|
|||||||
restore_mode: ALWAYS_OFF
|
restore_mode: ALWAYS_OFF
|
||||||
on_turn_on:
|
on_turn_on:
|
||||||
- light.turn_off: led_red
|
- light.turn_off: led_red
|
||||||
|
effects:
|
||||||
|
- pulse:
|
||||||
|
name: "Breathing"
|
||||||
|
transition_length: 1s
|
||||||
|
update_interval: 1s
|
||||||
|
min_brightness: 30%
|
||||||
|
max_brightness: 100%
|
||||||
|
|
||||||
|
# Switches for Home Assistant control
|
||||||
|
switch:
|
||||||
|
- platform: template
|
||||||
|
name: "Monitoring Enabled"
|
||||||
|
id: monitoring_enabled
|
||||||
|
restore_mode: RESTORE_DEFAULT_ON
|
||||||
|
optimistic: true
|
||||||
|
icon: "mdi:monitor-eye"
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Force Red LED"
|
||||||
|
id: force_red
|
||||||
|
restore_mode: RESTORE_DEFAULT_OFF
|
||||||
|
optimistic: true
|
||||||
|
icon: "mdi:alert-circle"
|
||||||
|
turn_on_action:
|
||||||
|
- switch.turn_off: force_green
|
||||||
|
- light.turn_off: led_green
|
||||||
|
- light.turn_on: led_red
|
||||||
|
turn_off_action:
|
||||||
|
- light.turn_off: led_red
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Force Green LED"
|
||||||
|
id: force_green
|
||||||
|
restore_mode: RESTORE_DEFAULT_OFF
|
||||||
|
optimistic: true
|
||||||
|
icon: "mdi:check-circle"
|
||||||
|
turn_on_action:
|
||||||
|
- switch.turn_off: force_red
|
||||||
|
- light.turn_off: led_red
|
||||||
|
- light.turn_on: led_green
|
||||||
|
turn_off_action:
|
||||||
|
- light.turn_off: led_green
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Quiet Hours Active"
|
||||||
|
id: quiet_hours
|
||||||
|
restore_mode: RESTORE_DEFAULT_OFF
|
||||||
|
optimistic: true
|
||||||
|
icon: "mdi:sleep"
|
||||||
|
turn_on_action:
|
||||||
|
- light.turn_off: led_red
|
||||||
|
- light.turn_off: led_green
|
||||||
|
|||||||
Reference in New Issue
Block a user