mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
feat: add Synology NAS, Home Assistant monitoring, multi-channel notifications
New tabs: - Synology NAS: CPU, RAM, temp, volumes, VMs, Docker containers - Home Assistant: sensors, automations, persons, climate, battery, doors New features: - Personalized PC agent download with custom name - Notification settings: per-category Telegram/Email/WhatsApp toggles - Email via SMTP, WhatsApp via CallMeBot API - Test notification buttons for each channel - Unified notifier replaces direct Telegram calls in alerter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
226
server/services/ha_client.py
Normal file
226
server/services/ha_client.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Home Assistant REST API client for monitoring."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeAssistantClient:
|
||||
"""Client for Home Assistant REST API."""
|
||||
|
||||
def __init__(self, url: str, token: str):
|
||||
"""
|
||||
Args:
|
||||
url: HA base URL, e.g. http://192.168.1.100:8123
|
||||
token: Long-Lived Access Token (from Profile → Security → Long-Lived Access Tokens)
|
||||
"""
|
||||
self.base_url = url.rstrip("/")
|
||||
self.token = token
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _get(self, path: str) -> Optional[Any]:
|
||||
"""GET request to HA API."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{self.base_url}/api{path}",
|
||||
headers=self.headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
ssl=False,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
logger.error(f"HA API {path} returned {resp.status}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"HA API error ({path}): {e}")
|
||||
return None
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Check if HA is reachable."""
|
||||
data = await self._get("/")
|
||||
return data is not None and data.get("message") == "API running."
|
||||
|
||||
async def get_config(self) -> Optional[dict]:
|
||||
"""Get HA configuration (name, version, components, location)."""
|
||||
return await self._get("/config")
|
||||
|
||||
async def get_states(self) -> Optional[List[dict]]:
|
||||
"""Get all entity states."""
|
||||
return await self._get("/states")
|
||||
|
||||
async def get_services(self) -> Optional[List[dict]]:
|
||||
"""Get available services."""
|
||||
return await self._get("/services")
|
||||
|
||||
async def collect_all_metrics(self) -> dict:
|
||||
"""Collect key metrics from Home Assistant."""
|
||||
result = {
|
||||
"online": False,
|
||||
"version": "",
|
||||
"location_name": "",
|
||||
"components_count": 0,
|
||||
"entities_count": 0,
|
||||
"automations": [],
|
||||
"sensors": {
|
||||
"temperature": [],
|
||||
"humidity": [],
|
||||
"pressure": [],
|
||||
"motion": [],
|
||||
"door": [],
|
||||
"light": [],
|
||||
"battery": [],
|
||||
},
|
||||
"climate": [],
|
||||
"media_players": [],
|
||||
"persons": [],
|
||||
"updates_available": [],
|
||||
"problem_entities": [],
|
||||
"uptime": "",
|
||||
}
|
||||
|
||||
# Check connection
|
||||
if not await self.check_connection():
|
||||
return result
|
||||
|
||||
result["online"] = True
|
||||
|
||||
# Config
|
||||
config = await self.get_config()
|
||||
if config:
|
||||
result["version"] = config.get("version", "")
|
||||
result["location_name"] = config.get("location_name", "")
|
||||
result["components_count"] = len(config.get("components", []))
|
||||
|
||||
# States
|
||||
states = await self.get_states()
|
||||
if not states:
|
||||
return result
|
||||
|
||||
result["entities_count"] = len(states)
|
||||
|
||||
for entity in states:
|
||||
eid = entity.get("entity_id", "")
|
||||
state = entity.get("state", "")
|
||||
attrs = entity.get("attributes", {})
|
||||
friendly = attrs.get("friendly_name", eid)
|
||||
|
||||
# Automations
|
||||
if eid.startswith("automation."):
|
||||
result["automations"].append({
|
||||
"name": friendly,
|
||||
"state": state, # on/off
|
||||
"last_triggered": attrs.get("last_triggered", ""),
|
||||
})
|
||||
|
||||
# Temperature sensors
|
||||
elif eid.startswith("sensor.") and attrs.get("device_class") == "temperature":
|
||||
try:
|
||||
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||
result["sensors"]["temperature"].append({
|
||||
"name": friendly,
|
||||
"value": val,
|
||||
"unit": attrs.get("unit_of_measurement", "°C"),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Humidity sensors
|
||||
elif eid.startswith("sensor.") and attrs.get("device_class") == "humidity":
|
||||
try:
|
||||
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||
result["sensors"]["humidity"].append({
|
||||
"name": friendly,
|
||||
"value": val,
|
||||
"unit": attrs.get("unit_of_measurement", "%"),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Battery sensors
|
||||
elif eid.startswith("sensor.") and attrs.get("device_class") == "battery":
|
||||
try:
|
||||
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||
result["sensors"]["battery"].append({
|
||||
"name": friendly,
|
||||
"value": val,
|
||||
"unit": "%",
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Motion / occupancy
|
||||
elif eid.startswith("binary_sensor.") and attrs.get("device_class") in ("motion", "occupancy"):
|
||||
result["sensors"]["motion"].append({
|
||||
"name": friendly,
|
||||
"state": state, # on/off
|
||||
})
|
||||
|
||||
# Door / window
|
||||
elif eid.startswith("binary_sensor.") and attrs.get("device_class") in ("door", "window", "opening"):
|
||||
result["sensors"]["door"].append({
|
||||
"name": friendly,
|
||||
"state": state, # on = open, off = closed
|
||||
})
|
||||
|
||||
# Lights
|
||||
elif eid.startswith("light."):
|
||||
result["sensors"]["light"].append({
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
"brightness": attrs.get("brightness"),
|
||||
})
|
||||
|
||||
# Climate
|
||||
elif eid.startswith("climate."):
|
||||
result["climate"].append({
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
"current_temp": attrs.get("current_temperature"),
|
||||
"target_temp": attrs.get("temperature"),
|
||||
"hvac_action": attrs.get("hvac_action", ""),
|
||||
})
|
||||
|
||||
# Media players
|
||||
elif eid.startswith("media_player."):
|
||||
result["media_players"].append({
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
"source": attrs.get("source", ""),
|
||||
})
|
||||
|
||||
# Person
|
||||
elif eid.startswith("person."):
|
||||
result["persons"].append({
|
||||
"name": friendly,
|
||||
"state": state, # home/not_home/zone
|
||||
})
|
||||
|
||||
# Updates available
|
||||
elif eid.startswith("update.") and state == "on":
|
||||
result["updates_available"].append({
|
||||
"name": friendly,
|
||||
"installed": attrs.get("installed_version", ""),
|
||||
"latest": attrs.get("latest_version", ""),
|
||||
})
|
||||
|
||||
# Problem entities (unavailable/unknown)
|
||||
if state in ("unavailable", "unknown") and not eid.startswith(("update.", "scene.")):
|
||||
result["problem_entities"].append({
|
||||
"entity_id": eid,
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
})
|
||||
|
||||
# HA uptime sensor
|
||||
if eid == "sensor.uptime" or eid.endswith("_uptime"):
|
||||
if attrs.get("device_class") == "timestamp" and state not in ("unknown", "unavailable"):
|
||||
result["uptime"] = state
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user