commit 5b258b62bc7f5bdb593e2129038917fd8d045a35 Author: Your Name Date: Mon Dec 15 11:08:23 2025 -0600 connectd HACS integration v1.1.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa9940e --- /dev/null +++ b/README.md @@ -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" +``` diff --git a/custom_components/connectd/__init__.py b/custom_components/connectd/__init__.py new file mode 100644 index 0000000..4f19a2f --- /dev/null +++ b/custom_components/connectd/__init__.py @@ -0,0 +1,117 @@ +"""connectd integration for home assistant.""" +from __future__ import annotations + +import asyncio +import logging +from datetime import timedelta + +import aiohttp + +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 asyncio.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() + + # get priority matches (optional) + priority_matches = {} + try: + async with session.get(f"{self.base_url}/api/priority_matches") as resp: + if resp.status == 200: + priority_matches = await resp.json() + except Exception: + pass + + # get top humans (optional) + top_humans = {} + try: + async with session.get(f"{self.base_url}/api/top_humans") as resp: + if resp.status == 200: + top_humans = await resp.json() + except Exception: + pass + + # get user info (optional) + user = {} + try: + async with session.get(f"{self.base_url}/api/user") as resp: + if resp.status == 200: + user = await resp.json() + except Exception: + pass + + return { + "stats": stats, + "state": state, + "priority_matches": priority_matches, + "top_humans": top_humans, + "user": user, + } + + except aiohttp.ClientError as err: + raise UpdateFailed(f"error communicating with connectd: {err}") + except Exception as err: + raise UpdateFailed(f"unexpected error: {err}") diff --git a/custom_components/connectd/branding/icon.png b/custom_components/connectd/branding/icon.png new file mode 100644 index 0000000..cc332d8 Binary files /dev/null and b/custom_components/connectd/branding/icon.png differ diff --git a/custom_components/connectd/branding/icon@2x.png b/custom_components/connectd/branding/icon@2x.png new file mode 100644 index 0000000..292a405 Binary files /dev/null and b/custom_components/connectd/branding/icon@2x.png differ diff --git a/custom_components/connectd/config_flow.py b/custom_components/connectd/config_flow.py new file mode 100644 index 0000000..79526b2 --- /dev/null +++ b/custom_components/connectd/config_flow.py @@ -0,0 +1,71 @@ +"""config flow for connectd integration.""" +from __future__ import annotations + +import logging + +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 + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "connectd" +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: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + url = f"http://{host}:{port}/api/health" + async with session.get(url) 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: + _LOGGER.error("connectd api returned status %s", resp.status) + errors["base"] = "cannot_connect" + except aiohttp.ClientError as err: + _LOGGER.error("connectd connection error: %s", err) + errors["base"] = "cannot_connect" + except Exception as err: + _LOGGER.exception("connectd unexpected error: %s", err) + 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, + ) diff --git a/custom_components/connectd/manifest.json b/custom_components/connectd/manifest.json new file mode 100644 index 0000000..86c0b8a --- /dev/null +++ b/custom_components/connectd/manifest.json @@ -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.1.0" +} diff --git a/custom_components/connectd/sensor.py b/custom_components/connectd/sensor.py new file mode 100644 index 0000000..a8c7c1e --- /dev/null +++ b/custom_components/connectd/sensor.py @@ -0,0 +1,363 @@ +"""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.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN, ConnectdDataUpdateCoordinator + + +def get_device_info(entry_id: str, host: str) -> DeviceInfo: + """return device info for connectd daemon.""" + return DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name="connectd daemon", + manufacturer="sudoxnym", + model="connectd", + sw_version="1.1.0", + configuration_url=f"http://{host}:8099", + ) + +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] + host = entry.data.get("host", "localhost") + device_info = get_device_info(entry.entry_id, host) + + entities = [] + for sensor_key, name, icon, data_source in SENSORS: + entities.append( + ConnectdSensor(coordinator, sensor_key, name, icon, data_source, device_info) + ) + + # add status sensor + entities.append(ConnectdStatusSensor(coordinator, device_info)) + + # add priority matches sensor + entities.append(ConnectdPriorityMatchesSensor(coordinator, device_info)) + + # add top humans sensor + entities.append(ConnectdTopHumansSensor(coordinator, device_info)) + + # add countdown sensors + entities.append(ConnectdCountdownSensor(coordinator, device_info, "scout", "mdi:radar")) + entities.append(ConnectdCountdownSensor(coordinator, device_info, "match", "mdi:handshake")) + entities.append(ConnectdCountdownSensor(coordinator, device_info, "intro", "mdi:email-fast")) + + # add personal score sensor + entities.append(ConnectdUserScoreSensor(coordinator, device_info)) + + # add platform sensors (by_platform dict) + entities.append(ConnectdPlatformSensor(coordinator, "github", device_info)) + entities.append(ConnectdPlatformSensor(coordinator, "mastodon", device_info)) + entities.append(ConnectdPlatformSensor(coordinator, "reddit", device_info)) + entities.append(ConnectdPlatformSensor(coordinator, "lemmy", device_info)) + entities.append(ConnectdPlatformSensor(coordinator, "discord", device_info)) + entities.append(ConnectdPlatformSensor(coordinator, "lobsters", device_info)) + + 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, + device_info: DeviceInfo, + ) -> 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 + self._attr_device_info = device_info + + @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, device_info: DeviceInfo) -> None: + """initialize.""" + super().__init__(coordinator) + self._attr_name = "connectd status" + self._attr_unique_id = "connectd_status" + self._attr_icon = "mdi:connection" + self._attr_device_info = device_info + + @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, + device_info: DeviceInfo, + ) -> 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 + self._attr_device_info = device_info + + 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 + + +class ConnectdPriorityMatchesSensor(CoordinatorEntity, SensorEntity): + """connectd priority matches sensor.""" + + def __init__(self, coordinator: ConnectdDataUpdateCoordinator, device_info: DeviceInfo) -> None: + """initialize.""" + super().__init__(coordinator) + self._attr_name = "connectd priority matches" + self._attr_unique_id = "connectd_priority_matches" + self._attr_icon = "mdi:account-star" + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_info = device_info + + @property + def native_value(self): + """return count of new priority matches.""" + if self.coordinator.data: + pm = self.coordinator.data.get("priority_matches", {}) + return pm.get("new_count", 0) + return 0 + + @property + def extra_state_attributes(self): + """return top matches as attributes.""" + if self.coordinator.data: + pm = self.coordinator.data.get("priority_matches", {}) + top = pm.get("top_matches", []) + attrs = { + "total_matches": pm.get("count", 0), + "new_matches": pm.get("new_count", 0), + } + for i, m in enumerate(top[:3]): + attrs[f"match_{i+1}_username"] = m.get("username") + attrs[f"match_{i+1}_platform"] = m.get("platform") + attrs[f"match_{i+1}_score"] = m.get("overlap_score") + attrs[f"match_{i+1}_reasons"] = ", ".join(m.get("reasons", [])) + return attrs + return {} + + +class ConnectdTopHumansSensor(CoordinatorEntity, SensorEntity): + """connectd top humans sensor.""" + + def __init__(self, coordinator: ConnectdDataUpdateCoordinator, device_info: DeviceInfo) -> None: + """initialize.""" + super().__init__(coordinator) + self._attr_name = "connectd top human" + self._attr_unique_id = "connectd_top_human" + self._attr_icon = "mdi:account-check" + self._attr_device_info = device_info + + @property + def native_value(self): + """return top human username.""" + if self.coordinator.data: + th = self.coordinator.data.get("top_humans", {}) + top = th.get("top_humans", []) + if top: + return top[0].get("username", "none") + return "none" + + @property + def extra_state_attributes(self): + """return top humans as attributes.""" + if self.coordinator.data: + th = self.coordinator.data.get("top_humans", {}) + top = th.get("top_humans", []) + attrs = {"total_high_score": th.get("count", 0)} + for i, h in enumerate(top[:5]): + attrs[f"human_{i+1}_username"] = h.get("username") + attrs[f"human_{i+1}_platform"] = h.get("platform") + attrs[f"human_{i+1}_score"] = h.get("score") + attrs[f"human_{i+1}_signals"] = ", ".join(h.get("signals", [])[:3]) + attrs[f"human_{i+1}_contact"] = h.get("contact_method") + return attrs + return {} + + +class ConnectdCountdownSensor(CoordinatorEntity, SensorEntity): + """connectd countdown timer sensor.""" + + def __init__( + self, + coordinator: ConnectdDataUpdateCoordinator, + device_info: DeviceInfo, + cycle_type: str, + icon: str, + ) -> None: + """initialize.""" + super().__init__(coordinator) + self._cycle_type = cycle_type + self._attr_name = f"connectd next {cycle_type}" + self._attr_unique_id = f"connectd_countdown_{cycle_type}" + self._attr_icon = icon + self._attr_device_info = device_info + self._attr_native_unit_of_measurement = "min" + + @property + def native_value(self): + """return minutes until next cycle.""" + if self.coordinator.data: + state = self.coordinator.data.get("state", {}) + secs = state.get(f"countdown_{self._cycle_type}", 0) + return int(secs / 60) + return 0 + + @property + def extra_state_attributes(self): + """return detailed countdown info.""" + if self.coordinator.data: + state = self.coordinator.data.get("state", {}) + secs = state.get(f"countdown_{self._cycle_type}", 0) + return { + "seconds": secs, + "hours": round(secs / 3600, 1), + f"last_{self._cycle_type}": state.get(f"last_{self._cycle_type}"), + } + return {} + + +class ConnectdUserScoreSensor(CoordinatorEntity, SensorEntity): + """connectd personal score sensor.""" + + def __init__(self, coordinator: ConnectdDataUpdateCoordinator, device_info: DeviceInfo) -> None: + """initialize.""" + super().__init__(coordinator) + self._attr_name = "connectd my score" + self._attr_unique_id = "connectd_user_score" + self._attr_icon = "mdi:star-circle" + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_info = device_info + + @property + def native_value(self): + """return user's personal score.""" + if self.coordinator.data: + user = self.coordinator.data.get("user", {}) + return user.get("score", 0) + return 0 + + @property + def extra_state_attributes(self): + """return user profile details.""" + if self.coordinator.data: + user = self.coordinator.data.get("user", {}) + signals = user.get("signals", []) + interests = user.get("interests", []) + return { + "configured": user.get("configured", False), + "name": user.get("name"), + "github": user.get("github"), + "mastodon": user.get("mastodon"), + "reddit": user.get("reddit"), + "lobsters": user.get("lobsters"), + "matrix": user.get("matrix"), + "lemmy": user.get("lemmy"), + "discord": user.get("discord"), + "bluesky": user.get("bluesky"), + "location": user.get("location"), + "bio": user.get("bio"), + "match_count": user.get("match_count", 0), + "new_matches": user.get("new_match_count", 0), + "signals": ", ".join(signals[:5]) if signals else "", + "interests": ", ".join(interests[:5]) if interests else "", + } + return {} diff --git a/custom_components/connectd/strings.json b/custom_components/connectd/strings.json new file mode 100644 index 0000000..fdc092f --- /dev/null +++ b/custom_components/connectd/strings.json @@ -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" + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..b898f8a --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "connectd", + "render_readme": true, + "domains": ["sensor"], + "homeassistant": "2023.1.0" +}