diff --git a/README.md b/README.md index 8141b56..874d62e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that lets AI ## 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 - **Zero config** — no API keys, no cloud, everything runs locally - **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_launch` | Launch app by name ("netflix") or ID | | `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_text` | Type text into active input fields | | `tv_cursor` | Move virtual cursor on screen | diff --git a/main.py b/main.py index 6ced0c3..fbecf9e 100644 --- a/main.py +++ b/main.py @@ -250,6 +250,64 @@ async def tv_close_app(app: str) -> dict[str, Any]: 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 ─────────────────────────────────────────────── diff --git a/tv.py b/tv.py index a276db4..39c9c16 100644 --- a/tv.py +++ b/tv.py @@ -430,6 +430,59 @@ class SamsungTV: raise ValueError(f"Unknown: '{action}'. Valid: play, pause, stop, seek, status") 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", "0", + ) + 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"0{ratio}", + ) + + # ── Captions ────────────────────────────────────────────── + + def get_captions(self) -> dict[str, str]: + ip = self._ensure_ip() + xml = _soap_call( + ip, "/upnp/control/RenderingControl1", "RenderingControl", + "X_GetCaptionState", "0", + ) + return { + "captions": _soap_value(xml, "Captions") or "", + "enabled": _soap_value(xml, "EnabledCaptions") or "", + } + def close(self) -> None: if self._ws: try: