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:
Andres Eduardo Garcia Marquez 2026-03-03 19:00:21 -05:00
parent 82b99225bc
commit 36a1ca8d1a
3 changed files with 115 additions and 1 deletions

View File

@ -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
View File

@ -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
View File

@ -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: