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