pytellybox 0.1.0__py3-none-any.whl

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.
pytellybox/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Async client for Tellybox (https://github.com/sandermvanvliet/Tellybox)."""
2
+
3
+ from pytellybox.client import EXTRA_MINUTES_MAX, TellyboxClient
4
+ from pytellybox.errors import (
5
+ TellyboxAuthError,
6
+ TellyboxConnectionError,
7
+ TellyboxError,
8
+ TellyboxForbiddenError,
9
+ TellyboxNotFoundError,
10
+ TellyboxRequestError,
11
+ TellyboxTimeUpError,
12
+ TellyboxUnavailableError,
13
+ )
14
+ from pytellybox.models import (
15
+ LAST_FIVE_S,
16
+ TV,
17
+ AdminState,
18
+ Day,
19
+ Disk,
20
+ Group,
21
+ Home,
22
+ Info,
23
+ Jobs,
24
+ KidProfile,
25
+ NowPlaying,
26
+ Profile,
27
+ Show,
28
+ ShowRef,
29
+ Tile,
30
+ )
31
+
32
+ __all__ = [
33
+ "EXTRA_MINUTES_MAX", "LAST_FIVE_S", "TV", "AdminState", "Day", "Disk", "Group", "Home", "Info", "Jobs",
34
+ "KidProfile", "NowPlaying", "Profile", "Show", "ShowRef", "TellyboxAuthError", "TellyboxClient",
35
+ "TellyboxConnectionError", "TellyboxError", "TellyboxForbiddenError", "TellyboxNotFoundError",
36
+ "TellyboxRequestError", "TellyboxTimeUpError", "TellyboxUnavailableError", "Tile",
37
+ ]
@@ -0,0 +1,21 @@
1
+ {
2
+ "instance_id": "3f2a9c0e5b7d4e1f8a6b2c3d4e5f6a7b",
3
+ "version": "2026.09.29.5",
4
+ "api": 1,
5
+ "day": {"date": "2026-09-29", "resets_at": "2026-09-30T02:00:00+00:00"},
6
+ "tv": {"connection": "CONNECTED", "reachable": true, "device": "TV"},
7
+ "now_playing": {"episode_id": 4, "show_id": 2, "title": "Alongside", "show": "Harbour Pups",
8
+ "state": "playing", "position_s": 312, "duration_s": 660, "profile_ids": [1]},
9
+ "group": {"remaining_s": 1790, "time_up": false, "last_five": false, "action": "continue", "reason": null,
10
+ "grace_ends_at": null, "session_started_at": "2026-09-29T13:10:00+00:00", "session_elapsed_s": 1200},
11
+ "profiles": [
12
+ {"id": 1, "name": "Mila", "avatar": "fox", "allowance_s": 3600, "extra_s": 900, "used_s": 2710,
13
+ "remaining_s": 1790, "unlimited": false, "blocked": false, "mode": "ignore_pauses", "max_session_s": 5400,
14
+ "session_elapsed_s": 1200, "can_start": true, "reason": null, "watching": true, "last_five": false},
15
+ {"id": 2, "name": "Noah", "avatar": null, "allowance_s": 2700, "extra_s": 0, "used_s": 2700,
16
+ "remaining_s": 0, "unlimited": false, "blocked": false, "mode": "ignore_pauses", "max_session_s": null,
17
+ "session_elapsed_s": null, "can_start": false, "reason": "allowance", "watching": false, "last_five": true}
18
+ ],
19
+ "jobs": {"queued": 1, "running": 1, "failed": 0, "held_ready": 3},
20
+ "disk": {"media_bytes": 48213000000, "free_bytes": 120000000000}
21
+ }
pytellybox/client.py ADDED
@@ -0,0 +1,234 @@
1
+ """Async client for a Tellybox server: the admin API (token) and the kid API (browse and play)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import AsyncIterator, Sequence
7
+ from typing import Any
8
+
9
+ import aiohttp
10
+
11
+ from pytellybox.errors import (
12
+ TellyboxAuthError,
13
+ TellyboxConnectionError,
14
+ TellyboxError,
15
+ TellyboxForbiddenError,
16
+ TellyboxNotFoundError,
17
+ TellyboxRequestError,
18
+ TellyboxTimeUpError,
19
+ TellyboxUnavailableError,
20
+ )
21
+ from pytellybox.models import AdminState, Home, Info, KidProfile, Show
22
+
23
+ DEFAULT_TIMEOUT_S = 10.0
24
+ EVENTS_READ_TIMEOUT_S = 45.0 # Tellybox sends a keepalive every 15 s
25
+ EXTRA_MINUTES_MAX = 240
26
+
27
+
28
+ class TellyboxClient:
29
+ """One Tellybox server.
30
+
31
+ `base_url` is the server's root, e.g. `https://tellybox.example` or `http://192.0.2.10:8080`; a trailing
32
+ slash is ignored. `token` is an API token (`tbx_...`) created on Tellybox's Integrations page; it may be
33
+ None for `info()` and the kid API only. The caller owns `session` (Home Assistant passes its shared one);
34
+ the client never closes it. The token is sent only as `Authorization: Bearer` to `/api/admin/*` and is
35
+ never logged or put in an exception message.
36
+
37
+ Error mapping, for every call: connection problems and timeouts raise TellyboxConnectionError;
38
+ 401 TellyboxAuthError; 403 TellyboxForbiddenError; 404 TellyboxNotFoundError; 400/422
39
+ TellyboxRequestError (with Tellybox's `detail`); 409 on play TellyboxTimeUpError; 503
40
+ TellyboxUnavailableError; any other non-2xx TellyboxError.
41
+ """
42
+
43
+ def __init__(
44
+ self, base_url: str, token: str | None, session: aiohttp.ClientSession, *, timeout: float = DEFAULT_TIMEOUT_S
45
+ ) -> None:
46
+ self._base_url = base_url.strip().rstrip("/")
47
+ self._token = token
48
+ self._session = session
49
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
50
+
51
+ def __repr__(self) -> str:
52
+ return f"TellyboxClient({self._base_url!r})"
53
+
54
+ @property
55
+ def base_url(self) -> str:
56
+ """The normalised base URL, without a trailing slash."""
57
+ return self._base_url
58
+
59
+ def url(self, path: str) -> str:
60
+ """Absolute URL for a server path such as `/img/episode/4.jpg` (images need no auth)."""
61
+ return f"{self._base_url}/{path.lstrip('/')}"
62
+
63
+ # ----------------------------------------------------------------------- plumbing
64
+
65
+ def _headers(self, path: str) -> dict[str, str]:
66
+ if self._token and path.startswith("/api/admin/"):
67
+ return {"Authorization": f"Bearer {self._token}"}
68
+ return {}
69
+
70
+ async def _request(
71
+ self, method: str, path: str, *, json_body: Any = None, params: dict[str, str] | None = None,
72
+ conflict_is_time_up: bool = False,
73
+ ) -> Any:
74
+ """One JSON request; returns the decoded body (None when empty) or raises a TellyboxError."""
75
+ try:
76
+ async with self._session.request(
77
+ method, self.url(path), headers=self._headers(path), json=json_body, params=params,
78
+ timeout=self._timeout,
79
+ ) as resp:
80
+ if resp.status >= 400:
81
+ raise await _error_for(resp, conflict_is_time_up)
82
+ raw = await resp.read()
83
+ except (aiohttp.ClientError, TimeoutError) as err:
84
+ raise TellyboxConnectionError(f"Cannot reach Tellybox: {type(err).__name__}") from None
85
+ if not raw:
86
+ return None
87
+ try:
88
+ return json.loads(raw)
89
+ except ValueError:
90
+ raise TellyboxError("Tellybox sent a response that is not JSON") from None
91
+
92
+ # ----------------------------------------------------------------------- admin API (docs/admin-api.md)
93
+
94
+ async def info(self) -> Info:
95
+ """`GET /api/info`, no token needed."""
96
+ return Info.from_dict(await self._request("GET", "/api/info"))
97
+
98
+ async def state(self) -> AdminState:
99
+ """`GET /api/admin/state` (read scope)."""
100
+ return AdminState.from_dict(await self._request("GET", "/api/admin/state"))
101
+
102
+ async def events(self) -> AsyncIterator[AdminState]:
103
+ """`GET /api/admin/events` (read scope): yields the current state at once, then one per change.
104
+
105
+ Keepalive comments are skipped. The read timeout is EVENTS_READ_TIMEOUT_S. When the stream ends or
106
+ breaks, the iterator raises TellyboxConnectionError; it never reconnects on its own (the caller
107
+ decides the backoff). A 401 on connect raises TellyboxAuthError.
108
+ """
109
+ path = "/api/admin/events"
110
+ timeout = aiohttp.ClientTimeout(total=None, sock_connect=self._timeout.total, sock_read=EVENTS_READ_TIMEOUT_S)
111
+ headers = {**self._headers(path), "Accept": "text/event-stream"}
112
+ try:
113
+ async with self._session.get(self.url(path), headers=headers, timeout=timeout) as resp:
114
+ if resp.status >= 400:
115
+ raise await _error_for(resp, False)
116
+ data: list[str] = []
117
+ async for raw in resp.content:
118
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
119
+ if not line:
120
+ if data:
121
+ payload, data = "\n".join(data), []
122
+ yield _parse_event(payload)
123
+ elif line.startswith(":"):
124
+ continue
125
+ elif line.startswith("data:"):
126
+ value = line[5:]
127
+ data.append(value[1:] if value.startswith(" ") else value)
128
+ except (aiohttp.ClientError, TimeoutError) as err:
129
+ raise TellyboxConnectionError(f"Event stream broke: {type(err).__name__}") from None
130
+ raise TellyboxConnectionError("Event stream ended")
131
+
132
+ async def _override(self, method: str, action: str, **kwargs: Any) -> AdminState:
133
+ return AdminState.from_dict(await self._request(method, f"/api/admin/overrides/{action}", **kwargs))
134
+
135
+ async def add_time(self, minutes: int, profile_ids: Sequence[int] | None = None) -> AdminState:
136
+ """`POST /api/admin/overrides/extra` (control). `minutes` 1..240; None = every kid."""
137
+ if isinstance(minutes, bool) or not isinstance(minutes, int) or not 1 <= minutes <= EXTRA_MINUTES_MAX:
138
+ raise ValueError(f"minutes must be an integer from 1 to {EXTRA_MINUTES_MAX}")
139
+ return await self._override("POST", "extra", json_body=_body(profile_ids, minutes=minutes))
140
+
141
+ async def set_unlimited(self, profile_ids: Sequence[int] | None = None) -> AdminState:
142
+ """`POST /api/admin/overrides/unlimited` (control): unlimited today."""
143
+ return await self._override("POST", "unlimited", json_body=_body(profile_ids))
144
+
145
+ async def block(self, profile_ids: Sequence[int] | None = None) -> AdminState:
146
+ """`POST /api/admin/overrides/block` (control): blocked today, immediately."""
147
+ return await self._override("POST", "block", json_body=_body(profile_ids))
148
+
149
+ async def stop_now(self) -> AdminState:
150
+ """`POST /api/admin/overrides/stop` (control)."""
151
+ return await self._override("POST", "stop", json_body={})
152
+
153
+ async def clear_today(self, profile_ids: Sequence[int] | None = None) -> AdminState:
154
+ """`DELETE /api/admin/overrides/today?profile_ids=1,3` (control): clears unlimited and block."""
155
+ params = {"profile_ids": _csv(profile_ids)} if profile_ids is not None else None
156
+ return await self._override("DELETE", "today", params=params)
157
+
158
+ # ----------------------------------------------------------------------- kid API (docs/kid-api.md), no token
159
+
160
+ async def kid_profiles(self) -> list[KidProfile]:
161
+ """`GET /api/kid/profiles`."""
162
+ return [KidProfile.from_dict(p) for p in await self._request("GET", "/api/kid/profiles")]
163
+
164
+ async def home(self, profile_ids: Sequence[int] | None = None) -> Home:
165
+ """`GET /api/kid/home?profiles=1,3`."""
166
+ return Home.from_dict(await self._request("GET", "/api/kid/home", params=_profiles_param(profile_ids)))
167
+
168
+ async def show(self, show_id: int, profile_ids: Sequence[int] | None = None) -> Show:
169
+ """`GET /api/kid/shows/{id}?profiles=1,3`."""
170
+ data = await self._request("GET", f"/api/kid/shows/{int(show_id)}", params=_profiles_param(profile_ids))
171
+ return Show.from_dict(data)
172
+
173
+ async def play(self, episode_id: int, profile_ids: Sequence[int]) -> None:
174
+ """`POST /api/kid/play`. 409 raises TellyboxTimeUpError: Tellybox's timer always decides."""
175
+ await self._request(
176
+ "POST", "/api/kid/play", json_body={"episode_id": episode_id, "profile_ids": list(profile_ids)},
177
+ conflict_is_time_up=True,
178
+ )
179
+
180
+ async def pause(self) -> None:
181
+ """`POST /api/kid/pause`."""
182
+ await self._request("POST", "/api/kid/pause", json_body={})
183
+
184
+ async def resume(self) -> None:
185
+ """`POST /api/kid/resume`."""
186
+ await self._request("POST", "/api/kid/resume", json_body={})
187
+
188
+
189
+ def _csv(ids: Sequence[int]) -> str:
190
+ return ",".join(str(int(i)) for i in ids)
191
+
192
+
193
+ def _body(profile_ids: Sequence[int] | None, **fields: Any) -> dict[str, Any]:
194
+ body = dict(fields)
195
+ if profile_ids is not None:
196
+ body["profile_ids"] = [int(i) for i in profile_ids]
197
+ return body
198
+
199
+
200
+ def _profiles_param(profile_ids: Sequence[int] | None) -> dict[str, str] | None:
201
+ return {"profiles": _csv(profile_ids)} if profile_ids is not None else None
202
+
203
+
204
+ def _parse_event(payload: str) -> AdminState:
205
+ try:
206
+ return AdminState.from_dict(json.loads(payload))
207
+ except (ValueError, KeyError, TypeError):
208
+ raise TellyboxError("Tellybox sent an event that is not a state") from None
209
+
210
+
211
+ async def _error_for(resp: aiohttp.ClientResponse, conflict_is_time_up: bool) -> TellyboxError:
212
+ """Map a non-2xx response to the matching error. Only Tellybox's `detail` ends up in the message."""
213
+ status = resp.status
214
+ detail = ""
215
+ try:
216
+ body = await resp.json(content_type=None)
217
+ except (ValueError, aiohttp.ClientError):
218
+ body = None
219
+ if isinstance(body, dict) and body.get("detail") is not None:
220
+ detail = str(body["detail"])
221
+ text = detail or f"HTTP {status}"
222
+ if status == 401:
223
+ return TellyboxAuthError(text)
224
+ if status == 403:
225
+ return TellyboxForbiddenError(text)
226
+ if status == 404:
227
+ return TellyboxNotFoundError(text)
228
+ if status in (400, 422):
229
+ return TellyboxRequestError(text)
230
+ if status == 409 and conflict_is_time_up:
231
+ return TellyboxTimeUpError(text)
232
+ if status == 503:
233
+ return TellyboxUnavailableError(text)
234
+ return TellyboxError(text)
pytellybox/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Errors raised by the Tellybox client. Every error is a `TellyboxError`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class TellyboxError(Exception):
7
+ """Base class for every client error."""
8
+
9
+
10
+ class TellyboxConnectionError(TellyboxError):
11
+ """Tellybox can't be reached: connection refused, timeout, or an event stream that ended."""
12
+
13
+
14
+ class TellyboxAuthError(TellyboxError):
15
+ """401: the token is missing, unknown or revoked. Home Assistant starts a reauth flow."""
16
+
17
+
18
+ class TellyboxForbiddenError(TellyboxError):
19
+ """403: the token lacks the scope, e.g. an override with a read-only token."""
20
+
21
+
22
+ class TellyboxNotFoundError(TellyboxError):
23
+ """404: no such episode or show (or it isn't visible to kids)."""
24
+
25
+
26
+ class TellyboxRequestError(TellyboxError):
27
+ """400/422: Tellybox refused the request (bad minutes, unknown profile ids...). `str()` is its detail."""
28
+
29
+
30
+ class TellyboxTimeUpError(TellyboxError):
31
+ """409 on play: someone in the group is out of time (PR-4). Playing is refused, never forced."""
32
+
33
+
34
+ class TellyboxUnavailableError(TellyboxError):
35
+ """503: Tellybox is up, but its cast service or the TV isn't; nothing was applied."""
pytellybox/mock.py ADDED
@@ -0,0 +1,445 @@
1
+ """A mock Tellybox server for developing and testing integrations without a real Tellybox.
2
+
3
+ `python -m pytellybox.mock --port 8099 --token tbx_dev [--read-token tbx_ro]` serves:
4
+ - `/api/info`, `/api/admin/state`, `/api/admin/events` (SSE with keepalive), the override routes, with the
5
+ same auth rules as Tellybox (401 / 403, a read-only token via `read_tokens`);
6
+ - `/api/kid/profiles`, `/home`, `/shows/{id}`, `/state`, `/play` (409 when a watcher can't start), `/pause`,
7
+ `/resume`;
8
+ - `/img/...` placeholder images.
9
+ Overrides change the scripted state the way Tellybox would (extra time, unlimited, block, clear, stop) and
10
+ push a new event. Tests and scripts drive it through `MockTellybox`: `set_state(dict)`, `push()`,
11
+ `calls` (recorded requests), and `app` (an aiohttp `web.Application`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import copy
19
+ import json
20
+ from collections.abc import Awaitable, Callable, Iterable
21
+ from importlib import resources
22
+ from typing import Any
23
+
24
+ from aiohttp import web
25
+
26
+ _LAST_FIVE_S = 300
27
+ _PLACEHOLDER_IMAGE = ( # a 1x1 GIF; the mock has no image library
28
+ b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x80\x80\x80\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,"
29
+ b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
30
+ )
31
+
32
+ _EPISODES = {
33
+ 4: {"show_id": 2, "title": "Alongside", "duration_s": 660},
34
+ 5: {"show_id": 2, "title": "Lost Ball", "duration_s": 600},
35
+ 6: {"show_id": 3, "title": "Big Splash", "duration_s": 480},
36
+ }
37
+ _SHOWS = {2: "Harbour Pups", 3: "Bubble Bay"}
38
+
39
+ Handler = Callable[[web.Request], Awaitable[web.StreamResponse]]
40
+
41
+
42
+ def default_state() -> dict[str, Any]:
43
+ """A copy of the packaged example state (Mila watching, Noah out of time)."""
44
+ return json.loads(resources.files("pytellybox").joinpath("admin_state.json").read_text(encoding="utf-8"))
45
+
46
+
47
+ def _json_error(status: int, detail: str) -> web.Response:
48
+ return web.json_response({"detail": detail}, status=status)
49
+
50
+
51
+ class MockTellybox:
52
+ """A scripted Tellybox. `tokens` may control; `read_tokens` may only read (403 on an override)."""
53
+
54
+ def __init__(
55
+ self, state: dict[str, Any] | None = None, *, token: str = "tbx_mock", read_tokens: Iterable[str] = (),
56
+ keepalive_s: float = 15.0,
57
+ ) -> None:
58
+ self.tokens: set[str] = {token}
59
+ self.read_tokens: set[str] = set(read_tokens)
60
+ self.keepalive_s = keepalive_s
61
+ self.calls: list[dict[str, Any]] = []
62
+ self._state: dict[str, Any] = copy.deepcopy(state) if state is not None else default_state()
63
+ self._subscribers: set[asyncio.Queue[str | None]] = set()
64
+ self._runner: web.AppRunner | None = None
65
+ self.app = web.Application(middlewares=[self._record, self._auth])
66
+ self.app.on_shutdown.append(self._close_streams)
67
+ self.app.add_routes([
68
+ web.get("/api/info", self._info),
69
+ web.get("/api/admin/state", self._get_state),
70
+ web.get("/api/admin/events", self._events),
71
+ web.post("/api/admin/overrides/extra", self._extra),
72
+ web.post("/api/admin/overrides/unlimited", self._unlimited),
73
+ web.post("/api/admin/overrides/block", self._block),
74
+ web.post("/api/admin/overrides/stop", self._stop),
75
+ web.delete("/api/admin/overrides/today", self._clear),
76
+ web.get("/api/kid/profiles", self._kid_profiles),
77
+ web.get("/api/kid/home", self._kid_home),
78
+ web.get("/api/kid/shows/{show_id}", self._kid_show),
79
+ web.get("/api/kid/state", self._kid_state),
80
+ web.post("/api/kid/play", self._kid_play),
81
+ web.post("/api/kid/pause", self._kid_pause),
82
+ web.post("/api/kid/resume", self._kid_resume),
83
+ web.get("/img/{kind}/{item}.jpg", self._image),
84
+ ])
85
+
86
+ # ------------------------------------------------------------------ driving the mock
87
+
88
+ @property
89
+ def state(self) -> dict[str, Any]:
90
+ """The current AdminState payload (mutable; call `push()` after changing it)."""
91
+ return self._state
92
+
93
+ def set_state(self, state: dict[str, Any]) -> None:
94
+ """Replace the state and push it to the event streams."""
95
+ self._state = copy.deepcopy(state)
96
+ self.push()
97
+
98
+ def push(self) -> None:
99
+ """Send the current state to every open event stream."""
100
+ payload = json.dumps(self._state)
101
+ for queue in self._subscribers:
102
+ queue.put_nowait(payload)
103
+
104
+ async def start(self, host: str = "127.0.0.1", port: int = 0) -> str:
105
+ """Listen on host:port (0 = any free port) and return the base URL."""
106
+ self._runner = web.AppRunner(self.app)
107
+ await self._runner.setup()
108
+ site = web.TCPSite(self._runner, host, port)
109
+ await site.start()
110
+ actual = self._runner.addresses[0][1]
111
+ return f"http://{host}:{actual}"
112
+
113
+ async def stop(self) -> None:
114
+ if self._runner is not None:
115
+ await self._runner.cleanup()
116
+ self._runner = None
117
+
118
+ # ------------------------------------------------------------------ middleware
119
+
120
+ @web.middleware
121
+ async def _record(self, request: web.Request, handler: Handler) -> web.StreamResponse:
122
+ entry: dict[str, Any] = {"method": request.method, "path": request.path, "query": dict(request.query),
123
+ "json": None}
124
+ entry["json"] = await self._json(request)
125
+ self.calls.append(entry)
126
+ return await handler(request)
127
+
128
+ @web.middleware
129
+ async def _auth(self, request: web.Request, handler: Handler) -> web.StreamResponse:
130
+ if request.path.startswith("/api/admin/"):
131
+ header = request.headers.get("Authorization", "")
132
+ token = header[7:] if header.startswith("Bearer ") else ""
133
+ if token not in self.tokens | self.read_tokens:
134
+ resp = _json_error(401, "unauthorized")
135
+ resp.headers["WWW-Authenticate"] = "Bearer"
136
+ return resp
137
+ if request.method != "GET" and token not in self.tokens:
138
+ return _json_error(403, "forbidden")
139
+ return await handler(request)
140
+
141
+ # ------------------------------------------------------------------ admin API
142
+
143
+ async def _info(self, request: web.Request) -> web.Response:
144
+ s = self._state
145
+ return web.json_response({"instance_id": s["instance_id"], "version": s["version"], "api": 1,
146
+ "capabilities": ["state", "events", "overrides", "profiles"]})
147
+
148
+ async def _get_state(self, request: web.Request) -> web.Response:
149
+ return web.json_response(self._state)
150
+
151
+ async def _events(self, request: web.Request) -> web.StreamResponse:
152
+ resp = web.StreamResponse(headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"})
153
+ await resp.prepare(request)
154
+ queue: asyncio.Queue[str | None] = asyncio.Queue()
155
+ queue.put_nowait(json.dumps(self._state))
156
+ self._subscribers.add(queue)
157
+ try:
158
+ while True:
159
+ try:
160
+ payload = await asyncio.wait_for(queue.get(), self.keepalive_s)
161
+ except TimeoutError:
162
+ await resp.write(b": keepalive\n\n")
163
+ continue
164
+ if payload is None:
165
+ break
166
+ await resp.write(f"data: {payload}\n\n".encode())
167
+ except ConnectionResetError:
168
+ pass
169
+ finally:
170
+ self._subscribers.discard(queue)
171
+ return resp
172
+
173
+ async def _close_streams(self, app: web.Application) -> None:
174
+ for queue in list(self._subscribers):
175
+ queue.put_nowait(None)
176
+
177
+ async def _ids(self, request: web.Request, body: Any) -> list[int] | web.Response:
178
+ """The profiles an override applies to, or a 422 response."""
179
+ if not isinstance(body, dict):
180
+ return _json_error(422, "body must be a JSON object")
181
+ ids = body.get("profile_ids")
182
+ everyone = [p["id"] for p in self._state["profiles"]]
183
+ if ids is None:
184
+ return everyone
185
+ if (not isinstance(ids, list) or not 1 <= len(ids) <= 20 or len(set(ids)) != len(ids)
186
+ or not all(isinstance(i, int) and not isinstance(i, bool) for i in ids)):
187
+ return _json_error(422, "profile_ids must be 1-20 distinct ids")
188
+ if any(i not in everyone for i in ids):
189
+ return _json_error(422, "unknown profile id")
190
+ return ids
191
+
192
+ @staticmethod
193
+ async def _json(request: web.Request) -> Any:
194
+ """The JSON body; None when absent or malformed (`read()` caches, so it can run twice)."""
195
+ raw = await request.read()
196
+ try:
197
+ return json.loads(raw) if raw else None
198
+ except ValueError:
199
+ return None
200
+
201
+ async def _body(self, request: web.Request) -> Any:
202
+ """The JSON body of an override or kid POST; an empty body counts as `{}`, junk as None."""
203
+ raw = await request.read()
204
+ return {} if not raw else await self._json(request)
205
+
206
+ def _profiles(self, ids: list[int]) -> list[dict[str, Any]]:
207
+ return [p for p in self._state["profiles"] if p["id"] in ids]
208
+
209
+ def _refresh(self, watchers: list[int] | None = None) -> None:
210
+ """Recompute each profile and the group the way the timer would."""
211
+ state = self._state
212
+ for p in state["profiles"]:
213
+ if p["blocked"]:
214
+ p["remaining_s"], p["can_start"], p["reason"] = 0, False, "blocked"
215
+ elif p["unlimited"]:
216
+ p["remaining_s"], p["can_start"], p["reason"] = None, True, None
217
+ else:
218
+ left = max(p["allowance_s"] + (p["extra_s"] or 0) - (p["used_s"] or 0), 0)
219
+ p["remaining_s"], p["can_start"] = left, left > 0
220
+ p["reason"] = None if left > 0 else "allowance"
221
+ p["last_five"] = p["remaining_s"] is not None and 0 < p["remaining_s"] <= _LAST_FIVE_S
222
+ if watchers is None:
223
+ watchers = (state.get("now_playing") or {}).get("profile_ids", [])
224
+ group = [p for p in state["profiles"] if p["id"] in watchers]
225
+ if not group:
226
+ return
227
+ finite = [p["remaining_s"] for p in group if p["remaining_s"] is not None]
228
+ remaining = min(finite) if finite else None
229
+ blocked = any(p["blocked"] for p in group)
230
+ out = any(p["can_start"] is False for p in group)
231
+ g = state["group"]
232
+ g["remaining_s"] = remaining
233
+ g["time_up"] = out
234
+ g["last_five"] = remaining is not None and 0 < remaining <= _LAST_FIVE_S
235
+ g["reason"] = "blocked" if blocked else ("allowance" if out else None)
236
+ g["action"] = "stop_now" if blocked else ("finish_then_stop" if out else "continue")
237
+
238
+ def _stop_playback(self) -> None:
239
+ self._state["now_playing"] = None
240
+ for p in self._state["profiles"]:
241
+ p["watching"] = False
242
+
243
+ async def _apply(self, request: web.Request, change: Callable[[list[dict[str, Any]], Any], None],
244
+ *, needs_ids: bool = True, body: Any = None) -> web.Response:
245
+ if body is None:
246
+ body = await self._body(request)
247
+ ids: list[int] | web.Response = await self._ids(request, body) if needs_ids else []
248
+ if isinstance(ids, web.Response):
249
+ return ids
250
+ watchers = list((self._state.get("now_playing") or {}).get("profile_ids", []))
251
+ change(self._profiles(ids), body)
252
+ self._refresh(watchers)
253
+ self.push()
254
+ return web.json_response(self._state)
255
+
256
+ async def _extra(self, request: web.Request) -> web.Response:
257
+ body = await self._body(request)
258
+ minutes = body.get("minutes") if isinstance(body, dict) else None
259
+ if isinstance(minutes, bool) or not isinstance(minutes, int) or not 1 <= minutes <= 240:
260
+ return _json_error(422, "minutes must be an integer from 1 to 240")
261
+
262
+ def change(profiles: list[dict[str, Any]], _: Any) -> None:
263
+ for p in profiles:
264
+ p["extra_s"] = (p["extra_s"] or 0) + minutes * 60
265
+
266
+ return await self._apply(request, change)
267
+
268
+ async def _unlimited(self, request: web.Request) -> web.Response:
269
+ def change(profiles: list[dict[str, Any]], _: Any) -> None:
270
+ for p in profiles:
271
+ p["unlimited"] = True
272
+
273
+ return await self._apply(request, change)
274
+
275
+ async def _block(self, request: web.Request) -> web.Response:
276
+ def change(profiles: list[dict[str, Any]], _: Any) -> None:
277
+ blocked = {p["id"] for p in profiles}
278
+ for p in profiles:
279
+ p["blocked"] = True
280
+ playing = self._state.get("now_playing")
281
+ if playing and blocked & set(playing["profile_ids"]):
282
+ self._stop_playback()
283
+
284
+ return await self._apply(request, change)
285
+
286
+ async def _stop(self, request: web.Request) -> web.Response:
287
+ return await self._apply(request, lambda _p, _b: self._stop_playback(), needs_ids=False)
288
+
289
+ async def _clear(self, request: web.Request) -> web.Response:
290
+ raw = request.query.get("profile_ids")
291
+ body: dict[str, Any] = {}
292
+ if raw:
293
+ try:
294
+ body["profile_ids"] = [int(i) for i in raw.split(",")]
295
+ except ValueError:
296
+ return _json_error(422, "bad profile_ids")
297
+
298
+ def change(profiles: list[dict[str, Any]], _: Any) -> None:
299
+ for p in profiles:
300
+ p["unlimited"] = p["blocked"] = False
301
+
302
+ return await self._apply(request, change, body=body)
303
+
304
+ # ------------------------------------------------------------------ kid API
305
+
306
+ def _group(self, request: web.Request) -> list[int] | web.Response:
307
+ raw = request.query.get("profiles")
308
+ everyone = [p["id"] for p in self._state["profiles"]]
309
+ if raw is None:
310
+ return everyone[:1]
311
+ try:
312
+ ids = [int(i) for i in raw.split(",")]
313
+ except ValueError:
314
+ return _json_error(400, "bad_profiles")
315
+ if not 1 <= len(ids) <= 20 or len(set(ids)) != len(ids) or any(i not in everyone for i in ids):
316
+ return _json_error(400, "bad_profiles")
317
+ return ids
318
+
319
+ def _kid_state_dict(self) -> dict[str, Any]:
320
+ s = self._state
321
+ playing = s.get("now_playing")
322
+ profiles = {}
323
+ for p in s["profiles"]:
324
+ allowance = p["allowance_s"] + (p["extra_s"] or 0)
325
+ fraction = None if p["remaining_s"] is None else round(p["remaining_s"] / allowance, 3) if allowance else 0
326
+ profiles[str(p["id"])] = {"fraction_left": fraction, "last_five": p["last_five"],
327
+ "unlimited": p["unlimited"], "time_up": p["can_start"] is False}
328
+ return {
329
+ "tv": "ok" if s["tv"]["reachable"] else "unreachable",
330
+ "now_playing": None if not playing else {
331
+ "episode_id": playing["episode_id"], "show_id": playing["show_id"],
332
+ "thumb": f"/img/episode/{playing['episode_id']}.jpg", "title": playing["title"],
333
+ "state": playing["state"]},
334
+ "watching": list(playing["profile_ids"]) if playing else [],
335
+ "sky": {"fraction_left": None, "last_five": s["group"]["last_five"], "unlimited": False},
336
+ "time_up": s["group"]["time_up"], "profiles": profiles, "day": s["day"]["date"],
337
+ }
338
+
339
+ @staticmethod
340
+ def _tile(episode_id: int) -> dict[str, Any]:
341
+ e = _EPISODES[episode_id]
342
+ return {"episode_id": episode_id, "show_id": e["show_id"], "thumb": f"/img/episode/{episode_id}.jpg",
343
+ "title": e["title"], "progress": None, "finished": False}
344
+
345
+ async def _kid_profiles(self, request: web.Request) -> web.Response:
346
+ return web.json_response([
347
+ {"profile_id": p["id"], "name": p["name"], "picture": None, "avatar": p["avatar"],
348
+ "time_up": p["can_start"] is False, "fraction_left": None, "last_five": p["last_five"],
349
+ "unlimited": p["unlimited"]}
350
+ for p in self._state["profiles"]])
351
+
352
+ async def _kid_home(self, request: web.Request) -> web.Response:
353
+ group = self._group(request)
354
+ if isinstance(group, web.Response):
355
+ return group
356
+ return web.json_response({
357
+ "continue": [{**self._tile(5), "kind": "next"}],
358
+ "shows": [{"show_id": i, "artwork": f"/img/show/{i}.jpg", "title": t} for i, t in _SHOWS.items()]})
359
+
360
+ async def _kid_show(self, request: web.Request) -> web.Response:
361
+ group = self._group(request)
362
+ if isinstance(group, web.Response):
363
+ return group
364
+ try:
365
+ show_id = int(request.match_info["show_id"])
366
+ except ValueError:
367
+ return _json_error(404, "not_found")
368
+ if show_id not in _SHOWS:
369
+ return _json_error(404, "not_found")
370
+ return web.json_response({
371
+ "show_id": show_id, "artwork": f"/img/show/{show_id}.jpg", "title": _SHOWS[show_id],
372
+ "episodes": [self._tile(i) for i, e in _EPISODES.items() if e["show_id"] == show_id]})
373
+
374
+ async def _kid_state(self, request: web.Request) -> web.Response:
375
+ return web.json_response(self._kid_state_dict())
376
+
377
+ async def _kid_play(self, request: web.Request) -> web.Response:
378
+ body = await self._body(request)
379
+ if not isinstance(body, dict):
380
+ return _json_error(400, "bad_request")
381
+ ids, episode_id = body.get("profile_ids"), body.get("episode_id")
382
+ everyone = [p["id"] for p in self._state["profiles"]]
383
+ if (not isinstance(ids, list) or not 1 <= len(ids) <= 20 or len(set(ids)) != len(ids)
384
+ or any(i not in everyone for i in ids)):
385
+ return _json_error(400, "bad_profiles")
386
+ if episode_id not in _EPISODES:
387
+ return _json_error(404, "not_found")
388
+ if not self._state["tv"]["reachable"]:
389
+ return web.json_response(self._kid_state_dict(), status=503)
390
+ if any(p["can_start"] is False for p in self._profiles(ids)):
391
+ return web.json_response(self._kid_state_dict(), status=409)
392
+ e = _EPISODES[episode_id]
393
+ self._state["now_playing"] = {
394
+ "episode_id": episode_id, "show_id": e["show_id"], "title": e["title"], "show": _SHOWS[e["show_id"]],
395
+ "state": "playing", "position_s": 0, "duration_s": e["duration_s"], "profile_ids": ids}
396
+ for p in self._state["profiles"]:
397
+ p["watching"] = p["id"] in ids
398
+ self._refresh(ids)
399
+ self.push()
400
+ return web.json_response(self._kid_state_dict())
401
+
402
+ async def _set_playback(self, state: str) -> web.Response:
403
+ if not self._state["tv"]["reachable"]:
404
+ return web.json_response(self._kid_state_dict(), status=503)
405
+ if self._state.get("now_playing"):
406
+ self._state["now_playing"]["state"] = state
407
+ self.push()
408
+ return web.json_response(self._kid_state_dict())
409
+
410
+ async def _kid_pause(self, request: web.Request) -> web.Response:
411
+ return await self._set_playback("paused")
412
+
413
+ async def _kid_resume(self, request: web.Request) -> web.Response:
414
+ return await self._set_playback("playing")
415
+
416
+ async def _image(self, request: web.Request) -> web.Response:
417
+ return web.Response(body=_PLACEHOLDER_IMAGE, content_type="image/gif")
418
+
419
+
420
+ async def _serve(host: str, port: int, mock: MockTellybox) -> None:
421
+ url = await mock.start(host, port)
422
+ print(f"Mock Tellybox listening on {url}") # noqa: T201
423
+ try:
424
+ await asyncio.Event().wait()
425
+ finally:
426
+ await mock.stop()
427
+
428
+
429
+ def main(argv: list[str] | None = None) -> None:
430
+ parser = argparse.ArgumentParser(prog="python -m pytellybox.mock", description="Run a mock Tellybox server.")
431
+ parser.add_argument("--host", default="127.0.0.1")
432
+ parser.add_argument("--port", type=int, default=8099)
433
+ parser.add_argument("--token", default="tbx_dev", help="token with the control scope")
434
+ parser.add_argument("--read-token", action="append", default=[], help="token with the read scope only")
435
+ parser.add_argument("--keepalive", type=float, default=15.0, help="seconds between SSE keepalives")
436
+ args = parser.parse_args(argv)
437
+ mock = MockTellybox(token=args.token, read_tokens=args.read_token, keepalive_s=args.keepalive)
438
+ try:
439
+ asyncio.run(_serve(args.host, args.port, mock))
440
+ except KeyboardInterrupt:
441
+ pass
442
+
443
+
444
+ if __name__ == "__main__":
445
+ main()
pytellybox/models.py ADDED
@@ -0,0 +1,267 @@
1
+ """Typed views of the Tellybox API payloads.
2
+
3
+ Admin types follow Tellybox's `docs/admin-api.md` (API version 1); kid types follow `docs/kid-api.md`.
4
+ Every `from_dict` tolerates unknown extra keys (newer servers) and keeps the original payload in `raw`
5
+ where it matters, so callers can reach fields this version doesn't model yet.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import date, datetime
12
+ from typing import Any
13
+
14
+ LAST_FIVE_S = 300
15
+
16
+
17
+ def _dt(value: str | None) -> datetime | None:
18
+ return datetime.fromisoformat(value) if value else None
19
+
20
+
21
+ def _date(value: str | None) -> date | None:
22
+ return date.fromisoformat(value) if value else None
23
+
24
+
25
+ # --------------------------------------------------------------------------- admin API
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Info:
30
+ """`GET /api/info` (HA-6), no auth needed."""
31
+
32
+ instance_id: str
33
+ version: str
34
+ api: int
35
+ capabilities: tuple[str, ...]
36
+
37
+ @classmethod
38
+ def from_dict(cls, d: dict[str, Any]) -> Info:
39
+ return cls(d["instance_id"], d["version"], int(d["api"]), tuple(d.get("capabilities", ())))
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Day:
44
+ date: date | None # the timer day (WT-1); None before the first cast state
45
+ resets_at: datetime | None # the next daily reset
46
+
47
+ @classmethod
48
+ def from_dict(cls, d: dict[str, Any]) -> Day:
49
+ return cls(_date(d.get("date")), _dt(d.get("resets_at")))
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class TV:
54
+ connection: str # the cast state's connection, or "unreachable" when the cast service is down
55
+ reachable: bool
56
+ device: str | None # the Chromecast's name
57
+
58
+ @classmethod
59
+ def from_dict(cls, d: dict[str, Any]) -> TV:
60
+ return cls(d["connection"], bool(d["reachable"]), d.get("device"))
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class NowPlaying:
65
+ episode_id: int
66
+ show_id: int
67
+ title: str
68
+ show: str | None
69
+ state: str # loading | playing | paused | buffering
70
+ position_s: int | None
71
+ duration_s: int | None
72
+ profile_ids: tuple[int, ...]
73
+
74
+ @property
75
+ def thumb_path(self) -> str:
76
+ """The episode thumbnail on the kid API (no auth): `/img/episode/{id}.jpg`."""
77
+ return f"/img/episode/{self.episode_id}.jpg"
78
+
79
+ @classmethod
80
+ def from_dict(cls, d: dict[str, Any]) -> NowPlaying:
81
+ return cls(
82
+ int(d["episode_id"]), int(d["show_id"]), d.get("title") or "", d.get("show"), d["state"],
83
+ d.get("position_s"), d.get("duration_s"), tuple(d.get("profile_ids") or ()),
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Group:
89
+ """The current watchers, as the cast service's timer describes them."""
90
+
91
+ remaining_s: int | None # None = all unlimited (or unknown)
92
+ time_up: bool
93
+ last_five: bool
94
+ action: str | None # continue | finish_then_stop | stop_now
95
+ reason: str | None # allowance | session_max | blocked
96
+ grace_ends_at: datetime | None
97
+ session_started_at: datetime | None
98
+ session_elapsed_s: int | None
99
+
100
+ @classmethod
101
+ def from_dict(cls, d: dict[str, Any]) -> Group:
102
+ return cls(
103
+ d.get("remaining_s"), bool(d.get("time_up")), bool(d.get("last_five")), d.get("action"),
104
+ d.get("reason"), _dt(d.get("grace_ends_at")), _dt(d.get("session_started_at")),
105
+ d.get("session_elapsed_s"),
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Profile:
111
+ """One kid. Timer fields are None on a cold start, before Tellybox has seen the cast state."""
112
+
113
+ id: int
114
+ name: str
115
+ avatar: str | None
116
+ allowance_s: int
117
+ extra_s: int | None
118
+ used_s: int | None
119
+ remaining_s: int | None # None = unlimited today (when `unlimited`) or unknown
120
+ unlimited: bool
121
+ blocked: bool
122
+ mode: str # ignore_pauses | wall_clock
123
+ max_session_s: int | None
124
+ session_elapsed_s: int | None
125
+ can_start: bool | None
126
+ reason: str | None
127
+ watching: bool
128
+ last_five: bool
129
+
130
+ @property
131
+ def time_up(self) -> bool:
132
+ """This kid may not start a pick now (out of time, blocked or past the session max)."""
133
+ return self.can_start is False
134
+
135
+ @classmethod
136
+ def from_dict(cls, d: dict[str, Any]) -> Profile:
137
+ return cls(
138
+ int(d["id"]), d["name"], d.get("avatar"), int(d["allowance_s"]), d.get("extra_s"), d.get("used_s"),
139
+ d.get("remaining_s"), bool(d.get("unlimited")), bool(d.get("blocked")), d.get("mode", "ignore_pauses"),
140
+ d.get("max_session_s"), d.get("session_elapsed_s"), d.get("can_start"), d.get("reason"),
141
+ bool(d.get("watching")), bool(d.get("last_five")),
142
+ )
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class Jobs:
147
+ queued: int
148
+ running: int
149
+ failed: int
150
+ held_ready: int # downloads waiting for approval
151
+
152
+ @classmethod
153
+ def from_dict(cls, d: dict[str, Any]) -> Jobs:
154
+ return cls(int(d.get("queued", 0)), int(d.get("running", 0)), int(d.get("failed", 0)),
155
+ int(d.get("held_ready", 0)))
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class Disk:
160
+ media_bytes: int
161
+ free_bytes: int | None
162
+
163
+ @classmethod
164
+ def from_dict(cls, d: dict[str, Any]) -> Disk:
165
+ return cls(int(d.get("media_bytes", 0)), d.get("free_bytes"))
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class AdminState:
170
+ """`GET /api/admin/state`, each `/api/admin/events` event and every override response (HA-2)."""
171
+
172
+ instance_id: str
173
+ version: str
174
+ api: int
175
+ day: Day
176
+ tv: TV
177
+ now_playing: NowPlaying | None
178
+ group: Group
179
+ profiles: tuple[Profile, ...] # in the admin's order
180
+ jobs: Jobs
181
+ disk: Disk
182
+ raw: dict[str, Any] = field(repr=False, compare=False, default_factory=dict)
183
+
184
+ def profile(self, profile_id: int) -> Profile | None:
185
+ return next((p for p in self.profiles if p.id == profile_id), None)
186
+
187
+ @classmethod
188
+ def from_dict(cls, d: dict[str, Any]) -> AdminState:
189
+ np = d.get("now_playing")
190
+ return cls(
191
+ d["instance_id"], d["version"], int(d.get("api", 1)), Day.from_dict(d.get("day") or {}),
192
+ TV.from_dict(d["tv"]), NowPlaying.from_dict(np) if np else None, Group.from_dict(d.get("group") or {}),
193
+ tuple(Profile.from_dict(p) for p in d.get("profiles", ())), Jobs.from_dict(d.get("jobs") or {}),
194
+ Disk.from_dict(d.get("disk") or {}), d,
195
+ )
196
+
197
+
198
+ # --------------------------------------------------------------------------- kid API (browse and play)
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class KidProfile:
203
+ """`GET /api/kid/profiles` item."""
204
+
205
+ profile_id: int
206
+ name: str
207
+ picture: str | None # path of the uploaded photo, e.g. /img/profile/1.jpg
208
+ avatar: str | None # built-in avatar key: /static/avatars/{avatar}.svg
209
+
210
+ @classmethod
211
+ def from_dict(cls, d: dict[str, Any]) -> KidProfile:
212
+ return cls(int(d["profile_id"]), d["name"], d.get("picture"), d.get("avatar"))
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class Tile:
217
+ episode_id: int
218
+ show_id: int
219
+ thumb: str # path, e.g. /img/episode/4.jpg
220
+ title: str
221
+ progress: float | None
222
+ finished: bool
223
+ kind: str | None = None # resume | next (continue watching only)
224
+
225
+ @classmethod
226
+ def from_dict(cls, d: dict[str, Any]) -> Tile:
227
+ return cls(int(d["episode_id"]), int(d["show_id"]), d["thumb"], d.get("title") or "", d.get("progress"),
228
+ bool(d.get("finished")), d.get("kind"))
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class ShowRef:
233
+ show_id: int
234
+ artwork: str # path, e.g. /img/show/2.jpg
235
+ title: str
236
+
237
+ @classmethod
238
+ def from_dict(cls, d: dict[str, Any]) -> ShowRef:
239
+ return cls(int(d["show_id"]), d["artwork"], d.get("title") or "")
240
+
241
+
242
+ @dataclass(frozen=True)
243
+ class Home:
244
+ """`GET /api/kid/home`."""
245
+
246
+ continue_watching: tuple[Tile, ...]
247
+ shows: tuple[ShowRef, ...]
248
+
249
+ @classmethod
250
+ def from_dict(cls, d: dict[str, Any]) -> Home:
251
+ return cls(tuple(Tile.from_dict(t) for t in d.get("continue", ())),
252
+ tuple(ShowRef.from_dict(s) for s in d.get("shows", ())))
253
+
254
+
255
+ @dataclass(frozen=True)
256
+ class Show:
257
+ """`GET /api/kid/shows/{id}`."""
258
+
259
+ show_id: int
260
+ artwork: str
261
+ title: str
262
+ episodes: tuple[Tile, ...]
263
+
264
+ @classmethod
265
+ def from_dict(cls, d: dict[str, Any]) -> Show:
266
+ return cls(int(d["show_id"]), d["artwork"], d.get("title") or "",
267
+ tuple(Tile.from_dict(t) for t in d.get("episodes", ())))
pytellybox/py.typed ADDED
File without changes
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytellybox
3
+ Version: 0.1.0
4
+ Summary: Async client for the Tellybox admin and kid APIs (used by the Home Assistant integration)
5
+ Project-URL: Homepage, https://github.com/sandermvanvliet/pytellybox
6
+ Project-URL: Tellybox, https://github.com/sandermvanvliet/Tellybox
7
+ Author: Sander van Vliet
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Home Automation
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.13
16
+ Requires-Dist: aiohttp>=3.10
17
+ Provides-Extra: test
18
+ Requires-Dist: aioresponses>=0.7; extra == 'test'
19
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
20
+ Requires-Dist: pytest>=8; extra == 'test'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pytellybox
24
+
25
+ An async Python client for [Tellybox](https://github.com/sandermvanvliet/Tellybox), the self-hosted app that lets young kids pick parent-approved videos for the TV within a daily time allowance. It is the library behind the [Home Assistant integration](https://github.com/sandermvanvliet/ha-tellybox).
26
+
27
+ - **Admin API** (bearer token from Tellybox's *Integrations* page): live state and its event stream, and the parent overrides (extra time, unlimited today, block today, stop now, clear today) for everyone or for chosen kids.
28
+ - **Kid API** (no token): browse shows and episodes, play, pause and resume. A play is refused when someone is out of time, because Tellybox's timer always decides.
29
+
30
+ ```python
31
+ import aiohttp
32
+ from pytellybox import TellyboxClient
33
+
34
+ async with aiohttp.ClientSession() as session:
35
+ tellybox = TellyboxClient("https://tellybox.example", "tbx_…", session)
36
+ state = await tellybox.state()
37
+ for kid in state.profiles:
38
+ print(kid.name, kid.remaining_s)
39
+ await tellybox.add_time(15, profile_ids=[1])
40
+ async for state in tellybox.events(): # the current state first, then one per change
41
+ print(state.group.remaining_s)
42
+ ```
43
+
44
+ For development without a Tellybox, run the mock server: `python -m pytellybox.mock --port 8099 --token tbx_dev` (add `--read-token tbx_ro` for a read-only token). Tests can use `MockTellybox` from `pytellybox.mock` with aiohttp's `TestServer`.
45
+
46
+ The API contract is Tellybox's [`docs/admin-api.md`](https://github.com/sandermvanvliet/Tellybox/blob/main/docs/admin-api.md) and [`docs/kid-api.md`](https://github.com/sandermvanvliet/Tellybox/blob/main/docs/kid-api.md). Tellybox is for the LAN and Tailscale only; use HTTPS behind your reverse proxy, and keep tokens out of logs.
47
+
48
+ ## Development
49
+
50
+ ```sh
51
+ python3 -m venv .venv && .venv/bin/pip install -e '.[test]'
52
+ .venv/bin/python -m pytest -q
53
+ ```
54
+
55
+ Releases are published to PyPI by GitHub Actions when a `v*` tag is pushed (trusted publishing).
56
+
57
+ Licensed under Apache-2.0.
@@ -0,0 +1,11 @@
1
+ pytellybox/__init__.py,sha256=v2oUAgGnBdVCXWrMlyS1zkV0OiwkT-CcsAiR0oEXR48,1008
2
+ pytellybox/admin_state.json,sha256=DyunpjBoi4iEVIT6Ktr7SgmLrgCUJPALxAd1M5EPGRY,1454
3
+ pytellybox/client.py,sha256=ZyPPTg2-iMqVTG6TJma-BPBJtiNH9gdBk7xZdwmJ0Oo,10642
4
+ pytellybox/errors.py,sha256=hLe23ExMOzY32qXn7ZpWk89lv4GbTObNsQKbRNGCZr4,1159
5
+ pytellybox/mock.py,sha256=O3dGPkQSQUA-SQpvZYRuWHyqlLLPSrJfkvnNPg5neu4,20462
6
+ pytellybox/models.py,sha256=tg4gD4Tl0a-i9EDqKM0uXtsZ88iz9k26zwdz9osuqMo,8281
7
+ pytellybox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pytellybox-0.1.0.dist-info/METADATA,sha256=LLb4cJcauQDCUaQ57evi-KzuMRIetXv63pUtdf0ZSGA,2825
9
+ pytellybox-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ pytellybox-0.1.0.dist-info/licenses/LICENSE,sha256=_DMpHboiA8TTPB3jrnYlmyWoJFhBCXXoYXKnGmQyiI8,11347
11
+ pytellybox-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,202 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Sander van Vliet
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+