Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user