diff --git a/homeassistant-event-gateway/.env.example b/homeassistant-event-gateway/.env.example new file mode 100644 index 0000000..94cf607 --- /dev/null +++ b/homeassistant-event-gateway/.env.example @@ -0,0 +1,81 @@ +# Home Assistant Event Gateway - Environment Configuration +# Copy this file to .env and fill in your values + +# ============================================================================= +# REQUIRED: Security +# ============================================================================= + +# Shared secret for authenticating Home Assistant webhooks +# Generate a random string: openssl rand -hex 32 +HA_SHARED_SECRET=your-secret-here + +# ============================================================================= +# OPTIONAL: Slack Integration +# ============================================================================= + +# Slack Incoming Webhook URL +# Create one at: https://api.slack.com/messaging/webhooks +# Leave empty to disable Slack notifications +SLACK_WEBHOOK_URL= + +# ============================================================================= +# OPTIONAL: AI Explanations +# ============================================================================= + +# OpenAI API key (optional - enables AI explanations) +# Get one at: https://platform.openai.com/api-keys +OPENAI_API_KEY= + +# OpenAI model to use (default: gpt-4o-mini) +# Options: gpt-4o-mini, gpt-4o, gpt-4-turbo, gpt-3.5-turbo +OPENAI_MODEL=gpt-4o-mini + +# OR use Anthropic Claude instead of OpenAI +# Get an API key at: https://console.anthropic.com/ +ANTHROPIC_API_KEY= + +# Anthropic model to use (default: claude-3-haiku-20240307) +# Options: claude-3-haiku-20240307, claude-3-5-sonnet-20241022, claude-3-opus-20240229 +ANTHROPIC_MODEL=claude-3-haiku-20240307 + +# ============================================================================= +# OPTIONAL: Noise Filtering +# ============================================================================= + +# Deduplication window in seconds (default: 60) +# Events with same entity_id and state within this window are ignored +DEDUPE_WINDOW_SEC=60 + +# Burst detection threshold (default: 5) +# Number of events from same entity that triggers burst flag +BURST_THRESHOLD=5 + +# Burst detection window in minutes (default: 5) +BURST_WINDOW_MIN=5 + +# Night hours (24-hour format, UTC) +# Motion/door events during night hours are flagged as notable +NIGHT_START_HOUR=22 +NIGHT_END_HOUR=6 + +# ============================================================================= +# OPTIONAL: Entity Filtering +# ============================================================================= + +# Allowlist of entities to process (comma-separated) +# Supports wildcards: binary_sensor.*, sensor.temperature_* +# Leave empty to allow all entities +HA_ENTITY_ALLOWLIST= + +# Denylist of entities to ignore (comma-separated) +# Supports wildcards: sensor.weather_*, automation.* +# These are filtered out even if in allowlist +HA_ENTITY_DENYLIST=sensor.time,sensor.date,sensor.uptime + +# Event types that are always notable (comma-separated) +# Default: alarm_triggered,device_offline,battery_low +HA_NOTABLE_EVENT_TYPES=alarm_triggered,device_offline,battery_low + +# State values that are always notable (comma-separated) +# Default: alarm,problem,unavailable +HA_NOTABLE_STATES=alarm,problem,unavailable diff --git a/homeassistant-event-gateway/.gitignore b/homeassistant-event-gateway/.gitignore new file mode 100644 index 0000000..3dcb9c8 --- /dev/null +++ b/homeassistant-event-gateway/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +node_modules/ + +# Environment files +.env +.env.local +.env.*.local + +# Codehooks +.codehooks/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* diff --git a/homeassistant-event-gateway/README.md b/homeassistant-event-gateway/README.md new file mode 100644 index 0000000..8b38ced --- /dev/null +++ b/homeassistant-event-gateway/README.md @@ -0,0 +1,386 @@ +# Home Assistant Event Gateway + +A Codehooks.io template that acts as an external event reasoning and notification layer for Home Assistant. It receives events via webhook, applies noise filtering rules, and forwards notable events to Slack with optional AI explanations. + +## Problem: Notification Fatigue + +Home Assistant generates a lot of events. A busy home might produce hundreds or thousands of events per day: + +- Motion sensors triggering as you walk through rooms +- Temperature readings updating every few minutes +- Lights turning on and off +- Doors opening and closing + +If you send all of these to your phone, you'll quickly learn to ignore them—and miss the events that actually matter. + +This template solves that problem by: + +1. **Filtering noise** using rules-based heuristics +2. **Detecting patterns** like burst events or unusual timing +3. **Notifying selectively** via Slack only for notable events +4. **Optionally explaining** events using an LLM + +## Architecture + +``` +┌──────────────────────┐ +│ Home Assistant │ +│ (Your Local HA) │ +└──────────┬───────────┘ + │ + │ POST /ha/event + │ X-HA-SECRET header + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Codehooks.io │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Event Gateway │ │ +│ │ │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │ Webhook │───▶│ Rules │───▶│ Storage │ │ │ +│ │ │ Handler │ │ Engine │ │ha_events│ │ │ +│ │ └─────────┘ └────┬────┘ └─────────┘ │ │ +│ │ │ │ │ +│ │ │ Notable? │ │ +│ │ ▼ │ │ +│ │ ┌───────────────┐ │ │ +│ │ │ Slack + AI │ │ │ +│ │ │ (Optional) │ │ │ +│ │ └───────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ + │ Webhook (if notable) + ▼ +┌──────────────────────┐ +│ Slack │ +│ #home-alerts │ +└──────────────────────┘ +``` + +**Key principle:** Home Assistant stays in control of all devices. This gateway is read-only—it receives events but cannot send commands back to HA. + +## Quick Start + +### 1. Deploy to Codehooks + +```bash +# Install Codehooks CLI +npm install -g codehooks + +# Create a new project +codehooks create homeassistant-gateway + +# Use this template +codehooks use homeassistant-event-gateway + +# Deploy +codehooks deploy +``` + +### 2. Set Environment Variables + +```bash +# Required: Set shared secret for webhook authentication +codehooks set-env HA_SHARED_SECRET "$(openssl rand -hex 32)" + +# Optional: Add Slack webhook +codehooks set-env SLACK_WEBHOOK_URL "https://hooks.slack.com/services/..." + +# Optional: Add AI (OpenAI or Anthropic) +codehooks set-env OPENAI_API_KEY "sk-..." +``` + +### 3. Configure Home Assistant + +Add this automation to your Home Assistant `configuration.yaml` or create it via the UI: + +```yaml +automation: + - id: codehooks_event_gateway + alias: "Send events to Codehooks gateway" + trigger: + # Add triggers for events you want to forward + - platform: state + entity_id: + - binary_sensor.front_door_contact + - binary_sensor.back_door_contact + - binary_sensor.motion_living_room + - binary_sensor.motion_garage + - lock.front_door + - alarm_control_panel.home_alarm + action: + - service: rest_command.codehooks_event + data: + entity_id: "{{ trigger.entity_id }}" + event_type: "state_changed" + state: "{{ trigger.to_state.state }}" + attributes: "{{ trigger.to_state.attributes | tojson }}" + timestamp: "{{ now().isoformat() }}" + +rest_command: + codehooks_event: + url: "https://YOUR-PROJECT.api.codehooks.io/ha/event" + method: POST + headers: + Content-Type: "application/json" + X-HA-SECRET: "your-shared-secret-here" + payload: > + { + "entity_id": "{{ entity_id }}", + "event_type": "{{ event_type }}", + "state": "{{ state }}", + "attributes": {{ attributes }}, + "timestamp": "{{ timestamp }}" + } +``` + +### 4. Test the Connection + +```bash +# From your terminal +curl -X POST https://YOUR-PROJECT.api.codehooks.io/ha/event \ + -H "Content-Type: application/json" \ + -H "X-HA-SECRET: your-shared-secret-here" \ + -d '{ + "entity_id": "binary_sensor.test", + "event_type": "state_changed", + "state": "on" + }' +``` + +## API Endpoints + +### `POST /ha/event` + +Receives events from Home Assistant. + +**Headers:** +- `X-HA-SECRET` (required): Shared secret for authentication + +**Body:** +```json +{ + "entity_id": "binary_sensor.front_door", + "event_type": "state_changed", + "state": "on", + "attributes": {}, + "timestamp": "2024-01-15T10:30:00Z" +} +``` + +**Response:** +```json +{ + "id": "abc123", + "notable": true, + "reasons": ["Night-time activity detected"] +} +``` + +### `GET /ha/digest` + +Returns a summary of the last 24 hours. + +**Response:** +```json +{ + "period": { + "start": "2024-01-14T10:00:00Z", + "end": "2024-01-15T10:00:00Z" + }, + "summary": { + "total_events": 156, + "notable_events": 12, + "notable_percentage": 8 + }, + "top_entities": [ + { "entity_id": "binary_sensor.motion_living_room", "count": 45 }, + { "entity_id": "sensor.temperature_kitchen", "count": 24 } + ], + "event_types": { + "state_changed": 150, + "alarm_triggered": 2 + } +} +``` + +### `GET /ha/events` + +Query stored events with filters. + +**Query Parameters:** +- `entity_id`: Filter by entity +- `event_type`: Filter by event type +- `notable`: Filter by notable status (`true`/`false`) +- `from`: Start timestamp +- `to`: End timestamp +- `limit`: Max results (default 100, max 1000) + +### `GET /ha/health` + +Health check endpoint. + +## Noise Filtering Rules + +The gateway applies these filters to determine if an event is notable: + +### 1. Deduplication + +Identical events (same entity + state) within 60 seconds are ignored. Configurable via `DEDUPE_WINDOW_SEC`. + +### 2. Burst Detection + +If the same entity fires 5+ times in 5 minutes, subsequent events are flagged. Configurable via `BURST_THRESHOLD` and `BURST_WINDOW_MIN`. + +### 3. Time-of-Day + +Motion and door events during night hours (22:00-06:00 UTC by default) are flagged as notable. Configurable via `NIGHT_START_HOUR` and `NIGHT_END_HOUR`. + +### 4. Entity Filtering + +- **Allowlist**: Only process specific entities (leave empty to allow all) +- **Denylist**: Always ignore specific entities + +Both support wildcards: `binary_sensor.*`, `sensor.temperature_*` + +### 5. Notable Types + +Certain event types are always notable: +- `alarm_triggered` +- `device_offline` +- `battery_low` + +Configurable via `HA_NOTABLE_EVENT_TYPES`. + +### 6. Notable States + +Certain state values are always notable: +- `alarm` +- `problem` +- `unavailable` + +Configurable via `HA_NOTABLE_STATES`. + +## AI Explanations + +When enabled, notable events are enriched with a brief AI-generated explanation. + +### How It Works + +1. The event data is sent to an LLM (OpenAI or Anthropic) +2. The LLM generates a 2-3 sentence explanation +3. The explanation is included in the Slack notification + +### Safety Constraints + +The LLM operates under strict constraints: + +- **Read-only**: It can only read event data, not control devices +- **No device control**: The system prompt explicitly prohibits control suggestions +- **Factual only**: Low temperature setting (0.3) for factual responses +- **Brief**: Max 150 tokens to prevent rambling + +### Example Output + +``` +Event: binary_sensor.front_door changed to "open" at 2:30 AM + +AI Explanation: The front door was opened at an unusual hour (2:30 AM). +This could indicate someone arriving home late or an unexpected entry. +Worth checking if this was expected. +``` + +### Disabling AI + +AI is disabled by default. To enable, set one of: +- `OPENAI_API_KEY` for OpenAI +- `ANTHROPIC_API_KEY` for Anthropic Claude + +To disable after enabling, remove the environment variable: +```bash +codehooks unset-env OPENAI_API_KEY +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `HA_SHARED_SECRET` | Yes | - | Shared secret for webhook auth | +| `SLACK_WEBHOOK_URL` | No | - | Slack Incoming Webhook URL | +| `OPENAI_API_KEY` | No | - | OpenAI API key for AI explanations | +| `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model to use | +| `ANTHROPIC_API_KEY` | No | - | Anthropic API key (alternative to OpenAI) | +| `ANTHROPIC_MODEL` | No | `claude-3-haiku-20240307` | Anthropic model | +| `DEDUPE_WINDOW_SEC` | No | `60` | Deduplication window | +| `BURST_THRESHOLD` | No | `5` | Events before burst flag | +| `BURST_WINDOW_MIN` | No | `5` | Burst detection window | +| `NIGHT_START_HOUR` | No | `22` | Night mode start (UTC) | +| `NIGHT_END_HOUR` | No | `6` | Night mode end (UTC) | +| `HA_ENTITY_ALLOWLIST` | No | - | Comma-separated entity allowlist | +| `HA_ENTITY_DENYLIST` | No | - | Comma-separated entity denylist | +| `HA_NOTABLE_EVENT_TYPES` | No | See above | Always-notable event types | +| `HA_NOTABLE_STATES` | No | See above | Always-notable states | + +## Project Structure + +``` +homeassistant-event-gateway/ +├── README.md # This file +├── .env.example # Environment variable template +└── src/ + ├── index.js # Main routes and handlers + ├── rules.js # Noise filtering logic + ├── slack.js # Slack message formatting and sending + └── ai.js # Optional LLM explanation helper +``` + +## Security + +Authentication uses the [webhook-verify](https://github.com/RestDB/webhook-verify) library for secure, constant-time token comparison. This prevents timing attacks when validating the `X-HA-Secret` header. + +The shared secret should be a random string of at least 32 characters: + +```bash +# Generate a secure secret +openssl rand -hex 32 +``` + +## Limitations + +This template intentionally does **not** support: + +- **Device control**: HA remains the single source of truth for device state +- **Bi-directional communication**: Events flow one way (HA → Gateway) +- **Complex automations**: Use HA's built-in automation engine +- **Real-time dashboards**: Use HA's frontend +- **Node-RED integration**: Not a replacement for Node-RED + +## Troubleshooting + +### Events not arriving + +1. Check the shared secret matches in both HA and Codehooks +2. Verify the webhook URL is correct +3. Check HA automation is enabled and triggering +4. Look at Codehooks logs: `codehooks logs` + +### Slack notifications not sending + +1. Verify `SLACK_WEBHOOK_URL` is set correctly +2. Test the webhook URL directly with curl +3. Check if events are being marked as notable + +### AI explanations not appearing + +1. Verify API key is set: `codehooks info` +2. Check logs for AI errors +3. Ensure the event is notable (AI only runs for notable events) + +## Contributing + +This template is part of the [Codehooks Templates](https://github.com/RestDB/codehooks-io-templates) repository. Issues and PRs welcome. + +## License + +MIT diff --git a/homeassistant-event-gateway/package.json b/homeassistant-event-gateway/package.json new file mode 100644 index 0000000..b91737c --- /dev/null +++ b/homeassistant-event-gateway/package.json @@ -0,0 +1,25 @@ +{ + "name": "homeassistant-event-gateway", + "version": "1.0.0", + "description": "Home Assistant event gateway with noise filtering and Slack notifications", + "main": "src/index.js", + "type": "module", + "scripts": { + "deploy": "codehooks deploy" + }, + "keywords": [ + "codehooks", + "home-assistant", + "webhook", + "slack", + "notifications", + "smart-home", + "iot" + ], + "author": "", + "license": "MIT", + "dependencies": { + "codehooks-js": "^1.0.0", + "webhook-verify": "^0.2.0" + } +} diff --git a/homeassistant-event-gateway/src/ai.js b/homeassistant-event-gateway/src/ai.js new file mode 100644 index 0000000..6f6cb89 --- /dev/null +++ b/homeassistant-event-gateway/src/ai.js @@ -0,0 +1,202 @@ +/** + * Optional AI explanation module for Home Assistant events + * + * This module provides LLM-powered explanations for notable events. + * It supports both OpenAI and Anthropic Claude APIs. + * + * IMPORTANT SAFETY NOTES: + * - The LLM is ONLY used to explain or classify events + * - The LLM has NO ability to control Home Assistant devices + * - The LLM receives read-only event data + * - If no API key is configured, the system works without AI + */ + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +/** + * Check if AI is enabled (any provider configured) + */ +export function isAIEnabled() { + return !!(process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY); +} + +/** + * Get the configured AI provider + */ +function getAIProvider() { + if (process.env.ANTHROPIC_API_KEY) return 'anthropic'; + if (process.env.OPENAI_API_KEY) return 'openai'; + return null; +} + +// ============================================================================ +// PROMPT ENGINEERING +// ============================================================================ + +/** + * Build the system prompt for event explanation + * The prompt is intentionally restrictive about what the LLM can do + */ +function buildSystemPrompt() { + return `You are a Home Assistant event analyst. Your ONLY job is to provide brief, helpful explanations of home automation events. + +RULES: +1. You can ONLY read and explain events - you cannot control any devices +2. Keep explanations to 2-3 sentences maximum +3. Focus on what the event means for the homeowner +4. If the event might indicate a problem, mention it briefly +5. Be factual and avoid speculation +6. Do not suggest actions that require device control + +You receive event data in JSON format and provide a plain text explanation.`; +} + +/** + * Build the user prompt with event details + */ +function buildUserPrompt(event, notableReasons) { + const eventSummary = { + entity_id: event.entity_id, + event_type: event.event_type, + state: event.state, + timestamp: event.timestamp, + time_context: event.hour_of_day >= 22 || event.hour_of_day < 6 ? 'night' : 'day', + notable_reasons: notableReasons + }; + + return `Explain this Home Assistant event briefly (2-3 sentences): + +${JSON.stringify(eventSummary, null, 2)}`; +} + +// ============================================================================ +// API CLIENTS +// ============================================================================ + +/** + * Call OpenAI API + */ +async function callOpenAI(systemPrompt, userPrompt) { + const apiKey = process.env.OPENAI_API_KEY; + const model = process.env.OPENAI_MODEL || 'gpt-4o-mini'; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + max_tokens: 150, + temperature: 0.3 + }) + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${body}`); + } + + const data = await response.json(); + return data.choices?.[0]?.message?.content?.trim() || null; +} + +/** + * Call Anthropic Claude API + */ +async function callAnthropic(systemPrompt, userPrompt) { + const apiKey = process.env.ANTHROPIC_API_KEY; + const model = process.env.ANTHROPIC_MODEL || 'claude-3-haiku-20240307'; + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model, + max_tokens: 150, + system: systemPrompt, + messages: [ + { role: 'user', content: userPrompt } + ] + }) + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Anthropic API error: ${response.status} - ${body}`); + } + + const data = await response.json(); + return data.content?.[0]?.text?.trim() || null; +} + +// ============================================================================ +// MAIN FUNCTION +// ============================================================================ + +/** + * Generate an AI explanation for a notable event + * + * @param {Object} event - The Home Assistant event + * @param {Array} notableReasons - Why this event was flagged as notable + * @returns {Promise} The explanation, or null if AI is disabled/fails + */ +export async function generateEventExplanation(event, notableReasons) { + const provider = getAIProvider(); + + if (!provider) { + return null; + } + + const systemPrompt = buildSystemPrompt(); + const userPrompt = buildUserPrompt(event, notableReasons); + + try { + let explanation; + + if (provider === 'openai') { + explanation = await callOpenAI(systemPrompt, userPrompt); + } else if (provider === 'anthropic') { + explanation = await callAnthropic(systemPrompt, userPrompt); + } + + if (explanation) { + console.log(`AI explanation generated (${provider})`); + } + + return explanation; + + } catch (error) { + console.error(`AI explanation failed (${provider}):`, error.message); + // Return null instead of throwing - AI is optional + return null; + } +} + +/** + * Get AI provider status for health checks + */ +export function getAIStatus() { + const provider = getAIProvider(); + + return { + enabled: !!provider, + provider: provider || 'none', + model: provider === 'openai' + ? (process.env.OPENAI_MODEL || 'gpt-4o-mini') + : provider === 'anthropic' + ? (process.env.ANTHROPIC_MODEL || 'claude-3-haiku-20240307') + : null + }; +} diff --git a/homeassistant-event-gateway/src/index.js b/homeassistant-event-gateway/src/index.js new file mode 100644 index 0000000..2c273cf --- /dev/null +++ b/homeassistant-event-gateway/src/index.js @@ -0,0 +1,297 @@ +/** + * Home Assistant Event Gateway + * + * A Codehooks.io template for receiving Home Assistant events, + * filtering noise, and forwarding notable events to Slack. + * + * Features: + * - Webhook endpoint for Home Assistant events + * - Event storage with normalized schema + * - Rules-based noise filtering (deduplication, burst detection, time-of-day) + * - Slack notifications for notable events + * - Optional LLM-powered event explanations + * - Daily digest endpoint + */ + +import { app, Datastore } from 'codehooks-js'; +import { verify } from 'webhook-verify'; +import { evaluateEvent, isEventNotable } from './rules.js'; +import { sendSlackNotification, formatSlackMessage } from './slack.js'; +import { generateEventExplanation, isAIEnabled } from './ai.js'; + +// Collection name for storing events +const EVENTS_COLLECTION = 'ha_events'; + +// ============================================================================ +// AUTHENTICATION +// ============================================================================ + +/** + * Validate shared secret from Home Assistant using webhook-verify + * Events are rejected if the secret doesn't match + */ +function validateSecret(req) { + const secret = process.env.HA_SHARED_SECRET; + + if (!secret) { + console.warn('HA_SHARED_SECRET not configured - webhook is unprotected'); + return true; // Allow if not configured (development mode) + } + + // Use webhook-verify for secure constant-time comparison + return verify('homeassistant', req.rawBody, req.headers, secret); +} + +// ============================================================================ +// API ENDPOINTS +// ============================================================================ + +/** + * Health check endpoint + */ +app.get('/ha/health', (req, res) => { + res.json({ + status: 'ok', + service: 'homeassistant-event-gateway', + timestamp: new Date().toISOString(), + features: { + slack: !!process.env.SLACK_WEBHOOK_URL, + ai: isAIEnabled() + } + }); +}); + +/** + * Main webhook endpoint for Home Assistant events + * POST /ha/event + * + * Expected payload from Home Assistant: + * { + * "entity_id": "binary_sensor.front_door", + * "event_type": "state_changed", + * "state": "on", + * "attributes": { ... }, + * "timestamp": "2024-01-15T10:30:00Z" // optional, will use server time if missing + * } + */ +app.post('/ha/event', async (req, res) => { + // Validate shared secret + if (!validateSecret(req)) { + console.warn('Invalid or missing X-HA-SECRET header'); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const payload = req.body; + + // Validate required fields + if (!payload.entity_id) { + res.status(400).json({ error: 'Missing required field: entity_id' }); + return; + } + + try { + const conn = await Datastore.open(); + const now = new Date(); + + // Normalize the event + const event = { + entity_id: payload.entity_id, + event_type: payload.event_type || 'state_changed', + state: payload.state ?? null, + attributes: payload.attributes || {}, + source: 'home_assistant', + timestamp: payload.timestamp || now.toISOString(), + received_at: now.toISOString(), + // Add computed fields for querying + hour_of_day: now.getUTCHours(), + day_of_week: now.getUTCDay(), + date_key: now.toISOString().split('T')[0] + }; + + // Evaluate against filtering rules + const evaluation = await evaluateEvent(conn, event, EVENTS_COLLECTION); + + // Store the event with evaluation metadata + const storedEvent = { + ...event, + _evaluation: { + is_notable: evaluation.isNotable, + reasons: evaluation.reasons, + filters_applied: evaluation.filtersApplied + } + }; + + const insertedEvent = await conn.insertOne(EVENTS_COLLECTION, storedEvent); + + // If notable, send Slack notification + if (evaluation.isNotable && process.env.SLACK_WEBHOOK_URL) { + // Generate AI explanation if enabled + let aiExplanation = null; + if (isAIEnabled()) { + try { + aiExplanation = await generateEventExplanation(event, evaluation.reasons); + } catch (aiError) { + console.error('AI explanation failed:', aiError.message); + // Continue without AI explanation + } + } + + const slackMessage = formatSlackMessage(event, evaluation.reasons, aiExplanation); + + // Send asynchronously - don't block the response + sendSlackNotification(slackMessage).catch(err => { + console.error('Slack notification failed:', err.message); + }); + } + + res.status(201).json({ + id: insertedEvent._id, + notable: evaluation.isNotable, + reasons: evaluation.reasons + }); + + } catch (error) { + console.error('Error processing event:', error); + res.status(500).json({ error: 'Failed to process event' }); + } +}); + +/** + * Daily digest endpoint + * GET /ha/digest + * + * Returns a summary of events from the last 24 hours + */ +app.get('/ha/digest', async (req, res) => { + try { + const conn = await Datastore.open(); + const now = new Date(); + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Get all events from last 24 hours + const events = await conn.getMany(EVENTS_COLLECTION, { + received_at: { $gte: twentyFourHoursAgo.toISOString() } + }).toArray(); + + // Calculate statistics + const totalEvents = events.length; + const notableEvents = events.filter(e => e._evaluation?.is_notable).length; + + // Count by entity_id + const entityCounts = {}; + for (const event of events) { + entityCounts[event.entity_id] = (entityCounts[event.entity_id] || 0) + 1; + } + + // Sort entities by frequency + const sortedEntities = Object.entries(entityCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); // Top 10 + + // Count by event_type + const eventTypeCounts = {}; + for (const event of events) { + eventTypeCounts[event.event_type] = (eventTypeCounts[event.event_type] || 0) + 1; + } + + // Count notable reasons + const reasonCounts = {}; + for (const event of events) { + if (event._evaluation?.reasons) { + for (const reason of event._evaluation.reasons) { + reasonCounts[reason] = (reasonCounts[reason] || 0) + 1; + } + } + } + + res.json({ + period: { + start: twentyFourHoursAgo.toISOString(), + end: now.toISOString() + }, + summary: { + total_events: totalEvents, + notable_events: notableEvents, + notable_percentage: totalEvents > 0 + ? Math.round((notableEvents / totalEvents) * 100) + : 0 + }, + top_entities: sortedEntities.map(([entity_id, count]) => ({ + entity_id, + count + })), + event_types: eventTypeCounts, + notable_reasons: reasonCounts, + generated_at: now.toISOString() + }); + + } catch (error) { + console.error('Error generating digest:', error); + res.status(500).json({ error: 'Failed to generate digest' }); + } +}); + +/** + * Query events endpoint + * GET /ha/events + * + * Query parameters: + * - entity_id: filter by entity + * - event_type: filter by event type + * - notable: filter by notable status (true/false) + * - from: start timestamp + * - to: end timestamp + * - limit: max results (default 100) + */ +app.get('/ha/events', async (req, res) => { + const { entity_id, event_type, notable, from, to, limit = 100 } = req.query; + + const query = {}; + + if (entity_id) query.entity_id = entity_id; + if (event_type) query.event_type = event_type; + if (notable !== undefined) { + query['_evaluation.is_notable'] = notable === 'true'; + } + if (from || to) { + query.received_at = {}; + if (from) query.received_at.$gte = from; + if (to) query.received_at.$lte = to; + } + + try { + const conn = await Datastore.open(); + const events = await conn.getMany(EVENTS_COLLECTION, query, { + sort: { received_at: -1 }, + limit: Math.min(parseInt(limit), 1000) + }).toArray(); + + res.json({ + count: events.length, + events + }); + + } catch (error) { + console.error('Error querying events:', error); + res.status(500).json({ error: 'Failed to query events' }); + } +}); + +// ============================================================================ +// AUTH CONFIGURATION +// ============================================================================ + +/** + * Allow public access to the HA webhook endpoint + * Authentication is handled via X-HA-SECRET header + */ +app.auth('/ha/*', (req, res, next) => { + next(); // Allow public access +}); + +// ============================================================================ +// EXPORT +// ============================================================================ + +export default app.init(); diff --git a/homeassistant-event-gateway/src/rules.js b/homeassistant-event-gateway/src/rules.js new file mode 100644 index 0000000..326ae1e --- /dev/null +++ b/homeassistant-event-gateway/src/rules.js @@ -0,0 +1,281 @@ +/** + * Rules-based noise filtering for Home Assistant events + * + * This module implements heuristics to determine if an event is "notable" + * and should trigger a notification. No AI is required for these rules. + * + * Filters: + * 1. Deduplication - ignore identical events within a time window + * 2. Burst detection - flag rapid repeated triggers from same entity + * 3. Time-of-day - different sensitivity for night vs day + * 4. Allowlist/Denylist - entity-based filtering via environment variables + */ + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +/** + * Parse comma-separated list from environment variable + */ +function parseEntityList(envVar) { + const value = process.env[envVar]; + if (!value) return []; + return value.split(',').map(s => s.trim()).filter(Boolean); +} + +/** + * Get configuration from environment with defaults + */ +function getConfig() { + return { + // Deduplication window in seconds (default: 60 seconds) + dedupeWindowSec: parseInt(process.env.DEDUPE_WINDOW_SEC || '60'), + + // Burst detection: N events in M minutes triggers a burst flag + burstThreshold: parseInt(process.env.BURST_THRESHOLD || '5'), + burstWindowMin: parseInt(process.env.BURST_WINDOW_MIN || '5'), + + // Night hours (24h format, UTC) + nightStartHour: parseInt(process.env.NIGHT_START_HOUR || '22'), + nightEndHour: parseInt(process.env.NIGHT_END_HOUR || '6'), + + // Entity lists + allowlist: parseEntityList('HA_ENTITY_ALLOWLIST'), + denylist: parseEntityList('HA_ENTITY_DENYLIST'), + + // Notable event types (always notify for these) + notableEventTypes: parseEntityList('HA_NOTABLE_EVENT_TYPES') || [ + 'alarm_triggered', + 'device_offline', + 'battery_low' + ], + + // Notable state changes (state values that are always notable) + notableStates: parseEntityList('HA_NOTABLE_STATES') || [ + 'alarm', + 'problem', + 'unavailable' + ] + }; +} + +// ============================================================================ +// FILTER IMPLEMENTATIONS +// ============================================================================ + +/** + * Check if entity is in denylist + * Supports wildcards: "sensor.temperature_*" matches "sensor.temperature_kitchen" + */ +function isEntityDenied(entityId, denylist) { + for (const pattern of denylist) { + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + if (regex.test(entityId)) return true; + } else if (entityId === pattern) { + return true; + } + } + return false; +} + +/** + * Check if entity is in allowlist + * If allowlist is empty, all entities are allowed + * Supports wildcards: "binary_sensor.*" matches all binary sensors + */ +function isEntityAllowed(entityId, allowlist) { + if (allowlist.length === 0) return true; // No allowlist = allow all + + for (const pattern of allowlist) { + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + if (regex.test(entityId)) return true; + } else if (entityId === pattern) { + return true; + } + } + return false; +} + +/** + * Check for duplicate events within the deduplication window + * Returns true if this is a duplicate (should be filtered) + */ +async function isDuplicateEvent(conn, event, collection, windowSec) { + const windowStart = new Date(Date.now() - windowSec * 1000); + + const recentEvents = await conn.getMany(collection, { + entity_id: event.entity_id, + state: event.state, + received_at: { $gte: windowStart.toISOString() } + }, { limit: 1 }).toArray(); + + return recentEvents.length > 0; +} + +/** + * Detect burst patterns - same entity firing rapidly + * Returns burst info if detected + */ +async function detectBurst(conn, event, collection, threshold, windowMin) { + const windowStart = new Date(Date.now() - windowMin * 60 * 1000); + + const recentEvents = await conn.getMany(collection, { + entity_id: event.entity_id, + received_at: { $gte: windowStart.toISOString() } + }).toArray(); + + const count = recentEvents.length; + + if (count >= threshold) { + return { + detected: true, + count, + windowMin + }; + } + + return { detected: false }; +} + +/** + * Determine if current time is during night hours + */ +function isNightTime(hourOfDay, nightStart, nightEnd) { + // Handle overnight spans (e.g., 22:00 to 06:00) + if (nightStart > nightEnd) { + return hourOfDay >= nightStart || hourOfDay < nightEnd; + } + return hourOfDay >= nightStart && hourOfDay < nightEnd; +} + +/** + * Check if event type is inherently notable + */ +function isNotableEventType(eventType, notableTypes) { + return notableTypes.includes(eventType); +} + +/** + * Check if state is inherently notable + */ +function isNotableState(state, notableStates) { + if (!state) return false; + const stateLower = String(state).toLowerCase(); + return notableStates.some(s => stateLower.includes(s.toLowerCase())); +} + +// ============================================================================ +// MAIN EVALUATION FUNCTION +// ============================================================================ + +/** + * Evaluate an event against all filtering rules + * + * @param {Object} conn - Database connection + * @param {Object} event - The normalized event + * @param {string} collection - Collection name + * @returns {Object} Evaluation result with isNotable flag and reasons + */ +export async function evaluateEvent(conn, event, collection) { + const config = getConfig(); + const reasons = []; + const filtersApplied = []; + + // Track what filters we're applying + filtersApplied.push('allowlist_denylist'); + filtersApplied.push('deduplication'); + filtersApplied.push('burst_detection'); + filtersApplied.push('time_of_day'); + filtersApplied.push('notable_types'); + + // 1. Check denylist first (quick exit) + if (isEntityDenied(event.entity_id, config.denylist)) { + return { + isNotable: false, + reasons: ['Entity is in denylist'], + filtersApplied, + filtered: true + }; + } + + // 2. Check allowlist + if (!isEntityAllowed(event.entity_id, config.allowlist)) { + return { + isNotable: false, + reasons: ['Entity is not in allowlist'], + filtersApplied, + filtered: true + }; + } + + // 3. Check for duplicates + const isDupe = await isDuplicateEvent(conn, event, collection, config.dedupeWindowSec); + if (isDupe) { + return { + isNotable: false, + reasons: [`Duplicate event within ${config.dedupeWindowSec}s window`], + filtersApplied, + filtered: true + }; + } + + // 4. Check for burst patterns + const burst = await detectBurst( + conn, event, collection, + config.burstThreshold, config.burstWindowMin + ); + if (burst.detected) { + reasons.push(`Burst detected: ${burst.count} events in ${burst.windowMin} minutes`); + } + + // 5. Check if event type is inherently notable + if (isNotableEventType(event.event_type, config.notableEventTypes)) { + reasons.push(`Notable event type: ${event.event_type}`); + } + + // 6. Check if state is inherently notable + if (isNotableState(event.state, config.notableStates)) { + reasons.push(`Notable state: ${event.state}`); + } + + // 7. Time-of-day context + const isNight = isNightTime( + event.hour_of_day, + config.nightStartHour, + config.nightEndHour + ); + if (isNight) { + // Night events from motion/door sensors are more notable + if (event.entity_id.includes('motion') || event.entity_id.includes('door')) { + if (event.state === 'on' || event.state === 'open') { + reasons.push('Night-time activity detected'); + } + } + } + + // Determine if notable + // An event is notable if it has at least one reason + const isNotable = reasons.length > 0; + + return { + isNotable, + reasons, + filtersApplied, + filtered: false, + context: { + isNightTime: isNight, + burstDetected: burst.detected + } + }; +} + +/** + * Simple helper to check if an event is notable + * (For use in other modules) + */ +export function isEventNotable(evaluation) { + return evaluation?.isNotable === true; +} diff --git a/homeassistant-event-gateway/src/slack.js b/homeassistant-event-gateway/src/slack.js new file mode 100644 index 0000000..41809f6 --- /dev/null +++ b/homeassistant-event-gateway/src/slack.js @@ -0,0 +1,278 @@ +/** + * Slack notification module for Home Assistant events + * + * Handles formatting events into Slack messages and sending + * them via Incoming Webhook. + * + * Slack integration is optional - if SLACK_WEBHOOK_URL is not set, + * notifications are silently skipped. + */ + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +/** + * Check if Slack is configured + */ +export function isSlackEnabled() { + return !!process.env.SLACK_WEBHOOK_URL; +} + +// ============================================================================ +// MESSAGE FORMATTING +// ============================================================================ + +/** + * Get emoji for entity type + */ +function getEntityEmoji(entityId) { + const domain = entityId.split('.')[0]; + + const emojiMap = { + 'binary_sensor': ':radio_button:', + 'sensor': ':bar_chart:', + 'light': ':bulb:', + 'switch': ':electric_plug:', + 'lock': ':lock:', + 'door': ':door:', + 'motion': ':runner:', + 'temperature': ':thermometer:', + 'humidity': ':droplet:', + 'camera': ':movie_camera:', + 'alarm': ':rotating_light:', + 'climate': ':snowflake:', + 'cover': ':roller_coaster:', + 'fan': ':dash:', + 'vacuum': ':robot_face:', + 'media_player': ':tv:' + }; + + // Check domain first + if (emojiMap[domain]) return emojiMap[domain]; + + // Check if entity_id contains keywords + for (const [keyword, emoji] of Object.entries(emojiMap)) { + if (entityId.includes(keyword)) return emoji; + } + + return ':house:'; // Default home emoji +} + +/** + * Format entity ID for display + * "binary_sensor.front_door_contact" -> "Front Door Contact" + */ +function formatEntityName(entityId) { + // Remove domain prefix + const name = entityId.split('.').slice(1).join('.'); + + // Convert snake_case to Title Case + return name + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** + * Format timestamp for display + */ +function formatTime(timestamp) { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); +} + +/** + * Format state value for display + */ +function formatState(state) { + if (state === null || state === undefined) return 'unknown'; + if (typeof state === 'boolean') return state ? 'On' : 'Off'; + if (state === 'on') return 'On'; + if (state === 'off') return 'Off'; + if (state === 'open') return 'Open'; + if (state === 'closed') return 'Closed'; + if (state === 'locked') return 'Locked'; + if (state === 'unlocked') return 'Unlocked'; + return String(state); +} + +/** + * Build Slack Block Kit message + * + * @param {Object} event - The Home Assistant event + * @param {Array} reasons - Why this event is notable + * @param {string|null} aiExplanation - Optional AI-generated explanation + * @returns {Object} Slack message payload + */ +export function formatSlackMessage(event, reasons, aiExplanation = null) { + const emoji = getEntityEmoji(event.entity_id); + const entityName = formatEntityName(event.entity_id); + const time = formatTime(event.timestamp); + const state = formatState(event.state); + + // Build the blocks + const blocks = []; + + // Header with emoji and entity name + blocks.push({ + type: 'header', + text: { + type: 'plain_text', + text: `${emoji} ${entityName}`, + emoji: true + } + }); + + // State and time section + blocks.push({ + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*State:*\n${state}` + }, + { + type: 'mrkdwn', + text: `*Time:*\n${time}` + } + ] + }); + + // Entity ID in smaller text + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Entity: \`${event.entity_id}\` | Type: \`${event.event_type}\`` + } + ] + }); + + // Reasons why this is notable + if (reasons && reasons.length > 0) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Why notable:*\n${reasons.map(r => `• ${r}`).join('\n')}` + } + }); + } + + // AI explanation (if available) + if (aiExplanation) { + blocks.push({ + type: 'divider' + }); + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `:robot_face: *AI Analysis:*\n${aiExplanation}` + } + }); + } + + // Footer with source + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Source: Home Assistant | ${new Date().toISOString().split('T')[0]}` + } + ] + }); + + return { + blocks, + // Fallback text for notifications + text: `${emoji} ${entityName}: ${state}` + }; +} + +/** + * Format a simple text-only message (fallback) + */ +export function formatSimpleMessage(event, reasons) { + const entityName = formatEntityName(event.entity_id); + const state = formatState(event.state); + const time = formatTime(event.timestamp); + + let message = `*${entityName}* is now *${state}* (${time})`; + + if (reasons && reasons.length > 0) { + message += `\n_Notable because: ${reasons.join(', ')}_`; + } + + return { text: message }; +} + +// ============================================================================ +// SLACK API +// ============================================================================ + +/** + * Send a message to Slack via Incoming Webhook + * + * @param {Object} message - Slack message payload + * @returns {Promise} + */ +export async function sendSlackNotification(message) { + const webhookUrl = process.env.SLACK_WEBHOOK_URL; + + if (!webhookUrl) { + console.log('Slack notification skipped - SLACK_WEBHOOK_URL not configured'); + return; + } + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(message) + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Slack API error: ${response.status} - ${body}`); + } + + console.log('Slack notification sent successfully'); +} + +/** + * Send a test message to verify Slack configuration + */ +export async function sendTestNotification() { + const message = { + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':white_check_mark: *Home Assistant Event Gateway* connected successfully!' + } + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Test sent at ${new Date().toISOString()}` + } + ] + } + ], + text: 'Home Assistant Event Gateway connected successfully!' + }; + + return sendSlackNotification(message); +} diff --git a/templates.json b/templates.json index dd98ce9..5fab737 100644 --- a/templates.json +++ b/templates.json @@ -63,6 +63,10 @@ { "name": "saas-metering-webhook", "description": "Usage metering system with multi-tenant event capture, batch aggregation, and HMAC-signed webhook delivery" + }, + { + "name": "homeassistant-event-gateway", + "description": "Home Assistant event gateway with noise filtering, Slack notifications, and optional AI explanations" } ] }