add REST API and HACS integration for home assistant

- api.py exposes /api/stats, /api/health, /api/state on port 8099
- daemon updates shared state for API
- HACS custom component with sensors for all stats
- config flow for easy setup
- example automations in docs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2025-12-15 10:11:47 -06:00
parent bd1efa2ae7
commit 1c525097f4
10 changed files with 589 additions and 0 deletions

125
api.py Normal file
View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
connectd/api.py - REST API for stats and control
exposes daemon stats for home assistant integration.
runs on port 8099 by default.
"""
import os
import json
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
from db import Database
API_PORT = int(os.environ.get('CONNECTD_API_PORT', 8099))
# shared state (updated by daemon)
_daemon_state = {
'running': False,
'dry_run': False,
'last_scout': None,
'last_match': None,
'last_intro': None,
'last_lost': None,
'intros_today': 0,
'lost_intros_today': 0,
'started_at': None,
}
def update_daemon_state(state_dict):
"""update shared daemon state (called by daemon)"""
global _daemon_state
_daemon_state.update(state_dict)
def get_daemon_state():
"""get current daemon state"""
return _daemon_state.copy()
class APIHandler(BaseHTTPRequestHandler):
"""simple REST API handler"""
def log_message(self, format, *args):
"""suppress default logging"""
pass
def _send_json(self, data, status=200):
"""send JSON response"""
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def do_GET(self):
"""handle GET requests"""
if self.path == '/api/stats':
self._handle_stats()
elif self.path == '/api/health':
self._handle_health()
elif self.path == '/api/state':
self._handle_state()
else:
self._send_json({'error': 'not found'}, 404)
def _handle_stats(self):
"""return database statistics"""
try:
db = Database()
stats = db.stats()
db.close()
self._send_json(stats)
except Exception as e:
self._send_json({'error': str(e)}, 500)
def _handle_health(self):
"""return daemon health status"""
state = get_daemon_state()
health = {
'status': 'running' if state['running'] else 'stopped',
'dry_run': state['dry_run'],
'uptime_seconds': None,
}
if state['started_at']:
uptime = datetime.now() - datetime.fromisoformat(state['started_at'])
health['uptime_seconds'] = int(uptime.total_seconds())
self._send_json(health)
def _handle_state(self):
"""return full daemon state"""
state = get_daemon_state()
# convert datetimes to strings
for key in ['last_scout', 'last_match', 'last_intro', 'last_lost', 'started_at']:
if state[key] and isinstance(state[key], datetime):
state[key] = state[key].isoformat()
self._send_json(state)
def run_api_server():
"""run the API server in a thread"""
server = HTTPServer(('0.0.0.0', API_PORT), APIHandler)
print(f"connectd api running on port {API_PORT}")
server.serve_forever()
def start_api_thread():
"""start API server in background thread"""
thread = threading.Thread(target=run_api_server, daemon=True)
thread.start()
return thread
if __name__ == '__main__':
# standalone mode for testing
print(f"starting connectd api on port {API_PORT}...")
run_api_server()

View file

@ -30,6 +30,7 @@ from introd.lost_intro import draft_lost_intro, get_lost_intro_config
from introd.send import send_email from introd.send import send_email
from introd.deliver import deliver_intro, determine_best_contact from introd.deliver import deliver_intro, determine_best_contact
from config import get_lost_config from config import get_lost_config
from api import start_api_thread, update_daemon_state
# daemon config # daemon config
SCOUT_INTERVAL = 3600 * 4 # full scout every 4 hours SCOUT_INTERVAL = 3600 * 4 # full scout every 4 hours
@ -59,9 +60,27 @@ class ConnectDaemon:
signal.signal(signal.SIGINT, self._shutdown) signal.signal(signal.SIGINT, self._shutdown)
signal.signal(signal.SIGTERM, self._shutdown) signal.signal(signal.SIGTERM, self._shutdown)
# update API state
self._update_api_state()
def _shutdown(self, signum, frame): def _shutdown(self, signum, frame):
print("\nconnectd: shutting down...") print("\nconnectd: shutting down...")
self.running = False self.running = False
self._update_api_state()
def _update_api_state(self):
"""update API state for HA integration"""
update_daemon_state({
'running': self.running,
'dry_run': self.dry_run,
'last_scout': self.last_scout.isoformat() if self.last_scout else None,
'last_match': self.last_match.isoformat() if self.last_match else None,
'last_intro': self.last_intro.isoformat() if self.last_intro else None,
'last_lost': self.last_lost.isoformat() if self.last_lost else None,
'intros_today': self.intros_today,
'lost_intros_today': self.lost_intros_today,
'started_at': datetime.now().isoformat(),
})
def log(self, msg): def log(self, msg):
"""timestamped log""" """timestamped log"""
@ -447,6 +466,11 @@ class ConnectDaemon:
def run(self): def run(self):
"""main daemon loop""" """main daemon loop"""
self.log("connectd daemon starting...") self.log("connectd daemon starting...")
# start API server
start_api_thread()
self.log("api server started on port 8099")
if self.dry_run: if self.dry_run:
self.log("*** DRY RUN MODE - no intros will be sent ***") self.log("*** DRY RUN MODE - no intros will be sent ***")
self.log(f"scout interval: {SCOUT_INTERVAL}s") self.log(f"scout interval: {SCOUT_INTERVAL}s")
@ -457,6 +481,7 @@ class ConnectDaemon:
# initial scout # initial scout
self.scout_cycle() self.scout_cycle()
self._update_api_state()
while self.running: while self.running:
now = datetime.now() now = datetime.now()
@ -464,19 +489,23 @@ class ConnectDaemon:
# scout cycle # scout cycle
if not self.last_scout or (now - self.last_scout).seconds >= SCOUT_INTERVAL: if not self.last_scout or (now - self.last_scout).seconds >= SCOUT_INTERVAL:
self.scout_cycle() self.scout_cycle()
self._update_api_state()
# match cycle # match cycle
if not self.last_match or (now - self.last_match).seconds >= MATCH_INTERVAL: if not self.last_match or (now - self.last_match).seconds >= MATCH_INTERVAL:
self.match_priority_users() self.match_priority_users()
self.match_strangers() self.match_strangers()
self._update_api_state()
# intro cycle # intro cycle
if not self.last_intro or (now - self.last_intro).seconds >= INTRO_INTERVAL: if not self.last_intro or (now - self.last_intro).seconds >= INTRO_INTERVAL:
self.send_stranger_intros() self.send_stranger_intros()
self._update_api_state()
# lost builder cycle # lost builder cycle
if not self.last_lost or (now - self.last_lost).seconds >= LOST_INTERVAL: if not self.last_lost or (now - self.last_lost).seconds >= LOST_INTERVAL:
self.send_lost_builder_intros() self.send_lost_builder_intros()
self._update_api_state()
# sleep between checks # sleep between checks
time.sleep(60) time.sleep(60)

View file

@ -5,6 +5,8 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
ports:
- "8099:8099"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./db:/app/db - ./db:/app/db

88
hacs/README.md Normal file
View file

@ -0,0 +1,88 @@
# connectd home assistant integration
monitor your connectd daemon from home assistant.
## installation
### HACS (recommended)
1. open HACS in home assistant
2. click the three dots menu → custom repositories
3. add `https://github.com/sudoxnym/connectd` with category "integration"
4. search for "connectd" and install
5. restart home assistant
6. go to settings → devices & services → add integration → connectd
### manual
1. copy `custom_components/connectd` to your HA `config/custom_components/` directory
2. restart home assistant
3. go to settings → devices & services → add integration → connectd
## configuration
enter the host and port of your connectd daemon:
- **host**: IP or hostname where connectd is running (e.g., `192.168.1.8`)
- **port**: API port (default: `8099`)
## sensors
the integration creates these sensors:
### stats
- `sensor.connectd_total_humans` - total discovered humans
- `sensor.connectd_high_score_humans` - humans with high values alignment
- `sensor.connectd_total_matches` - total matches found
- `sensor.connectd_total_intros` - total intro drafts
- `sensor.connectd_sent_intros` - intros successfully sent
- `sensor.connectd_active_builders` - active builder count
- `sensor.connectd_lost_builders` - lost builder count
- `sensor.connectd_recovering_builders` - recovering builder count
- `sensor.connectd_lost_outreach_sent` - lost builder outreach count
### state
- `sensor.connectd_intros_today` - intros sent today
- `sensor.connectd_lost_intros_today` - lost builder intros today
- `sensor.connectd_status` - daemon status (running/dry_run/stopped)
### per-platform
- `sensor.connectd_github_humans`
- `sensor.connectd_mastodon_humans`
- `sensor.connectd_reddit_humans`
- `sensor.connectd_lemmy_humans`
- `sensor.connectd_discord_humans`
- `sensor.connectd_lobsters_humans`
## example dashboard card
```yaml
type: entities
title: connectd
entities:
- entity: sensor.connectd_status
- entity: sensor.connectd_total_humans
- entity: sensor.connectd_intros_today
- entity: sensor.connectd_lost_intros_today
- entity: sensor.connectd_active_builders
- entity: sensor.connectd_lost_builders
```
## automations
example: notify when an intro is sent:
```yaml
automation:
- alias: "connectd intro notification"
trigger:
- platform: state
entity_id: sensor.connectd_intros_today
condition:
- condition: template
value_template: "{{ trigger.to_state.state | int > trigger.from_state.state | int }}"
action:
- service: notify.mobile_app
data:
title: "connectd"
message: "sent intro #{{ states('sensor.connectd_intros_today') }} today"
```

View file

@ -0,0 +1,84 @@
"""connectd integration for home assistant."""
from __future__ import annotations
import logging
from datetime import timedelta
import aiohttp
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
DOMAIN = "connectd"
PLATFORMS = [Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=1)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""set up connectd from a config entry."""
host = entry.data["host"]
port = entry.data["port"]
coordinator = ConnectdDataUpdateCoordinator(hass, host, port)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class ConnectdDataUpdateCoordinator(DataUpdateCoordinator):
"""class to manage fetching connectd data."""
def __init__(self, hass: HomeAssistant, host: str, port: int) -> None:
"""initialize."""
self.host = host
self.port = port
self.base_url = f"http://{host}:{port}"
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self):
"""fetch data from connectd api."""
try:
async with async_timeout.timeout(10):
async with aiohttp.ClientSession() as session:
# get stats
async with session.get(f"{self.base_url}/api/stats") as resp:
if resp.status != 200:
raise UpdateFailed(f"error fetching stats: {resp.status}")
stats = await resp.json()
# get state
async with session.get(f"{self.base_url}/api/state") as resp:
if resp.status != 200:
raise UpdateFailed(f"error fetching state: {resp.status}")
state = await resp.json()
return {"stats": stats, "state": state}
except aiohttp.ClientError as err:
raise UpdateFailed(f"error communicating with connectd: {err}")
except Exception as err:
raise UpdateFailed(f"unexpected error: {err}")

View file

@ -0,0 +1,64 @@
"""config flow for connectd integration."""
from __future__ import annotations
import aiohttp
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
from . import DOMAIN
DEFAULT_PORT = 8099
class ConnectdConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""handle a config flow for connectd."""
VERSION = 1
async def async_step_user(
self, user_input: dict | None = None
) -> FlowResult:
"""handle the initial step."""
errors = {}
if user_input is not None:
host = user_input[CONF_HOST]
port = user_input.get(CONF_PORT, DEFAULT_PORT)
# test connection
try:
async with aiohttp.ClientSession() as session:
url = f"http://{host}:{port}/api/health"
async with session.get(url, timeout=5) as resp:
if resp.status == 200:
# connection works
await self.async_set_unique_id(f"{host}:{port}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"connectd ({host})",
data={
"host": host,
"port": port,
},
)
else:
errors["base"] = "cannot_connect"
except aiohttp.ClientError:
errors["base"] = "cannot_connect"
except Exception:
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST, default="192.168.1.8"): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
}
),
errors=errors,
)

View file

@ -0,0 +1,11 @@
{
"domain": "connectd",
"name": "connectd",
"codeowners": ["@sudoxnym"],
"config_flow": true,
"documentation": "https://github.com/sudoxnym/connectd",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/sudoxnym/connectd/issues",
"requirements": [],
"version": "1.0.0"
}

View file

@ -0,0 +1,162 @@
"""sensor platform for connectd."""
from __future__ import annotations
from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DOMAIN, ConnectdDataUpdateCoordinator
SENSORS = [
# stats sensors
("total_humans", "total humans", "mdi:account-group", "stats"),
("high_score_humans", "high score humans", "mdi:account-star", "stats"),
("total_matches", "total matches", "mdi:handshake", "stats"),
("total_intros", "total intros", "mdi:email-outline", "stats"),
("sent_intros", "sent intros", "mdi:email-check", "stats"),
("active_builders", "active builders", "mdi:hammer-wrench", "stats"),
("lost_builders", "lost builders", "mdi:account-question", "stats"),
("recovering_builders", "recovering builders", "mdi:account-heart", "stats"),
("lost_outreach_sent", "lost outreach sent", "mdi:heart-pulse", "stats"),
# state sensors
("intros_today", "intros today", "mdi:email-fast", "state"),
("lost_intros_today", "lost intros today", "mdi:heart-outline", "state"),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""set up connectd sensors."""
coordinator = hass.data[DOMAIN][entry.entry_id]
entities = []
for sensor_key, name, icon, data_source in SENSORS:
entities.append(
ConnectdSensor(coordinator, sensor_key, name, icon, data_source)
)
# add status sensor
entities.append(ConnectdStatusSensor(coordinator))
# add platform sensors (by_platform dict)
entities.append(ConnectdPlatformSensor(coordinator, "github"))
entities.append(ConnectdPlatformSensor(coordinator, "mastodon"))
entities.append(ConnectdPlatformSensor(coordinator, "reddit"))
entities.append(ConnectdPlatformSensor(coordinator, "lemmy"))
entities.append(ConnectdPlatformSensor(coordinator, "discord"))
entities.append(ConnectdPlatformSensor(coordinator, "lobsters"))
async_add_entities(entities)
class ConnectdSensor(CoordinatorEntity, SensorEntity):
"""connectd sensor entity."""
def __init__(
self,
coordinator: ConnectdDataUpdateCoordinator,
sensor_key: str,
name: str,
icon: str,
data_source: str,
) -> None:
"""initialize."""
super().__init__(coordinator)
self._sensor_key = sensor_key
self._attr_name = f"connectd {name}"
self._attr_unique_id = f"connectd_{sensor_key}"
self._attr_icon = icon
self._data_source = data_source
self._attr_state_class = SensorStateClass.MEASUREMENT
@property
def native_value(self):
"""return the state."""
if self.coordinator.data:
data = self.coordinator.data.get(self._data_source, {})
return data.get(self._sensor_key, 0)
return None
class ConnectdStatusSensor(CoordinatorEntity, SensorEntity):
"""connectd daemon status sensor."""
def __init__(self, coordinator: ConnectdDataUpdateCoordinator) -> None:
"""initialize."""
super().__init__(coordinator)
self._attr_name = "connectd status"
self._attr_unique_id = "connectd_status"
self._attr_icon = "mdi:connection"
@property
def native_value(self):
"""return the state."""
if self.coordinator.data:
state = self.coordinator.data.get("state", {})
if state.get("running"):
return "running" if not state.get("dry_run") else "dry_run"
return "stopped"
return "unavailable"
@property
def extra_state_attributes(self):
"""return extra attributes."""
if self.coordinator.data:
state = self.coordinator.data.get("state", {})
return {
"last_scout": state.get("last_scout"),
"last_match": state.get("last_match"),
"last_intro": state.get("last_intro"),
"last_lost": state.get("last_lost"),
"started_at": state.get("started_at"),
}
return {}
class ConnectdPlatformSensor(CoordinatorEntity, SensorEntity):
"""connectd per-platform sensor."""
def __init__(
self,
coordinator: ConnectdDataUpdateCoordinator,
platform: str,
) -> None:
"""initialize."""
super().__init__(coordinator)
self._platform = platform
self._attr_name = f"connectd {platform} humans"
self._attr_unique_id = f"connectd_platform_{platform}"
self._attr_icon = self._get_platform_icon(platform)
self._attr_state_class = SensorStateClass.MEASUREMENT
def _get_platform_icon(self, platform: str) -> str:
"""get icon for platform."""
icons = {
"github": "mdi:github",
"mastodon": "mdi:mastodon",
"reddit": "mdi:reddit",
"lemmy": "mdi:alpha-l-circle",
"discord": "mdi:discord",
"lobsters": "mdi:web",
"bluesky": "mdi:cloud",
"matrix": "mdi:matrix",
}
return icons.get(platform, "mdi:web")
@property
def native_value(self):
"""return the state."""
if self.coordinator.data:
stats = self.coordinator.data.get("stats", {})
by_platform = stats.get("by_platform", {})
return by_platform.get(self._platform, 0)
return 0

View file

@ -0,0 +1,18 @@
{
"config": {
"step": {
"user": {
"title": "connectd daemon",
"description": "connect to your connectd daemon for monitoring.",
"data": {
"host": "host",
"port": "port"
}
}
},
"error": {
"cannot_connect": "failed to connect to connectd api",
"unknown": "unexpected error"
}
}
}

6
hacs/hacs.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "connectd",
"render_readme": true,
"domains": ["sensor"],
"homeassistant": "2023.1.0"
}