Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -0,0 +1,16 @@
|
||||
# Homey connection
|
||||
HOMEY_HOST=192.168.178.201
|
||||
HOMEY_TOKEN=4a6d5464-2e25-4fbe-bd2a-f9c4902c90e5:ac38c7fb-3131-4af5-997e-fd0e22c931c3:a3445d0d939932384f5de2594dae1857f5d2a53f
|
||||
HOMEY_SCHEME=http
|
||||
HOMEY_PORT=4859
|
||||
HOMEY_TIMEOUT=10
|
||||
HOMEY_EVENT_BUFFER=200
|
||||
HOMEY_REALTIME=true
|
||||
|
||||
# LAN transport (optional) - only used by run-lan.bat / HOMEY_MCP_TRANSPORT=streamable-http
|
||||
# HOMEY_MCP_TRANSPORT=streamable-http
|
||||
# HOMEY_MCP_HOST=0.0.0.0 # uvicorn bind address (0.0.0.0 for all interfaces)
|
||||
# HOMEY_MCP_ADVERTISED_HOST=10.10.10.50 # advertised host for MCP Host header validation
|
||||
# HOMEY_MCP_PORT=8080
|
||||
# HOMEY_MCP_AUTH_MODE=none
|
||||
# HOMEY_MCP_AUTH_TOKEN=replace-with-a-long-random-secret
|
||||
@@ -0,0 +1,22 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Tooling
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,200 @@
|
||||
# homey-mcp
|
||||
|
||||
An MCP (Model Context Protocol) server that exposes a local **Homey Pro** instance to LLM clients (Claude Desktop, VS Code / GitHub Copilot Chat, MCP Inspector, …).
|
||||
|
||||
It talks to Homey's local HTTP Web API (`/api/manager/...`) using a Personal Access Token, and optionally subscribes to Socket.IO realtime events.
|
||||
|
||||
## Features
|
||||
|
||||
Tools:
|
||||
|
||||
- `ping` — verify connectivity / return Homey system info
|
||||
- `list_devices(zone_id?)` — devices with current capability values
|
||||
- `get_device(device_id)` — full device object
|
||||
- `list_zones` — rooms / areas
|
||||
- `set_capability(device_id, capability, value)` — set `onoff`, `dim`, `target_temperature`, …
|
||||
- `list_flows` — all flows
|
||||
- `trigger_flow(flow_id)` — start a flow
|
||||
- `list_recent_events(limit, kind?)` — recent realtime events from the in-memory buffer
|
||||
|
||||
Resources:
|
||||
|
||||
- `homey://devices`
|
||||
- `homey://zones`
|
||||
- `homey://events/recent`
|
||||
|
||||
Transport: **stdio** (default, for local LLM hosts like Javis) or **streamable-http / sse** (for remote MCP clients over the LAN, e.g. the Odysseus AI helper) — see [Run on the LAN](#run-on-the-lan-remote-mcp-clients) below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python **3.10+**
|
||||
- A Homey Pro on the local network
|
||||
- A **Personal Access Token** for the Homey Web API — create one at the Homey Developer Tools: <https://tools.developer.homey.app/> (My Homey → API Keys / Personal Access Token).
|
||||
|
||||
## Install
|
||||
|
||||
From this folder:
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
`uv` works equivalently:
|
||||
|
||||
```powershell
|
||||
uv venv
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Copy `.env.example` to `.env` and fill in:
|
||||
|
||||
```ini
|
||||
HOMEY_HOST=192.168.1.10
|
||||
HOMEY_TOKEN=eyJhbGciOi...your PAT...
|
||||
HOMEY_SCHEME=http
|
||||
HOMEY_PORT=4859
|
||||
HOMEY_TIMEOUT=10
|
||||
HOMEY_EVENT_BUFFER=200
|
||||
HOMEY_REALTIME=true
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
homey-mcp
|
||||
# or
|
||||
python -m homey_mcp
|
||||
# or
|
||||
.\run.bat
|
||||
```
|
||||
|
||||
### Inspect with the MCP Inspector
|
||||
|
||||
```powershell
|
||||
uv run mcp dev src/homey_mcp/server.py
|
||||
# or
|
||||
npx -y @modelcontextprotocol/inspector homey-mcp
|
||||
```
|
||||
|
||||
### Run on the LAN (remote MCP clients)
|
||||
|
||||
To let a remote MCP client on the same network (e.g. **Odysseus**) connect over
|
||||
HTTP instead of stdio, set the transport env vars and run `run-lan.bat`
|
||||
(or set them yourself and run `homey-mcp` / `python -m homey_mcp`):
|
||||
|
||||
```ini
|
||||
HOMEY_MCP_TRANSPORT=streamable-http # or: sse
|
||||
HOMEY_MCP_HOST=0.0.0.0 # bind address (or specific LAN IP)
|
||||
HOMEY_MCP_ADVERTISED_HOST=10.10.10.50 # advertised host for MCP validation (required for 0.0.0.0 bind)
|
||||
HOMEY_MCP_PORT=8080
|
||||
HOMEY_MCP_AUTH_MODE=none # compatible trusted-LAN default
|
||||
HOMEY_MCP_AUTH_TOKEN=<a long random secret>
|
||||
```
|
||||
|
||||
```powershell
|
||||
.\run-lan.bat
|
||||
```
|
||||
|
||||
The remote client connects to `http://<this-machine-ip>:8080/mcp`
|
||||
(streamable-http) or `/sse`. Streamable HTTP clients that only accept a base
|
||||
URL may use `http://<this-machine-ip>:8080/`; MCP traffic at `/` is forwarded
|
||||
to `/mcp`, while an ordinary browser GET returns service discovery.
|
||||
|
||||
**Important for MCP transport over LAN:** The MCP SDK validates that clients
|
||||
send the correct `Host` header. If you bind to `0.0.0.0`, you **must** set
|
||||
`HOMEY_MCP_ADVERTISED_HOST` to your actual server IP (e.g., `10.10.10.50`).
|
||||
If you instead bind to a specific IP, omit `HOMEY_MCP_ADVERTISED_HOST` and it
|
||||
will default to the bind address.
|
||||
|
||||
`HOMEY_MCP_AUTH_MODE=none` is the compatible mode for OAuth-negotiating MCP
|
||||
clients such as Odysseus, because this server is not an OAuth authorization
|
||||
server. Use it only on a trusted, firewall-restricted LAN. Set the mode to
|
||||
`bearer` only when the MCP client can be configured to send
|
||||
`Authorization: Bearer <HOMEY_MCP_AUTH_TOKEN>` directly; OAuth discovery and
|
||||
dynamic registration are not provided. Generate a token with
|
||||
`python -c "import secrets; print(secrets.token_urlsafe(32))"`.
|
||||
|
||||
When `HOMEY_MCP_AUTH_TOKEN` is set, the OpenAPI REST routes always require it,
|
||||
regardless of the MCP auth mode.
|
||||
|
||||
This is independent of Javis's own connection: Javis keeps talking to
|
||||
homey-mcp over stdio via `mcp_servers.json` as before — run the LAN instance
|
||||
as a second, separate process for other clients.
|
||||
|
||||
#### Odysseus API integration
|
||||
|
||||
If Odysseus exposes integrations through its `app_api` tool rather than a
|
||||
native MCP client, import this server's OpenAPI document:
|
||||
|
||||
- Schema URL: `http://<this-machine-ip>:8080/openapi.json`
|
||||
- Authentication: Bearer token
|
||||
- Token: the value of `HOMEY_MCP_AUTH_TOKEN`
|
||||
|
||||
The API provides reads at `/api/system`, `/api/zones`, and `/api/devices`,
|
||||
plus writes at `/api/devices/{device_id}/capabilities/{capability}` and
|
||||
`/api/zones/control`.
|
||||
|
||||
Odysseus must invoke its registered `app_api` tool as a structured tool call.
|
||||
It must not print or execute Python such as `api_call(...)`; `api_call` is not
|
||||
a Python function and is not the registered tool name. Use this integration
|
||||
instruction in Odysseus:
|
||||
|
||||
```text
|
||||
For Homey requests, always call the app_api tool with integration="Homey".
|
||||
Never write api_call(...) or place tool calls in Python/code blocks.
|
||||
Use GET /api/system to check connectivity, GET /api/zones and GET /api/devices
|
||||
to discover IDs, PUT /api/devices/{device_id}/capabilities/{capability} with
|
||||
{"value": ...} for one device, and POST /api/zones/control with
|
||||
{"zone": "...", "capability": "onoff", "value": true|false} for a room.
|
||||
After the tool returns, summarize the actual result. Never claim success before
|
||||
receiving a successful tool response.
|
||||
```
|
||||
|
||||
### Use from Claude Desktop / VS Code
|
||||
|
||||
Add to your MCP client config (path varies per client):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homey": {
|
||||
"command": "homey-mcp",
|
||||
"env": {
|
||||
"HOMEY_HOST": "192.168.1.10",
|
||||
"HOMEY_TOKEN": "your-personal-access-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `homey-mcp` is not on PATH, use the full venv path, e.g. `C:\\local\\Code\\PRIVAT\\homey-mcp\\.venv\\Scripts\\homey-mcp.exe`.
|
||||
|
||||
## Notes & limitations
|
||||
|
||||
- Local API only (no cloud OAuth).
|
||||
- LAN MCP transport authentication is controlled by `HOMEY_MCP_AUTH_MODE`.
|
||||
Never expose `none` mode beyond a trusted, firewall-restricted LAN.
|
||||
- Realtime events are best-effort — if the Socket.IO connection fails the REST tools still work. Set `HOMEY_REALTIME=false` to disable.
|
||||
- No admin operations (zone/user/app management) are exposed.
|
||||
- Capability values are typed as `bool | int | float | str`; the model picks based on the capability (`onoff` → bool, `dim` → 0..1 float, etc.).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
homey-mcp/
|
||||
pyproject.toml
|
||||
README.md
|
||||
.env.example
|
||||
run.bat
|
||||
src/homey_mcp/
|
||||
__init__.py
|
||||
__main__.py
|
||||
config.py
|
||||
homey_client.py
|
||||
server.py
|
||||
```
|
||||
@@ -0,0 +1,137 @@
|
||||
# Homey MCP Server - Ubuntu Setup Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Run the setup script
|
||||
```bash
|
||||
cd homey-mcp
|
||||
chmod +x setup-ubuntu.sh run.sh run-lan.sh
|
||||
./setup-ubuntu.sh
|
||||
```
|
||||
|
||||
### 2. Configure your credentials
|
||||
Edit the `.env` file with your Homey Pro credentials:
|
||||
```bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required fields:
|
||||
- `HOMEY_TOKEN` - Your Homey Pro personal access token
|
||||
- `HOMEY_HOST` - Your Homey Pro hub IP (e.g., `192.168.1.100`)
|
||||
- `HOMEY_SCHEME` / `HOMEY_PORT` - Local API protocol and port
|
||||
|
||||
Optional for LAN mode:
|
||||
- `HOMEY_MCP_AUTH_MODE` - `none` for trusted-LAN compatibility, or `bearer`
|
||||
- `HOMEY_MCP_AUTH_TOKEN` - Protects REST and, in `bearer` mode, MCP requests
|
||||
- `HOMEY_MCP_ADVERTISED_HOST` - Set this to your server's LAN IP (e.g., `10.10.10.50`). Required if using `HOMEY_MCP_HOST=0.0.0.0`.
|
||||
|
||||
### 3. Start the server
|
||||
|
||||
**For remote Odysseus client (LAN mode):**
|
||||
```bash
|
||||
./run-lan.sh
|
||||
```
|
||||
|
||||
**For local stdio mode (Javis integration):**
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
## Manual Installation
|
||||
|
||||
If you prefer to set up manually:
|
||||
|
||||
```bash
|
||||
# Install Python (if not installed)
|
||||
sudo apt install python3.10 python3.venv build-essential libffi-dev libssl-dev
|
||||
|
||||
# Create virtual environment
|
||||
cd homey-mcp
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install --upgrade pip
|
||||
pip install -e .
|
||||
pip install uvicorn
|
||||
|
||||
# Configure
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your credentials
|
||||
|
||||
# Run
|
||||
HOMEY_MCP_TRANSPORT=streamable-http HOMEY_MCP_HOST=0.0.0.0 HOMEY_MCP_PORT=8080 \
|
||||
python -m homey_mcp
|
||||
```
|
||||
|
||||
## Persistent Operation with systemd
|
||||
|
||||
Install the service file for automatic startup:
|
||||
|
||||
```bash
|
||||
# Copy service file
|
||||
sudo cp homey-mcp.service /etc/systemd/system/
|
||||
|
||||
# Reload systemd
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable and start (replace $USER with your username)
|
||||
sudo systemctl enable --now homey-mcp@$USER.service
|
||||
|
||||
# Check status
|
||||
sudo journalctl -u homey-mcp@$USER.service -f
|
||||
```
|
||||
|
||||
## Connecting Odysseus
|
||||
|
||||
To connect Odysseus as an MCP client, use the streamable-http transport:
|
||||
|
||||
1. **Important:** Before starting the server, set `HOMEY_MCP_ADVERTISED_HOST` in `.env` to your Ubuntu server's LAN IP address (e.g., `10.10.10.50`). This is required for MCP Host header validation.
|
||||
2. Ensure homey-mcp is running on port 8080 (or your configured port)
|
||||
3. Configure Odysseus with the MCP endpoint:
|
||||
```
|
||||
URL: http://<YOUR_UBUNTU_IP>:8080/mcp
|
||||
Transport: streamable-http
|
||||
Authentication: none
|
||||
```
|
||||
|
||||
If you see a "421 Misdirected Request" error, your `HOMEY_MCP_ADVERTISED_HOST` is not set correctly; it must match the IP that Odysseus uses to connect.
|
||||
|
||||
Use `HOMEY_MCP_AUTH_MODE=none` only on a trusted, firewall-restricted LAN.
|
||||
This avoids an OAuth discovery flow, which homey-mcp does not implement. If
|
||||
Odysseus supports a manually configured static Authorization header, you may
|
||||
instead set `HOMEY_MCP_AUTH_MODE=bearer` and configure the value of
|
||||
`HOMEY_MCP_AUTH_TOKEN` as its bearer token.
|
||||
|
||||
For Odysseus OpenAPI/`app_api` integration, import
|
||||
`http://<YOUR_UBUNTU_IP>:8080/openapi.json` and configure
|
||||
`HOMEY_MCP_AUTH_TOKEN` as a bearer token. REST routes remain protected even
|
||||
when MCP authentication is disabled.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Virtual environment issues
|
||||
```bash
|
||||
rm -rf .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :8080
|
||||
|
||||
# Use a different port
|
||||
HOMEY_MCP_PORT=8081 ./run-lan.sh
|
||||
```
|
||||
|
||||
### Permission denied
|
||||
```bash
|
||||
chmod +x run.sh run-lan.sh setup-ubuntu.sh
|
||||
```
|
||||
|
||||
### Check logs
|
||||
```bash
|
||||
sudo journalctl -u homey-mcp@$USER.service -f --since "1 hour ago"
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=Homey MCP Server (LAN mode for Odysseus)
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%i
|
||||
WorkingDirectory=%h/homey-mcp
|
||||
ExecStart=%h/homey-mcp/run-lan.sh
|
||||
Environment=HOMEY_MCP_TRANSPORT=streamable-http
|
||||
Environment=HOMEY_MCP_HOST=0.0.0.0
|
||||
Environment=HOMEY_MCP_PORT=8080
|
||||
EnvironmentFile=%h/homey-mcp/.env
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=%h/homey-mcp
|
||||
ProtectHome=read-only
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"homey": {
|
||||
"url": "http://<HOMEY_IP>:8080/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <HOMEY_MCP_AUTH_TOKEN>"
|
||||
},
|
||||
"transport": "streamable-http"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "homey-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP server exposing a local Homey Pro instance to LLM clients."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "homey-mcp" }]
|
||||
keywords = ["mcp", "homey", "llm", "smart-home"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.2",
|
||||
"httpx>=0.27",
|
||||
"python-socketio[asyncio_client]>=5.11",
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.3",
|
||||
"python-dotenv>=1.0",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"starlette>=0.37",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
homey-mcp = "homey_mcp.server:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://api.developer.homey.app/"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/homey_mcp"]
|
||||
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
rem Runs homey-mcp as a LAN-reachable HTTP service (streamable-http transport)
|
||||
rem for remote MCP clients (e.g. the Odysseus AI helper), instead of the
|
||||
rem stdio transport Javis uses locally.
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Creating virtual environment...
|
||||
python -m venv .venv
|
||||
call .venv\Scripts\activate.bat
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .
|
||||
) else (
|
||||
call .venv\Scripts\activate.bat
|
||||
)
|
||||
|
||||
set HOMEY_MCP_TRANSPORT=streamable-http
|
||||
if "%HOMEY_MCP_HOST%"=="" set HOMEY_MCP_HOST=0.0.0.0
|
||||
if "%HOMEY_MCP_PORT%"=="" set HOMEY_MCP_PORT=8080
|
||||
|
||||
python -m homey_mcp %*
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run homey-mcp as a LAN-reachable HTTP MCP server for remote clients (e.g., Odysseus).
|
||||
# Usage: ./run-lan.sh
|
||||
#
|
||||
# Environment variables (with defaults):
|
||||
# HOMEY_MCP_TRANSPORT - streamable-http (default) or sse
|
||||
# HOMEY_MCP_HOST - 0.0.0.0 (default)
|
||||
# HOMEY_MCP_PORT - 8080 (default)
|
||||
# HOMEY_MCP_AUTH_MODE - none (compatible default) or bearer
|
||||
# HOMEY_MCP_AUTH_TOKEN - protects REST; also MCP when auth mode is bearer
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
|
||||
# Upgrade pip and install dependencies
|
||||
python -m pip install --upgrade pip >/dev/null 2>&1
|
||||
pip install -e . >/dev/null 2>&1
|
||||
|
||||
# Set transport to streamable-http (recommended) or sse
|
||||
export HOMEY_MCP_TRANSPORT="${HOMEY_MCP_TRANSPORT:-streamable-http}"
|
||||
export HOMEY_MCP_HOST="${HOMEY_MCP_HOST:-0.0.0.0}"
|
||||
export HOMEY_MCP_PORT="${HOMEY_MCP_PORT:-8080}"
|
||||
|
||||
echo "Starting homey-mcp (LAN mode) on http://${HOMEY_MCP_HOST}:${HOMEY_MCP_PORT}"
|
||||
echo "Transport: ${HOMEY_MCP_TRANSPORT}"
|
||||
|
||||
exec python -m homey_mcp "$@"
|
||||
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Creating virtual environment...
|
||||
python -m venv .venv
|
||||
call .venv\Scripts\activate.bat
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .
|
||||
) else (
|
||||
call .venv\Scripts\activate.bat
|
||||
)
|
||||
|
||||
python -m homey_mcp %*
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run homey-mcp as a local MCP server via stdio transport.
|
||||
# Usage: ./run.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
|
||||
# Upgrade pip and install dependencies
|
||||
python -m pip install --upgrade pip >/dev/null 2>&1
|
||||
pip install -e . >/dev/null 2>&1
|
||||
|
||||
# Run with stdio transport (default for local MCP clients)
|
||||
export HOMEY_MCP_TRANSPORT="${HOMEY_MCP_TRANSPORT:-stdio}"
|
||||
|
||||
exec python -m homey_mcp "$@"
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup script for Ubuntu environment.
|
||||
# Installs system dependencies, sets up the Python venv, and prepares .env.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Homey MCP Server Ubuntu Setup ==="
|
||||
|
||||
# 1. Check Python version
|
||||
PYTHON_CMD=""
|
||||
if command -v python3 &>/dev/null; then
|
||||
PYTHON_CMD="python3"
|
||||
elif command -v python &>/dev/null; then
|
||||
PYTHON_CMD="python"
|
||||
else
|
||||
echo "ERROR: Python not found. Install Python 3.10+ first."
|
||||
echo " Ubuntu: sudo apt install python3.10 python3.venv"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PY_VER=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
echo "Found Python $PY_VER"
|
||||
|
||||
# 2. Install system dependencies if needed
|
||||
if ! command -v uvicorn &>/dev/null 2>&1; then
|
||||
echo "Installing system dependencies..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y python3-pip python3-dev python3-venv build-essential libffi-dev libssl-dev
|
||||
fi
|
||||
|
||||
# 3. Create virtual environment if it doesn't exist
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
$PYTHON_CMD -m venv .venv
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
|
||||
# 4. Install/update Python dependencies
|
||||
echo "Installing Python dependencies..."
|
||||
$PYTHON_CMD -m pip install --upgrade pip >/dev/null 2>&1
|
||||
pip install -e . >/dev/null 2>&1
|
||||
pip install uvicorn >/dev/null 2>&1
|
||||
|
||||
# 5. Create .env file from example if it doesn't exist
|
||||
if [ ! -f ".env" ]; then
|
||||
if [ -f ".env.example" ]; then
|
||||
echo "Creating .env from .env.example (please edit with your credentials)..."
|
||||
cp .env.example .env
|
||||
else
|
||||
echo "Creating minimal .env file..."
|
||||
cat > .env << 'EOF'
|
||||
# Homey Pro API configuration
|
||||
HOMEY_HOST=your-homey-pro-ip
|
||||
HOMEY_TOKEN=your_homey_api_token_here
|
||||
HOMEY_SCHEME=http
|
||||
HOMEY_PORT=4859
|
||||
|
||||
# MCP server configuration (LAN mode)
|
||||
HOMEY_MCP_TRANSPORT=streamable-http
|
||||
HOMEY_MCP_HOST=0.0.0.0
|
||||
HOMEY_MCP_ADVERTISED_HOST=<your-server-ip>
|
||||
HOMEY_MCP_PORT=8080
|
||||
HOMEY_MCP_AUTH_MODE=none
|
||||
HOMEY_MCP_AUTH_TOKEN=your_mcp_auth_token_here
|
||||
|
||||
# Socket.IO realtime configuration (optional)
|
||||
HOMEY_REALTIME=true
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete! ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Edit .env with your Homey Pro credentials (HOMEY_TOKEN and HOMEY_HOST)"
|
||||
echo "2. For LAN mode (recommended for Odysseus): ./run-lan.sh"
|
||||
echo "3. For local stdio mode: ./run.sh"
|
||||
echo ""
|
||||
echo "For persistent operation, install the systemd service:"
|
||||
echo " sudo cp homey-mcp.service /etc/systemd/system/"
|
||||
echo " sudo systemctl daemon-reload"
|
||||
echo " sudo systemctl enable --now homey-mcp@$(whoami).service"
|
||||
echo ""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""homey-mcp: MCP server for a local Homey Pro instance."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Configuration loaded from environment variables / .env."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Settings for the homey-mcp server.
|
||||
|
||||
Values are read from environment variables (prefix ``HOMEY_``) or a
|
||||
local ``.env`` file.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="HOMEY_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
host: str = Field(..., description="IP or hostname of the Homey Pro.")
|
||||
token: str = Field(..., description="Personal Access Token (Bearer).")
|
||||
scheme: str = Field("http", description="http or https.")
|
||||
port: int | None = Field(
|
||||
None,
|
||||
description="Optional TCP port (e.g. 4859 when Homey runs in a container).",
|
||||
)
|
||||
timeout: float = Field(10.0, description="HTTP timeout in seconds.")
|
||||
event_buffer: int = Field(200, description="Max realtime events to retain.")
|
||||
realtime: bool = Field(True, description="Enable Socket.IO realtime events.")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
if self.port is None:
|
||||
return f"{self.scheme}://{self.host}"
|
||||
return f"{self.scheme}://{self.host}:{self.port}"
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Async client for the Homey Pro local Web API + Socket.IO realtime events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
import socketio # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
socketio = None # type: ignore
|
||||
|
||||
from .config import Settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeyClient:
|
||||
"""Thin async wrapper around Homey's local HTTP + Socket.IO APIs."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self._http = httpx.AsyncClient(
|
||||
base_url=settings.base_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
self._events: deque[dict[str, Any]] = deque(maxlen=settings.event_buffer)
|
||||
self._sio: Any | None = None
|
||||
self._sio_connected: bool = False
|
||||
|
||||
# ---- lifecycle --------------------------------------------------------
|
||||
|
||||
async def aclose(self) -> None:
|
||||
try:
|
||||
if self._sio is not None and self._sio_connected:
|
||||
await self._sio.disconnect()
|
||||
except Exception: # pragma: no cover
|
||||
log.exception("Error disconnecting Socket.IO")
|
||||
await self._http.aclose()
|
||||
|
||||
# ---- helpers ----------------------------------------------------------
|
||||
|
||||
async def _get(self, path: str) -> Any:
|
||||
r = await self._http.get(path)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def _post(self, path: str, json: Any | None = None) -> Any:
|
||||
r = await self._http.post(path, json=json)
|
||||
r.raise_for_status()
|
||||
if r.content:
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _put(self, path: str, json: Any | None = None) -> Any:
|
||||
r = await self._http.put(path, json=json)
|
||||
r.raise_for_status()
|
||||
if r.content:
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ---- REST: devices / zones / flows -----------------------------------
|
||||
|
||||
async def get_system_info(self) -> dict[str, Any]:
|
||||
return await self._get("/api/manager/system/info")
|
||||
|
||||
async def list_devices(self) -> dict[str, Any]:
|
||||
return await self._get("/api/manager/devices/device")
|
||||
|
||||
async def get_device(self, device_id: str) -> dict[str, Any]:
|
||||
return await self._get(f"/api/manager/devices/device/{device_id}")
|
||||
|
||||
async def list_zones(self) -> dict[str, Any]:
|
||||
return await self._get("/api/manager/zones/zone")
|
||||
|
||||
async def set_capability(
|
||||
self, device_id: str, capability_id: str, value: Any
|
||||
) -> Any:
|
||||
return await self._put(
|
||||
f"/api/manager/devices/device/{device_id}/capability/{capability_id}",
|
||||
json={"value": value},
|
||||
)
|
||||
|
||||
async def list_flows(self) -> dict[str, Any]:
|
||||
return await self._get("/api/manager/flow/flow")
|
||||
|
||||
async def trigger_flow(self, flow_id: str) -> Any:
|
||||
return await self._post(f"/api/manager/flow/flow/{flow_id}/trigger")
|
||||
|
||||
# ---- REST: logic variables -------------------------------------------
|
||||
|
||||
async def list_variables(self) -> dict[str, Any]:
|
||||
"""Return all Homey Logic Variables (type, current value, etc.)."""
|
||||
return await self._get("/api/manager/logic/variable")
|
||||
|
||||
async def set_variable(self, variable_id: str, value: Any) -> Any:
|
||||
"""Set a Homey Logic Variable to *value*."""
|
||||
return await self._put(
|
||||
f"/api/manager/logic/variable/{variable_id}",
|
||||
json={"value": value},
|
||||
)
|
||||
|
||||
# ---- REST: notifications ---------------------------------------------
|
||||
|
||||
async def send_notification(self, title: str, excerpt: str = "") -> Any:
|
||||
"""Push a notification to the Homey app on all registered phones."""
|
||||
return await self._post(
|
||||
"/api/manager/notifications/notification",
|
||||
json={
|
||||
"ownerUri": "homey:manager:notifications",
|
||||
"title": title,
|
||||
"excerpt": excerpt,
|
||||
},
|
||||
)
|
||||
|
||||
# ---- realtime events --------------------------------------------------
|
||||
|
||||
def recent_events(
|
||||
self, limit: int = 50, kind: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
items = list(self._events)
|
||||
if kind:
|
||||
items = [e for e in items if e.get("kind") == kind]
|
||||
return items[-limit:]
|
||||
|
||||
async def start_realtime(self) -> None:
|
||||
"""Best-effort Socket.IO connection. Failures are logged, not raised."""
|
||||
if not self.settings.realtime:
|
||||
return
|
||||
if socketio is None:
|
||||
log.warning("python-socketio not installed; realtime disabled")
|
||||
return
|
||||
if self._sio_connected:
|
||||
return
|
||||
|
||||
sio = socketio.AsyncClient(reconnection=True, logger=False, engineio_logger=False)
|
||||
self._sio = sio
|
||||
|
||||
def _record(kind: str) -> Any:
|
||||
async def _handler(data: Any = None) -> None:
|
||||
self._events.append(
|
||||
{"ts": time.time(), "kind": kind, "data": data}
|
||||
)
|
||||
|
||||
return _handler
|
||||
|
||||
for kind in (
|
||||
"device.create",
|
||||
"device.update",
|
||||
"device.delete",
|
||||
"capability",
|
||||
):
|
||||
sio.on(kind, _record(kind))
|
||||
|
||||
try:
|
||||
await sio.connect(
|
||||
self.settings.base_url,
|
||||
auth={"token": self.settings.token},
|
||||
transports=["websocket"],
|
||||
wait_timeout=self.settings.timeout,
|
||||
)
|
||||
self._sio_connected = True
|
||||
log.info("Socket.IO connected to %s", self.settings.base_url)
|
||||
except Exception as exc: # pragma: no cover
|
||||
log.warning("Socket.IO connection failed: %s", exc)
|
||||
self._sio_connected = False
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user