From 84cd2ded5066c71c5c260145ba30d8e8d425097e Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 29 Jun 2026 15:44:28 -0500 Subject: [PATCH] Add HA poller, fix domain filters, create missing DB tables, update schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- agent/jarvis-ha-poller.py | 236 ++++++++++++++++++++++++++ api/endpoints/ha.php | 3 +- db/schema.sql | 210 ++++++++++++++++++++++- public_html/agent/jarvis-ha-poller.py | 236 ++++++++++++++++++++++++++ 4 files changed, 676 insertions(+), 9 deletions(-) create mode 100644 agent/jarvis-ha-poller.py create mode 100644 public_html/agent/jarvis-ha-poller.py diff --git a/agent/jarvis-ha-poller.py b/agent/jarvis-ha-poller.py new file mode 100644 index 0000000..372859b --- /dev/null +++ b/agent/jarvis-ha-poller.py @@ -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() diff --git a/api/endpoints/ha.php b/api/endpoints/ha.php index 648ad3a..89c7637 100644 --- a/api/endpoints/ha.php +++ b/api/endpoints/ha.php @@ -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', diff --git a/db/schema.sql b/db/schema.sql index 8cf7fd6..5742f63 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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 diff --git a/public_html/agent/jarvis-ha-poller.py b/public_html/agent/jarvis-ha-poller.py new file mode 100644 index 0000000..372859b --- /dev/null +++ b/public_html/agent/jarvis-ha-poller.py @@ -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()