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

@@ -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()