From fdd7065e78e8f0f1b51b81cfe01f4c09680a044d Mon Sep 17 00:00:00 2001 From: uttarayan21 Date: Fri, 15 Aug 2025 18:19:01 +0530 Subject: [PATCH] feat: Added homeassistant plugin --- homeassistant/README.md | 315 +++++++++++++++++ .../hyprmonitors/__init__.py | 206 +++++++++++ .../hyprmonitors/config_flow.py | 134 +++++++ .../custom_components/hyprmonitors/const.py | 32 ++ .../hyprmonitors/manifest.json | 17 + .../custom_components/hyprmonitors/sensor.py | 207 +++++++++++ .../hyprmonitors/services.yaml | 29 ++ .../hyprmonitors/strings.json | 88 +++++ .../custom_components/hyprmonitors/switch.py | 218 ++++++++++++ homeassistant/docker-compose.example.yml | 80 +++++ homeassistant/examples.yaml | 330 ++++++++++++++++++ homeassistant/install.sh | 316 +++++++++++++++++ 12 files changed, 1972 insertions(+) create mode 100644 homeassistant/README.md create mode 100644 homeassistant/custom_components/hyprmonitors/__init__.py create mode 100644 homeassistant/custom_components/hyprmonitors/config_flow.py create mode 100644 homeassistant/custom_components/hyprmonitors/const.py create mode 100644 homeassistant/custom_components/hyprmonitors/manifest.json create mode 100644 homeassistant/custom_components/hyprmonitors/sensor.py create mode 100644 homeassistant/custom_components/hyprmonitors/services.yaml create mode 100644 homeassistant/custom_components/hyprmonitors/strings.json create mode 100644 homeassistant/custom_components/hyprmonitors/switch.py create mode 100644 homeassistant/docker-compose.example.yml create mode 100644 homeassistant/examples.yaml create mode 100755 homeassistant/install.sh diff --git a/homeassistant/README.md b/homeassistant/README.md new file mode 100644 index 0000000..2e346bf --- /dev/null +++ b/homeassistant/README.md @@ -0,0 +1,315 @@ +# Hyprland Monitor Control Integration for Home Assistant + +This custom integration allows you to control your Hyprland desktop monitors directly from Home Assistant. It connects to your Hyprmonitors Rust server to provide full monitor control capabilities. + +## Features + +- **Monitor Status Sensors**: Real-time status of all monitors (on/off/mixed) +- **Individual Monitor Switches**: Control each monitor separately +- **Master Switch**: Turn all monitors on/off at once +- **Custom Services**: Advanced automation capabilities +- **Auto-discovery**: Automatically detects available monitors +- **Real-time Updates**: Monitor status updates every 30 seconds + +## Prerequisites + +1. **Hyprmonitors Server**: The Rust server must be running and accessible +2. **Home Assistant**: Version 2023.1.0 or newer +3. **Network Access**: Home Assistant must be able to reach your Hyprmonitors server + +## Installation + +### Method 1: Manual Installation + +1. Create the custom components directory in your Home Assistant configuration: + ```bash + mkdir -p config/custom_components/hyprmonitors + ``` + +2. Copy all files from this directory to your Home Assistant custom components: + ```bash + cp -r custom_components/hyprmonitors/* config/custom_components/hyprmonitors/ + ``` + +3. Restart Home Assistant + +### Method 2: HACS (Recommended when available) + +1. Add this repository to HACS as a custom repository +2. Search for "Hyprland Monitor Control" in HACS +3. Install the integration +4. Restart Home Assistant + +## Configuration + +### Via UI (Recommended) + +1. Go to **Settings** → **Devices & Services** +2. Click **Add Integration** +3. Search for "Hyprland Monitor Control" +4. Enter your Hyprmonitors server details: + - **Host**: IP address or hostname (default: localhost) + - **Port**: Port number (default: 3000) +5. Click **Submit** + +The integration will automatically discover your monitors and create entities. + +### Via configuration.yaml (Legacy) + +```yaml +hyprmonitors: + host: localhost + port: 3000 +``` + +## Entities Created + +### Sensors + +- **`sensor.hyprmonitors_monitor_status`**: Overall status of all monitors + - States: `all_on`, `all_off`, `mixed`, `unavailable` + - Attributes: total monitors, monitors on/off count, monitor details + +- **`sensor.hyprmonitors_[monitor_name]_status`**: Individual monitor status + - States: `on`, `off`, `unknown`, `unavailable` + - Attributes: monitor name, friendly name + +### Switches + +- **`switch.hyprmonitors_all_monitors`**: Master switch for all monitors + - Turn all monitors on/off simultaneously + - Shows mixed state when some monitors are on/off + +- **`switch.hyprmonitors_[monitor_name]`**: Individual monitor switches + - Control specific monitors by name + - Examples: `switch.hyprmonitors_dp_1`, `switch.hyprmonitors_hdmi_a_1` + +## Services + +The integration provides several services for advanced automation: + +### `hyprmonitors.turn_on_monitor` +Turn on a specific monitor by name. + +```yaml +service: hyprmonitors.turn_on_monitor +data: + monitor: "DP-1" +``` + +### `hyprmonitors.turn_off_monitor` +Turn off a specific monitor by name. + +```yaml +service: hyprmonitors.turn_off_monitor +data: + monitor: "HDMI-A-1" +``` + +### `hyprmonitors.turn_on_all_monitors` +Turn on all monitors. + +```yaml +service: hyprmonitors.turn_on_all_monitors +``` + +### `hyprmonitors.turn_off_all_monitors` +Turn off all monitors. + +```yaml +service: hyprmonitors.turn_off_all_monitors +``` + +## Automation Examples + +### Turn off monitors when away + +```yaml +automation: + - alias: "Turn off monitors when away" + trigger: + - platform: state + entity_id: person.your_name + to: "not_home" + action: + - service: hyprmonitors.turn_off_all_monitors + + - alias: "Turn on monitors when home" + trigger: + - platform: state + entity_id: person.your_name + to: "home" + action: + - service: hyprmonitors.turn_on_all_monitors +``` + +### Scheduled monitor control + +```yaml +automation: + - alias: "Turn off monitors at night" + trigger: + - platform: time + at: "23:00:00" + action: + - service: switch.turn_off + entity_id: switch.hyprmonitors_all_monitors + + - alias: "Turn on monitors in the morning" + trigger: + - platform: time + at: "07:00:00" + condition: + - condition: state + entity_id: person.your_name + state: "home" + action: + - service: switch.turn_on + entity_id: switch.hyprmonitors_all_monitors +``` + +### Smart meeting room control + +```yaml +automation: + - alias: "Turn off secondary monitor during meetings" + trigger: + - platform: calendar + event: start + entity_id: calendar.work + condition: + - condition: template + value_template: "{{ 'meeting' in trigger.calendar_event.summary.lower() }}" + action: + - service: hyprmonitors.turn_off_monitor + data: + monitor: "DP-2" # Secondary monitor + + - alias: "Turn on secondary monitor after meetings" + trigger: + - platform: calendar + event: end + entity_id: calendar.work + condition: + - condition: template + value_template: "{{ 'meeting' in trigger.calendar_event.summary.lower() }}" + action: + - service: hyprmonitors.turn_on_monitor + data: + monitor: "DP-2" +``` + +## Dashboard Cards + +### Monitor Status Card + +```yaml +type: entities +entities: + - entity: sensor.hyprmonitors_monitor_status + name: Monitor Status + - entity: switch.hyprmonitors_all_monitors + name: All Monitors +title: Monitor Control +``` + +### Individual Monitor Controls + +```yaml +type: glance +entities: + - entity: switch.hyprmonitors_dp_1 + name: Main Monitor + icon: mdi:monitor + - entity: switch.hyprmonitors_hdmi_a_1 + name: Secondary + icon: mdi:monitor-speaker + - entity: switch.hyprmonitors_all_monitors + name: All Monitors + icon: mdi:monitor-multiple +title: Monitor Control +``` + +### Monitor Status with Attributes + +```yaml +type: custom:auto-entities +card: + type: entities + title: Monitor Details +filter: + include: + - entity_id: "sensor.*monitor*status" + options: + type: custom:multiple-entity-row + name: "{{ state_attr(config.entity, 'friendly_name') }}" + show_state: true + entities: + - attribute: monitors_on + name: "On" + - attribute: monitors_off + name: "Off" + - attribute: total_monitors + name: "Total" +``` + +## Troubleshooting + +### Connection Issues + +1. **Cannot connect to server**: + - Verify the Hyprmonitors server is running: `curl http://localhost:3000/health` + - Check host and port configuration + - Ensure firewall allows connections + +2. **Monitors not detected**: + - Check if Hyprland is running + - Verify monitors are detected: `hyprctl monitors` + - Restart the Hyprmonitors server + +3. **Integration not loading**: + - Check Home Assistant logs for errors + - Verify all files are copied correctly + - Restart Home Assistant completely + +### Debug Logging + +Add to your `configuration.yaml` to enable debug logging: + +```yaml +logger: + default: info + logs: + custom_components.hyprmonitors: debug +``` + +### Monitor Names + +To find your exact monitor names, check: +- Hyprmonitors server logs on startup +- Run `hyprctl monitors` in terminal +- Check the sensor attributes in Home Assistant + +Common patterns: +- **DisplayPort**: `DP-1`, `DP-2`, etc. +- **HDMI**: `HDMI-A-1`, `HDMI-A-2`, etc. +- **Laptop screen**: `eDP-1` + +## Configuration Options + +### Advanced Configuration + +The integration supports these advanced options (configure via UI): + +- **Scan Interval**: How often to check monitor status (default: 30 seconds) +- **Timeout**: API request timeout (default: 10 seconds) + +## Support + +- **Issues**: Report bugs and feature requests on GitHub +- **Documentation**: Check the main Hyprmonitors README +- **Community**: Join the Home Assistant Community forums + +## License + +This integration is part of the Hyprmonitors project and follows the same license terms. \ No newline at end of file diff --git a/homeassistant/custom_components/hyprmonitors/__init__.py b/homeassistant/custom_components/hyprmonitors/__init__.py new file mode 100644 index 0000000..60be07d --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/__init__.py @@ -0,0 +1,206 @@ +"""Hyprland Monitor Control integration for Home Assistant.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import aiohttp +import async_timeout +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import voluptuous as vol +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, PLATFORMS, SERVICE_TURN_ON_MONITOR, SERVICE_TURN_OFF_MONITOR, SERVICE_TURN_ON_ALL, SERVICE_TURN_OFF_ALL + +_LOGGER = logging.getLogger(__name__) + +type HyprmonitorsConfigEntry = ConfigEntry[HyprmonitorsData] + + +class HyprmonitorsData: + """Data for Hyprmonitors integration.""" + + def __init__(self, session: aiohttp.ClientSession, host: str, port: int) -> None: + """Initialize the data object.""" + self.session = session + self.host = host + self.port = port + self.base_url = f"http://{host}:{port}" + + async def async_get_monitors(self) -> dict[str, Any]: + """Get monitor status from the API.""" + url = f"{self.base_url}/monitors/status" + try: + async with async_timeout.timeout(10): + async with self.session.get(url) as response: + response.raise_for_status() + data = await response.json() + return data.get("monitors", {}) + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout connecting to Hyprmonitors API at %s", url) + raise ConfigEntryNotReady("Timeout connecting to API") from err + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Hyprmonitors API at %s: %s", url, err) + raise ConfigEntryNotReady("Error connecting to API") from err + + async def async_turn_monitor_on(self, monitor: str) -> bool: + """Turn on a monitor.""" + url = f"{self.base_url}/monitors/{monitor}/on" + try: + async with async_timeout.timeout(10): + async with self.session.post(url) as response: + response.raise_for_status() + data = await response.json() + return data.get("success", False) + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Error turning on monitor %s: %s", monitor, err) + return False + + async def async_turn_monitor_off(self, monitor: str) -> bool: + """Turn off a monitor.""" + url = f"{self.base_url}/monitors/{monitor}/off" + try: + async with async_timeout.timeout(10): + async with self.session.post(url) as response: + response.raise_for_status() + data = await response.json() + return data.get("success", False) + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Error turning off monitor %s: %s", monitor, err) + return False + + async def async_turn_all_monitors_on(self) -> bool: + """Turn on all monitors.""" + url = f"{self.base_url}/monitors/on" + try: + async with async_timeout.timeout(10): + async with self.session.post(url) as response: + response.raise_for_status() + data = await response.json() + return data.get("success", False) + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Error turning on all monitors: %s", err) + return False + + async def async_turn_all_monitors_off(self) -> bool: + """Turn off all monitors.""" + url = f"{self.base_url}/monitors/off" + try: + async with async_timeout.timeout(10): + async with self.session.post(url) as response: + response.raise_for_status() + data = await response.json() + return data.get("success", False) + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Error turning off all monitors: %s", err) + return False + + async def async_test_connection(self) -> bool: + """Test connection to the API.""" + url = f"{self.base_url}/health" + try: + async with async_timeout.timeout(5): + async with self.session.get(url) as response: + response.raise_for_status() + data = await response.json() + return data.get("success", False) + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.debug("Connection test failed: %s", err) + return False + + +async def async_setup_entry(hass: HomeAssistant, entry: HyprmonitorsConfigEntry) -> bool: + """Set up Hyprmonitors from a config entry.""" + host = entry.data["host"] + port = entry.data["port"] + + session = async_get_clientsession(hass) + data = HyprmonitorsData(session, host, port) + + # Test connection + if not await data.async_test_connection(): + raise ConfigEntryNotReady("Unable to connect to Hyprmonitors API") + + # Store the data object in the config entry + entry.runtime_data = data + + # Forward the setup to the sensor and switch platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Register services + await async_setup_services(hass, data) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HyprmonitorsConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_reload_entry(hass: HomeAssistant, entry: HyprmonitorsConfigEntry) -> None: + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) + + +async def async_setup_services(hass: HomeAssistant, api: HyprmonitorsData) -> None: + """Set up services for Hyprmonitors.""" + + async def turn_on_monitor_service(call) -> None: + """Service to turn on a specific monitor.""" + monitor = call.data.get("monitor") + success = await api.async_turn_monitor_on(monitor) + if not success: + _LOGGER.error("Failed to turn on monitor %s", monitor) + + async def turn_off_monitor_service(call) -> None: + """Service to turn off a specific monitor.""" + monitor = call.data.get("monitor") + success = await api.async_turn_monitor_off(monitor) + if not success: + _LOGGER.error("Failed to turn off monitor %s", monitor) + + async def turn_on_all_monitors_service(call) -> None: + """Service to turn on all monitors.""" + success = await api.async_turn_all_monitors_on() + if not success: + _LOGGER.error("Failed to turn on all monitors") + + async def turn_off_all_monitors_service(call) -> None: + """Service to turn off all monitors.""" + success = await api.async_turn_all_monitors_off() + if not success: + _LOGGER.error("Failed to turn off all monitors") + + # Register services + hass.services.async_register( + DOMAIN, + SERVICE_TURN_ON_MONITOR, + turn_on_monitor_service, + schema=vol.Schema({vol.Required("monitor"): cv.string}), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_TURN_OFF_MONITOR, + turn_off_monitor_service, + schema=vol.Schema({vol.Required("monitor"): cv.string}), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_TURN_ON_ALL, + turn_on_all_monitors_service, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_TURN_OFF_ALL, + turn_off_all_monitors_service, + ) diff --git a/homeassistant/custom_components/hyprmonitors/config_flow.py b/homeassistant/custom_components/hyprmonitors/config_flow.py new file mode 100644 index 0000000..7c82045 --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for Hyprland Monitor Control integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import async_timeout +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + host = data[CONF_HOST] + port = data[CONF_PORT] + + session = async_get_clientsession(hass) + url = f"http://{host}:{port}/health" + + try: + async with async_timeout.timeout(10): + async with session.get(url) as response: + if response.status != 200: + raise CannotConnect + + result = await response.json() + if not result.get("success", False): + raise CannotConnect + + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Hyprmonitors API: %s", err) + raise CannotConnect from err + except Exception as err: + _LOGGER.exception("Unexpected exception") + raise InvalidAuth from err + + # Try to get monitor list to verify full functionality + try: + async with async_timeout.timeout(10): + async with session.get(f"http://{host}:{port}/monitors/status") as response: + if response.status != 200: + raise CannotConnect + + result = await response.json() + monitors = result.get("monitors", {}) + + except aiohttp.ClientError as err: + _LOGGER.error("Error getting monitor status: %s", err) + raise CannotConnect from err + + # Return info that you want to store in the config entry. + return { + "title": f"Hyprmonitors ({host}:{port})", + "host": host, + "port": port, + "monitor_count": len(monitors), + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hyprland Monitor Control.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Create a unique ID based on host and port + unique_id = f"{user_input[CONF_HOST]}_{user_input[CONF_PORT]}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info["title"], + data={ + CONF_HOST: info["host"], + CONF_PORT: info["port"], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "default_host": DEFAULT_HOST, + "default_port": str(DEFAULT_PORT), + }, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + return await self.async_step_user(import_info) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/custom_components/hyprmonitors/const.py b/homeassistant/custom_components/hyprmonitors/const.py new file mode 100644 index 0000000..fee018a --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/const.py @@ -0,0 +1,32 @@ +"""Constants for the Hyprland Monitor Control integration.""" +from homeassistant.const import Platform + +DOMAIN = "hyprmonitors" + +# Platforms +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] + +# Default values +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 3000 +DEFAULT_SCAN_INTERVAL = 30 + +# Configuration keys +CONF_HOST = "host" +CONF_PORT = "port" + +# Attributes +ATTR_MONITOR_NAME = "monitor_name" +ATTR_RESOLUTION = "resolution" +ATTR_REFRESH_RATE = "refresh_rate" +ATTR_SCALE = "scale" +ATTR_POSITION = "position" + +# Service names +SERVICE_TURN_ON_MONITOR = "turn_on_monitor" +SERVICE_TURN_OFF_MONITOR = "turn_off_monitor" +SERVICE_TURN_ON_ALL = "turn_on_all_monitors" +SERVICE_TURN_OFF_ALL = "turn_off_all_monitors" + +# Entity names +ENTITY_ID_ALL_MONITORS = "switch.hyprmonitors_all_monitors" diff --git a/homeassistant/custom_components/hyprmonitors/manifest.json b/homeassistant/custom_components/hyprmonitors/manifest.json new file mode 100644 index 0000000..6d14799 --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "hyprmonitors", + "name": "Hyprland Monitor Control", + "codeowners": ["@hyprmonitors"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/your-username/hyprmonitors", + "homeassistant": "2023.1.0", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/your-username/hyprmonitors/issues", + "requirements": [ + "aiohttp>=3.8.0" + ], + "ssdp": [], + "version": "1.0.0", + "zeroconf": [] +} diff --git a/homeassistant/custom_components/hyprmonitors/sensor.py b/homeassistant/custom_components/hyprmonitors/sensor.py new file mode 100644 index 0000000..77605a5 --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/sensor.py @@ -0,0 +1,207 @@ +"""Sensor platform for Hyprland Monitor Control integration.""" +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from . import HyprmonitorsConfigEntry, HyprmonitorsData +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) + + +class HyprmonitorsDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + *, + api: HyprmonitorsData, + ) -> None: + """Initialize.""" + self.api = api + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + return await self.api.async_get_monitors() + except Exception as exception: + raise UpdateFailed(f"Error communicating with API: {exception}") from exception + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HyprmonitorsConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + api = config_entry.runtime_data + + # Create coordinator + coordinator = HyprmonitorsDataUpdateCoordinator(hass, api=api) + + # Fetch initial data so we have data when entities are added + await coordinator.async_config_entry_first_refresh() + + # Create sensors for each monitor + entities = [] + + # Add overall status sensor + entities.append( + HyprmonitorsOverallSensor( + coordinator=coordinator, + config_entry=config_entry, + ) + ) + + # Add individual monitor sensors + if coordinator.data: + for monitor_name in coordinator.data: + entities.append( + HyprmonitorsMonitorSensor( + coordinator=coordinator, + config_entry=config_entry, + monitor_name=monitor_name, + ) + ) + + async_add_entities(entities) + + +class HyprmonitorsBaseSensor(CoordinatorEntity, SensorEntity): + """Base class for Hyprmonitors sensors.""" + + def __init__( + self, + coordinator: HyprmonitorsDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._config_entry = config_entry + self._attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self._config_entry.entry_id)}, + name="Hyprland Monitors", + manufacturer="Hyprland", + model="Monitor Control", + sw_version="1.0.0", + configuration_url=f"http://{self._config_entry.data['host']}:{self._config_entry.data['port']}", + ) + + +class HyprmonitorsOverallSensor(HyprmonitorsBaseSensor): + """Sensor for overall monitor status.""" + + def __init__( + self, + coordinator: HyprmonitorsDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, config_entry) + self._attr_name = "Monitor Status" + self._attr_unique_id = f"{config_entry.entry_id}_overall_status" + self._attr_icon = "mdi:monitor-multiple" + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + if not self.coordinator.data: + return "unavailable" + + monitors = self.coordinator.data + total_monitors = len(monitors) + on_monitors = sum(1 for status in monitors.values() if status == "on") + + if on_monitors == 0: + return "all_off" + elif on_monitors == total_monitors: + return "all_on" + else: + return "mixed" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + if not self.coordinator.data: + return {} + + monitors = self.coordinator.data + total_monitors = len(monitors) + on_monitors = sum(1 for status in monitors.values() if status == "on") + off_monitors = total_monitors - on_monitors + + return { + "total_monitors": total_monitors, + "monitors_on": on_monitors, + "monitors_off": off_monitors, + "monitor_details": monitors, + } + + +class HyprmonitorsMonitorSensor(HyprmonitorsBaseSensor): + """Sensor for individual monitor status.""" + + def __init__( + self, + coordinator: HyprmonitorsDataUpdateCoordinator, + config_entry: ConfigEntry, + monitor_name: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, config_entry) + self._monitor_name = monitor_name + self._attr_name = f"{monitor_name} Status" + self._attr_unique_id = f"{config_entry.entry_id}_{monitor_name}_status" + self._attr_icon = "mdi:monitor" + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + if not self.coordinator.data: + return "unavailable" + + return self.coordinator.data.get(self._monitor_name, "unknown") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + return { + "monitor_name": self._monitor_name, + "friendly_name": f"Monitor {self._monitor_name}", + } + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self._monitor_name in self.coordinator.data + ) diff --git a/homeassistant/custom_components/hyprmonitors/services.yaml b/homeassistant/custom_components/hyprmonitors/services.yaml new file mode 100644 index 0000000..9c8918c --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/services.yaml @@ -0,0 +1,29 @@ +turn_on_monitor: + name: Turn on monitor + description: Turn on a specific monitor by name + fields: + monitor: + name: Monitor name + description: Name of the monitor to turn on (e.g., DP-1, HDMI-A-1) + required: true + selector: + text: + +turn_off_monitor: + name: Turn off monitor + description: Turn off a specific monitor by name + fields: + monitor: + name: Monitor name + description: Name of the monitor to turn off (e.g., DP-1, HDMI-A-1) + required: true + selector: + text: + +turn_on_all_monitors: + name: Turn on all monitors + description: Turn on all monitors connected to Hyprland + +turn_off_all_monitors: + name: Turn off all monitors + description: Turn off all monitors connected to Hyprland diff --git a/homeassistant/custom_components/hyprmonitors/strings.json b/homeassistant/custom_components/hyprmonitors/strings.json new file mode 100644 index 0000000..400f6d6 --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/strings.json @@ -0,0 +1,88 @@ +{ + "config": { + "step": { + "user": { + "title": "Hyprland Monitor Control", + "description": "Configure connection to your Hyprmonitors server", + "data": { + "host": "Host", + "port": "Port" + }, + "data_description": { + "host": "IP address or hostname of the Hyprmonitors server (default: {default_host})", + "port": "Port number of the Hyprmonitors server (default: {default_port})" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the Hyprmonitors server. Please check the host and port.", + "invalid_auth": "Authentication failed. Please check your credentials.", + "unknown": "Unexpected error occurred." + }, + "abort": { + "already_configured": "This Hyprmonitors server is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Hyprland Monitor Control Options", + "description": "Configure advanced options for the integration", + "data": { + "scan_interval": "Scan Interval (seconds)" + } + } + } + }, + "entity": { + "sensor": { + "monitor_status": { + "name": "Monitor Status", + "state": { + "all_on": "All On", + "all_off": "All Off", + "mixed": "Mixed", + "unavailable": "Unavailable" + } + } + }, + "switch": { + "all_monitors": { + "name": "All Monitors" + }, + "monitor": { + "name": "Monitor {monitor_name}" + } + } + }, + "services": { + "turn_on_monitor": { + "name": "Turn on monitor", + "description": "Turn on a specific monitor by name", + "fields": { + "monitor": { + "name": "Monitor name", + "description": "Name of the monitor to turn on (e.g., DP-1, HDMI-A-1)" + } + } + }, + "turn_off_monitor": { + "name": "Turn off monitor", + "description": "Turn off a specific monitor by name", + "fields": { + "monitor": { + "name": "Monitor name", + "description": "Name of the monitor to turn off (e.g., DP-1, HDMI-A-1)" + } + } + }, + "turn_on_all_monitors": { + "name": "Turn on all monitors", + "description": "Turn on all monitors connected to Hyprland" + }, + "turn_off_all_monitors": { + "name": "Turn off all monitors", + "description": "Turn off all monitors connected to Hyprland" + } + } +} diff --git a/homeassistant/custom_components/hyprmonitors/switch.py b/homeassistant/custom_components/hyprmonitors/switch.py new file mode 100644 index 0000000..191bde6 --- /dev/null +++ b/homeassistant/custom_components/hyprmonitors/switch.py @@ -0,0 +1,218 @@ +"""Switch platform for Hyprland Monitor Control integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import HyprmonitorsConfigEntry, HyprmonitorsData +from .const import DOMAIN +from .sensor import HyprmonitorsDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HyprmonitorsConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the switch platform.""" + api = config_entry.runtime_data + + # Get coordinator from sensor platform + coordinator = None + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.entry_id == config_entry.entry_id: + # Create coordinator if it doesn't exist + coordinator = HyprmonitorsDataUpdateCoordinator(hass, api=api) + await coordinator.async_config_entry_first_refresh() + break + + if coordinator is None: + _LOGGER.error("Could not find coordinator for config entry") + return + + entities = [] + + # Add master switch for all monitors + entities.append( + HyprmonitorsAllMonitorsSwitch( + coordinator=coordinator, + config_entry=config_entry, + api=api, + ) + ) + + # Add individual monitor switches + if coordinator.data: + for monitor_name in coordinator.data: + entities.append( + HyprmonitorsMonitorSwitch( + coordinator=coordinator, + config_entry=config_entry, + api=api, + monitor_name=monitor_name, + ) + ) + + async_add_entities(entities) + + +class HyprmonitorsBaseSwitch(CoordinatorEntity, SwitchEntity): + """Base class for Hyprmonitors switches.""" + + def __init__( + self, + coordinator: HyprmonitorsDataUpdateCoordinator, + config_entry: ConfigEntry, + api: HyprmonitorsData, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self._api = api + self._config_entry = config_entry + self._attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self._config_entry.entry_id)}, + name="Hyprland Monitors", + manufacturer="Hyprland", + model="Monitor Control", + sw_version="1.0.0", + configuration_url=f"http://{self._config_entry.data['host']}:{self._config_entry.data['port']}", + ) + + +class HyprmonitorsAllMonitorsSwitch(HyprmonitorsBaseSwitch): + """Switch to control all monitors at once.""" + + def __init__( + self, + coordinator: HyprmonitorsDataUpdateCoordinator, + config_entry: ConfigEntry, + api: HyprmonitorsData, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, config_entry, api) + self._attr_name = "All Monitors" + self._attr_unique_id = f"{config_entry.entry_id}_all_monitors" + self._attr_icon = "mdi:monitor-multiple" + + @property + def is_on(self) -> bool | None: + """Return true if all monitors are on.""" + if not self.coordinator.data: + return None + + monitors = self.coordinator.data + if not monitors: + return None + + # Return True only if ALL monitors are on + return all(status == "on" for status in monitors.values()) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + if not self.coordinator.data: + return {} + + monitors = self.coordinator.data + total_monitors = len(monitors) + on_monitors = sum(1 for status in monitors.values() if status == "on") + + return { + "total_monitors": total_monitors, + "monitors_on": on_monitors, + "monitors_off": total_monitors - on_monitors, + "monitor_names": list(monitors.keys()), + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn all monitors on.""" + success = await self._api.async_turn_all_monitors_on() + if success: + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("Failed to turn on all monitors") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn all monitors off.""" + success = await self._api.async_turn_all_monitors_off() + if success: + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("Failed to turn off all monitors") + + +class HyprmonitorsMonitorSwitch(HyprmonitorsBaseSwitch): + """Switch to control individual monitors.""" + + def __init__( + self, + coordinator: HyprmonitorsDataUpdateCoordinator, + config_entry: ConfigEntry, + api: HyprmonitorsData, + monitor_name: str, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, config_entry, api) + self._monitor_name = monitor_name + self._attr_name = f"Monitor {monitor_name}" + self._attr_unique_id = f"{config_entry.entry_id}_{monitor_name}" + self._attr_icon = "mdi:monitor" + + @property + def is_on(self) -> bool | None: + """Return true if the monitor is on.""" + if not self.coordinator.data: + return None + + status = self.coordinator.data.get(self._monitor_name) + if status is None: + return None + + return status == "on" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes.""" + return { + "monitor_name": self._monitor_name, + "friendly_name": f"Monitor {self._monitor_name}", + } + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self._monitor_name in self.coordinator.data + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the monitor on.""" + success = await self._api.async_turn_monitor_on(self._monitor_name) + if success: + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("Failed to turn on monitor %s", self._monitor_name) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the monitor off.""" + success = await self._api.async_turn_monitor_off(self._monitor_name) + if success: + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("Failed to turn off monitor %s", self._monitor_name) diff --git a/homeassistant/docker-compose.example.yml b/homeassistant/docker-compose.example.yml new file mode 100644 index 0000000..8eed8fb --- /dev/null +++ b/homeassistant/docker-compose.example.yml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + homeassistant: + container_name: homeassistant + image: ghcr.io/home-assistant/home-assistant:stable + volumes: + # Main Home Assistant configuration + - ./homeassistant-config:/config + + # Mount the Hyprmonitors integration directly + - ./custom_components/hyprmonitors:/config/custom_components/hyprmonitors:ro + + # Optional: Mount examples for reference + - ./examples.yaml:/config/hyprmonitors-examples.yaml:ro + + # System access (needed for some integrations) + - /etc/localtime:/etc/localtime:ro + + restart: unless-stopped + privileged: true + network_mode: host + + environment: + # Set timezone + - TZ=America/New_York # Change to your timezone + + # Health check + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8123/api/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # Optional: Include the Hyprmonitors server in the same compose file + hyprmonitors: + container_name: hyprmonitors-server + build: + context: ../ + dockerfile: Dockerfile + ports: + - "3000:3000" + restart: unless-stopped + environment: + - HYPRMONITORS_HOST=0.0.0.0 + - HYPRMONITORS_PORT=3000 + - RUST_LOG=info + + # Mount Hyprland socket (adjust path as needed) + volumes: + - /tmp/hypr:/tmp/hypr:ro + - $XDG_RUNTIME_DIR/hypr:/run/user/1000/hypr:ro + + # Network access to Hyprland + network_mode: host + + # Ensure Hyprland environment is available + environment: + - XDG_RUNTIME_DIR=/run/user/1000 + - HYPRLAND_INSTANCE_SIGNATURE=${HYPRLAND_INSTANCE_SIGNATURE} + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +# Optional: Create a custom network if not using host networking +networks: + homelab: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + +volumes: + homeassistant-config: + driver: local diff --git a/homeassistant/examples.yaml b/homeassistant/examples.yaml new file mode 100644 index 0000000..18d215b --- /dev/null +++ b/homeassistant/examples.yaml @@ -0,0 +1,330 @@ +# Sample Home Assistant configuration for Hyprland Monitor Control +# Copy relevant sections to your configuration files + +# Automations +automation: + # Basic presence-based control + - alias: "Monitors - Turn off when away" + description: "Turn off all monitors when nobody is home" + trigger: + - platform: state + entity_id: group.all_persons + to: "not_home" + for: "00:05:00" # Wait 5 minutes to avoid false triggers + action: + - service: hyprmonitors.turn_off_all_monitors + - service: notify.persistent_notification + data: + title: "Monitor Control" + message: "All monitors turned off - nobody home" + + - alias: "Monitors - Turn on when arriving home" + description: "Turn on monitors when someone arrives home" + trigger: + - platform: state + entity_id: group.all_persons + to: "home" + condition: + - condition: time + after: "07:00:00" + before: "23:00:00" + action: + - service: hyprmonitors.turn_on_all_monitors + - service: notify.persistent_notification + data: + title: "Monitor Control" + message: "Monitors turned on - welcome home!" + + # Time-based control + - alias: "Monitors - Morning startup" + description: "Turn on monitors in the morning on weekdays" + trigger: + - platform: time + at: "07:30:00" + condition: + - condition: time + weekday: + - mon + - tue + - wed + - thu + - fri + - condition: state + entity_id: group.all_persons + state: "home" + action: + - service: switch.turn_on + entity_id: switch.hyprmonitors_all_monitors + + - alias: "Monitors - Evening shutdown" + description: "Turn off monitors late at night" + trigger: + - platform: time + at: "23:30:00" + action: + - service: switch.turn_off + entity_id: switch.hyprmonitors_all_monitors + + # Work mode automation + - alias: "Monitors - Focus mode" + description: "Turn off secondary monitors during focus time" + trigger: + - platform: state + entity_id: input_boolean.focus_mode + to: "on" + action: + - service: hyprmonitors.turn_off_monitor + data: + monitor: "DP-2" # Adjust to your secondary monitor + - service: hyprmonitors.turn_off_monitor + data: + monitor: "HDMI-A-1" # Adjust as needed + + - alias: "Monitors - Exit focus mode" + description: "Turn on all monitors when exiting focus mode" + trigger: + - platform: state + entity_id: input_boolean.focus_mode + to: "off" + action: + - service: hyprmonitors.turn_on_all_monitors + + # Meeting automation (requires calendar integration) + - alias: "Monitors - Meeting mode" + description: "Adjust monitors for meetings" + trigger: + - platform: calendar + event: start + entity_id: calendar.work + condition: + - condition: template + value_template: > + {{ 'meeting' in trigger.calendar_event.summary.lower() or + 'call' in trigger.calendar_event.summary.lower() }} + action: + # Keep only main monitor on for meetings + - service: hyprmonitors.turn_on_monitor + data: + monitor: "DP-1" # Main monitor + - service: hyprmonitors.turn_off_monitor + data: + monitor: "DP-2" # Secondary monitor + + # Power saving based on computer usage + - alias: "Monitors - Idle detection" + description: "Turn off monitors when computer is idle" + trigger: + - platform: state + entity_id: binary_sensor.computer_active # You'd need to create this + to: "off" + for: "00:10:00" # 10 minutes idle + condition: + - condition: time + after: "09:00:00" + before: "22:00:00" + action: + - service: hyprmonitors.turn_off_all_monitors + + - alias: "Monitors - Activity detected" + description: "Turn on monitors when computer becomes active" + trigger: + - platform: state + entity_id: binary_sensor.computer_active + to: "on" + condition: + - condition: state + entity_id: group.all_persons + state: "home" + action: + - service: hyprmonitors.turn_on_all_monitors + +# Input Booleans for manual control +input_boolean: + focus_mode: + name: "Focus Mode" + icon: mdi:focus-field + + monitor_automation: + name: "Monitor Automation Enabled" + initial: true + icon: mdi:auto-mode + +# Scripts for complex monitor control +script: + gaming_mode: + alias: "Gaming Mode" + description: "Optimize monitors for gaming" + sequence: + - service: hyprmonitors.turn_on_monitor + data: + monitor: "DP-1" # Main gaming monitor + - service: hyprmonitors.turn_off_monitor + data: + monitor: "DP-2" # Turn off secondary to reduce distractions + - service: notify.persistent_notification + data: + title: "Monitor Control" + message: "Gaming mode activated" + + work_mode: + alias: "Work Mode" + description: "Setup monitors for work" + sequence: + - service: hyprmonitors.turn_on_all_monitors + - delay: "00:00:02" # Small delay between commands + - service: notify.persistent_notification + data: + title: "Monitor Control" + message: "Work mode activated - all monitors on" + + presentation_mode: + alias: "Presentation Mode" + description: "Setup for presentations" + sequence: + - service: hyprmonitors.turn_on_monitor + data: + monitor: "HDMI-A-1" # Projector/external display + - service: hyprmonitors.turn_on_monitor + data: + monitor: "DP-1" # Laptop screen for notes + - service: notify.persistent_notification + data: + title: "Monitor Control" + message: "Presentation mode ready" + +# Sensors for monitoring +sensor: + - platform: template + sensors: + monitor_power_usage: + friendly_name: "Estimated Monitor Power Usage" + unit_of_measurement: "W" + value_template: > + {% set total_monitors = state_attr('sensor.hyprmonitors_monitor_status', 'total_monitors') | int %} + {% set monitors_on = state_attr('sensor.hyprmonitors_monitor_status', 'monitors_on') | int %} + {{ monitors_on * 30 }} # Assuming 30W per monitor + icon_template: mdi:lightning-bolt + +# Groups for organization +group: + monitor_controls: + name: "Monitor Controls" + entities: + - switch.hyprmonitors_all_monitors + - sensor.hyprmonitors_monitor_status + - input_boolean.focus_mode + - input_boolean.monitor_automation + +# Lovelace Dashboard Configuration +# Add this to your dashboard YAML or use the UI editor + +# Main Monitor Control Card +type: vertical-stack +cards: + - type: entities + title: "Monitor Status" + entities: + - entity: sensor.hyprmonitors_monitor_status + name: "Overall Status" + icon: mdi:monitor-multiple + - entity: sensor.monitor_power_usage + name: "Est. Power Usage" + - type: divider + - entity: switch.hyprmonitors_all_monitors + name: "All Monitors" + icon: mdi:power + + - type: horizontal-stack + cards: + - type: button + entity: script.work_mode + name: "Work Mode" + icon: mdi:briefcase + tap_action: + action: call-service + service: script.work_mode + + - type: button + entity: script.gaming_mode + name: "Gaming" + icon: mdi:gamepad-variant + tap_action: + action: call-service + service: script.gaming_mode + + - type: button + entity: script.presentation_mode + name: "Present" + icon: mdi:presentation + tap_action: + action: call-service + service: script.presentation_mode + + - type: entities + title: "Individual Monitors" + entities: + - entity: switch.hyprmonitors_dp_1 + name: "Main Monitor (DP-1)" + icon: mdi:monitor + - entity: switch.hyprmonitors_dp_2 + name: "Secondary (DP-2)" + icon: mdi:monitor + - entity: switch.hyprmonitors_hdmi_a_1 + name: "External (HDMI)" + icon: mdi:monitor-speaker + + - type: entities + title: "Automation Settings" + entities: + - entity: input_boolean.monitor_automation + name: "Enable Automation" + - entity: input_boolean.focus_mode + name: "Focus Mode" + +# Alternative Glance Card Layout +type: glance +entities: + - entity: sensor.hyprmonitors_monitor_status + name: "Status" + - entity: switch.hyprmonitors_all_monitors + name: "All" + - entity: switch.hyprmonitors_dp_1 + name: "Main" + - entity: switch.hyprmonitors_dp_2 + name: "Secondary" +title: "Monitor Quick Control" +columns: 4 + +# Advanced Card with Custom Button Row +type: custom:button-card +name: "Monitor Control Hub" +styles: + card: + - height: 120px +tap_action: + action: more-info + entity: sensor.hyprmonitors_monitor_status +custom_fields: + status: | + [[[ + const status = states['sensor.hyprmonitors_monitor_status'].state; + const icon = status === 'all_on' ? 'mdi:monitor-multiple' : + status === 'all_off' ? 'mdi:monitor-off' : 'mdi:monitor-shimmer'; + return ``; + ]]] + buttons: | + [[[ + return ` +
+ + Work + + + Game + + + Off + +
+ `; + ]]] diff --git a/homeassistant/install.sh b/homeassistant/install.sh new file mode 100755 index 0000000..0ae5f75 --- /dev/null +++ b/homeassistant/install.sh @@ -0,0 +1,316 @@ +#!/bin/bash + +# Hyprland Monitor Control - Home Assistant Integration Installer +# This script helps install the custom integration into Home Assistant + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default paths +DEFAULT_HA_CONFIG="$HOME/.homeassistant" +DEFAULT_HA_CONFIG_ALT="/config" +INTEGRATION_NAME="hyprmonitors" +SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +print_header() { + echo -e "${BLUE}================================================${NC}" + echo -e "${BLUE} Hyprland Monitor Control - HA Integration${NC}" + echo -e "${BLUE}================================================${NC}" + echo +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +print_info() { + echo -e "${BLUE}ℹ $1${NC}" +} + +detect_ha_config() { + local config_dir="" + + # Try common Home Assistant config directories + if [[ -d "$DEFAULT_HA_CONFIG" ]]; then + config_dir="$DEFAULT_HA_CONFIG" + elif [[ -d "$DEFAULT_HA_CONFIG_ALT" ]]; then + config_dir="$DEFAULT_HA_CONFIG_ALT" + elif [[ -d "/usr/share/hassio/homeassistant" ]]; then + config_dir="/usr/share/hassio/homeassistant" + elif [[ -d "/data" ]]; then + config_dir="/data" + fi + + echo "$config_dir" +} + +check_ha_running() { + if command -v systemctl &> /dev/null; then + if systemctl is-active --quiet home-assistant || systemctl is-active --quiet homeassistant; then + return 0 + fi + fi + + # Check for common HA processes + if pgrep -f "homeassistant" &> /dev/null || pgrep -f "hass" &> /dev/null; then + return 0 + fi + + return 1 +} + +create_backup() { + local target_dir="$1" + local backup_dir="${target_dir}.backup.$(date +%Y%m%d_%H%M%S)" + + if [[ -d "$target_dir" ]]; then + print_info "Creating backup of existing integration..." + cp -r "$target_dir" "$backup_dir" + print_success "Backup created: $backup_dir" + return 0 + fi + return 1 +} + +install_integration() { + local ha_config="$1" + local custom_components_dir="$ha_config/custom_components" + local target_dir="$custom_components_dir/$INTEGRATION_NAME" + local source_components_dir="$SOURCE_DIR/custom_components/$INTEGRATION_NAME" + + # Verify source directory exists + if [[ ! -d "$source_components_dir" ]]; then + print_error "Source integration directory not found: $source_components_dir" + exit 1 + fi + + # Create custom_components directory if it doesn't exist + mkdir -p "$custom_components_dir" + print_success "Custom components directory ready: $custom_components_dir" + + # Create backup if integration already exists + create_backup "$target_dir" + + # Copy integration files + print_info "Installing Hyprland Monitor Control integration..." + cp -r "$source_components_dir" "$target_dir" + + # Set appropriate permissions + chmod -R 755 "$target_dir" + + print_success "Integration installed successfully!" + print_info "Installation location: $target_dir" + + # List installed files + echo + print_info "Installed files:" + find "$target_dir" -type f -name "*.py" -o -name "*.json" -o -name "*.yaml" | sort | while read -r file; do + echo " - ${file#$target_dir/}" + done +} + +verify_installation() { + local ha_config="$1" + local target_dir="$ha_config/custom_components/$INTEGRATION_NAME" + + # Check required files + local required_files=( + "__init__.py" + "manifest.json" + "config_flow.py" + "const.py" + "sensor.py" + "switch.py" + "strings.json" + "services.yaml" + ) + + print_info "Verifying installation..." + + for file in "${required_files[@]}"; do + if [[ -f "$target_dir/$file" ]]; then + print_success "Found: $file" + else + print_error "Missing: $file" + return 1 + fi + done + + # Check manifest.json structure + if command -v python3 &> /dev/null; then + if python3 -c "import json; json.load(open('$target_dir/manifest.json'))" 2>/dev/null; then + print_success "manifest.json is valid JSON" + else + print_warning "manifest.json may have syntax errors" + fi + fi + + return 0 +} + +check_hyprmonitors_server() { + local host="${1:-localhost}" + local port="${2:-3000}" + + print_info "Checking Hyprmonitors server connection..." + + if command -v curl &> /dev/null; then + if curl -s --max-time 5 "http://$host:$port/health" > /dev/null 2>&1; then + print_success "Hyprmonitors server is reachable at $host:$port" + return 0 + else + print_warning "Hyprmonitors server not reachable at $host:$port" + print_info "Make sure the Rust server is running: cargo run" + return 1 + fi + else + print_warning "curl not available, cannot test server connection" + return 1 + fi +} + +show_next_steps() { + echo + print_info "Installation complete! Next steps:" + echo + echo "1. Restart Home Assistant" + echo " - If using systemctl: sudo systemctl restart home-assistant" + echo " - If using Docker: docker restart homeassistant" + echo " - If using HAOS: Settings → System → Restart" + echo + echo "2. Add the integration:" + echo " - Go to Settings → Devices & Services" + echo " - Click 'Add Integration'" + echo " - Search for 'Hyprland Monitor Control'" + echo " - Configure with your server details" + echo + echo "3. Make sure your Hyprmonitors server is running:" + echo " - cd $(dirname "$SOURCE_DIR")" + echo " - cargo run" + echo + echo "4. Optional: Check the examples.yaml file for automation ideas" + echo + print_success "Enjoy controlling your monitors from Home Assistant! 🖥️" +} + +usage() { + echo "Usage: $0 [OPTIONS]" + echo + echo "Options:" + echo " -c, --config-dir DIR Home Assistant configuration directory" + echo " -h, --help Show this help message" + echo " --check-server HOST:PORT Check if Hyprmonitors server is running" + echo " --dry-run Show what would be done without making changes" + echo + echo "Examples:" + echo " $0 # Auto-detect HA config directory" + echo " $0 -c /config # Specify config directory" + echo " $0 --check-server localhost:3000 # Test server connection" + echo " $0 --dry-run # Preview installation" +} + +main() { + local ha_config="" + local check_server="" + local dry_run=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + -c|--config-dir) + ha_config="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --check-server) + check_server="$2" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + *) + print_error "Unknown option: $1" + usage + exit 1 + ;; + esac + done + + print_header + + # Handle server check + if [[ -n "$check_server" ]]; then + IFS=':' read -r host port <<< "$check_server" + check_hyprmonitors_server "$host" "$port" + exit $? + fi + + # Detect Home Assistant config directory + if [[ -z "$ha_config" ]]; then + ha_config=$(detect_ha_config) + if [[ -z "$ha_config" ]]; then + print_error "Could not find Home Assistant configuration directory" + print_info "Please specify it with: $0 -c /path/to/homeassistant/config" + exit 1 + fi + print_info "Auto-detected HA config: $ha_config" + fi + + # Verify config directory exists + if [[ ! -d "$ha_config" ]]; then + print_error "Home Assistant config directory not found: $ha_config" + exit 1 + fi + + # Check if HA is running + if check_ha_running; then + print_warning "Home Assistant appears to be running" + print_info "You'll need to restart it after installation" + fi + + if [[ "$dry_run" == true ]]; then + print_info "DRY RUN - Would install integration to:" + print_info " Source: $SOURCE_DIR/custom_components/$INTEGRATION_NAME" + print_info " Target: $ha_config/custom_components/$INTEGRATION_NAME" + exit 0 + fi + + # Install the integration + install_integration "$ha_config" + + # Verify installation + if verify_installation "$ha_config"; then + print_success "Installation verification passed!" + else + print_error "Installation verification failed!" + exit 1 + fi + + # Check server connection + check_hyprmonitors_server + + # Show next steps + show_next_steps +} + +# Run main function +main "$@"