mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Add HA poller, fix domain filters, create missing DB tables, update schema
- jarvis-ha-poller.py: new service polling HA entities → JARVIS (running on VM211) - ha.php: add camera/siren/remote/todo/lawn_mower to skipDomains - db/schema.sql: add tasks, appointments, usage_patterns tables; fix registered_agents enum (windows/macos) + version column Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS HA Poller — pulls entity states from Home Assistant REST API
|
||||
and pushes them to JARVIS as a homeassistant-type agent.
|
||||
Runs on VM211 as a systemd service (jarvis-ha-poller).
|
||||
|
||||
Config: /etc/jarvis-agent/ha-poller.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = "/etc/jarvis-agent/ha-poller.json"
|
||||
STATE_PATH = "/var/lib/jarvis-agent/ha-poller-state.json"
|
||||
AGENT_VERSION = "1.0"
|
||||
AGENT_ID = "homeassistant_ha"
|
||||
HOSTNAME = "homeassistant"
|
||||
|
||||
# Domains to skip — don't send to JARVIS (saves DB space, keeps UI clean)
|
||||
SKIP_DOMAINS = {
|
||||
'sensor', 'binary_sensor', 'button', 'update', 'select', 'number',
|
||||
'device_tracker', 'event', 'image', 'person', 'zone', 'tts',
|
||||
'conversation', 'assist_satellite', 'input_button', 'media_player',
|
||||
'scene', 'water_heater', 'alarm_control_panel', 'automation',
|
||||
'script', 'calendar', 'notify', 'weather', 'sun', 'persistent_notification',
|
||||
'tag', 'system_health', 'timer', 'counter',
|
||||
'camera', 'siren', 'remote', 'todo', 'lawn_mower',
|
||||
}
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def load_config() -> dict:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}", flush=True)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def _ssl_ctx(verify: bool):
|
||||
if not verify:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
return None
|
||||
|
||||
def jarvis_post(url: str, payload: dict, headers: dict, ssl_verify: bool, timeout: int = 15) -> dict:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def ha_get(url: str, token: str, timeout: int = 15) -> dict | list | None:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
log(f"HA API error: {e}")
|
||||
return None
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
reg_key = cfg["registration_key"]
|
||||
|
||||
log(f"Registering HA poller with JARVIS at {jarvis_url}...")
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/register",
|
||||
{
|
||||
"hostname": HOSTNAME,
|
||||
"version": AGENT_VERSION,
|
||||
"agent_type": "homeassistant",
|
||||
"ip_address": cfg.get("ha_url", "").split("//")[-1].split(":")[0],
|
||||
"capabilities": ["ha_entities", "ha_state"],
|
||||
"agent_id": AGENT_ID,
|
||||
},
|
||||
{"X-Registration-Key": reg_key},
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Registration failed: {result['error']}")
|
||||
return ""
|
||||
api_key = result.get("api_key", "")
|
||||
if api_key:
|
||||
state["api_key"] = api_key
|
||||
state["agent_id"] = AGENT_ID
|
||||
save_state(state)
|
||||
log(f"Registered. agent_id={AGENT_ID}")
|
||||
return api_key
|
||||
|
||||
def push_entities(cfg: dict, api_key: str, entities: list) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
|
||||
# Send in batches of 200
|
||||
batch_size = 200
|
||||
total = len(entities)
|
||||
ok = True
|
||||
for i in range(0, total, batch_size):
|
||||
batch = entities[i:i+batch_size]
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/ha_state",
|
||||
{"entities": batch},
|
||||
headers,
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Push batch {i//batch_size+1} failed: {result['error']}")
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def heartbeat(cfg: dict, api_key: str) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/heartbeat",
|
||||
{"version": AGENT_VERSION},
|
||||
{"X-Agent-Key": api_key},
|
||||
ssl_verify,
|
||||
timeout=10,
|
||||
)
|
||||
return "error" not in result
|
||||
|
||||
def fetch_ha_states(cfg: dict) -> list:
|
||||
ha_url = cfg["ha_url"].rstrip("/")
|
||||
token = cfg["ha_token"]
|
||||
states = ha_get(f"{ha_url}/api/states", token)
|
||||
if not states or not isinstance(states, list):
|
||||
return []
|
||||
|
||||
entities = []
|
||||
for s in states:
|
||||
entity_id = s.get("entity_id", "")
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if domain in SKIP_DOMAINS:
|
||||
continue
|
||||
attrs = s.get("attributes", {})
|
||||
# Convert ISO 8601 (e.g. "2026-06-28T21:26:01.922366+00:00") to MySQL datetime
|
||||
lc = s.get("last_changed", "")
|
||||
try:
|
||||
dt = datetime.fromisoformat(lc.replace("Z", "+00:00"))
|
||||
lc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
lc = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
entities.append({
|
||||
"entity_id": entity_id,
|
||||
"name": attrs.get("friendly_name") or entity_id,
|
||||
"state": s.get("state", ""),
|
||||
"attributes": attrs,
|
||||
"last_changed": lc,
|
||||
})
|
||||
return entities
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
state = load_state()
|
||||
|
||||
poll_interval = int(cfg.get("poll_interval", 30))
|
||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
||||
|
||||
api_key = state.get("api_key", "")
|
||||
if not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
log("Could not register. Retrying in 60s...")
|
||||
time.sleep(60)
|
||||
main()
|
||||
return
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_push = 0
|
||||
log(f"HA Poller v{AGENT_VERSION} running. Polling HA every {poll_interval}s, heartbeat every {heartbeat_every}s.")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Heartbeat
|
||||
if not heartbeat(cfg, api_key):
|
||||
log("Heartbeat failed (401?) — re-registering...")
|
||||
state.clear()
|
||||
save_state(state)
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# Push entity states every poll_interval
|
||||
if now - last_push >= poll_interval:
|
||||
entities = fetch_ha_states(cfg)
|
||||
if entities:
|
||||
ok = push_entities(cfg, api_key, entities)
|
||||
if ok:
|
||||
log(f"Pushed {len(entities)} HA entities to JARVIS.")
|
||||
last_push = now
|
||||
else:
|
||||
log("No HA entities fetched (HA down or token invalid?)")
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -83,7 +83,8 @@ if ($method === 'POST' && $action === 'service') {
|
||||
// Serve entities from ha_entities table (real-time agent push data)
|
||||
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
|
||||
'device_tracker','event','image','person','zone','tts','conversation',
|
||||
'assist_satellite','input_button','media_player','scene','water_heater'];
|
||||
'assist_satellite','input_button','media_player','scene','water_heater',
|
||||
'alarm_control_panel','automation','script','calendar','notify','weather','camera','siren','remote','todo','lawn_mower'];
|
||||
$skipKeywords = [
|
||||
// HACS / system toggles
|
||||
'pre_release','get_hacs','matter_server','zerotier','mariadb',
|
||||
|
||||
+202
-8
@@ -1,4 +1,9 @@
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
-- MariaDB dump 10.19 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: jarvis_db
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 10.11.14-MariaDB-0ubuntu0.24.04.1
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
@@ -10,6 +15,11 @@
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||
|
||||
--
|
||||
-- Table structure for table `agent_commands`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `agent_commands`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -28,6 +38,11 @@ CREATE TABLE `agent_commands` (
|
||||
KEY `idx_created` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `agent_metrics`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `agent_metrics`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -40,8 +55,13 @@ CREATE TABLE `agent_metrics` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_agent_time` (`agent_id`,`recorded_at`),
|
||||
KEY `idx_recorded` (`recorded_at`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=28329 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=29445 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `alerts`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `alerts`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -58,8 +78,13 @@ CREATE TABLE `alerts` (
|
||||
`auto_resolve` tinyint(1) DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_source_key` (`source_key`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `api_cache`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `api_cache`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -70,6 +95,80 @@ CREATE TABLE `api_cache` (
|
||||
PRIMARY KEY (`cache_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `appointments`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `appointments`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `appointments` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`category` varchar(64) DEFAULT 'personal',
|
||||
`start_at` datetime NOT NULL,
|
||||
`end_at` datetime DEFAULT NULL,
|
||||
`location` varchar(255) DEFAULT NULL,
|
||||
`all_day` tinyint(1) DEFAULT 0,
|
||||
`reminder_min` int(11) DEFAULT 30,
|
||||
`alerted` tinyint(1) DEFAULT 0,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_start` (`start_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `arc_jobs`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `arc_jobs`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `arc_jobs` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`job_type` varchar(64) NOT NULL,
|
||||
`payload` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||
`priority` int(11) DEFAULT 0,
|
||||
`status` enum('queued','running','done','failed','cancelled') DEFAULT 'queued',
|
||||
`result` longtext DEFAULT NULL,
|
||||
`error` varchar(2000) DEFAULT NULL,
|
||||
`created_by` varchar(128) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
`started_at` datetime DEFAULT NULL,
|
||||
`completed_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `arc_status`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `arc_status`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `arc_status` (
|
||||
`id` int(11) NOT NULL DEFAULT 1,
|
||||
`version` varchar(20) DEFAULT NULL,
|
||||
`started_at` datetime DEFAULT NULL,
|
||||
`last_heartbeat` datetime DEFAULT NULL,
|
||||
`active_jobs` int(11) DEFAULT 0,
|
||||
`jobs_done` int(11) DEFAULT 0,
|
||||
`jobs_failed` int(11) DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `conversations`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `conversations`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -85,6 +184,11 @@ CREATE TABLE `conversations` (
|
||||
KEY `idx_created` (`created_at`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=325 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `ha_entities`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `ha_entities`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -102,8 +206,13 @@ CREATE TABLE `ha_entities` (
|
||||
UNIQUE KEY `uk_agent_entity` (`agent_id`,`entity_id`),
|
||||
KEY `idx_domain` (`domain`),
|
||||
KEY `idx_updated` (`updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8436 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_facts`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_facts`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -117,8 +226,13 @@ CREATE TABLE `kb_facts` (
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_fact` (`category`,`fact_key`,`host`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=26088 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=39129 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_intents`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_intents`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -135,6 +249,11 @@ CREATE TABLE `kb_intents` (
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_ollama_models`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_ollama_models`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -149,6 +268,11 @@ CREATE TABLE `kb_ollama_models` (
|
||||
UNIQUE KEY `model_name` (`model_name`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `kb_preferences`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `kb_preferences`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -161,6 +285,11 @@ CREATE TABLE `kb_preferences` (
|
||||
UNIQUE KEY `pref_key` (`pref_key`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `known_commands`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `known_commands`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -173,6 +302,11 @@ CREATE TABLE `known_commands` (
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `metrics_history`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `metrics_history`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -184,8 +318,13 @@ CREATE TABLE `metrics_history` (
|
||||
`recorded_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_metric_time` (`metric_name`,`recorded_at`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=33415 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=34771 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `network_devices`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `network_devices`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -203,6 +342,11 @@ CREATE TABLE `network_devices` (
|
||||
UNIQUE KEY `uk_ip` (`ip`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=409 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `registered_agents`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `registered_agents`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -210,10 +354,11 @@ CREATE TABLE `registered_agents` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`agent_id` varchar(128) NOT NULL,
|
||||
`hostname` varchar(255) NOT NULL,
|
||||
`agent_type` enum('linux','homeassistant','proxmox') NOT NULL DEFAULT 'linux',
|
||||
`agent_type` enum('linux','homeassistant','proxmox','windows','macos') NOT NULL DEFAULT 'linux',
|
||||
`ip_address` varchar(45) DEFAULT NULL,
|
||||
`api_key` varchar(64) NOT NULL,
|
||||
`capabilities` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`capabilities`)),
|
||||
`version` varchar(32) DEFAULT NULL,
|
||||
`last_seen` datetime DEFAULT NULL,
|
||||
`status` enum('online','offline','unknown') NOT NULL DEFAULT 'unknown',
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
@@ -221,8 +366,56 @@ CREATE TABLE `registered_agents` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_agent_id` (`agent_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `tasks`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tasks`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tasks` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`notes` text DEFAULT NULL,
|
||||
`category` varchar(64) DEFAULT 'personal',
|
||||
`priority` enum('urgent','high','normal','low') DEFAULT 'normal',
|
||||
`status` enum('pending','in_progress','done','cancelled') DEFAULT 'pending',
|
||||
`due_date` date DEFAULT NULL,
|
||||
`due_time` time DEFAULT NULL,
|
||||
`completed_at` datetime DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status_due` (`status`,`due_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `usage_patterns`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `usage_patterns`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `usage_patterns` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`intent_name` varchar(64) NOT NULL,
|
||||
`hour` tinyint(2) NOT NULL,
|
||||
`dow` tinyint(1) NOT NULL,
|
||||
`hit_count` int(11) DEFAULT 1,
|
||||
`last_used` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_intent_time` (`intent_name`,`hour`,`dow`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `users`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
@@ -236,7 +429,7 @@ CREATE TABLE `users` (
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
@@ -248,3 +441,4 @@ CREATE TABLE `users` (
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2026-06-29 20:43:24
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS HA Poller — pulls entity states from Home Assistant REST API
|
||||
and pushes them to JARVIS as a homeassistant-type agent.
|
||||
Runs on VM211 as a systemd service (jarvis-ha-poller).
|
||||
|
||||
Config: /etc/jarvis-agent/ha-poller.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = "/etc/jarvis-agent/ha-poller.json"
|
||||
STATE_PATH = "/var/lib/jarvis-agent/ha-poller-state.json"
|
||||
AGENT_VERSION = "1.0"
|
||||
AGENT_ID = "homeassistant_ha"
|
||||
HOSTNAME = "homeassistant"
|
||||
|
||||
# Domains to skip — don't send to JARVIS (saves DB space, keeps UI clean)
|
||||
SKIP_DOMAINS = {
|
||||
'sensor', 'binary_sensor', 'button', 'update', 'select', 'number',
|
||||
'device_tracker', 'event', 'image', 'person', 'zone', 'tts',
|
||||
'conversation', 'assist_satellite', 'input_button', 'media_player',
|
||||
'scene', 'water_heater', 'alarm_control_panel', 'automation',
|
||||
'script', 'calendar', 'notify', 'weather', 'sun', 'persistent_notification',
|
||||
'tag', 'system_health', 'timer', 'counter',
|
||||
'camera', 'siren', 'remote', 'todo', 'lawn_mower',
|
||||
}
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def load_config() -> dict:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}", flush=True)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def _ssl_ctx(verify: bool):
|
||||
if not verify:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
return None
|
||||
|
||||
def jarvis_post(url: str, payload: dict, headers: dict, ssl_verify: bool, timeout: int = 15) -> dict:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _ssl_ctx(ssl_verify)
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def ha_get(url: str, token: str, timeout: int = 15) -> dict | list | None:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
log(f"HA API error: {e}")
|
||||
return None
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
reg_key = cfg["registration_key"]
|
||||
|
||||
log(f"Registering HA poller with JARVIS at {jarvis_url}...")
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/register",
|
||||
{
|
||||
"hostname": HOSTNAME,
|
||||
"version": AGENT_VERSION,
|
||||
"agent_type": "homeassistant",
|
||||
"ip_address": cfg.get("ha_url", "").split("//")[-1].split(":")[0],
|
||||
"capabilities": ["ha_entities", "ha_state"],
|
||||
"agent_id": AGENT_ID,
|
||||
},
|
||||
{"X-Registration-Key": reg_key},
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Registration failed: {result['error']}")
|
||||
return ""
|
||||
api_key = result.get("api_key", "")
|
||||
if api_key:
|
||||
state["api_key"] = api_key
|
||||
state["agent_id"] = AGENT_ID
|
||||
save_state(state)
|
||||
log(f"Registered. agent_id={AGENT_ID}")
|
||||
return api_key
|
||||
|
||||
def push_entities(cfg: dict, api_key: str, entities: list) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
|
||||
# Send in batches of 200
|
||||
batch_size = 200
|
||||
total = len(entities)
|
||||
ok = True
|
||||
for i in range(0, total, batch_size):
|
||||
batch = entities[i:i+batch_size]
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/ha_state",
|
||||
{"entities": batch},
|
||||
headers,
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Push batch {i//batch_size+1} failed: {result['error']}")
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def heartbeat(cfg: dict, api_key: str) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/heartbeat",
|
||||
{"version": AGENT_VERSION},
|
||||
{"X-Agent-Key": api_key},
|
||||
ssl_verify,
|
||||
timeout=10,
|
||||
)
|
||||
return "error" not in result
|
||||
|
||||
def fetch_ha_states(cfg: dict) -> list:
|
||||
ha_url = cfg["ha_url"].rstrip("/")
|
||||
token = cfg["ha_token"]
|
||||
states = ha_get(f"{ha_url}/api/states", token)
|
||||
if not states or not isinstance(states, list):
|
||||
return []
|
||||
|
||||
entities = []
|
||||
for s in states:
|
||||
entity_id = s.get("entity_id", "")
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if domain in SKIP_DOMAINS:
|
||||
continue
|
||||
attrs = s.get("attributes", {})
|
||||
# Convert ISO 8601 (e.g. "2026-06-28T21:26:01.922366+00:00") to MySQL datetime
|
||||
lc = s.get("last_changed", "")
|
||||
try:
|
||||
dt = datetime.fromisoformat(lc.replace("Z", "+00:00"))
|
||||
lc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
lc = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
entities.append({
|
||||
"entity_id": entity_id,
|
||||
"name": attrs.get("friendly_name") or entity_id,
|
||||
"state": s.get("state", ""),
|
||||
"attributes": attrs,
|
||||
"last_changed": lc,
|
||||
})
|
||||
return entities
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
state = load_state()
|
||||
|
||||
poll_interval = int(cfg.get("poll_interval", 30))
|
||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
||||
|
||||
api_key = state.get("api_key", "")
|
||||
if not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
log("Could not register. Retrying in 60s...")
|
||||
time.sleep(60)
|
||||
main()
|
||||
return
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_push = 0
|
||||
log(f"HA Poller v{AGENT_VERSION} running. Polling HA every {poll_interval}s, heartbeat every {heartbeat_every}s.")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Heartbeat
|
||||
if not heartbeat(cfg, api_key):
|
||||
log("Heartbeat failed (401?) — re-registering...")
|
||||
state.clear()
|
||||
save_state(state)
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# Push entity states every poll_interval
|
||||
if now - last_push >= poll_interval:
|
||||
entities = fetch_ha_states(cfg)
|
||||
if entities:
|
||||
ok = push_entities(cfg, api_key, entities)
|
||||
if ok:
|
||||
log(f"Pushed {len(entities)} HA entities to JARVIS.")
|
||||
last_push = now
|
||||
else:
|
||||
log("No HA entities fetched (HA down or token invalid?)")
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user