teleq-mcp 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/bin/teleq-mcp.mjs +98 -0
- package/package.json +14 -4
- package/src/bridge.mjs +268 -0
- package/src/catalog.json +626 -0
- package/src/config.mjs +49 -0
- package/src/tv.mjs +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TeleQ contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# teleq-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Let Claude (or any MCP client) put things on your TV through the [TeleQ](https://teleq.online) app:
|
|
4
|
+
large text cards, slides, pictures, video and small HTML dashboards. It talks to the TV over your
|
|
5
|
+
home Wi‑Fi; nothing goes through a cloud server.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Claude Code / Claude Desktop / Cursor / Gemini CLI ──stdio──▶ teleq-mcp ──HTTP POST /mcp──▶ TeleQ on the TV
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`teleq-mcp` is a thin bridge: the TV itself is the MCP server and decides what an assistant may do.
|
|
12
|
+
It has no dependencies besides Node.js.
|
|
13
|
+
|
|
14
|
+
For Claude on the web or phone, and for ChatGPT, use the cloud connector
|
|
15
|
+
`https://relay.teleq.online/mcp` instead (see <https://teleq.online/ai/>).
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
* TeleQ open on an Android TV.
|
|
20
|
+
* A computer on the **same Wi‑Fi** as the TV, with Node.js 18.17 or newer.
|
|
21
|
+
|
|
22
|
+
## Install and link
|
|
23
|
+
|
|
24
|
+
1. On the TV: TeleQ → **Linked devices** → **Link an assistant**. A 9‑digit code appears (valid for 5 minutes).
|
|
25
|
+
2. On the computer:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx teleq-mcp pair
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
It looks for TeleQ on your network, asks for the code and stores a key for this TV in your user
|
|
32
|
+
folder (owner-only file). The TV keeps only a hash of the key. Options:
|
|
33
|
+
|
|
34
|
+
* `--host 192.168.1.20` — skip the search and use this address (`--host 192.168.1.20:8766` for another port);
|
|
35
|
+
* `--name "Claude on my laptop"` — how the assistant is listed on the TV.
|
|
36
|
+
|
|
37
|
+
3. Add it to your client:
|
|
38
|
+
|
|
39
|
+
| Client | How |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Claude Code | `claude mcp add teleq -- npx -y teleq-mcp` |
|
|
42
|
+
| Claude Desktop | `"teleq": { "command": "npx", "args": ["-y", "teleq-mcp"] }` under `mcpServers` in `claude_desktop_config.json`, then restart |
|
|
43
|
+
| Cursor | the same JSON in `~/.cursor/mcp.json` (`mcpServers`) |
|
|
44
|
+
| VS Code | `"teleq": { "type": "stdio", "command": "npx", "args": ["-y", "teleq-mcp"] }` under `servers` in `.vscode/mcp.json` |
|
|
45
|
+
| Gemini CLI | the same JSON in `~/.gemini/settings.json` (`mcpServers`) |
|
|
46
|
+
|
|
47
|
+
Then ask: “show tonight's recipe on the TV”, “make a 5-slide presentation about the Moon and check that
|
|
48
|
+
everything fits”, “play this stream on the TV, then pause it”.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
| Command | What it does |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `npx teleq-mcp pair [--host <ip[:port]>] [--name <label>]` | Link this computer with the code shown on the TV. |
|
|
55
|
+
| `npx teleq-mcp status` | Show the linked TV, whether it answers right now, and where the config lives. |
|
|
56
|
+
| `npx teleq-mcp unlink` | Forget the TV on this computer. To also revoke the key, choose **Unlink** next to it on the TV. |
|
|
57
|
+
| `npx teleq-mcp` (or `serve`) | Run as an MCP server over stdio. This is what the client starts; you don't run it by hand. |
|
|
58
|
+
|
|
59
|
+
## Environment variables
|
|
60
|
+
|
|
61
|
+
| Variable | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `TELEQ_CONFIG_DIR` | Where the key (`tv.json`) and the tool cache (`cache.json`) are kept. Default: `$XDG_CONFIG_HOME/teleq`, `%APPDATA%\teleq` on Windows, else `~/.config/teleq`. Handy for linking two TVs: run each server with its own directory. |
|
|
64
|
+
| `XDG_CONFIG_HOME`, `APPDATA` | Used for the default above. |
|
|
65
|
+
|
|
66
|
+
## How it behaves
|
|
67
|
+
|
|
68
|
+
* **The TV moved to a new address** (DHCP): when a call fails, the bridge searches the local /24 again,
|
|
69
|
+
prefers a TV with the same name and saves the new address.
|
|
70
|
+
* **The TV is off**: `initialize` and `tools/list` are answered from the last description the TV sent,
|
|
71
|
+
so the client starts normally; tool calls return a readable error ("Can't reach the TV…").
|
|
72
|
+
* **Unlinked on the TV**: calls return "This TV no longer trusts this computer"; run `pair` again.
|
|
73
|
+
* **Limits** are enforced on the TV: 30 calls a minute per assistant, public `https://` links only
|
|
74
|
+
(nothing on your home network, no YouTube), HTML pages run in a sandbox without network access.
|
|
75
|
+
While an assistant's content is up the TV shows who put it there, and **Back** on the remote stops it.
|
|
76
|
+
|
|
77
|
+
## Troubleshooting
|
|
78
|
+
|
|
79
|
+
| Symptom | What to do |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `No TV found` during `pair` | TeleQ must be open on the TV and both devices on the same Wi‑Fi (not a guest network, no VPN on the computer). Pass the TV's address with `--host`; it is shown in TeleQ on the TV under the QR code. |
|
|
82
|
+
| `Wrong code` / `Too many wrong codes` | Codes last 5 minutes; 5 wrong tries burn the code and pause entry for 30 s. Open **Link an assistant** again for a fresh one. |
|
|
83
|
+
| The client lists no TeleQ tools | Run `npx teleq-mcp status`. If it says "Not linked", run `pair`. Check the client's MCP log for `teleq-mcp:` lines (they go to stderr). |
|
|
84
|
+
| `Can't reach the TV` on every call | The TV is asleep or TeleQ is closed. TeleQ can't be woken from the computer. |
|
|
85
|
+
| Corporate or mesh Wi‑Fi blocks device-to-device traffic | Use the cloud connector (`https://relay.teleq.online/mcp`) instead. |
|
|
86
|
+
| Start over | `npx teleq-mcp unlink`, unlink it on the TV too, then `pair`. |
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm test # node --test, no dependencies
|
|
92
|
+
node bin/teleq-mcp.mjs pair --host 192.168.1.20
|
|
93
|
+
claude mcp add teleq-dev -- node "$PWD/bin/teleq-mcp.mjs"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
License: MIT.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import readline from 'node:readline/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { createAutoBridge, serve } from '../src/bridge.mjs';
|
|
5
|
+
import { configDir, deleteConfig, loadConfig, saveConfig } from '../src/config.mjs';
|
|
6
|
+
import { DEFAULT_PORT, discover, isPrivateIPv4, pairWithCode, probe } from '../src/tv.mjs';
|
|
7
|
+
|
|
8
|
+
const clientName = () => (process.env.TELEQ_CLIENT_NAME || `Claude on ${os.hostname().replace(/\.local$/, '')}`).slice(0, 60);
|
|
9
|
+
|
|
10
|
+
const HELP = `teleq-mcp: let Claude put things on your TV
|
|
11
|
+
|
|
12
|
+
npx teleq-mcp pair [--host <tv-ip>] [--name <label>] link this computer with the code on the TV
|
|
13
|
+
npx teleq-mcp status show the linked TV and whether it answers
|
|
14
|
+
npx teleq-mcp unlink forget the TV on this computer
|
|
15
|
+
npx teleq-mcp run as an MCP server over stdio (what Claude starts);
|
|
16
|
+
without a linked TV it offers a link_tv tool
|
|
17
|
+
|
|
18
|
+
On the TV: open TeleQ -> "AI assistants" -> "Link an assistant" to get a code.
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
function arg(name) {
|
|
22
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
23
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function pair() {
|
|
27
|
+
const log = (s) => process.stderr.write(s + '\n');
|
|
28
|
+
let target;
|
|
29
|
+
const host = arg('host');
|
|
30
|
+
if (host) {
|
|
31
|
+
if (!isPrivateIPv4(host)) throw new Error('--host must be the TV\'s address on your home network (like 192.168.1.20).');
|
|
32
|
+
const [h, p] = host.split(':');
|
|
33
|
+
target = { host: h, port: Number(p) || DEFAULT_PORT };
|
|
34
|
+
if (!(await probe(target.host, target.port))) throw new Error(`No TeleQ TV answered at ${host}.`);
|
|
35
|
+
} else {
|
|
36
|
+
log('Looking for TVs on your network…');
|
|
37
|
+
const found = await discover();
|
|
38
|
+
if (found.length === 0) {
|
|
39
|
+
throw new Error('No TV found. Open TeleQ on the TV (same Wi-Fi), or pass its address with --host 192.168.x.x');
|
|
40
|
+
}
|
|
41
|
+
if (found.length === 1) target = found[0];
|
|
42
|
+
else {
|
|
43
|
+
found.forEach((t, i) => log(` ${i + 1}) ${t.tvName ?? 'TV'} ${t.host}`));
|
|
44
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
45
|
+
const n = Number(await rl.question('Which one? '));
|
|
46
|
+
rl.close();
|
|
47
|
+
target = found[n - 1];
|
|
48
|
+
if (!target) throw new Error('No such TV.');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
log(`Found ${target.tvName ?? 'a TV'} at ${target.host}.`);
|
|
52
|
+
log('On the TV: TeleQ -> AI assistants -> Link an assistant. Type the code it shows.');
|
|
53
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
54
|
+
const code = await rl.question('Code: ');
|
|
55
|
+
rl.close();
|
|
56
|
+
const name = arg('name') ?? clientName();
|
|
57
|
+
const { key, tvName } = await pairWithCode(target, code, name);
|
|
58
|
+
saveConfig({ host: target.host, port: target.port, key, tvName: tvName ?? target.tvName });
|
|
59
|
+
log(`Linked with ${tvName ?? 'the TV'} as "${name}". Claude can use it now.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function status() {
|
|
63
|
+
const cfg = loadConfig();
|
|
64
|
+
if (!cfg) {
|
|
65
|
+
console.log('Not linked. Run: npx teleq-mcp pair');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const info = await probe(cfg.host, cfg.port);
|
|
69
|
+
console.log(`${cfg.tvName ?? 'TV'} at ${cfg.host}:${cfg.port}: ${info ? 'reachable' : 'not reachable (switched off, or it moved: it is searched for again when Claude starts)'}`);
|
|
70
|
+
console.log(`config: ${configDir()}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function run() {
|
|
74
|
+
// Works without a linked TV too: the assistant then offers link_tv (the Claude Desktop extension relies on it).
|
|
75
|
+
const bridge = createAutoBridge({
|
|
76
|
+
configDir: configDir(),
|
|
77
|
+
deps: { loadConfig, saveConfig, discover, probe, pairWithCode, name: clientName },
|
|
78
|
+
});
|
|
79
|
+
await serve(bridge);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cmd = process.argv[2];
|
|
83
|
+
try {
|
|
84
|
+
if (cmd === 'pair') await pair();
|
|
85
|
+
else if (cmd === 'status') await status();
|
|
86
|
+
else if (cmd === 'unlink') {
|
|
87
|
+
deleteConfig();
|
|
88
|
+
console.log('Forgot the TV on this computer. To also revoke the key, unlink it in TeleQ on the TV (AI assistants).');
|
|
89
|
+
} else if (cmd === '--help' || cmd === '-h' || cmd === 'help') process.stdout.write(HELP);
|
|
90
|
+
else if (cmd === undefined || cmd === 'serve') await run();
|
|
91
|
+
else {
|
|
92
|
+
process.stderr.write(HELP);
|
|
93
|
+
process.exitCode = 2;
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
process.stderr.write(`teleq-mcp: ${e.message}\n`);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teleq-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Let Claude (or any MCP client) show text, slides, images, video and web pages on your TV through the TeleQ app, over your home network.",
|
|
5
|
+
"mcpName": "io.github.max4et/teleq",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "teleq-mcp": "bin/teleq-mcp.mjs" },
|
|
8
|
+
"files": ["bin", "src", "README.md", "LICENSE"],
|
|
9
|
+
"engines": { "node": ">=18.17" },
|
|
10
|
+
"scripts": { "test": "node --test test/*.test.mjs", "pack:mcpb": "node scripts/pack-mcpb.mjs" },
|
|
11
|
+
"repository": { "type": "git", "url": "git+https://github.com/max4et/teleq.git", "directory": "mcp" },
|
|
12
|
+
"homepage": "https://teleq.online/ai/",
|
|
13
|
+
"bugs": { "url": "https://github.com/max4et/teleq/issues", "email": "support@teleq.online" },
|
|
14
|
+
"keywords": ["mcp", "mcp-server", "model-context-protocol", "tv", "android-tv", "claude", "chatgpt", "teleq", "presentation", "smart-tv"],
|
|
15
|
+
"license": "MIT"
|
|
16
|
+
}
|
package/src/bridge.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// MCP stdio <-> TeleQ TV. Newline-delimited JSON-RPC in, the same out; every message is forwarded
|
|
2
|
+
// to POST /mcp on the TV. The bridge holds no tool logic of its own: the TV is the MCP server.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { DEFAULT_PORT, discover, isPrivateIPv4, probe } from './tv.mjs';
|
|
5
|
+
import { loadCache, saveCache } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
/** What TeleQ offers (tools/ai/catalog.mjs), for clients that start before this computer ever reached the TV. */
|
|
8
|
+
const CATALOG = JSON.parse(fs.readFileSync(new URL('./catalog.json', import.meta.url), 'utf8'));
|
|
9
|
+
const CACHED = { initialize: 'initialize', 'tools/list': 'tools', 'prompts/list': 'prompts' };
|
|
10
|
+
|
|
11
|
+
const UNREACHABLE =
|
|
12
|
+
"Can't reach the TV. Open the TeleQ app on it (same Wi-Fi as this computer) and try again.";
|
|
13
|
+
const REVOKED =
|
|
14
|
+
'This TV no longer trusts this computer (it was unlinked). Run "npx teleq-mcp pair" to link it again.';
|
|
15
|
+
|
|
16
|
+
function rpcError(id, code, message) {
|
|
17
|
+
return JSON.stringify({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** An older TV answers a tool it doesn't have with a JSON-RPC "Unknown tool" error. */
|
|
21
|
+
function olderTv(reply) {
|
|
22
|
+
return typeof reply?.error?.message === 'string' && reply.error.message.startsWith('Unknown tool:');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const updateTv = (tool) =>
|
|
26
|
+
`This TV runs an older version of TeleQ that doesn't have "${tool}". Ask the user to update TeleQ on the TV (Google Play), or use the tools listed by tv_status.`;
|
|
27
|
+
|
|
28
|
+
function toolError(id, text) {
|
|
29
|
+
return JSON.stringify({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* [config] is the saved { host, port, key, tvName }; [onConfig] is called when the TV moved to a new
|
|
34
|
+
* address so the caller can persist it. Returns an async function: one input line in, one output
|
|
35
|
+
* line (or null when nothing should be written) out.
|
|
36
|
+
*/
|
|
37
|
+
export function createBridge({ config, onConfig = () => {}, configDir, rediscover = discover, timeoutMs = 20000 }) {
|
|
38
|
+
let current = { ...config };
|
|
39
|
+
|
|
40
|
+
async function post(body) {
|
|
41
|
+
return fetch(`http://${current.host}:${current.port}/mcp`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${current.key}` },
|
|
44
|
+
body,
|
|
45
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The TV may have a new DHCP address: find it again, preferring the one with the same name. */
|
|
50
|
+
async function relocate() {
|
|
51
|
+
const found = await rediscover();
|
|
52
|
+
const match = found.find((t) => t.tvName && t.tvName === current.tvName) ?? (found.length === 1 ? found[0] : null);
|
|
53
|
+
if (!match) return false;
|
|
54
|
+
current = { ...current, host: match.host, port: match.port };
|
|
55
|
+
onConfig(current);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function send(line, msg) {
|
|
60
|
+
try {
|
|
61
|
+
return await post(line);
|
|
62
|
+
} catch {
|
|
63
|
+
if (!(await relocate())) return null;
|
|
64
|
+
try {
|
|
65
|
+
return await post(line);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The TV's last answer to initialize / tools/list / prompts/list, else what TeleQ offers in general. */
|
|
73
|
+
function fromCache(msg) {
|
|
74
|
+
const key = CACHED[msg.method];
|
|
75
|
+
if (!key) return null;
|
|
76
|
+
const hit = loadCache(configDir)[key] ?? CATALOG[key];
|
|
77
|
+
if (!hit) return null;
|
|
78
|
+
const result = msg.method === 'initialize' && typeof msg.params?.protocolVersion === 'string'
|
|
79
|
+
? { ...hit, protocolVersion: hit.protocolVersion ?? msg.params.protocolVersion }
|
|
80
|
+
: hit;
|
|
81
|
+
return JSON.stringify({ jsonrpc: '2.0', id: msg.id, result });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return async function handleLine(line) {
|
|
85
|
+
if (!line.trim()) return null;
|
|
86
|
+
let msg;
|
|
87
|
+
try {
|
|
88
|
+
msg = JSON.parse(line);
|
|
89
|
+
} catch {
|
|
90
|
+
return rpcError(null, -32700, 'Parse error');
|
|
91
|
+
}
|
|
92
|
+
const isRequest = msg && typeof msg === 'object' && msg.id !== undefined && msg.id !== null && typeof msg.method === 'string';
|
|
93
|
+
|
|
94
|
+
const res = await send(line, msg);
|
|
95
|
+
if (!res) {
|
|
96
|
+
if (!isRequest) return null;
|
|
97
|
+
return fromCache(msg) ?? (msg.method === 'tools/call' ? toolError(msg.id, UNREACHABLE) : rpcError(msg.id, -32000, UNREACHABLE));
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 202) return null;
|
|
100
|
+
if (res.status === 401) {
|
|
101
|
+
if (!isRequest) return null;
|
|
102
|
+
return msg.method === 'tools/call' ? toolError(msg.id, REVOKED) : rpcError(msg.id, -32001, REVOKED);
|
|
103
|
+
}
|
|
104
|
+
if (res.status === 429) {
|
|
105
|
+
if (!isRequest) return null;
|
|
106
|
+
return msg.method === 'tools/call'
|
|
107
|
+
? toolError(msg.id, 'The TV is rate limiting requests. Wait a few seconds and try again.')
|
|
108
|
+
: rpcError(msg.id, -32002, 'Rate limited by the TV');
|
|
109
|
+
}
|
|
110
|
+
const text = await res.text();
|
|
111
|
+
if (isRequest && res.ok) {
|
|
112
|
+
let reply;
|
|
113
|
+
try {
|
|
114
|
+
reply = JSON.parse(text);
|
|
115
|
+
} catch {
|
|
116
|
+
// Not JSON: pass it through untouched.
|
|
117
|
+
}
|
|
118
|
+
if (reply?.result && CACHED[msg.method]) {
|
|
119
|
+
const cache = loadCache(configDir);
|
|
120
|
+
cache[CACHED[msg.method]] = reply.result;
|
|
121
|
+
saveCache(cache, configDir);
|
|
122
|
+
}
|
|
123
|
+
if (msg.method === 'tools/call' && olderTv(reply)) return toolError(msg.id, updateTv(String(msg.params?.name ?? 'that tool').slice(0, 64)));
|
|
124
|
+
// A TV from before prompts: "Method not found" for prompts/list means none.
|
|
125
|
+
if (msg.method === 'prompts/list' && reply?.error?.code === -32601) {
|
|
126
|
+
return JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { prompts: [] } });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return res.ok ? text : isRequest ? rpcError(msg.id, -32003, `The TV answered HTTP ${res.status}`) : null;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Wires [handleLine] to a pair of streams. Resolves when stdin ends. */
|
|
134
|
+
export async function serve(handleLine, input = process.stdin, output = process.stdout) {
|
|
135
|
+
let buffer = '';
|
|
136
|
+
const pending = new Set();
|
|
137
|
+
const write = (s) => s && output.write(s + '\n');
|
|
138
|
+
const run = (line) => {
|
|
139
|
+
const p = handleLine(line).then(write, (e) => write(rpcError(null, -32603, String(e?.message ?? e))));
|
|
140
|
+
pending.add(p);
|
|
141
|
+
p.finally(() => pending.delete(p));
|
|
142
|
+
};
|
|
143
|
+
input.setEncoding('utf8');
|
|
144
|
+
for await (const chunk of input) {
|
|
145
|
+
buffer += chunk;
|
|
146
|
+
let i;
|
|
147
|
+
while ((i = buffer.indexOf('\n')) >= 0) {
|
|
148
|
+
const line = buffer.slice(0, i);
|
|
149
|
+
buffer = buffer.slice(i + 1);
|
|
150
|
+
run(line);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (buffer.trim()) run(buffer);
|
|
154
|
+
await Promise.allSettled([...pending]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { probe };
|
|
158
|
+
|
|
159
|
+
// ---- linking from inside the assistant (Claude Desktop extension, no terminal) ----
|
|
160
|
+
|
|
161
|
+
export const LINK_TV_TOOL = {
|
|
162
|
+
name: 'link_tv',
|
|
163
|
+
title: 'Link a TV',
|
|
164
|
+
description:
|
|
165
|
+
'Link this computer with a TV running TeleQ on the same Wi-Fi. Ask the user to open TeleQ on the TV, choose Linked devices → Link an assistant, and read you the 9-digit code. Only needed once per TV (or after it was unlinked).',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
code: { type: 'string', description: 'The 9-digit code shown on the TV.' },
|
|
170
|
+
tv_address: { type: 'string', description: "Optional: the TV's address on the home network (like 192.168.1.20), when several TVs are found." },
|
|
171
|
+
},
|
|
172
|
+
required: ['code'],
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
},
|
|
175
|
+
annotations: { title: 'Link a TV', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const NOT_LINKED =
|
|
179
|
+
'No TV is linked yet. Ask the user to open TeleQ on the TV → Linked devices → Link an assistant, then call link_tv with the 9-digit code.';
|
|
180
|
+
|
|
181
|
+
const json = (id, result) => JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
182
|
+
const text = (id, t, isError = false) => json(id, { content: [{ type: 'text', text: t }], isError });
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The bridge as the client starts it: works whether or not a TV is linked, and offers link_tv either way.
|
|
186
|
+
* [deps] are injectable for tests: loadConfig/saveConfig, discover, probe, pairWithCode, name.
|
|
187
|
+
*/
|
|
188
|
+
export function createAutoBridge({ configDir, deps, defaultHost = process.env.TELEQ_TV_HOST }) {
|
|
189
|
+
let inner = null;
|
|
190
|
+
const config = () => deps.loadConfig(configDir);
|
|
191
|
+
const ensure = () => {
|
|
192
|
+
const cfg = config();
|
|
193
|
+
if (cfg && (!inner || inner.key !== cfg.key)) {
|
|
194
|
+
inner = { key: cfg.key, handle: createBridge({ config: cfg, configDir, onConfig: (c) => deps.saveConfig(c, configDir), rediscover: deps.discover }) };
|
|
195
|
+
}
|
|
196
|
+
return inner;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
async function linkTv(id, args) {
|
|
200
|
+
const code = String(args?.code ?? '').replace(/\D/g, '');
|
|
201
|
+
if (code.length !== 9) return text(id, 'The code has 9 digits. Ask the user for the code shown on the TV.', true);
|
|
202
|
+
let target;
|
|
203
|
+
const address = String(args?.tv_address ?? defaultHost ?? '').trim();
|
|
204
|
+
if (address) {
|
|
205
|
+
const [host, p] = address.split(':');
|
|
206
|
+
if (!isPrivateIPv4(host)) return text(id, 'tv_address must be the TV\'s address on the home network, like 192.168.1.20.', true);
|
|
207
|
+
const port = Number(p) || DEFAULT_PORT;
|
|
208
|
+
const info = await deps.probe(host, port);
|
|
209
|
+
if (!info) return text(id, `No TeleQ TV answered at ${address}. Is TeleQ open on it?`, true);
|
|
210
|
+
target = { host, port, tvName: info.tvName };
|
|
211
|
+
} else {
|
|
212
|
+
const found = await deps.discover();
|
|
213
|
+
if (found.length === 0) return text(id, "No TV found on this network. TeleQ must be open on the TV, on the same Wi-Fi as this computer (not a guest network). You can also pass tv_address.", true);
|
|
214
|
+
if (found.length > 1) {
|
|
215
|
+
return text(id, `Several TVs found: ${found.map((t) => `${t.tvName ?? 'TV'} (${t.host})`).join(', ')}. Call link_tv again with tv_address set to the one showing the code.`, true);
|
|
216
|
+
}
|
|
217
|
+
target = found[0];
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const { key, tvName } = await deps.pairWithCode(target, code, deps.name());
|
|
221
|
+
deps.saveConfig({ host: target.host, port: target.port, key, tvName: tvName ?? target.tvName }, configDir);
|
|
222
|
+
inner = null;
|
|
223
|
+
return text(id, `Linked with ${tvName ?? target.tvName ?? 'the TV'}. You can use the TV tools now (start with tv_status).`);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return text(id, e.message, true);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return async function handleLine(line) {
|
|
230
|
+
let msg;
|
|
231
|
+
try {
|
|
232
|
+
msg = JSON.parse(line);
|
|
233
|
+
} catch {
|
|
234
|
+
return line.trim() ? rpcError(null, -32700, 'Parse error') : null;
|
|
235
|
+
}
|
|
236
|
+
const isRequest = msg && msg.id !== undefined && msg.id !== null && typeof msg.method === 'string';
|
|
237
|
+
if (isRequest && msg.method === 'tools/call' && msg.params?.name === 'link_tv') return linkTv(msg.id, msg.params.arguments);
|
|
238
|
+
const linked = ensure();
|
|
239
|
+
if (linked) {
|
|
240
|
+
const out = await linked.handle(line);
|
|
241
|
+
if (out && isRequest && msg.method === 'tools/list') {
|
|
242
|
+
try {
|
|
243
|
+
const reply = JSON.parse(out);
|
|
244
|
+
if (Array.isArray(reply.result?.tools) && !reply.result.tools.some((t) => t.name === 'link_tv')) reply.result.tools.push(LINK_TV_TOOL);
|
|
245
|
+
return JSON.stringify(reply);
|
|
246
|
+
} catch {
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
// Not linked: describe what TeleQ offers, and point every tool call at link_tv.
|
|
253
|
+
if (!isRequest) return null;
|
|
254
|
+
switch (msg.method) {
|
|
255
|
+
case 'initialize':
|
|
256
|
+
return json(msg.id, {
|
|
257
|
+
...CATALOG.initialize,
|
|
258
|
+
protocolVersion: typeof msg.params?.protocolVersion === 'string' ? msg.params.protocolVersion : '2025-06-18',
|
|
259
|
+
instructions: `${CATALOG.initialize.instructions} ${NOT_LINKED}`,
|
|
260
|
+
});
|
|
261
|
+
case 'ping': return json(msg.id, {});
|
|
262
|
+
case 'tools/list': return json(msg.id, { tools: [...CATALOG.tools.tools, LINK_TV_TOOL] });
|
|
263
|
+
case 'prompts/list': return json(msg.id, CATALOG.prompts);
|
|
264
|
+
case 'tools/call': return text(msg.id, NOT_LINKED, true);
|
|
265
|
+
default: return rpcError(msg.id, -32601, `Method not found: ${msg.method}`);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
package/src/catalog.json
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
{
|
|
2
|
+
"initialize": {
|
|
3
|
+
"capabilities": {
|
|
4
|
+
"tools": {
|
|
5
|
+
"listChanged": false
|
|
6
|
+
},
|
|
7
|
+
"prompts": {
|
|
8
|
+
"listChanged": false
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"serverInfo": {
|
|
12
|
+
"name": "teleq-tv",
|
|
13
|
+
"title": "TeleQ TV",
|
|
14
|
+
"version": "catalog"
|
|
15
|
+
},
|
|
16
|
+
"instructions": "This server controls one TV in the user's home through the TeleQ app. Put content up with show_text, show_slides, show_html, show_image, show_queue and play_video; every show_* call returns an id that update_text / update_html / slides_control use. Design for a 10-foot screen: 16:9, dark background, light text, at least 28 px body text and 48 px headings at 1080p, at most about 6 lines or bullets per screen, no scrolling, nothing interactive, keep 5% margins (TV overscan) and leave the top-right corner free for the TeleQ badge. After showing something important, call screen_snapshot to see what the TV really shows and fix overflow or tiny text with update_html or by showing it again. Video frames may appear black in snapshots. For presentations use show_slides (one idea per slide, speaker notes in notes) and slides_control to move; the viewer can also use ←/→ on the remote. For live boards (scores, timers, progress) show_html once and push new values with update_html data; the page receives them as window \"message\" events with {type:\"teleq:update\", data}. Pages have no network access: inline everything. Use notify for a short heads-up without interrupting what is playing; it is limited to one every 10 s. Call tv_status first if unsure what is on screen; its capabilities list says which tools this TV supports (older TVs may lack some: ask the user to update TeleQ). The user sees on the TV which assistant put content up and can stop it with Back on the remote. Don't show anything the user didn't ask for."
|
|
17
|
+
},
|
|
18
|
+
"tools": {
|
|
19
|
+
"tools": [
|
|
20
|
+
{
|
|
21
|
+
"name": "tv_status",
|
|
22
|
+
"title": "TV status",
|
|
23
|
+
"description": "Check what the TV is showing right now (and its id, slide or queue position), whether a phone is also connected, the screen size and safe area, and which tools this TV supports (capabilities). Call this before changing the screen if you are unsure what is on it.",
|
|
24
|
+
"inputSchema": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {},
|
|
27
|
+
"additionalProperties": false
|
|
28
|
+
},
|
|
29
|
+
"_meta": {
|
|
30
|
+
"openai/toolInvocation/invoking": "Checking the TV…",
|
|
31
|
+
"openai/toolInvocation/invoked": "Checked the TV"
|
|
32
|
+
},
|
|
33
|
+
"annotations": {
|
|
34
|
+
"title": "TV status",
|
|
35
|
+
"readOnlyHint": true,
|
|
36
|
+
"idempotentHint": true,
|
|
37
|
+
"openWorldHint": false
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "show_text",
|
|
42
|
+
"title": "Show text on the TV",
|
|
43
|
+
"description": "Show a large, readable text card on the TV: a recipe, a list, a summary, an answer. Keep it short; people read it from the couch. Markdown subset: # headings, **bold**, - bullet lists, 1. numbered lists, blank lines between paragraphs. Returns an id for update_text.",
|
|
44
|
+
"inputSchema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"markdown": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "The text to show (Markdown subset). Up to 20000 characters."
|
|
50
|
+
},
|
|
51
|
+
"title": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Optional heading shown above the text (up to 120 characters)."
|
|
54
|
+
},
|
|
55
|
+
"duration_s": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"minimum": 1,
|
|
58
|
+
"maximum": 3600,
|
|
59
|
+
"description": "Optional: clear the screen after this many seconds (1-3600). Without it the content stays until replaced or stopped."
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"required": [
|
|
63
|
+
"markdown"
|
|
64
|
+
],
|
|
65
|
+
"additionalProperties": false
|
|
66
|
+
},
|
|
67
|
+
"_meta": {
|
|
68
|
+
"openai/toolInvocation/invoking": "Putting the text on the TV…",
|
|
69
|
+
"openai/toolInvocation/invoked": "Text is on the TV"
|
|
70
|
+
},
|
|
71
|
+
"annotations": {
|
|
72
|
+
"title": "Show text on the TV",
|
|
73
|
+
"readOnlyHint": false,
|
|
74
|
+
"destructiveHint": false,
|
|
75
|
+
"idempotentHint": false,
|
|
76
|
+
"openWorldHint": false
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "show_slides",
|
|
81
|
+
"title": "Present slides on the TV",
|
|
82
|
+
"description": "Show a presentation: a list of slides, each either Markdown (rendered as a big, centred TV slide) or a complete self-contained HTML page (sandboxed, no network). The viewer moves with ←/→ on the remote; you move with slides_control. Put what you would say in notes (not shown on the TV; returned by slides_control). Up to 50 slides and 1024 KB in total. Returns an id.",
|
|
83
|
+
"inputSchema": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"slides": {
|
|
87
|
+
"type": "array",
|
|
88
|
+
"minItems": 1,
|
|
89
|
+
"maxItems": 50,
|
|
90
|
+
"description": "The slides in order. Each needs markdown or html (not both).",
|
|
91
|
+
"items": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": {
|
|
94
|
+
"markdown": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"description": "Slide content in the Markdown subset (# heading, - bullets, **bold**). Aim for a heading and at most 5 short bullets."
|
|
97
|
+
},
|
|
98
|
+
"html": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"description": "A complete HTML document for this slide, designed for 1920x1080, dark background, no scrolling."
|
|
101
|
+
},
|
|
102
|
+
"title": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"description": "Optional slide title, used in status and the slide counter."
|
|
105
|
+
},
|
|
106
|
+
"notes": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "Optional speaker notes (up to 4000 characters), never shown on the TV."
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"additionalProperties": false
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"title": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": "Optional presentation title (up to 120 characters)."
|
|
117
|
+
},
|
|
118
|
+
"start_slide": {
|
|
119
|
+
"type": "integer",
|
|
120
|
+
"minimum": 1,
|
|
121
|
+
"maximum": 50,
|
|
122
|
+
"description": "Optional slide to open first (1-based). Default 1."
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"required": [
|
|
126
|
+
"slides"
|
|
127
|
+
],
|
|
128
|
+
"additionalProperties": false
|
|
129
|
+
},
|
|
130
|
+
"_meta": {
|
|
131
|
+
"openai/toolInvocation/invoking": "Putting the slides on the TV…",
|
|
132
|
+
"openai/toolInvocation/invoked": "Slides are on the TV"
|
|
133
|
+
},
|
|
134
|
+
"annotations": {
|
|
135
|
+
"title": "Present slides on the TV",
|
|
136
|
+
"readOnlyHint": false,
|
|
137
|
+
"destructiveHint": false,
|
|
138
|
+
"idempotentHint": false,
|
|
139
|
+
"openWorldHint": false
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"name": "slides_control",
|
|
144
|
+
"title": "Move through the slides",
|
|
145
|
+
"description": "Go to the next, previous, first, last or a given slide of the presentation on screen. Returns the slide number, the count and that slide's notes.",
|
|
146
|
+
"inputSchema": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"properties": {
|
|
149
|
+
"action": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"enum": [
|
|
152
|
+
"next",
|
|
153
|
+
"prev",
|
|
154
|
+
"first",
|
|
155
|
+
"last",
|
|
156
|
+
"goto"
|
|
157
|
+
],
|
|
158
|
+
"description": "Where to go."
|
|
159
|
+
},
|
|
160
|
+
"slide": {
|
|
161
|
+
"type": "integer",
|
|
162
|
+
"minimum": 1,
|
|
163
|
+
"maximum": 50,
|
|
164
|
+
"description": "Slide number (1-based). Required for \"goto\"."
|
|
165
|
+
},
|
|
166
|
+
"id": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"description": "Optional: the id from show_slides, to make sure the right presentation is still on screen."
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"required": [
|
|
172
|
+
"action"
|
|
173
|
+
],
|
|
174
|
+
"additionalProperties": false
|
|
175
|
+
},
|
|
176
|
+
"_meta": {
|
|
177
|
+
"openai/toolInvocation/invoking": "Changing the slide…",
|
|
178
|
+
"openai/toolInvocation/invoked": "Slide changed"
|
|
179
|
+
},
|
|
180
|
+
"annotations": {
|
|
181
|
+
"title": "Move through the slides",
|
|
182
|
+
"readOnlyHint": false,
|
|
183
|
+
"destructiveHint": false,
|
|
184
|
+
"idempotentHint": false,
|
|
185
|
+
"openWorldHint": false
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"name": "show_html",
|
|
190
|
+
"title": "Show an HTML page on the TV",
|
|
191
|
+
"description": "Show a self-contained HTML page (a dashboard, a chart, a scoreboard, a timer, a visual) full screen on the TV. It runs in a locked-down sandbox: scripts and CSS work, but there is no network access, so inline everything; images may be data: URIs or https:// URLs. Design for 1920x1080, large type, dark background, no scrolling, no input. To change values later without reloading, listen for window \"message\" events whose data is {type:\"teleq:update\", data} and call update_html. Returns an id.",
|
|
192
|
+
"inputSchema": {
|
|
193
|
+
"type": "object",
|
|
194
|
+
"properties": {
|
|
195
|
+
"html": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"description": "A complete HTML document. Up to 262144 characters."
|
|
198
|
+
},
|
|
199
|
+
"title": {
|
|
200
|
+
"type": "string",
|
|
201
|
+
"description": "Optional title (not drawn; used in the status) (up to 120 characters)."
|
|
202
|
+
},
|
|
203
|
+
"duration_s": {
|
|
204
|
+
"type": "integer",
|
|
205
|
+
"minimum": 1,
|
|
206
|
+
"maximum": 3600,
|
|
207
|
+
"description": "Optional: clear the screen after this many seconds (1-3600). Without it the content stays until replaced or stopped."
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"required": [
|
|
211
|
+
"html"
|
|
212
|
+
],
|
|
213
|
+
"additionalProperties": false
|
|
214
|
+
},
|
|
215
|
+
"_meta": {
|
|
216
|
+
"openai/toolInvocation/invoking": "Putting the page on the TV…",
|
|
217
|
+
"openai/toolInvocation/invoked": "Page is on the TV"
|
|
218
|
+
},
|
|
219
|
+
"annotations": {
|
|
220
|
+
"title": "Show an HTML page on the TV",
|
|
221
|
+
"readOnlyHint": false,
|
|
222
|
+
"destructiveHint": false,
|
|
223
|
+
"idempotentHint": false,
|
|
224
|
+
"openWorldHint": false
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"name": "update_text",
|
|
229
|
+
"title": "Update the text on the TV",
|
|
230
|
+
"description": "Replace the text of a card shown with show_text, in place and without flicker. Only works while that card is still on screen.",
|
|
231
|
+
"inputSchema": {
|
|
232
|
+
"type": "object",
|
|
233
|
+
"properties": {
|
|
234
|
+
"id": {
|
|
235
|
+
"type": "string",
|
|
236
|
+
"description": "The id returned by the show_* call that put the content up."
|
|
237
|
+
},
|
|
238
|
+
"markdown": {
|
|
239
|
+
"type": "string",
|
|
240
|
+
"description": "The new text (Markdown subset). Up to 20000 characters."
|
|
241
|
+
},
|
|
242
|
+
"title": {
|
|
243
|
+
"type": "string",
|
|
244
|
+
"description": "Optional new heading (up to 120 characters)."
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
"required": [
|
|
248
|
+
"id",
|
|
249
|
+
"markdown"
|
|
250
|
+
],
|
|
251
|
+
"additionalProperties": false
|
|
252
|
+
},
|
|
253
|
+
"_meta": {
|
|
254
|
+
"openai/toolInvocation/invoking": "Updating the TV…",
|
|
255
|
+
"openai/toolInvocation/invoked": "TV updated"
|
|
256
|
+
},
|
|
257
|
+
"annotations": {
|
|
258
|
+
"title": "Update the text on the TV",
|
|
259
|
+
"readOnlyHint": false,
|
|
260
|
+
"destructiveHint": false,
|
|
261
|
+
"idempotentHint": false,
|
|
262
|
+
"openWorldHint": false
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"name": "update_html",
|
|
267
|
+
"title": "Update the page on the TV",
|
|
268
|
+
"description": "Update a page shown with show_html. With data, the page stays and receives a window \"message\" event {type:\"teleq:update\", data} (no reload, no flicker; up to 64 KB of JSON). With html, the page is replaced (a short reload). Only works while that page is still on screen.",
|
|
269
|
+
"inputSchema": {
|
|
270
|
+
"type": "object",
|
|
271
|
+
"properties": {
|
|
272
|
+
"id": {
|
|
273
|
+
"type": "string",
|
|
274
|
+
"description": "The id returned by the show_* call that put the content up."
|
|
275
|
+
},
|
|
276
|
+
"data": {
|
|
277
|
+
"type": "object",
|
|
278
|
+
"additionalProperties": true,
|
|
279
|
+
"description": "New values for the page, delivered as message event data."
|
|
280
|
+
},
|
|
281
|
+
"html": {
|
|
282
|
+
"type": "string",
|
|
283
|
+
"description": "Optional replacement HTML document (up to 262144 characters)."
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
"required": [
|
|
287
|
+
"id"
|
|
288
|
+
],
|
|
289
|
+
"additionalProperties": false
|
|
290
|
+
},
|
|
291
|
+
"_meta": {
|
|
292
|
+
"openai/toolInvocation/invoking": "Updating the TV…",
|
|
293
|
+
"openai/toolInvocation/invoked": "TV updated"
|
|
294
|
+
},
|
|
295
|
+
"annotations": {
|
|
296
|
+
"title": "Update the page on the TV",
|
|
297
|
+
"readOnlyHint": false,
|
|
298
|
+
"destructiveHint": false,
|
|
299
|
+
"idempotentHint": false,
|
|
300
|
+
"openWorldHint": false
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"name": "show_image",
|
|
305
|
+
"title": "Show an image on the TV",
|
|
306
|
+
"description": "Show a picture on the TV, letterboxed on a black background. The image must be reachable on the public internet over https://.",
|
|
307
|
+
"inputSchema": {
|
|
308
|
+
"type": "object",
|
|
309
|
+
"properties": {
|
|
310
|
+
"url": {
|
|
311
|
+
"type": "string",
|
|
312
|
+
"description": "Public https:// URL of a JPEG, PNG, WebP or GIF image."
|
|
313
|
+
},
|
|
314
|
+
"title": {
|
|
315
|
+
"type": "string",
|
|
316
|
+
"description": "Optional caption (up to 120 characters)."
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
"required": [
|
|
320
|
+
"url"
|
|
321
|
+
],
|
|
322
|
+
"additionalProperties": false
|
|
323
|
+
},
|
|
324
|
+
"_meta": {
|
|
325
|
+
"openai/toolInvocation/invoking": "Showing the picture on the TV…",
|
|
326
|
+
"openai/toolInvocation/invoked": "Picture sent to the TV"
|
|
327
|
+
},
|
|
328
|
+
"annotations": {
|
|
329
|
+
"title": "Show an image on the TV",
|
|
330
|
+
"readOnlyHint": false,
|
|
331
|
+
"destructiveHint": false,
|
|
332
|
+
"idempotentHint": false,
|
|
333
|
+
"openWorldHint": true
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"name": "show_queue",
|
|
338
|
+
"title": "Show several photos and videos",
|
|
339
|
+
"description": "Show up to 50 photos and videos one after another: each photo stays for its duration_s (default 8 s), each video plays to the end. The viewer can skip with ←/→ on the remote. Public https:// links only. Returns an id.",
|
|
340
|
+
"inputSchema": {
|
|
341
|
+
"type": "object",
|
|
342
|
+
"properties": {
|
|
343
|
+
"items": {
|
|
344
|
+
"type": "array",
|
|
345
|
+
"minItems": 1,
|
|
346
|
+
"maxItems": 50,
|
|
347
|
+
"description": "The photos and videos in order.",
|
|
348
|
+
"items": {
|
|
349
|
+
"type": "object",
|
|
350
|
+
"properties": {
|
|
351
|
+
"url": {
|
|
352
|
+
"type": "string",
|
|
353
|
+
"description": "Public https:// URL of an image or a video (file, HLS or DASH)."
|
|
354
|
+
},
|
|
355
|
+
"type": {
|
|
356
|
+
"type": "string",
|
|
357
|
+
"enum": [
|
|
358
|
+
"image",
|
|
359
|
+
"video"
|
|
360
|
+
],
|
|
361
|
+
"description": "Optional; guessed from the link when missing."
|
|
362
|
+
},
|
|
363
|
+
"title": {
|
|
364
|
+
"type": "string",
|
|
365
|
+
"description": "Optional caption."
|
|
366
|
+
},
|
|
367
|
+
"duration_s": {
|
|
368
|
+
"type": "integer",
|
|
369
|
+
"minimum": 1,
|
|
370
|
+
"maximum": 3600,
|
|
371
|
+
"description": "Photos only: seconds on screen (default 8)."
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
"required": [
|
|
375
|
+
"url"
|
|
376
|
+
],
|
|
377
|
+
"additionalProperties": false
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
"required": [
|
|
382
|
+
"items"
|
|
383
|
+
],
|
|
384
|
+
"additionalProperties": false
|
|
385
|
+
},
|
|
386
|
+
"_meta": {
|
|
387
|
+
"openai/toolInvocation/invoking": "Showing the pictures on the TV…",
|
|
388
|
+
"openai/toolInvocation/invoked": "Pictures sent to the TV"
|
|
389
|
+
},
|
|
390
|
+
"annotations": {
|
|
391
|
+
"title": "Show several photos and videos",
|
|
392
|
+
"readOnlyHint": false,
|
|
393
|
+
"destructiveHint": false,
|
|
394
|
+
"idempotentHint": false,
|
|
395
|
+
"openWorldHint": true
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"name": "play_video",
|
|
400
|
+
"title": "Play a video on the TV",
|
|
401
|
+
"description": "Play a video on the TV in full screen. Works with direct video files (.mp4, .webm, .mkv) and HLS (.m3u8) or DASH (.mpd) streams over public https://. YouTube and DRM-protected services are not supported. Use `playback` afterwards to pause, resume or seek.",
|
|
402
|
+
"inputSchema": {
|
|
403
|
+
"type": "object",
|
|
404
|
+
"properties": {
|
|
405
|
+
"url": {
|
|
406
|
+
"type": "string",
|
|
407
|
+
"description": "Public https:// URL of the video file or stream."
|
|
408
|
+
},
|
|
409
|
+
"title": {
|
|
410
|
+
"type": "string",
|
|
411
|
+
"description": "Optional title shown on the TV (up to 120 characters)."
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
"required": [
|
|
415
|
+
"url"
|
|
416
|
+
],
|
|
417
|
+
"additionalProperties": false
|
|
418
|
+
},
|
|
419
|
+
"_meta": {
|
|
420
|
+
"openai/toolInvocation/invoking": "Starting the video on the TV…",
|
|
421
|
+
"openai/toolInvocation/invoked": "Video sent to the TV"
|
|
422
|
+
},
|
|
423
|
+
"annotations": {
|
|
424
|
+
"title": "Play a video on the TV",
|
|
425
|
+
"readOnlyHint": false,
|
|
426
|
+
"destructiveHint": false,
|
|
427
|
+
"idempotentHint": false,
|
|
428
|
+
"openWorldHint": true
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"name": "notify",
|
|
433
|
+
"title": "Show a short notice on the TV",
|
|
434
|
+
"description": "Show a short notice for a few seconds on top of whatever is on screen, without interrupting a video. Up to 200 characters, at most one every 10 seconds.",
|
|
435
|
+
"inputSchema": {
|
|
436
|
+
"type": "object",
|
|
437
|
+
"properties": {
|
|
438
|
+
"text": {
|
|
439
|
+
"type": "string",
|
|
440
|
+
"description": "The notice (up to 200 characters)."
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
"required": [
|
|
444
|
+
"text"
|
|
445
|
+
],
|
|
446
|
+
"additionalProperties": false
|
|
447
|
+
},
|
|
448
|
+
"_meta": {
|
|
449
|
+
"openai/toolInvocation/invoking": "Sending a notice to the TV…",
|
|
450
|
+
"openai/toolInvocation/invoked": "Notice shown"
|
|
451
|
+
},
|
|
452
|
+
"annotations": {
|
|
453
|
+
"title": "Show a short notice on the TV",
|
|
454
|
+
"readOnlyHint": false,
|
|
455
|
+
"destructiveHint": false,
|
|
456
|
+
"idempotentHint": false,
|
|
457
|
+
"openWorldHint": false
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
"name": "screen_snapshot",
|
|
462
|
+
"title": "See the TV screen",
|
|
463
|
+
"description": "Get a picture (at most 1280 px wide) of what TeleQ is showing right now, to check that your content fits and looks right. Only TeleQ's own content an assistant put up is captured, never other apps or the user's own photos; video frames may come out black.",
|
|
464
|
+
"inputSchema": {
|
|
465
|
+
"type": "object",
|
|
466
|
+
"properties": {},
|
|
467
|
+
"additionalProperties": false
|
|
468
|
+
},
|
|
469
|
+
"_meta": {
|
|
470
|
+
"openai/toolInvocation/invoking": "Looking at the TV screen…",
|
|
471
|
+
"openai/toolInvocation/invoked": "Looked at the TV"
|
|
472
|
+
},
|
|
473
|
+
"annotations": {
|
|
474
|
+
"title": "See the TV screen",
|
|
475
|
+
"readOnlyHint": true,
|
|
476
|
+
"idempotentHint": true,
|
|
477
|
+
"openWorldHint": false
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
"name": "playback",
|
|
482
|
+
"title": "Control video playback",
|
|
483
|
+
"description": "Control the video that is playing on the TV: play, pause, seek to a position, or stop.",
|
|
484
|
+
"inputSchema": {
|
|
485
|
+
"type": "object",
|
|
486
|
+
"properties": {
|
|
487
|
+
"action": {
|
|
488
|
+
"type": "string",
|
|
489
|
+
"enum": [
|
|
490
|
+
"play",
|
|
491
|
+
"pause",
|
|
492
|
+
"seek",
|
|
493
|
+
"stop"
|
|
494
|
+
],
|
|
495
|
+
"description": "What to do."
|
|
496
|
+
},
|
|
497
|
+
"position_s": {
|
|
498
|
+
"type": "number",
|
|
499
|
+
"minimum": 0,
|
|
500
|
+
"maximum": 86400,
|
|
501
|
+
"description": "Seconds from the start. Required when action is \"seek\"."
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
"required": [
|
|
505
|
+
"action"
|
|
506
|
+
],
|
|
507
|
+
"additionalProperties": false
|
|
508
|
+
},
|
|
509
|
+
"_meta": {
|
|
510
|
+
"openai/toolInvocation/invoking": "Controlling the video…",
|
|
511
|
+
"openai/toolInvocation/invoked": "Done"
|
|
512
|
+
},
|
|
513
|
+
"annotations": {
|
|
514
|
+
"title": "Control video playback",
|
|
515
|
+
"readOnlyHint": false,
|
|
516
|
+
"destructiveHint": false,
|
|
517
|
+
"idempotentHint": false,
|
|
518
|
+
"openWorldHint": false
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
"name": "clear_screen",
|
|
523
|
+
"title": "Clear the TV screen",
|
|
524
|
+
"description": "Stop whatever is being shown and return the TV to its idle screen.",
|
|
525
|
+
"inputSchema": {
|
|
526
|
+
"type": "object",
|
|
527
|
+
"properties": {},
|
|
528
|
+
"additionalProperties": false
|
|
529
|
+
},
|
|
530
|
+
"_meta": {
|
|
531
|
+
"openai/toolInvocation/invoking": "Clearing the TV…",
|
|
532
|
+
"openai/toolInvocation/invoked": "TV cleared"
|
|
533
|
+
},
|
|
534
|
+
"annotations": {
|
|
535
|
+
"title": "Clear the TV screen",
|
|
536
|
+
"readOnlyHint": false,
|
|
537
|
+
"destructiveHint": false,
|
|
538
|
+
"idempotentHint": false,
|
|
539
|
+
"openWorldHint": false
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
]
|
|
543
|
+
},
|
|
544
|
+
"prompts": {
|
|
545
|
+
"prompts": [
|
|
546
|
+
{
|
|
547
|
+
"name": "present_topic",
|
|
548
|
+
"title": "Present a topic on the TV",
|
|
549
|
+
"description": "Prepare a short slide presentation about a topic and present it on the TV.",
|
|
550
|
+
"arguments": [
|
|
551
|
+
{
|
|
552
|
+
"name": "topic",
|
|
553
|
+
"description": "What the presentation is about.",
|
|
554
|
+
"required": true
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
"name": "slides",
|
|
558
|
+
"description": "How many slides (default 6).",
|
|
559
|
+
"required": false
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
"name": "audience",
|
|
563
|
+
"description": "Who is watching (default: a general audience).",
|
|
564
|
+
"required": false
|
|
565
|
+
}
|
|
566
|
+
]
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
"name": "daily_brief",
|
|
570
|
+
"title": "Daily brief on the TV",
|
|
571
|
+
"description": "A one-screen morning brief (date, weather, agenda, one fun fact) as a TV dashboard.",
|
|
572
|
+
"arguments": [
|
|
573
|
+
{
|
|
574
|
+
"name": "city",
|
|
575
|
+
"description": "City for the weather (default: ask me).",
|
|
576
|
+
"required": false
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
"name": "agenda",
|
|
580
|
+
"description": "Things on my plate today, if you don't know them already.",
|
|
581
|
+
"required": false
|
|
582
|
+
}
|
|
583
|
+
]
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
"name": "show_recipe_steps",
|
|
587
|
+
"title": "Recipe step by step",
|
|
588
|
+
"description": "Show a recipe on the TV as slides: ingredients first, then one step per slide.",
|
|
589
|
+
"arguments": [
|
|
590
|
+
{
|
|
591
|
+
"name": "dish",
|
|
592
|
+
"description": "What to cook.",
|
|
593
|
+
"required": true
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
"name": "servings",
|
|
597
|
+
"description": "How many people (default 4).",
|
|
598
|
+
"required": false
|
|
599
|
+
}
|
|
600
|
+
]
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
"name": "kids_quiz",
|
|
604
|
+
"title": "Quiz for kids on the TV",
|
|
605
|
+
"description": "A friendly quiz on the TV with a live scoreboard.",
|
|
606
|
+
"arguments": [
|
|
607
|
+
{
|
|
608
|
+
"name": "topic",
|
|
609
|
+
"description": "Quiz topic.",
|
|
610
|
+
"required": true
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
"name": "age",
|
|
614
|
+
"description": "Age of the players (default 8).",
|
|
615
|
+
"required": false
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
"name": "questions",
|
|
619
|
+
"description": "Number of questions (default 8).",
|
|
620
|
+
"required": false
|
|
621
|
+
}
|
|
622
|
+
]
|
|
623
|
+
}
|
|
624
|
+
]
|
|
625
|
+
}
|
|
626
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function configDir(env = process.env) {
|
|
6
|
+
if (env.TELEQ_CONFIG_DIR) return env.TELEQ_CONFIG_DIR;
|
|
7
|
+
const base = env.XDG_CONFIG_HOME || (process.platform === 'win32' && env.APPDATA) || path.join(os.homedir(), '.config');
|
|
8
|
+
return path.join(base, 'teleq');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadConfig(dir = configDir()) {
|
|
12
|
+
try {
|
|
13
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(dir, 'tv.json'), 'utf8'));
|
|
14
|
+
return cfg && typeof cfg.key === 'string' && typeof cfg.host === 'string' ? cfg : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The key is a credential for the TV: owner-only file, owner-only directory. */
|
|
21
|
+
export function saveConfig(cfg, dir = configDir()) {
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
23
|
+
const file = path.join(dir, 'tv.json');
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
25
|
+
fs.chmodSync(file, 0o600);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function deleteConfig(dir = configDir()) {
|
|
29
|
+
fs.rmSync(path.join(dir, 'tv.json'), { force: true });
|
|
30
|
+
fs.rmSync(path.join(dir, 'cache.json'), { force: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Last `initialize` / `tools/list` results, so Claude can start while the TV is switched off. */
|
|
34
|
+
export function loadCache(dir = configDir()) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'cache.json'), 'utf8')) ?? {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function saveCache(cache, dir = configDir()) {
|
|
43
|
+
try {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
fs.writeFileSync(path.join(dir, 'cache.json'), JSON.stringify(cache), { mode: 0o600 });
|
|
46
|
+
} catch {
|
|
47
|
+
// The cache is a convenience only.
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/tv.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Finding and talking to the TV. No dependencies: Node's own fetch and os.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PORT = 8765;
|
|
5
|
+
const PORT_FALLBACKS = Array.from({ length: 10 }, (_, i) => DEFAULT_PORT + 1 + i);
|
|
6
|
+
|
|
7
|
+
/** True when [host] is an RFC 1918 / link-local IPv4 literal: the only addresses we ever talk to. */
|
|
8
|
+
export function isPrivateIPv4(host) {
|
|
9
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
10
|
+
if (!m) return false;
|
|
11
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
12
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** GET /health; resolves to { tvName } when [host]:[port] is a TeleQ TV, else null. */
|
|
16
|
+
export async function probe(host, port, timeoutMs = 1500) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`http://${host}:${port}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
19
|
+
if (!res.ok) return null;
|
|
20
|
+
const body = await res.json();
|
|
21
|
+
if (body?.status !== 'ok' || typeof body.protocolVersion !== 'number') return null;
|
|
22
|
+
return { tvName: typeof body.tvName === 'string' ? body.tvName : null };
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every other address of each local /24 (the home network), without our own. */
|
|
29
|
+
export function localCandidates(interfaces = os.networkInterfaces()) {
|
|
30
|
+
const out = new Set();
|
|
31
|
+
for (const addrs of Object.values(interfaces)) {
|
|
32
|
+
for (const a of addrs ?? []) {
|
|
33
|
+
if (a.family !== 'IPv4' || a.internal || !isPrivateIPv4(a.address)) continue;
|
|
34
|
+
const base = a.address.split('.').slice(0, 3).join('.');
|
|
35
|
+
for (let i = 1; i <= 254; i++) {
|
|
36
|
+
const host = `${base}.${i}`;
|
|
37
|
+
if (host !== a.address) out.add(host);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...out];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function sweep(hosts, ports, concurrency, timeoutMs) {
|
|
45
|
+
const jobs = hosts.flatMap((host) => ports.map((port) => ({ host, port })));
|
|
46
|
+
const found = [];
|
|
47
|
+
let next = 0;
|
|
48
|
+
await Promise.all(
|
|
49
|
+
Array.from({ length: Math.min(concurrency, jobs.length) }, async () => {
|
|
50
|
+
while (next < jobs.length) {
|
|
51
|
+
const { host, port } = jobs[next++];
|
|
52
|
+
const info = await probe(host, port, timeoutMs);
|
|
53
|
+
if (info) found.push({ host, port, tvName: info.tvName });
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Looks for TVs on the local network: the default port everywhere first (a few seconds), the
|
|
62
|
+
* fallback ports only when nothing answered.
|
|
63
|
+
*/
|
|
64
|
+
export async function discover({ hosts = localCandidates(), ports, concurrency = 64, timeoutMs = 800 } = {}) {
|
|
65
|
+
if (ports) return sweep(hosts, ports, concurrency, timeoutMs);
|
|
66
|
+
const first = await sweep(hosts, [DEFAULT_PORT], concurrency, timeoutMs);
|
|
67
|
+
return first.length ? first : sweep(hosts, PORT_FALLBACKS, concurrency, timeoutMs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Links this computer with the code shown on the TV. Throws Error with a user-facing message. */
|
|
71
|
+
export async function pairWithCode({ host, port }, code, name) {
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetch(`http://${host}:${port}/agent/pair`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ protocolVersion: 1, code: String(code).replace(/\D/g, ''), name }),
|
|
78
|
+
signal: AbortSignal.timeout(8000),
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
throw new Error(`Can't reach the TV at ${host}:${port}. Is TeleQ open on it, on the same Wi-Fi?`);
|
|
82
|
+
}
|
|
83
|
+
const body = await res.json().catch(() => ({}));
|
|
84
|
+
if (res.ok && body.success && body.key) return { key: body.key, tvName: body.tvName ?? null };
|
|
85
|
+
if (res.status === 429) throw new Error('Too many wrong codes. Wait half a minute and try again.');
|
|
86
|
+
if (res.status === 401) {
|
|
87
|
+
throw new Error(`${body.message ?? 'Wrong code'}. On the TV, open "AI assistants" and choose "Link an assistant" for a fresh code.`);
|
|
88
|
+
}
|
|
89
|
+
throw new Error(body.message ?? `The TV refused the request (HTTP ${res.status}).`);
|
|
90
|
+
}
|