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
|
||||
|
||||
- **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 |
|
||||
|
|
|
|||
58
main.py
58
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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
53
tv.py
53
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", "<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:
|
||||
if self._ws:
|
||||
try:
|
||||
|
|
|
|||
Loading…
Reference in New Issue