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.
- aiosamstream-0.1.0/.gitignore +58 -0
- aiosamstream-0.1.0/PKG-INFO +29 -0
- aiosamstream-0.1.0/README.md +10 -0
- aiosamstream-0.1.0/pyproject.toml +32 -0
- aiosamstream-0.1.0/src/aiosamstream/__init__.py +47 -0
- aiosamstream-0.1.0/src/aiosamstream/__main__.py +104 -0
- aiosamstream-0.1.0/src/aiosamstream/apps.py +83 -0
- aiosamstream-0.1.0/src/aiosamstream/art.py +518 -0
- aiosamstream-0.1.0/src/aiosamstream/ca/NOTICE.md +8 -0
- aiosamstream-0.1.0/src/aiosamstream/ca/vd_tizen_dev_author_ca.cer +27 -0
- aiosamstream-0.1.0/src/aiosamstream/ca/vd_tizen_dev_partner2.crt +28 -0
- aiosamstream-0.1.0/src/aiosamstream/ca/vd_tizen_dev_public2.crt +28 -0
- aiosamstream-0.1.0/src/aiosamstream/cast.py +43 -0
- aiosamstream-0.1.0/src/aiosamstream/const.py +18 -0
- aiosamstream-0.1.0/src/aiosamstream/exceptions.py +52 -0
- aiosamstream-0.1.0/src/aiosamstream/lounge.py +234 -0
- aiosamstream-0.1.0/src/aiosamstream/models.py +100 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/__init__.py +51 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/cbor.py +165 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/certs.py +163 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/cloud.py +845 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/coap_tcp.py +217 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/dtls.py +533 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/onboard.py +652 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/onboard_cli.py +126 -0
- aiosamstream-0.1.0/src/aiosamstream/ocf/state.py +324 -0
- aiosamstream-0.1.0/src/aiosamstream/py.typed +0 -0
- aiosamstream-0.1.0/src/aiosamstream/samsung_cert.py +291 -0
- aiosamstream-0.1.0/src/aiosamstream/sdb.py +469 -0
- aiosamstream-0.1.0/src/aiosamstream/signing.py +238 -0
- aiosamstream-0.1.0/src/aiosamstream/tv.py +425 -0
- aiosamstream-0.1.0/src/aiosamstream/upnp.py +86 -0
- aiosamstream-0.1.0/src/aiosamstream/wol.py +47 -0
- aiosamstream-0.1.0/tests/conftest.py +211 -0
- aiosamstream-0.1.0/tests/fixtures/device_info_qn800c.json +33 -0
- aiosamstream-0.1.0/tests/fixtures/vd_applist_excerpt.txt +22 -0
- aiosamstream-0.1.0/tests/test_apps.py +179 -0
- aiosamstream-0.1.0/tests/test_art.py +310 -0
- aiosamstream-0.1.0/tests/test_cast.py +22 -0
- aiosamstream-0.1.0/tests/test_dtls.py +94 -0
- aiosamstream-0.1.0/tests/test_lounge.py +101 -0
- aiosamstream-0.1.0/tests/test_models.py +53 -0
- aiosamstream-0.1.0/tests/test_ocf.py +645 -0
- aiosamstream-0.1.0/tests/test_onboard.py +187 -0
- aiosamstream-0.1.0/tests/test_package.py +12 -0
- aiosamstream-0.1.0/tests/test_remote.py +218 -0
- aiosamstream-0.1.0/tests/test_samsung_cert.py +258 -0
- aiosamstream-0.1.0/tests/test_sdb.py +222 -0
- aiosamstream-0.1.0/tests/test_signing.py +251 -0
- aiosamstream-0.1.0/tests/test_tv.py +116 -0
- aiosamstream-0.1.0/tests/test_upnp.py +92 -0
- 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
|