feat: add tv_current_app, tv_aspect_ratio, tv_captions tools
- current_app: detects running app by iterating known aliases via REST - aspect_ratio: get/set via UPnP X_GetAspectRatio/X_SetAspectRatio - captions: get state via UPnP X_GetCaptionState, toggle via KEY_CAPTION - Updated README to reflect 18 tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
82b99225bc
commit
36a1ca8d1a
|
|
@ -6,7 +6,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that lets AI
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **15 tools** for complete TV control via natural language
|
- **18 tools** for complete TV control via natural language
|
||||||
- **Auto-discovery** — finds Samsung TVs on your network via SSDP
|
- **Auto-discovery** — finds Samsung TVs on your network via SSDP
|
||||||
- **Zero config** — no API keys, no cloud, everything runs locally
|
- **Zero config** — no API keys, no cloud, everything runs locally
|
||||||
- **Multi-protocol** — combines WebSocket, REST, UPnP/DLNA, and Wake-on-LAN
|
- **Multi-protocol** — combines WebSocket, REST, UPnP/DLNA, and Wake-on-LAN
|
||||||
|
|
@ -28,6 +28,9 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that lets AI
|
||||||
| `tv_apps` | List all installed apps |
|
| `tv_apps` | List all installed apps |
|
||||||
| `tv_launch` | Launch app by name ("netflix") or ID |
|
| `tv_launch` | Launch app by name ("netflix") or ID |
|
||||||
| `tv_close_app` | Close a running app |
|
| `tv_close_app` | Close a running app |
|
||||||
|
| `tv_current_app` | Detect which app is currently running |
|
||||||
|
| `tv_aspect_ratio` | Get/set aspect ratio (Default, 16:9, Zoom, 4:3) |
|
||||||
|
| `tv_captions` | Get caption state or toggle subtitles |
|
||||||
| `tv_browser` | Open a URL in the TV's browser |
|
| `tv_browser` | Open a URL in the TV's browser |
|
||||||
| `tv_text` | Type text into active input fields |
|
| `tv_text` | Type text into active input fields |
|
||||||
| `tv_cursor` | Move virtual cursor on screen |
|
| `tv_cursor` | Move virtual cursor on screen |
|
||||||
|
|
|
||||||
58
main.py
58
main.py
|
|
@ -250,6 +250,64 @@ async def tv_close_app(app: str) -> dict[str, Any]:
|
||||||
return await _safe(_do)
|
return await _safe(_do)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Current App ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def tv_current_app() -> dict[str, Any]:
|
||||||
|
"""Detect which app is currently running on the TV.
|
||||||
|
|
||||||
|
Checks all known apps via REST API. Returns the running app's
|
||||||
|
name, ID, and visibility, or a message if no known app is active.
|
||||||
|
"""
|
||||||
|
def _do():
|
||||||
|
app = _tv.current_app()
|
||||||
|
if app:
|
||||||
|
return _ok(f"Running: {app['name']}", **app)
|
||||||
|
return _ok("No known app is currently running")
|
||||||
|
return await _safe(_do, timeout=40)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Aspect Ratio ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def tv_aspect_ratio(ratio: Optional[str] = None) -> dict[str, Any]:
|
||||||
|
"""Get or set the TV's aspect ratio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ratio: Set to this value. Known values: "Default", "16:9", "Zoom",
|
||||||
|
"4:3", "Screen Fit". Omit to just read current ratio.
|
||||||
|
"""
|
||||||
|
def _do():
|
||||||
|
if ratio:
|
||||||
|
_tv.set_aspect_ratio(ratio)
|
||||||
|
return _ok(f"Aspect ratio set to {ratio}", ratio=ratio)
|
||||||
|
current = _tv.get_aspect_ratio()
|
||||||
|
return _ok(f"Aspect ratio: {current}", ratio=current)
|
||||||
|
return await _safe(_do)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Captions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def tv_captions(toggle: bool = False) -> dict[str, Any]:
|
||||||
|
"""Get caption/subtitle state or toggle captions on/off.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toggle: If True, sends the CAPTION key to toggle subtitles.
|
||||||
|
If False (default), just returns current caption state.
|
||||||
|
"""
|
||||||
|
def _do():
|
||||||
|
if toggle:
|
||||||
|
_tv.send_key("KEY_CAPTION")
|
||||||
|
return _ok("Caption toggled")
|
||||||
|
state = _tv.get_captions()
|
||||||
|
return _ok("Caption state", **state)
|
||||||
|
return await _safe(_do)
|
||||||
|
|
||||||
|
|
||||||
# ── Browser & Text ───────────────────────────────────────────────
|
# ── Browser & Text ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
53
tv.py
53
tv.py
|
|
@ -430,6 +430,59 @@ class SamsungTV:
|
||||||
raise ValueError(f"Unknown: '{action}'. Valid: play, pause, stop, seek, status")
|
raise ValueError(f"Unknown: '{action}'. Valid: play, pause, stop, seek, status")
|
||||||
return {"action": a, "done": True}
|
return {"action": a, "done": True}
|
||||||
|
|
||||||
|
# ── App Detection ────────────────────────────────────────
|
||||||
|
|
||||||
|
def current_app(self) -> dict[str, Any] | None:
|
||||||
|
"""Detect which known app is currently running via REST."""
|
||||||
|
ip = self._ensure_ip()
|
||||||
|
for name, ids in APP_ALIASES.items():
|
||||||
|
try:
|
||||||
|
raw = urlopen(
|
||||||
|
f"http://{ip}:8001/api/v2/applications/{ids[0]}",
|
||||||
|
timeout=2,
|
||||||
|
).read()
|
||||||
|
data = json.loads(raw)
|
||||||
|
if data.get("running"):
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"id": ids[0],
|
||||||
|
"visible": data.get("visible", False),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Aspect Ratio ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_aspect_ratio(self) -> str:
|
||||||
|
ip = self._ensure_ip()
|
||||||
|
xml = _soap_call(
|
||||||
|
ip, "/upnp/control/RenderingControl1", "RenderingControl",
|
||||||
|
"X_GetAspectRatio", "<InstanceID>0</InstanceID>",
|
||||||
|
)
|
||||||
|
return _soap_value(xml, "AspectRatio") or "Unknown"
|
||||||
|
|
||||||
|
def set_aspect_ratio(self, ratio: str) -> None:
|
||||||
|
ip = self._ensure_ip()
|
||||||
|
_soap_call(
|
||||||
|
ip, "/upnp/control/RenderingControl1", "RenderingControl",
|
||||||
|
"X_SetAspectRatio",
|
||||||
|
f"<InstanceID>0</InstanceID><AspectRatio>{ratio}</AspectRatio>",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Captions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_captions(self) -> dict[str, str]:
|
||||||
|
ip = self._ensure_ip()
|
||||||
|
xml = _soap_call(
|
||||||
|
ip, "/upnp/control/RenderingControl1", "RenderingControl",
|
||||||
|
"X_GetCaptionState", "<InstanceID>0</InstanceID>",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"captions": _soap_value(xml, "Captions") or "",
|
||||||
|
"enabled": _soap_value(xml, "EnabledCaptions") or "",
|
||||||
|
}
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self._ws:
|
if self._ws:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue