aiosamstream 0.1.0__tar.gz

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.
Files changed (52) hide show
  1. aiosamstream-0.1.0/.gitignore +58 -0
  2. aiosamstream-0.1.0/PKG-INFO +29 -0
  3. aiosamstream-0.1.0/README.md +10 -0
  4. aiosamstream-0.1.0/pyproject.toml +32 -0
  5. aiosamstream-0.1.0/src/aiosamstream/__init__.py +47 -0
  6. aiosamstream-0.1.0/src/aiosamstream/__main__.py +104 -0
  7. aiosamstream-0.1.0/src/aiosamstream/apps.py +83 -0
  8. aiosamstream-0.1.0/src/aiosamstream/art.py +518 -0
  9. aiosamstream-0.1.0/src/aiosamstream/ca/NOTICE.md +8 -0
  10. aiosamstream-0.1.0/src/aiosamstream/ca/vd_tizen_dev_author_ca.cer +27 -0
  11. aiosamstream-0.1.0/src/aiosamstream/ca/vd_tizen_dev_partner2.crt +28 -0
  12. aiosamstream-0.1.0/src/aiosamstream/ca/vd_tizen_dev_public2.crt +28 -0
  13. aiosamstream-0.1.0/src/aiosamstream/cast.py +43 -0
  14. aiosamstream-0.1.0/src/aiosamstream/const.py +18 -0
  15. aiosamstream-0.1.0/src/aiosamstream/exceptions.py +52 -0
  16. aiosamstream-0.1.0/src/aiosamstream/lounge.py +234 -0
  17. aiosamstream-0.1.0/src/aiosamstream/models.py +100 -0
  18. aiosamstream-0.1.0/src/aiosamstream/ocf/__init__.py +51 -0
  19. aiosamstream-0.1.0/src/aiosamstream/ocf/cbor.py +165 -0
  20. aiosamstream-0.1.0/src/aiosamstream/ocf/certs.py +163 -0
  21. aiosamstream-0.1.0/src/aiosamstream/ocf/cloud.py +845 -0
  22. aiosamstream-0.1.0/src/aiosamstream/ocf/coap_tcp.py +217 -0
  23. aiosamstream-0.1.0/src/aiosamstream/ocf/dtls.py +533 -0
  24. aiosamstream-0.1.0/src/aiosamstream/ocf/onboard.py +652 -0
  25. aiosamstream-0.1.0/src/aiosamstream/ocf/onboard_cli.py +126 -0
  26. aiosamstream-0.1.0/src/aiosamstream/ocf/state.py +324 -0
  27. aiosamstream-0.1.0/src/aiosamstream/py.typed +0 -0
  28. aiosamstream-0.1.0/src/aiosamstream/samsung_cert.py +291 -0
  29. aiosamstream-0.1.0/src/aiosamstream/sdb.py +469 -0
  30. aiosamstream-0.1.0/src/aiosamstream/signing.py +238 -0
  31. aiosamstream-0.1.0/src/aiosamstream/tv.py +425 -0
  32. aiosamstream-0.1.0/src/aiosamstream/upnp.py +86 -0
  33. aiosamstream-0.1.0/src/aiosamstream/wol.py +47 -0
  34. aiosamstream-0.1.0/tests/conftest.py +211 -0
  35. aiosamstream-0.1.0/tests/fixtures/device_info_qn800c.json +33 -0
  36. aiosamstream-0.1.0/tests/fixtures/vd_applist_excerpt.txt +22 -0
  37. aiosamstream-0.1.0/tests/test_apps.py +179 -0
  38. aiosamstream-0.1.0/tests/test_art.py +310 -0
  39. aiosamstream-0.1.0/tests/test_cast.py +22 -0
  40. aiosamstream-0.1.0/tests/test_dtls.py +94 -0
  41. aiosamstream-0.1.0/tests/test_lounge.py +101 -0
  42. aiosamstream-0.1.0/tests/test_models.py +53 -0
  43. aiosamstream-0.1.0/tests/test_ocf.py +645 -0
  44. aiosamstream-0.1.0/tests/test_onboard.py +187 -0
  45. aiosamstream-0.1.0/tests/test_package.py +12 -0
  46. aiosamstream-0.1.0/tests/test_remote.py +218 -0
  47. aiosamstream-0.1.0/tests/test_samsung_cert.py +258 -0
  48. aiosamstream-0.1.0/tests/test_sdb.py +222 -0
  49. aiosamstream-0.1.0/tests/test_signing.py +251 -0
  50. aiosamstream-0.1.0/tests/test_tv.py +116 -0
  51. aiosamstream-0.1.0/tests/test_upnp.py +92 -0
  52. aiosamstream-0.1.0/tests/test_wol.py +44 -0
@@ -0,0 +1,58 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ *.egg-info/
11
+
12
+ node_modules/
13
+ tv-app/dist/
14
+ tv-app/.buildResult/
15
+ *.wgt
16
+ !custom_components/samstream/bundled/*.wgt
17
+
18
+ # Local stream URLs / camera credentials
19
+ .env.local
20
+ tv-app/.signing/
21
+ .env.*.local
22
+
23
+ # Signing material never goes in git
24
+ *.p12
25
+ author.xml
26
+ distributor.xml
27
+
28
+ # Pairing token written by `python -m aiosamstream pair`
29
+ .samstream-token
30
+
31
+ # HA URL + long-lived token for dev deploys
32
+ .env.ha
33
+
34
+ # Local dev secrets (sdb RSA key, etc.)
35
+ .dev/
36
+
37
+ # Pairing tokens for each TV, and TV state dumps
38
+ .samstream-token*
39
+ tv_state*.json
40
+
41
+ # OCF research: private keys and TV credentials never go in git
42
+ *.key
43
+ oicseek/tv_cred*.json
44
+ oicseek/tv_state*.json
45
+
46
+ # Scratch files
47
+ scratchpad/
48
+
49
+ # OCF / recovery spikes and tooling caches: keys, certs, device ACL dumps, vendored
50
+ # deps and agent runtime state never go in git.
51
+ oicseek/
52
+ dsh-acl-recovery/
53
+ tv-app/probe/
54
+ .claude-flow/
55
+
56
+ # .NET notifier build output (the prebuilt .tpk is bundled in the integration)
57
+ tv-app/notifier-dotnet/bin/
58
+ tv-app/notifier-dotnet/obj/
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.5
2
+ Name: aiosamstream
3
+ Version: 0.1.0
4
+ Summary: Async client for Samsung Tizen TVs: control, sdb sideloading and the Samstream player app
5
+ Project-URL: Source, https://github.com/Mrslimon/samstream
6
+ Author: Islam Ahmady
7
+ License-Expression: Apache-2.0
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.14.2
13
+ Requires-Dist: aiohttp>=3.12
14
+ Requires-Dist: async-timeout>=4
15
+ Requires-Dist: cryptography>=43
16
+ Requires-Dist: samsungtvws[async]<4,>=2.7.2
17
+ Requires-Dist: websockets>=15
18
+ Description-Content-Type: text/markdown
19
+
20
+ # aiosamstream
21
+
22
+ Async Python library behind the Samstream Home Assistant integration.
23
+
24
+ - `tv` – pairing, token persistence, remote keys, device info, app list/launch (wraps `samsungtvws`)
25
+ - `sdb` – pure-Python Smart Development Bridge client (no Tizen Studio binary needed)
26
+ - `signing` – per-TV `.wgt` signing (author + distributor XML signatures)
27
+ - `wol` – directed Wake-on-LAN
28
+
29
+ Status: skeleton, no logic yet.
@@ -0,0 +1,10 @@
1
+ # aiosamstream
2
+
3
+ Async Python library behind the Samstream Home Assistant integration.
4
+
5
+ - `tv` – pairing, token persistence, remote keys, device info, app list/launch (wraps `samsungtvws`)
6
+ - `sdb` – pure-Python Smart Development Bridge client (no Tizen Studio binary needed)
7
+ - `signing` – per-TV `.wgt` signing (author + distributor XML signatures)
8
+ - `wol` – directed Wake-on-LAN
9
+
10
+ Status: skeleton, no logic yet.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aiosamstream"
7
+ version = "0.1.0"
8
+ description = "Async client for Samsung Tizen TVs: control, sdb sideloading and the Samstream player app"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.14.2"
12
+ authors = [{ name = "Islam Ahmady" }]
13
+ classifiers = [
14
+ "Development Status :: 2 - Pre-Alpha",
15
+ "Framework :: AsyncIO",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "aiohttp>=3.12",
21
+ "cryptography>=43", # sdb RSA handshake; already in Home Assistant
22
+ "samsungtvws[async]>=2.7.2,<4", # HA core samsungtv pins 2.7.2
23
+ "websockets>=15",
24
+ # samsungtvws 2.7.x imports it without declaring it
25
+ "async-timeout>=4",
26
+ ]
27
+
28
+ [project.urls]
29
+ Source = "https://github.com/Mrslimon/samstream"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/aiosamstream"]
@@ -0,0 +1,47 @@
1
+ """Async client for Samsung Tizen TVs and the Samstream player app."""
2
+
3
+ from .apps import KNOWN_APPS, YOUTUBE_APP_ID, ListedApp, parse_app_list
4
+ from .art import ArtConnection
5
+ from .cast import youtube_id
6
+ from .const import SAMSTREAM_APP_ID
7
+ from .exceptions import (
8
+ OcfError,
9
+ OcfNotConnectedError,
10
+ OcfTvOwnedError,
11
+ SamstreamAuthError,
12
+ SamstreamConnectionError,
13
+ SamstreamError,
14
+ SamstreamTimeoutError,
15
+ SdbConnectionError,
16
+ SdbError,
17
+ )
18
+ from .models import AppStatus, DeviceInfo
19
+ from .sdb import InstallResult, SdbClient, generate_key
20
+ from .tv import SamsungTV
21
+ from .wol import send_magic_packet
22
+
23
+ __all__ = [
24
+ "KNOWN_APPS",
25
+ "SAMSTREAM_APP_ID",
26
+ "YOUTUBE_APP_ID",
27
+ "AppStatus",
28
+ "ArtConnection",
29
+ "DeviceInfo",
30
+ "InstallResult",
31
+ "ListedApp",
32
+ "OcfError",
33
+ "OcfNotConnectedError",
34
+ "OcfTvOwnedError",
35
+ "SamstreamAuthError",
36
+ "SamstreamConnectionError",
37
+ "SamstreamError",
38
+ "SamstreamTimeoutError",
39
+ "SamsungTV",
40
+ "SdbClient",
41
+ "SdbConnectionError",
42
+ "SdbError",
43
+ "generate_key",
44
+ "parse_app_list",
45
+ "send_magic_packet",
46
+ "youtube_id",
47
+ ]
@@ -0,0 +1,104 @@
1
+ """Developer CLI: `python -m aiosamstream {info,pair,wol} --host <tv>`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import dataclasses
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import aiohttp
12
+
13
+ from . import SamstreamError, SamsungTV, send_magic_packet
14
+ from .wol import DEFAULT_TARGET
15
+
16
+
17
+ async def _run(args: argparse.Namespace, token: str | None) -> int:
18
+ token_file = Path(args.token_file)
19
+ async with aiohttp.ClientSession() as session:
20
+ tv = SamsungTV(args.host, session, token=token)
21
+ if args.command == "info":
22
+ info = await tv.device_info()
23
+ print(json.dumps(dataclasses.asdict(info), indent=2, ensure_ascii=False))
24
+ elif args.command == "pair":
25
+ if token:
26
+ print("Stored token found: the TV should accept without a popup.")
27
+ else:
28
+ print('Accept the "Allow" popup on the TV...')
29
+ new_token = await tv.pair(accept_within=args.wait)
30
+ if new_token is None:
31
+ print("This TV does not use token authentication.")
32
+ elif new_token == token:
33
+ print("Connected with the stored token (no popup).")
34
+ else:
35
+ await asyncio.to_thread(token_file.write_text, new_token, "utf-8")
36
+ print(f"Paired, token saved to {token_file}.")
37
+ elif args.command == "wol":
38
+ mac = args.mac or (await tv.device_info()).mac
39
+ if not mac:
40
+ print("No MAC known, pass --mac.")
41
+ return 1
42
+ await send_magic_packet(mac, target=args.target)
43
+ print(f"Magic packet sent to {mac} via {args.target}.")
44
+ elif args.command == "art":
45
+ return await _run_art(tv, args)
46
+ return 0
47
+
48
+
49
+ async def _run_art(tv: SamsungTV, args: argparse.Namespace) -> int:
50
+ """Run one `art` subcommand (probing a Frame's art-app channel)."""
51
+ async with tv.art() as art:
52
+ if args.art == "status":
53
+ print(f"art mode: {'on' if await art.get_art_mode() else 'off'}")
54
+ elif args.art == "list":
55
+ items = await art.available()
56
+ print(f"{len(items)} artworks:")
57
+ for item in items:
58
+ print(f" {item.get('content_id')} {item.get('category_id')}")
59
+ elif args.art == "current":
60
+ print(f"current: {await art.current()}")
61
+ elif args.art in ("on", "off"):
62
+ await art.set_art_mode(on=args.art == "on")
63
+ print(f"art mode -> {args.art}")
64
+ elif args.art == "select":
65
+ if not args.content_id:
66
+ print("Pass --content-id.")
67
+ return 1
68
+ await art.select(args.content_id)
69
+ print(f"selected {args.content_id}")
70
+ return 0
71
+
72
+
73
+ def main() -> int:
74
+ """Parse arguments and run one command."""
75
+ parser = argparse.ArgumentParser(prog="python -m aiosamstream")
76
+ parser.add_argument("command", choices=["info", "pair", "wol", "art"])
77
+ parser.add_argument("--host", required=True)
78
+ parser.add_argument(
79
+ "--art",
80
+ choices=["status", "list", "current", "on", "off", "select"],
81
+ default="status",
82
+ help="art subcommand (with `art`)",
83
+ )
84
+ parser.add_argument("--content-id", help="artwork id for `art --art select`")
85
+ parser.add_argument("--token-file", default=".samstream-token")
86
+ parser.add_argument("--wait", type=float, default=60, help="seconds to accept")
87
+ parser.add_argument("--mac", help="for wol; read from the TV when omitted")
88
+ parser.add_argument(
89
+ "--target", default=DEFAULT_TARGET, help="wol broadcast/unicast address"
90
+ )
91
+ args = parser.parse_args()
92
+ token_file = Path(args.token_file)
93
+ token = None
94
+ if token_file.exists():
95
+ token = token_file.read_text(encoding="utf-8").strip()
96
+ try:
97
+ return asyncio.run(_run(args, token))
98
+ except SamstreamError as err:
99
+ print(f"Error: {err}")
100
+ return 1
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())
@@ -0,0 +1,83 @@
1
+ """Known Tizen app IDs.
2
+
3
+ 2020+ firmwares no longer answer `ed.installedApp.get`, so the installed apps are
4
+ found by asking the TV about each known ID (unknown ones answer 404). Names here
5
+ are only fallbacks: the TV reports its own.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from typing import Final
13
+
14
+ from .const import SAMSTREAM_APP_ID
15
+
16
+ # The YouTube Tizen app; launched with a `v=<id>` deep link to cast a video.
17
+ YOUTUBE_APP_ID: Final = "111299001912"
18
+
19
+ # Confirmed on a 2023 QN800C (Tizen 9): YouTube, Netflix, Prime Video, Spotify,
20
+ # Apple TV, Browser. The others are widely published IDs.
21
+ KNOWN_APPS: Final[dict[str, str]] = {
22
+ YOUTUBE_APP_ID: "YouTube",
23
+ "3201907018807": "Netflix",
24
+ "3201910019365": "Prime Video",
25
+ "3201901017640": "Disney+",
26
+ "3201606009684": "Spotify",
27
+ "3201807016597": "Apple TV",
28
+ "3201908019041": "Apple Music",
29
+ "3202010022079": "Browser",
30
+ "3201512006963": "Plex",
31
+ "3201611010983": "YouTube Kids",
32
+ "3201601007230": "HBO Max",
33
+ "3201710014981": "Paramount+",
34
+ "3201808016802": "Pluto TV",
35
+ "3201511006428": "Rakuten TV",
36
+ "3201608010385": "Deezer",
37
+ "3201702011851": "Steam Link",
38
+ "11091000000": "Facebook Watch",
39
+ SAMSTREAM_APP_ID: "Samstream",
40
+ }
41
+
42
+
43
+ # vd_applist prints blocks of "-------<key> =<value>-------" lines between rules.
44
+ _FIELD = re.compile(r"^\s*-{3,}\s*(?P<key>[^\s=]+)\s*=\s*(?P<value>.*?)\s*-{3,}\s*$")
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class ListedApp:
49
+ """One installed app from `vd_applist` (needs Developer Mode / sdb)."""
50
+
51
+ # The store ID the REST API understands (e.g. 111299001912), not the Tizen ID.
52
+ app_id: str
53
+ tizen_id: str
54
+ name: str
55
+ version: str | None
56
+
57
+
58
+ def parse_app_list(text: str) -> list[ListedApp]:
59
+ """Parse `0 vd_applist` output; hidden apps are left out."""
60
+ blocks: list[dict[str, str]] = []
61
+ current: dict[str, str] = {}
62
+ for line in text.splitlines():
63
+ if match := _FIELD.match(line):
64
+ current[match.group("key")] = match.group("value")
65
+ elif current and "----" in line and "=" not in line:
66
+ blocks.append(current)
67
+ current = {}
68
+ if current:
69
+ blocks.append(current)
70
+ listed = []
71
+ for block in blocks:
72
+ app_id = block.get("app_id") or block.get("app_tizen_id")
73
+ if not app_id or block.get("is_hidden") == "1":
74
+ continue
75
+ listed.append(
76
+ ListedApp(
77
+ app_id=app_id,
78
+ tizen_id=block.get("app_tizen_id", app_id),
79
+ name=block.get("app_title") or app_id,
80
+ version=block.get("app_version") or None,
81
+ )
82
+ )
83
+ return listed