Fixed state tracking across service & esp for cleaner communication and experience
This commit is contained in:
142
listen.py
142
listen.py
@@ -16,8 +16,7 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,18 +24,18 @@ logger = logging.getLogger(__name__)
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
ESP_IP = os.getenv('ESP_IP')
|
ESP_IP = os.getenv("ESP_IP")
|
||||||
ESP_API_KEY = os.getenv('ESP_API_KEY', '')
|
ESP_API_KEY = os.getenv("ESP_API_KEY", "")
|
||||||
KUMA_URL = os.getenv('KUMA_URL')
|
KUMA_URL = os.getenv("KUMA_URL")
|
||||||
KUMA_KEY = os.getenv('KUMA_KEY')
|
KUMA_KEY = os.getenv("KUMA_KEY")
|
||||||
POLL_INTERVAL = int(os.getenv('POLL_INTERVAL', '30'))
|
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "30"))
|
||||||
|
|
||||||
|
|
||||||
class UptimeKumaMonitor:
|
class UptimeKumaMonitor:
|
||||||
"""Handles communication with Uptime Kuma API"""
|
"""Handles communication with Uptime Kuma API"""
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession, base_url: str, api_key: str):
|
def __init__(self, session: aiohttp.ClientSession, base_url: str, api_key: str):
|
||||||
self.base_url = base_url.rstrip('/')
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.kuma = UptimeKuma(session, base_url, api_key)
|
self.kuma = UptimeKuma(session, base_url, api_key)
|
||||||
|
|
||||||
@@ -63,7 +62,9 @@ class UptimeKumaMonitor:
|
|||||||
# Check monitor_status attribute (it's an enum)
|
# Check monitor_status attribute (it's an enum)
|
||||||
if monitor.monitor_status.value != 1:
|
if monitor.monitor_status.value != 1:
|
||||||
all_up = False
|
all_up = False
|
||||||
down_monitors.append(monitor.monitor_name or f"Monitor {monitor_id}")
|
down_monitors.append(
|
||||||
|
monitor.monitor_name or f"Monitor {monitor_id}"
|
||||||
|
)
|
||||||
|
|
||||||
if down_monitors:
|
if down_monitors:
|
||||||
logger.warning(f"Monitors DOWN: {', '.join(down_monitors)}")
|
logger.warning(f"Monitors DOWN: {', '.join(down_monitors)}")
|
||||||
@@ -79,13 +80,15 @@ class UptimeKumaMonitor:
|
|||||||
class ESP32Controller:
|
class ESP32Controller:
|
||||||
"""Handles communication with ESP32 via ESPHome API"""
|
"""Handles communication with ESP32 via ESPHome API"""
|
||||||
|
|
||||||
def __init__(self, host: str, encryption_key: str = ''):
|
def __init__(self, host: str, encryption_key: str = ""):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.encryption_key = encryption_key
|
self.encryption_key = encryption_key
|
||||||
self.client: Optional[APIClient] = None
|
self.client: Optional[APIClient] = None
|
||||||
self.reconnect_logic: Optional[ReconnectLogic] = None
|
self.reconnect_logic: Optional[ReconnectLogic] = None
|
||||||
self.current_state = None # Track current LED state to minimize traffic
|
self.current_state = None # Track current LED state to minimize traffic
|
||||||
self.light_keys = {} # Map light IDs to entity keys
|
self.light_keys = {} # Map light IDs to entity keys
|
||||||
|
self.switch_keys = {} # Map switch IDs to entity keys
|
||||||
|
self.switch_states = {} # Track switch states
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Connect to ESP32"""
|
"""Connect to ESP32"""
|
||||||
@@ -93,20 +96,71 @@ class ESP32Controller:
|
|||||||
self.client = APIClient(
|
self.client = APIClient(
|
||||||
address=self.host,
|
address=self.host,
|
||||||
port=6053,
|
port=6053,
|
||||||
password='',
|
password="",
|
||||||
noise_psk=self.encryption_key if self.encryption_key else None
|
noise_psk=self.encryption_key if self.encryption_key else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.client.connect(login=True)
|
await self.client.connect(login=True)
|
||||||
logger.info(f"Connected to ESP32 at {self.host}")
|
logger.info(f"Connected to ESP32 at {self.host}")
|
||||||
|
|
||||||
# Get entity list to map light IDs to keys
|
# Get entity list to map entity IDs to keys
|
||||||
entities, services = await self.client.list_entities_services()
|
entities, services = await self.client.list_entities_services()
|
||||||
|
switch_names = ['monitoring_enabled', 'force_red_led', 'force_green_led', 'quiet_hours_active']
|
||||||
|
|
||||||
for entity in entities:
|
for entity in entities:
|
||||||
if hasattr(entity, 'object_id') and hasattr(entity, 'key'):
|
if hasattr(entity, "object_id") and hasattr(entity, "key"):
|
||||||
|
# Separate switches from lights
|
||||||
|
if entity.object_id in switch_names:
|
||||||
|
self.switch_keys[entity.object_id] = entity.key
|
||||||
|
else:
|
||||||
self.light_keys[entity.object_id] = entity.key
|
self.light_keys[entity.object_id] = entity.key
|
||||||
|
|
||||||
logger.info(f"Found lights: {list(self.light_keys.keys())}")
|
logger.info(f"Found lights: {list(self.light_keys.keys())}")
|
||||||
|
logger.info(f"Found switches: {list(self.switch_keys.keys())}")
|
||||||
|
|
||||||
|
# Fetch initial states for switches
|
||||||
|
for entity in entities:
|
||||||
|
if hasattr(entity, "object_id") and entity.object_id in self.switch_keys:
|
||||||
|
if hasattr(entity, "state"):
|
||||||
|
self.switch_states[entity.object_id] = entity.state
|
||||||
|
logger.info(f"Initial state for '{entity.object_id}': {entity.state}")
|
||||||
|
|
||||||
|
# Subscribe to state changes for switches
|
||||||
|
def on_state_changed(state):
|
||||||
|
# Log all state changes for debugging
|
||||||
|
entity_name = None
|
||||||
|
entity_type = None
|
||||||
|
|
||||||
|
# Check if it's a switch
|
||||||
|
for switch_id, key in self.switch_keys.items():
|
||||||
|
if state.key == key:
|
||||||
|
self.switch_states[switch_id] = state.state
|
||||||
|
entity_name = switch_id
|
||||||
|
entity_type = "switch"
|
||||||
|
logger.info(
|
||||||
|
f"Switch '{switch_id}' changed to: {state.state}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if it's a light (for debugging)
|
||||||
|
if entity_name is None:
|
||||||
|
for light_id, key in self.light_keys.items():
|
||||||
|
if state.key == key:
|
||||||
|
entity_name = light_id
|
||||||
|
entity_type = "light"
|
||||||
|
logger.debug(
|
||||||
|
f"Light '{light_id}' state changed (key={state.key})"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Log unknown entities
|
||||||
|
if entity_name is None:
|
||||||
|
logger.debug(f"Unknown entity state change (key={state.key})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.subscribe_states(on_state_changed)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to subscribe to states: {e}")
|
||||||
|
|
||||||
# Set up reconnection logic
|
# Set up reconnection logic
|
||||||
async def on_disconnect():
|
async def on_disconnect():
|
||||||
@@ -119,7 +173,7 @@ class ESP32Controller:
|
|||||||
client=self.client,
|
client=self.client,
|
||||||
on_disconnect=on_disconnect,
|
on_disconnect=on_disconnect,
|
||||||
on_connect=on_connect,
|
on_connect=on_connect,
|
||||||
name=self.host
|
name=self.host,
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -128,9 +182,29 @@ class ESP32Controller:
|
|||||||
logger.error(f"Failed to connect to ESP32: {e}")
|
logger.error(f"Failed to connect to ESP32: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def should_update_leds(self) -> bool:
|
||||||
|
"""Check if LEDs should be updated based on switch states"""
|
||||||
|
# Check if monitoring is enabled
|
||||||
|
monitoring_enabled = self.switch_states.get("monitoring_enabled", True)
|
||||||
|
if not monitoring_enabled:
|
||||||
|
logger.debug("Monitoring disabled - skipping LED update")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if quiet hours are active
|
||||||
|
quiet_hours = self.switch_states.get("quiet_hours", False)
|
||||||
|
if quiet_hours:
|
||||||
|
logger.debug("Quiet hours active - skipping LED update")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
async def set_all_monitors_up(self):
|
async def set_all_monitors_up(self):
|
||||||
"""Set green LED with breathing effect (all monitors UP)"""
|
"""Set green LED with breathing effect (all monitors UP)"""
|
||||||
if self.current_state == 'all_up':
|
if not self.should_update_leds():
|
||||||
|
logger.info("Skipping LED update due to switch states")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.current_state == "all_up":
|
||||||
return # Already in correct state
|
return # Already in correct state
|
||||||
|
|
||||||
if not self.client:
|
if not self.client:
|
||||||
@@ -139,19 +213,19 @@ class ESP32Controller:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Turn off red LED (sanitized name: rote_led)
|
# Turn off red LED (sanitized name: rote_led)
|
||||||
if 'rote_led' in self.light_keys:
|
if "rote_led" in self.light_keys:
|
||||||
self.client.light_command(key=self.light_keys['rote_led'], state=False)
|
self.client.light_command(key=self.light_keys["rote_led"], state=False)
|
||||||
|
|
||||||
# Turn on green LED with breathing effect (sanitized name: gr_ne_led)
|
# Turn on green LED with breathing effect (sanitized name: gr_ne_led)
|
||||||
if 'gr_ne_led' in self.light_keys:
|
if "gr_ne_led" in self.light_keys:
|
||||||
self.client.light_command(
|
self.client.light_command(
|
||||||
key=self.light_keys['gr_ne_led'],
|
key=self.light_keys["gr_ne_led"],
|
||||||
state=True,
|
state=True,
|
||||||
brightness=1.0,
|
brightness=1.0,
|
||||||
effect='Breathing'
|
effect="Breathing",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.current_state = 'all_up'
|
self.current_state = "all_up"
|
||||||
logger.info("✓ All monitors UP - Green LED breathing")
|
logger.info("✓ All monitors UP - Green LED breathing")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -159,7 +233,11 @@ class ESP32Controller:
|
|||||||
|
|
||||||
async def set_monitors_down(self):
|
async def set_monitors_down(self):
|
||||||
"""Set red LED with fast heartbeat effect (monitors DOWN)"""
|
"""Set red LED with fast heartbeat effect (monitors DOWN)"""
|
||||||
if self.current_state == 'monitors_down':
|
if not self.should_update_leds():
|
||||||
|
logger.info("Skipping LED update due to switch states")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.current_state == "monitors_down":
|
||||||
return # Already in correct state
|
return # Already in correct state
|
||||||
|
|
||||||
if not self.client:
|
if not self.client:
|
||||||
@@ -168,19 +246,19 @@ class ESP32Controller:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Turn off green LED (sanitized name: gr_ne_led)
|
# Turn off green LED (sanitized name: gr_ne_led)
|
||||||
if 'gr_ne_led' in self.light_keys:
|
if "gr_ne_led" in self.light_keys:
|
||||||
self.client.light_command(key=self.light_keys['gr_ne_led'], state=False)
|
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)
|
# Turn on red LED with fast heartbeat effect (sanitized name: rote_led)
|
||||||
if 'rote_led' in self.light_keys:
|
if "rote_led" in self.light_keys:
|
||||||
self.client.light_command(
|
self.client.light_command(
|
||||||
key=self.light_keys['rote_led'],
|
key=self.light_keys["rote_led"],
|
||||||
state=True,
|
state=True,
|
||||||
brightness=1.0,
|
brightness=1.0,
|
||||||
effect='Fast Heartbeat'
|
effect="Fast Heartbeat",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.current_state = 'monitors_down'
|
self.current_state = "monitors_down"
|
||||||
logger.warning("✗ Monitors DOWN - Red LED fast heartbeat")
|
logger.warning("✗ Monitors DOWN - Red LED fast heartbeat")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -224,6 +302,10 @@ async def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# Wait for esp to be fully connected
|
||||||
|
logger.info("Waiting for ESP32 to stabilize...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
logger.info("(actually) Starting Uptime Kuma monitoring...")
|
||||||
# Initialize Uptime Kuma monitor
|
# Initialize Uptime Kuma monitor
|
||||||
kuma = UptimeKumaMonitor(session, KUMA_URL, KUMA_KEY)
|
kuma = UptimeKumaMonitor(session, KUMA_URL, KUMA_KEY)
|
||||||
|
|
||||||
@@ -248,7 +330,7 @@ async def main():
|
|||||||
await esp.disconnect()
|
await esp.disconnect()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
Reference in New Issue
Block a user