Initial commit: Javis project

This commit is contained in:
Javis committed 2026-09-21 21:44:00 +02:00
commit 435c645f36
164 files changed
+35279

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
# Javis Slave MCP
MCP (Model Context Protocol) server for controlling local Javis Slave devices.
Slaves can run on Windows tablets or Linux boards (Raspberry Pi, see
[javis-slave/RASPBERRY_PI.md](../javis-slave/RASPBERRY_PI.md)); the control
endpoints are identical on both.
## Features
- **Status Monitoring**: Check device status, audio state, mic level, muted state
- **Device Info**: Get device configuration (sample rate, audio devices, wake word model)
- **Network Discovery**: Query the master for the IP of any slave, or list all connected slaves
- **Microphone Control**: Mute/unmute/toggle microphone on slave devices
- **Audio Engine Recovery**: Restart audio engine (stop + start) when needed
- **Volume Control**: Get/set/increase/decrease master volume, mute/unmute output
- **Brightness Control**: Get/set/increase/decrease display brightness
- **Power Control**: Sleep / shutdown / restart / cancel shutdown
## Configuration
### Environment Variables
```bash
# Comma-separated list of device_id=host:port
export JAVIS_SLAVE_DEVICES="device1=192.168.1.100:8766,device2=192.168.1.101:8766"
```
### Config File
Create `~/.javis/slave_mcp_config.json`:
```json
{
"slaves": {
"bedroom": {
"host": "192.168.1.100",
"port": 8766
},
"kitchen": {
"host": "192.168.1.101",
"port": 8766
}
}
}
```
### Default
If no configuration is provided, the server defaults to `localhost:8766` (for local development).
## Installation
```bash
pip install -e .
```
## Usage
Run the server:
```bash
python -m javis_slave_mcp
```
Or with a custom config:
```bash
export JAVIS_SLAVE_DEVICES="my_device=192.168.1.100:8766"
python -m javis_slave_mcp
```
## MCP Tools
All tools take `device_id` (default `"localhost"`) unless noted otherwise.
**Status & discovery**
- `slave_list_devices()` — List all configured slave devices
- `slave_get_status(device_id)` — Get current device status (incl. `muted`)
- `slave_get_info(device_id)` — Get device information
- `slave_get_ip_address(device_id)` — IP of the slave as last reported by the master
- `slave_get_connected_devices(device_id)` — All slaves known to the master, with IPs
**Microphone**
- `slave_mute_mic(device_id)` — Mute the microphone
- `slave_unmute_mic(device_id)` — Unmute the microphone
- `slave_toggle_mic(device_id)` — Toggle microphone state
- `slave_restart_audio(device_id)` — Restart the audio engine (stop + start)
**Volume & output**
- `slave_get_volume(device_id)` — Current master volume (0.0–1.0)
- `slave_set_volume(device_id, level)` — Set master volume
- `slave_increase_volume(device_id, amount=0.05)` — Increase volume
- `slave_decrease_volume(device_id, amount=0.05)` — Decrease volume
- `slave_mute_output(device_id)` — Mute output
- `slave_unmute_output(device_id)` — Unmute output
**Brightness**
- `slave_get_brightness(device_id)` — Current display brightness (0–100)
- `slave_set_brightness(device_id, level)` — Set brightness
- `slave_increase_brightness(device_id, amount=10)` — Increase brightness
- `slave_decrease_brightness(device_id, amount=10)` — Decrease brightness
**Power**
- `slave_sleep(device_id, delay=0)` — Suspend (delay in seconds, 0–3600)
- `slave_shutdown(device_id, delay=0)` — Power off
- `slave_restart_system(device_id, delay=0)` — Reboot the OS
- `slave_cancel_shutdown(device_id)` — Cancel a pending shutdown/restart
## Master-Derived Tools
`slave_get_ip_address` and `slave_get_connected_devices` route through the
slave's **authenticated pass-through** endpoints
(`GET /mcp/v1/master/slaves`, `GET /mcp/v1/master/slaves/{id}/ip`). The slave
calls the master with its own `X-Device-Id` / `Bearer` credentials, so this
works on LAN deployments where the MCP host and the slave host differ.
## Integration with Javis
Add to `mcp_servers.json`:
```json
{
"servers": {
"javis-slave": {
"command": "python",
"args": ["-m", "javis_slave_mcp"],
"env": {
"JAVIS_SLAVE_DEVICES": "device1=192.168.1.100:8766"
},
"transport": "stdio",
"approval": "allow"
}
}
}
```
Now Javis can use tools like `javis_slave__slave_mute_mic` to control slave devices.
## Implementation Details
The slave app runs an HTTP server on port 8766 alongside the GUI and audio engine. This MCP server connects to slave instances via their HTTP APIs and exposes control tools.
See:
- [Slave HTTP Server](../javis-slave/src/javis_slave/http_server.py) — Endpoints and models
- [Slave App Integration](../javis-slave/src/javis_slave/app.py) — HTTP server lifecycle
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "javis-slave-mcp"
version = "0.1.0"
description = "MCP server for controlling Javis Slave devices — mute/unmute microphone, check status, restart audio engine."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
dependencies = [
"mcp>=0.6.0,<2.0",
"httpx>=0.27",
"pydantic>=2.0",
]
[project.scripts]
javis-slave-mcp = "javis_slave_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 110
target-version = "py310"
+15
View File
@@ -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 javis_slave_mcp %*
@@ -0,0 +1,2 @@
"""javis-slave-mcp: Expose local javis-slave instances as MCP tools for remote control."""
__version__ = "0.1.0"
@@ -0,0 +1,21 @@
"""Entry point for javis-slave-mcp."""
from __future__ import annotations
import sys
from .server import main
def __main__() -> int:
try:
main()
return 0
except KeyboardInterrupt:
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(__main__())
@@ -0,0 +1,945 @@
"""MCP server for controlling local Javis Slave devices."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Optional
import httpx
from mcp.server.fastmcp import FastMCP, Context
from pydantic import BaseModel, Field
log = logging.getLogger("javis_slave_mcp")
# ---- Models ----
class SlaveStatus(BaseModel):
"""Current status of a slave device."""
device_id: str
device_name: str
room: Optional[str] = None
paired: bool
engine_running: bool
audio_state: str
muted: bool = False
mic_level: float = 0.0
wake_word_score: float = 0.0
last_error: Optional[str] = None
class DeviceInfo(BaseModel):
"""Static device information."""
device_id: str
device_name: str
sample_rate: int
input_device: Optional[int] = None
output_device: Optional[int] = None
wake_word_model: str
class ControlResult(BaseModel):
"""Result of a control operation."""
success: bool
message: str = ""
device_id: str = ""
class SlaveDeviceInfo(BaseModel):
"""Information about a connected slave device."""
device_id: str
name: str
room: Optional[str] = None
ip_address: Optional[str] = None
last_seen: Optional[str] = None
last_seen_ip: Optional[str] = None
created_at: Optional[str] = None
# ---- Client ----
@dataclass
class SlaveClient:
"""HTTP client for a Javis Slave device."""
device_id: str
host: str
port: int = 8766
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
async def get_status(self) -> SlaveStatus:
"""Fetch current device status."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url()}/mcp/v1/status")
r.raise_for_status()
return SlaveStatus(**r.json())
async def get_device_info(self) -> DeviceInfo:
"""Fetch device information."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url()}/mcp/v1/device-info")
r.raise_for_status()
return DeviceInfo(**r.json())
async def mute_mic(self) -> ControlResult:
"""Mute the microphone."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/mic/mute")
r.raise_for_status()
result = r.json()
result["device_id"] = self.device_id
return ControlResult(**result)
async def unmute_mic(self) -> ControlResult:
"""Unmute the microphone."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/mic/unmute")
r.raise_for_status()
result = r.json()
result["device_id"] = self.device_id
return ControlResult(**result)
async def toggle_mic(self) -> ControlResult:
"""Toggle microphone on/off."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/mic/toggle")
r.raise_for_status()
result = r.json()
result["device_id"] = self.device_id
return ControlResult(**result)
async def restart_audio(self) -> ControlResult:
"""Restart the audio engine."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/restart")
r.raise_for_status()
result = r.json()
result["device_id"] = self.device_id
return ControlResult(**result)
# ---- Volume Control Methods ----
async def get_volume(self) -> dict:
"""Get current master volume (0.0-1.0)."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url()}/mcp/v1/volume")
r.raise_for_status()
return r.json()
async def set_volume(self, level: float) -> dict:
"""Set master volume (0.0-1.0)."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/volume/set", params={"level": level})
r.raise_for_status()
return r.json()
async def increase_volume(self, amount: float = 0.05) -> dict:
"""Increase volume by amount."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/volume/increase", params={"amount": amount})
r.raise_for_status()
return r.json()
async def decrease_volume(self, amount: float = 0.05) -> dict:
"""Decrease volume by amount."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/volume/decrease", params={"amount": amount})
r.raise_for_status()
return r.json()
async def mute_output(self) -> dict:
"""Mute master audio output."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/volume/mute")
r.raise_for_status()
return r.json()
async def unmute_output(self) -> dict:
"""Unmute master audio output."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/volume/unmute")
r.raise_for_status()
return r.json()
# ---- Brightness Control Methods ----
async def get_brightness(self) -> dict:
"""Get current display brightness (0-100)."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url()}/mcp/v1/brightness")
r.raise_for_status()
return r.json()
async def set_brightness(self, level: int) -> dict:
"""Set display brightness (0-100)."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/brightness/set", params={"level": level})
r.raise_for_status()
return r.json()
async def increase_brightness(self, amount: int = 5) -> dict:
"""Increase brightness by amount."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/brightness/increase", params={"amount": amount})
r.raise_for_status()
return r.json()
async def decrease_brightness(self, amount: int = 5) -> dict:
"""Decrease brightness by amount."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/brightness/decrease", params={"amount": amount})
r.raise_for_status()
return r.json()
# ---- Power Control Methods ----
async def sleep(self) -> dict:
"""Put system to sleep."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/power/sleep")
r.raise_for_status()
return r.json()
async def shutdown(self, delay: int = 0) -> dict:
"""Shutdown system (delay in seconds)."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/power/shutdown", params={"delay": delay})
r.raise_for_status()
return r.json()
async def restart(self, delay: int = 0) -> dict:
"""Restart system (delay in seconds)."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/power/restart", params={"delay": delay})
r.raise_for_status()
return r.json()
async def cancel_shutdown(self) -> dict:
"""Cancel pending shutdown/restart."""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(f"{self.base_url()}/mcp/v1/power/cancel")
r.raise_for_status()
return r.json()
async def get_connected_slaves(self) -> list[SlaveDeviceInfo]:
"""Fetch list of all connected slaves from the master.
Uses the slave's authenticated pass-through endpoint
(``GET /mcp/v1/master/slaves`` on the slave's own port). The slave
calls the master with its own ``X-Device-Id`` / Bearer credentials,
so this works whether or not the MCP host and the slave host match.
"""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url()}/mcp/v1/master/slaves")
r.raise_for_status()
return [SlaveDeviceInfo(**item) for item in r.json()]
async def get_slave_ip(self, target_device_id: str) -> dict:
"""Fetch IP info for a specific slave from the master via the pass-through.
The slave's own credentials authenticate the request to the master, so
this works whether or not the MCP host and the slave host match.
Args:
target_device_id: The device_id of the slave whose IP to look up
(may be this same slave or a different one).
Returns:
The ``MasterSlaveIp`` dict (``device_id``, ``ip_address``,
``last_seen_ip``, ``last_seen``) as reported by the master.
"""
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{self.base_url()}/mcp/v1/master/slaves/{target_device_id}/ip")
r.raise_for_status()
return r.json()
# ---- MCP Server ----
def load_slave_config() -> dict[str, SlaveClient]:
"""Load configured slave devices from environment or config file.
For now, supports:
- Environment variables: JAVIS_SLAVE_DEVICES={device_id}={host}:{port},...
- Hardcoded localhost:8766 as default fallback
"""
import os
import json
slaves: dict[str, SlaveClient] = {}
# Try env var
env_slaves = os.environ.get("JAVIS_SLAVE_DEVICES", "")
if env_slaves:
try:
for pair in env_slaves.split(","):
device_id, hostport = pair.split("=")
host, port_str = hostport.rsplit(":", 1)
slaves[device_id] = SlaveClient(
device_id=device_id.strip(),
host=host.strip(),
port=int(port_str),
)
except ValueError as e:
log.warning(f"Failed to parse JAVIS_SLAVE_DEVICES: {e}")
# Try config file
config_file = os.environ.get(
"JAVIS_SLAVE_MCP_CONFIG",
os.path.expanduser("~/.javis/slave_mcp_config.json"),
)
if os.path.exists(config_file):
try:
with open(config_file) as f:
config = json.load(f)
for device_id, device_cfg in config.get("slaves", {}).items():
slaves[device_id] = SlaveClient(
device_id=device_id,
host=device_cfg.get("host", "127.0.0.1"),
port=device_cfg.get("port", 8766),
)
except Exception as e:
log.warning(f"Failed to load slave config from {config_file}: {e}")
# Default fallback: localhost
if not slaves:
log.info("No slaves configured; using localhost:8766 as default")
slaves["localhost"] = SlaveClient(
device_id="localhost",
host="127.0.0.1",
port=8766,
)
return slaves
# Global slave registry
_slaves: dict[str, SlaveClient] = {}
@dataclass
class AppContext:
slaves: dict[str, SlaveClient]
mcp = FastMCP(
name="javis-slave",
instructions=(
"Use these tools to query and control local Javis Slave devices. "
"Supported operations: status, device info, IP lookup, "
"mute/unmute/toggle microphone, restart audio engine, "
"volume get/set/increase/decrease, output mute/unmute, "
"brightness get/set/increase/decrease, "
"power sleep/shutdown/restart, and cancel shutdown. "
"Master-derived tools (slave_get_ip_address, "
"slave_get_connected_devices) route through the slave's own "
"credentials, so they work on LAN deployments where the MCP "
"host and the slave host differ."
),
)
# ---- Tools ----
@mcp.tool(
description="Get the current status of a Javis Slave device, including audio state and mic level."
)
def slave_get_status(device_id: str = "localhost") -> str:
"""Get slave device status.
Args:
device_id: The slave device ID (default: localhost).
Returns:
JSON status object with device info and current audio state.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
status = asyncio.run(_slaves[device_id].get_status())
return status.model_dump_json(indent=2)
except Exception as e:
log.error(f"Failed to get status for {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Get device information for a Javis Slave (static config like device name, sample rate, audio devices)."
)
def slave_get_info(device_id: str = "localhost") -> str:
"""Get slave device info.
Args:
device_id: The slave device ID (default: localhost).
Returns:
JSON device info object.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
info = asyncio.run(_slaves[device_id].get_device_info())
return info.model_dump_json(indent=2)
except Exception as e:
log.error(f"Failed to get info for {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Get the IP address of a configured Javis Slave device, as last reported by the master."
)
def slave_get_ip_address(device_id: str = "localhost") -> str:
"""Get slave device IP address from master records.
Routes through the slave's authenticated pass-through
(``GET /mcp/v1/master/slaves/{id}/ip``), so the slave presents its own
credentials to the master. This works even when the MCP host and the
slave host differ.
Args:
device_id: The slave device ID to look up (default: localhost).
Returns:
IP address string, or error message if not found.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
data = asyncio.run(_slaves[device_id].get_slave_ip(device_id))
return data.get("ip_address") or data.get("last_seen_ip") or "Unknown"
except Exception as e:
log.error(f"Failed to get IP for {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Mute the microphone on a Javis Slave device."
)
def slave_mute_mic(device_id: str = "localhost") -> str:
"""Mute slave microphone.
Args:
device_id: The slave device ID (default: localhost).
Returns:
JSON control result.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
result = asyncio.run(_slaves[device_id].mute_mic())
return result.model_dump_json(indent=2)
except Exception as e:
log.error(f"Failed to mute mic on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Unmute the microphone on a Javis Slave device."
)
def slave_unmute_mic(device_id: str = "localhost") -> str:
"""Unmute slave microphone.
Args:
device_id: The slave device ID (default: localhost).
Returns:
JSON control result.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
result = asyncio.run(_slaves[device_id].unmute_mic())
return result.model_dump_json(indent=2)
except Exception as e:
log.error(f"Failed to unmute mic on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Toggle the microphone on/off on a Javis Slave device."
)
def slave_toggle_mic(device_id: str = "localhost") -> str:
"""Toggle slave microphone.
Args:
device_id: The slave device ID (default: localhost).
Returns:
JSON control result with new mic state.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
result = asyncio.run(_slaves[device_id].toggle_mic())
return result.model_dump_json(indent=2)
except Exception as e:
log.error(f"Failed to toggle mic on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Restart the audio engine on a Javis Slave device (useful for recovery from errors)."
)
def slave_restart_audio(device_id: str = "localhost") -> str:
"""Restart slave audio engine.
Args:
device_id: The slave device ID (default: localhost).
Returns:
JSON control result.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
result = asyncio.run(_slaves[device_id].restart_audio())
return result.model_dump_json(indent=2)
except Exception as e:
log.error(f"Failed to restart audio on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="List all configured Javis Slave devices."
)
def slave_list_devices() -> str:
"""List all configured slave devices.
Returns:
JSON array of device IDs.
"""
devices = list(_slaves.keys())
return f"Configured slave devices: {devices}"
@mcp.tool(
description="Get list of all connected slaves with their IP addresses from the master."
)
def slave_get_connected_devices(device_id: str = "localhost") -> str:
"""Get list of connected slaves with IPs from master.
Routes through the configured slave's authenticated pass-through
(``GET /mcp/v1/master/slaves``). The slave presents its own credentials
to the master, so this works on LAN deployments where the MCP host and
the slave host differ.
Args:
device_id: Configured slave to route the master query through
(any paired device works; default: localhost).
Returns:
JSON array of slave device info objects with IP addresses.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
slaves_data = asyncio.run(_slaves[device_id].get_connected_slaves())
import json
return json.dumps([s.model_dump() for s in slaves_data], indent=2)
except Exception as e:
log.error(f"Failed to get connected slaves: {e}")
return f"ERROR: {e}"
# ---- Volume Control Tools ----
@mcp.tool(
description="Get the current master volume level of a slave device (0.0-1.0)."
)
def slave_get_volume(device_id: str = "localhost") -> str:
"""Get slave device volume level.
Args:
device_id: The slave device ID (default: localhost).
Returns:
Volume level as JSON response.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].get_volume())
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to get volume from {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Set the master volume level on a slave device (0.0-1.0)."
)
def slave_set_volume(device_id: str = "localhost", level: float = 0.5) -> str:
"""Set slave device volume level.
Args:
device_id: The slave device ID (default: localhost).
level: Volume level 0.0-1.0 (default 0.5).
Returns:
Volume control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].set_volume(level))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to set volume on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Increase the master volume on a slave device."
)
def slave_increase_volume(device_id: str = "localhost", amount: float = 0.05) -> str:
"""Increase slave device volume.
Args:
device_id: The slave device ID (default: localhost).
amount: Amount to increase 0.0-1.0 (default 0.05 = 5%).
Returns:
Volume control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].increase_volume(amount))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to increase volume on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Decrease the master volume on a slave device."
)
def slave_decrease_volume(device_id: str = "localhost", amount: float = 0.05) -> str:
"""Decrease slave device volume.
Args:
device_id: The slave device ID (default: localhost).
amount: Amount to decrease 0.0-1.0 (default 0.05 = 5%).
Returns:
Volume control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].decrease_volume(amount))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to decrease volume on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Mute the master audio output on a slave device."
)
def slave_mute_output(device_id: str = "localhost") -> str:
"""Mute slave device audio output.
Args:
device_id: The slave device ID (default: localhost).
Returns:
Volume control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].mute_output())
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to mute output on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Unmute the master audio output on a slave device."
)
def slave_unmute_output(device_id: str = "localhost") -> str:
"""Unmute slave device audio output.
Args:
device_id: The slave device ID (default: localhost).
Returns:
Volume control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].unmute_output())
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to unmute output on {device_id}: {e}")
return f"ERROR: {e}"
# ---- Brightness Control Tools ----
@mcp.tool(
description="Get the current display brightness on a slave device (0-100)."
)
def slave_get_brightness(device_id: str = "localhost") -> str:
"""Get slave device brightness level.
Args:
device_id: The slave device ID (default: localhost).
Returns:
Brightness level as JSON response.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].get_brightness())
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to get brightness from {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Set the display brightness on a slave device (0-100)."
)
def slave_set_brightness(device_id: str = "localhost", level: int = 50) -> str:
"""Set slave device brightness level.
Args:
device_id: The slave device ID (default: localhost).
level: Brightness level 0-100 (default 50).
Returns:
Brightness control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].set_brightness(level))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to set brightness on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Increase the display brightness on a slave device."
)
def slave_increase_brightness(device_id: str = "localhost", amount: int = 5) -> str:
"""Increase slave device brightness.
Args:
device_id: The slave device ID (default: localhost).
amount: Amount to increase in % points (default 5).
Returns:
Brightness control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].increase_brightness(amount))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to increase brightness on {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Decrease the display brightness on a slave device."
)
def slave_decrease_brightness(device_id: str = "localhost", amount: int = 5) -> str:
"""Decrease slave device brightness.
Args:
device_id: The slave device ID (default: localhost).
amount: Amount to decrease in % points (default 5).
Returns:
Brightness control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].decrease_brightness(amount))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to decrease brightness on {device_id}: {e}")
return f"ERROR: {e}"
# ---- Power Control Tools ----
@mcp.tool(
description="Put a slave device to sleep."
)
def slave_sleep(device_id: str = "localhost") -> str:
"""Put slave device to sleep.
Args:
device_id: The slave device ID (default: localhost).
Returns:
Power control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].sleep())
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to sleep {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Shutdown a slave device."
)
def slave_shutdown(device_id: str = "localhost", delay: int = 0) -> str:
"""Shutdown slave device.
Args:
device_id: The slave device ID (default: localhost).
delay: Delay in seconds before shutdown (default 0 = immediate).
Returns:
Power control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].shutdown(delay))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to shutdown {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Restart a slave device."
)
def slave_restart_system(device_id: str = "localhost", delay: int = 0) -> str:
"""Restart slave device.
Args:
device_id: The slave device ID (default: localhost).
delay: Delay in seconds before restart (default 0 = immediate).
Returns:
Power control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].restart(delay))
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to restart {device_id}: {e}")
return f"ERROR: {e}"
@mcp.tool(
description="Cancel a pending shutdown or restart on a slave device."
)
def slave_cancel_shutdown(device_id: str = "localhost") -> str:
"""Cancel pending shutdown/restart on slave device.
Args:
device_id: The slave device ID (default: localhost).
Returns:
Power control result as JSON.
"""
if device_id not in _slaves:
return f"ERROR: device '{device_id}' not found"
try:
import asyncio
import json
result = asyncio.run(_slaves[device_id].cancel_shutdown())
return json.dumps(result, indent=2)
except Exception as e:
log.error(f"Failed to cancel shutdown on {device_id}: {e}")
return f"ERROR: {e}"
# ---- Entry Point ----
def main() -> None:
global _slaves
logging.basicConfig(level=logging.INFO)
_slaves = load_slave_config()
if _slaves:
log.info(f"Loaded {len(_slaves)} slave device(s): {list(_slaves.keys())}")
mcp.run(transport="stdio")
if __name__ == "__main__":
main()