atvnotif 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.
- atvnotif-0.1.0/PKG-INFO +60 -0
- atvnotif-0.1.0/README.md +35 -0
- atvnotif-0.1.0/atvnotif/__init__.py +5 -0
- atvnotif-0.1.0/atvnotif/client.py +149 -0
- atvnotif-0.1.0/atvnotif/discover.py +90 -0
- atvnotif-0.1.0/atvnotif/qr.py +184 -0
- atvnotif-0.1.0/atvnotif.egg-info/PKG-INFO +60 -0
- atvnotif-0.1.0/atvnotif.egg-info/SOURCES.txt +11 -0
- atvnotif-0.1.0/atvnotif.egg-info/dependency_links.txt +1 -0
- atvnotif-0.1.0/atvnotif.egg-info/requires.txt +3 -0
- atvnotif-0.1.0/atvnotif.egg-info/top_level.txt +1 -0
- atvnotif-0.1.0/pyproject.toml +38 -0
- atvnotif-0.1.0/setup.cfg +4 -0
atvnotif-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atvnotif
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python client for sending encrypted notifications to Android TV Notifier
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/atvnotif/atvnotif.py
|
|
7
|
+
Project-URL: Repository, https://github.com/atvnotif/atvnotif.py
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/atvnotif/atvnotif.py/issues
|
|
9
|
+
Keywords: android-tv,notifications,home-automation,atvnotif
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Classifier: Topic :: Communications
|
|
19
|
+
Classifier: Framework :: AsyncIO
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: cryptography>=35.0.0
|
|
23
|
+
Requires-Dist: httpx>=0.20.0
|
|
24
|
+
Requires-Dist: zeroconf>=0.33.0
|
|
25
|
+
|
|
26
|
+
# atvnotif
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/atvnotif/)
|
|
29
|
+
[](https://pypi.org/project/atvnotif/)
|
|
30
|
+
[](https://github.com/atvnotif/atvnotif.py/blob/main/LICENSE)
|
|
31
|
+
|
|
32
|
+
A Python client library for sending encrypted notifications to the **Android TV Notifier** app (`com.smrtprjcts.atvnotif`).
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install atvnotif
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from atvnotif import ATVNotifier
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
# Replace with your TV's IP address and pairing code (remove hyphens or leave them, the library cleans it)
|
|
48
|
+
notifier = ATVNotifier("192.168.1.100", "1234-5678-ABCD")
|
|
49
|
+
|
|
50
|
+
await notifier.async_notify(
|
|
51
|
+
message="Someone is at the door!",
|
|
52
|
+
title="Doorbell Alert",
|
|
53
|
+
duration=10,
|
|
54
|
+
position=0, # Top-Right
|
|
55
|
+
notif_sound=True
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
atvnotif-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# atvnotif
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/atvnotif/)
|
|
4
|
+
[](https://pypi.org/project/atvnotif/)
|
|
5
|
+
[](https://github.com/atvnotif/atvnotif.py/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
A Python client library for sending encrypted notifications to the **Android TV Notifier** app (`com.smrtprjcts.atvnotif`).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install atvnotif
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
from atvnotif import ATVNotifier
|
|
20
|
+
|
|
21
|
+
async def main():
|
|
22
|
+
# Replace with your TV's IP address and pairing code (remove hyphens or leave them, the library cleans it)
|
|
23
|
+
notifier = ATVNotifier("192.168.1.100", "1234-5678-ABCD")
|
|
24
|
+
|
|
25
|
+
await notifier.async_notify(
|
|
26
|
+
message="Someone is at the door!",
|
|
27
|
+
title="Doorbell Alert",
|
|
28
|
+
duration=10,
|
|
29
|
+
position=0, # Top-Right
|
|
30
|
+
notif_sound=True
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
asyncio.run(main())
|
|
35
|
+
```
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import httpx
|
|
4
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
5
|
+
|
|
6
|
+
class ATVNotifier:
|
|
7
|
+
"""Client class to communicate and send encrypted notifications to Android TV Notifier."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, host: str, pairing_code: str, port: int = 7878):
|
|
10
|
+
self.host = host
|
|
11
|
+
self.port = port
|
|
12
|
+
self.pairing_code = pairing_code
|
|
13
|
+
self.key = self._derive_key(pairing_code)
|
|
14
|
+
|
|
15
|
+
def _derive_key(self, pairing_code: str) -> bytes:
|
|
16
|
+
"""Derives the 32-byte AES key from the pairing code by reversing and repeating it."""
|
|
17
|
+
cleaned = pairing_code.replace("-", "")
|
|
18
|
+
reversed_code = cleaned[::-1]
|
|
19
|
+
padded = (reversed_code * 32)[:32]
|
|
20
|
+
return padded.encode("utf-8")
|
|
21
|
+
|
|
22
|
+
def encrypt_payload(self, data: dict) -> bytes:
|
|
23
|
+
"""Encrypts the JSON payload using AES-256-GCM with a random 12-byte IV."""
|
|
24
|
+
json_payload = json.dumps(data)
|
|
25
|
+
return self.encrypt_raw_string(json_payload)
|
|
26
|
+
|
|
27
|
+
def encrypt_raw_string(self, text: str) -> bytes:
|
|
28
|
+
"""Encrypts a raw string using AES-256-GCM with a random 12-byte IV."""
|
|
29
|
+
aesgcm = AESGCM(self.key)
|
|
30
|
+
iv = os.urandom(12)
|
|
31
|
+
ciphertext = aesgcm.encrypt(iv, text.encode("utf-8"), None)
|
|
32
|
+
return iv + ciphertext
|
|
33
|
+
|
|
34
|
+
def decrypt_payload(self, encrypted_data: bytes) -> str:
|
|
35
|
+
"""Decrypts a response payload using AES-256-GCM."""
|
|
36
|
+
if len(encrypted_data) < 28:
|
|
37
|
+
raise ValueError("Encrypted data too short")
|
|
38
|
+
iv = encrypted_data[:12]
|
|
39
|
+
ciphertext = encrypted_data[12:]
|
|
40
|
+
aesgcm = AESGCM(self.key)
|
|
41
|
+
decrypted = aesgcm.decrypt(iv, ciphertext, None)
|
|
42
|
+
return decrypted.decode("utf-8")
|
|
43
|
+
|
|
44
|
+
async def async_get_apps(self) -> list[str]:
|
|
45
|
+
"""Retrieves a list of installed apps on the TV."""
|
|
46
|
+
url = f"http://{self.host}:{self.port}/apps"
|
|
47
|
+
encrypted_body = self.encrypt_payload({})
|
|
48
|
+
async with httpx.AsyncClient() as client:
|
|
49
|
+
response = await client.post(
|
|
50
|
+
url,
|
|
51
|
+
content=encrypted_body,
|
|
52
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
53
|
+
timeout=10.0,
|
|
54
|
+
)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
decrypted_resp = self.decrypt_payload(response.content)
|
|
57
|
+
return json.loads(decrypted_resp)
|
|
58
|
+
|
|
59
|
+
async def async_get_info(self) -> str:
|
|
60
|
+
"""Retrieves TV info (usually the device name)."""
|
|
61
|
+
url = f"http://{self.host}:{self.port}/info"
|
|
62
|
+
encrypted_body = self.encrypt_payload({})
|
|
63
|
+
async with httpx.AsyncClient() as client:
|
|
64
|
+
response = await client.post(
|
|
65
|
+
url,
|
|
66
|
+
content=encrypted_body,
|
|
67
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
68
|
+
timeout=10.0,
|
|
69
|
+
)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return response.text
|
|
72
|
+
|
|
73
|
+
async def async_open_app(self, package_name: str) -> bool:
|
|
74
|
+
"""Opens an application on the TV by package name."""
|
|
75
|
+
url = f"http://{self.host}:{self.port}/open"
|
|
76
|
+
encrypted_body = self.encrypt_raw_string(package_name)
|
|
77
|
+
async with httpx.AsyncClient() as client:
|
|
78
|
+
response = await client.post(
|
|
79
|
+
url,
|
|
80
|
+
content=encrypted_body,
|
|
81
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
82
|
+
timeout=10.0,
|
|
83
|
+
)
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
return response.text.strip().lower() == "true"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def async_notify(
|
|
89
|
+
self,
|
|
90
|
+
message: str,
|
|
91
|
+
title: str = None,
|
|
92
|
+
sender: str = "Home Assistant",
|
|
93
|
+
duration: int = 5,
|
|
94
|
+
position: int = 0,
|
|
95
|
+
priority: int = 1,
|
|
96
|
+
bg_color: int = None,
|
|
97
|
+
title_color: int = None,
|
|
98
|
+
msg_color: int = None,
|
|
99
|
+
title_size: float = None,
|
|
100
|
+
msg_size: float = None,
|
|
101
|
+
icon: str = None,
|
|
102
|
+
small_icon: str = None,
|
|
103
|
+
big_image: str = None,
|
|
104
|
+
interact: bool = False,
|
|
105
|
+
notif_sound: bool = True,
|
|
106
|
+
wakeup: bool = True,
|
|
107
|
+
) -> httpx.Response:
|
|
108
|
+
"""Sends an encrypted notification payload asynchronously to the TV."""
|
|
109
|
+
payload = {
|
|
110
|
+
"msg": message,
|
|
111
|
+
"src": sender,
|
|
112
|
+
"duration": duration,
|
|
113
|
+
"position": position,
|
|
114
|
+
"prio": priority,
|
|
115
|
+
"interact": interact,
|
|
116
|
+
"notifSound": notif_sound,
|
|
117
|
+
"wakeup": wakeup,
|
|
118
|
+
}
|
|
119
|
+
if title:
|
|
120
|
+
payload["title"] = title
|
|
121
|
+
if bg_color is not None:
|
|
122
|
+
payload["bgColor"] = bg_color
|
|
123
|
+
if title_color is not None:
|
|
124
|
+
payload["titleColor"] = title_color
|
|
125
|
+
if msg_color is not None:
|
|
126
|
+
payload["msgColor"] = msg_color
|
|
127
|
+
if title_size is not None:
|
|
128
|
+
payload["titleSize"] = title_size
|
|
129
|
+
if msg_size is not None:
|
|
130
|
+
payload["msgSize"] = msg_size
|
|
131
|
+
if icon:
|
|
132
|
+
payload["icon"] = icon
|
|
133
|
+
if small_icon:
|
|
134
|
+
payload["smallIcon"] = small_icon
|
|
135
|
+
if big_image:
|
|
136
|
+
payload["bigImg"] = big_image
|
|
137
|
+
|
|
138
|
+
encrypted_body = self.encrypt_payload(payload)
|
|
139
|
+
url = f"http://{self.host}:{self.port}/notify"
|
|
140
|
+
|
|
141
|
+
async with httpx.AsyncClient() as client:
|
|
142
|
+
response = await client.post(
|
|
143
|
+
url,
|
|
144
|
+
content=encrypted_body,
|
|
145
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
146
|
+
timeout=10.0,
|
|
147
|
+
)
|
|
148
|
+
response.raise_for_status()
|
|
149
|
+
return response
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
_LOGGER = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
def decode_base58_uuid(s: str) -> str:
|
|
8
|
+
"""Decodes a Base58 string into a standard UUID string."""
|
|
9
|
+
alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
|
10
|
+
try:
|
|
11
|
+
num = 0
|
|
12
|
+
for char in s:
|
|
13
|
+
num = num * 58 + alphabet.index(char)
|
|
14
|
+
val = num.to_bytes(16, 'big')
|
|
15
|
+
return str(uuid.UUID(bytes=val))
|
|
16
|
+
except Exception as e:
|
|
17
|
+
_LOGGER.debug("Failed to decode base58 UUID from %s: %s", s, e)
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
def discover_devices(timeout: float = 5.0) -> list[dict]:
|
|
21
|
+
"""Scans the network for ATV Notifier devices using zeroconf.
|
|
22
|
+
|
|
23
|
+
Returns a list of dicts, e.g.:
|
|
24
|
+
[
|
|
25
|
+
{
|
|
26
|
+
"name": "Vodafone Vodafone TV 3",
|
|
27
|
+
"host": "192.168.2.104",
|
|
28
|
+
"port": 7878,
|
|
29
|
+
"pairing_code": "60ce69b0-ced7-4872-ba77-66f8dcaf8ba4"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
from zeroconf import Zeroconf, ServiceBrowser
|
|
35
|
+
except ImportError:
|
|
36
|
+
_LOGGER.warning("zeroconf library is not installed. Discovery is disabled.")
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
found_devices = []
|
|
40
|
+
|
|
41
|
+
class ATVNotifListener:
|
|
42
|
+
def remove_service(self, zc, type_, name):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def update_service(self, zc, type_, name):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def add_service(self, zc, type_, name):
|
|
49
|
+
info = zc.get_service_info(type_, name)
|
|
50
|
+
if not info:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Resolve properties
|
|
54
|
+
props = {}
|
|
55
|
+
for k, v in info.properties.items():
|
|
56
|
+
try:
|
|
57
|
+
# Zeroconf keys/values might be bytes
|
|
58
|
+
key_str = k.decode("utf-8") if isinstance(k, bytes) else str(k)
|
|
59
|
+
val_str = v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
|
60
|
+
props[key_str] = val_str
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
name_attr = props.get("n", info.server.split(".")[0])
|
|
65
|
+
base58_uuid = props.get("i")
|
|
66
|
+
pairing_code = decode_base58_uuid(base58_uuid) if base58_uuid else None
|
|
67
|
+
|
|
68
|
+
# Get host address
|
|
69
|
+
host = None
|
|
70
|
+
if info.addresses:
|
|
71
|
+
import socket
|
|
72
|
+
# Convert first address to string
|
|
73
|
+
host = socket.inet_ntoa(info.addresses[0])
|
|
74
|
+
|
|
75
|
+
if host:
|
|
76
|
+
found_devices.append({
|
|
77
|
+
"name": name_attr,
|
|
78
|
+
"host": host,
|
|
79
|
+
"port": info.port or 7878,
|
|
80
|
+
"pairing_code": pairing_code
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
zc = Zeroconf()
|
|
84
|
+
listener = ATVNotifListener()
|
|
85
|
+
browser = ServiceBrowser(zc, "_atvnotif._tcp.local.", listener)
|
|
86
|
+
|
|
87
|
+
time.sleep(timeout)
|
|
88
|
+
zc.close()
|
|
89
|
+
|
|
90
|
+
return found_devices
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
from .discover import decode_base58_uuid
|
|
7
|
+
|
|
8
|
+
_LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
def parse_qr_json(qr_text: str) -> dict:
|
|
11
|
+
"""Decodes and parses the reversed-base64 JSON QR code content."""
|
|
12
|
+
try:
|
|
13
|
+
# First try direct base64 decode of reversed or non-reversed text
|
|
14
|
+
# The TV app reverses bytes and base64 encodes them.
|
|
15
|
+
# When we base64 decode, we get a reversed JSON string.
|
|
16
|
+
# So we try direct base64 decode, then reverse the string.
|
|
17
|
+
for text_candidate in [qr_text, qr_text[::-1]]:
|
|
18
|
+
# Try to base64 decode
|
|
19
|
+
for pad in ['', '=', '==', '===']:
|
|
20
|
+
try:
|
|
21
|
+
decoded_bytes = base64.b64decode(text_candidate + pad)
|
|
22
|
+
# Try both normal and reversed byte order
|
|
23
|
+
for byte_candidate in [decoded_bytes, decoded_bytes[::-1]]:
|
|
24
|
+
try:
|
|
25
|
+
decoded_str = byte_candidate.decode('utf-8')
|
|
26
|
+
# Check if it looks like JSON
|
|
27
|
+
if '{' in decoded_str and '}' in decoded_str:
|
|
28
|
+
# Try parsing
|
|
29
|
+
data = json.loads(decoded_str)
|
|
30
|
+
if "ip" in data or "i" in data or "n" in data:
|
|
31
|
+
# Convert pairing code to UUID format if present
|
|
32
|
+
if "i" in data:
|
|
33
|
+
data["pairing_code"] = decode_base58_uuid(data["i"])
|
|
34
|
+
return data
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
except Exception as e:
|
|
40
|
+
_LOGGER.debug("Failed parsing QR JSON from raw text %s: %s", qr_text, e)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def decode_qr_image(img_input, try_ocr: bool = True) -> dict:
|
|
44
|
+
"""Decodes a QR code from a file path, bytes, or file-like object.
|
|
45
|
+
|
|
46
|
+
If QR code decoding fails and try_ocr is True, attempts to extract
|
|
47
|
+
the IP address using OCR.
|
|
48
|
+
|
|
49
|
+
Returns a dict with resolved properties:
|
|
50
|
+
{
|
|
51
|
+
"ip": "192.168.2.104",
|
|
52
|
+
"pairing_code": "60ce69b0-ced7-4872-ba77-66f8dcaf8ba4",
|
|
53
|
+
"name": "Vodafone Vodafone TV 3"
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
import cv2
|
|
58
|
+
import numpy as np
|
|
59
|
+
except ImportError:
|
|
60
|
+
raise ImportError("opencv-python and numpy are required to decode QR codes. Run 'pip install opencv-python numpy'")
|
|
61
|
+
|
|
62
|
+
# Load image from path or bytes
|
|
63
|
+
img = None
|
|
64
|
+
if isinstance(img_input, str):
|
|
65
|
+
if img_input.startswith(("http://", "https://")):
|
|
66
|
+
try:
|
|
67
|
+
import urllib.request
|
|
68
|
+
req = urllib.request.Request(
|
|
69
|
+
img_input,
|
|
70
|
+
headers={"User-Agent": "Mozilla/5.0"}
|
|
71
|
+
)
|
|
72
|
+
with urllib.request.urlopen(req, timeout=10.0) as response:
|
|
73
|
+
img_bytes = response.read()
|
|
74
|
+
nparr = np.frombuffer(img_bytes, np.uint8)
|
|
75
|
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
raise ValueError(f"Failed to fetch image from URL {img_input}: {e}")
|
|
78
|
+
else:
|
|
79
|
+
img = cv2.imread(img_input)
|
|
80
|
+
elif isinstance(img_input, bytes):
|
|
81
|
+
nparr = np.frombuffer(img_input, np.uint8)
|
|
82
|
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
83
|
+
elif hasattr(img_input, "read"):
|
|
84
|
+
img_bytes = img_input.read()
|
|
85
|
+
nparr = np.frombuffer(img_bytes, np.uint8)
|
|
86
|
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
87
|
+
|
|
88
|
+
if img is None:
|
|
89
|
+
raise ValueError("Failed to load image from input source")
|
|
90
|
+
|
|
91
|
+
# Grayscale
|
|
92
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
93
|
+
|
|
94
|
+
# Prepare candidates for QR decoding (original, inverted, adaptive threshold, etc.)
|
|
95
|
+
candidates = [
|
|
96
|
+
gray,
|
|
97
|
+
255 - gray, # Inverted (since TV app shows white-on-black QR code)
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
# Try OTSU thresholding
|
|
101
|
+
try:
|
|
102
|
+
_, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
103
|
+
candidates.append(otsu)
|
|
104
|
+
candidates.append(255 - otsu)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# Try decoding with OpenCV QRCodeDetector
|
|
109
|
+
detector = cv2.QRCodeDetector()
|
|
110
|
+
for candidate in candidates:
|
|
111
|
+
try:
|
|
112
|
+
data, bbox, _ = detector.detectAndDecode(candidate)
|
|
113
|
+
if data:
|
|
114
|
+
parsed = parse_qr_json(data)
|
|
115
|
+
if parsed:
|
|
116
|
+
return parsed
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Fallback 1: Try pyzbar if installed
|
|
121
|
+
try:
|
|
122
|
+
from pyzbar.pyzbar import decode as pyzbar_decode
|
|
123
|
+
from PIL import Image
|
|
124
|
+
pil_img = Image.fromarray(gray)
|
|
125
|
+
decoded_objs = pyzbar_decode(pil_img)
|
|
126
|
+
for obj in decoded_objs:
|
|
127
|
+
qr_text = obj.data.decode("utf-8")
|
|
128
|
+
parsed = parse_qr_json(qr_text)
|
|
129
|
+
if parsed:
|
|
130
|
+
return parsed
|
|
131
|
+
except ImportError:
|
|
132
|
+
pass
|
|
133
|
+
except Exception as e:
|
|
134
|
+
_LOGGER.debug("pyzbar decoding failed: %s", e)
|
|
135
|
+
|
|
136
|
+
# Fallback 2: Try calling zbarimg CLI if available
|
|
137
|
+
# Save a temporary inverted/preprocessed version of the image to pass to zbarimg
|
|
138
|
+
import subprocess
|
|
139
|
+
import tempfile
|
|
140
|
+
import os
|
|
141
|
+
for i, candidate in enumerate(candidates):
|
|
142
|
+
try:
|
|
143
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
144
|
+
tmp_path = tmp.name
|
|
145
|
+
cv2.imwrite(tmp_path, candidate)
|
|
146
|
+
try:
|
|
147
|
+
res = subprocess.run(["zbarimg", "-q", tmp_path], capture_output=True, text=True)
|
|
148
|
+
if res.returncode == 0:
|
|
149
|
+
# zbarimg outputs: "QR-Code:<data>"
|
|
150
|
+
output_text = res.stdout.strip()
|
|
151
|
+
if output_text.startswith("QR-Code:"):
|
|
152
|
+
qr_text = output_text[len("QR-Code:"):]
|
|
153
|
+
parsed = parse_qr_json(qr_text)
|
|
154
|
+
if parsed:
|
|
155
|
+
os.remove(tmp_path)
|
|
156
|
+
return parsed
|
|
157
|
+
finally:
|
|
158
|
+
if os.path.exists(tmp_path):
|
|
159
|
+
os.remove(tmp_path)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
_LOGGER.debug("zbarimg subprocess attempt failed for candidate %d: %s", i, e)
|
|
162
|
+
|
|
163
|
+
# OCR Fallback for IP address extraction if QR code decoding failed
|
|
164
|
+
if try_ocr:
|
|
165
|
+
try:
|
|
166
|
+
import pytesseract
|
|
167
|
+
# Try to run OCR on grayscale
|
|
168
|
+
ocr_text = pytesseract.image_to_string(gray)
|
|
169
|
+
# Find IP address pattern
|
|
170
|
+
ip_pattern = re.compile(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b')
|
|
171
|
+
match = ip_pattern.search(ocr_text)
|
|
172
|
+
if match:
|
|
173
|
+
return {
|
|
174
|
+
"ip": match.group(0),
|
|
175
|
+
"pairing_code": None,
|
|
176
|
+
"name": None,
|
|
177
|
+
"ocr_extracted": True
|
|
178
|
+
}
|
|
179
|
+
except ImportError:
|
|
180
|
+
_LOGGER.debug("pytesseract is not installed; OCR fallback skipped.")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
_LOGGER.debug("OCR extraction failed: %s", e)
|
|
183
|
+
|
|
184
|
+
raise ValueError("Failed to decode QR code from the image")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atvnotif
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python client for sending encrypted notifications to Android TV Notifier
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/atvnotif/atvnotif.py
|
|
7
|
+
Project-URL: Repository, https://github.com/atvnotif/atvnotif.py
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/atvnotif/atvnotif.py/issues
|
|
9
|
+
Keywords: android-tv,notifications,home-automation,atvnotif
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Classifier: Topic :: Communications
|
|
19
|
+
Classifier: Framework :: AsyncIO
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: cryptography>=35.0.0
|
|
23
|
+
Requires-Dist: httpx>=0.20.0
|
|
24
|
+
Requires-Dist: zeroconf>=0.33.0
|
|
25
|
+
|
|
26
|
+
# atvnotif
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/atvnotif/)
|
|
29
|
+
[](https://pypi.org/project/atvnotif/)
|
|
30
|
+
[](https://github.com/atvnotif/atvnotif.py/blob/main/LICENSE)
|
|
31
|
+
|
|
32
|
+
A Python client library for sending encrypted notifications to the **Android TV Notifier** app (`com.smrtprjcts.atvnotif`).
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install atvnotif
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from atvnotif import ATVNotifier
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
# Replace with your TV's IP address and pairing code (remove hyphens or leave them, the library cleans it)
|
|
48
|
+
notifier = ATVNotifier("192.168.1.100", "1234-5678-ABCD")
|
|
49
|
+
|
|
50
|
+
await notifier.async_notify(
|
|
51
|
+
message="Someone is at the door!",
|
|
52
|
+
title="Doorbell Alert",
|
|
53
|
+
duration=10,
|
|
54
|
+
position=0, # Top-Right
|
|
55
|
+
notif_sound=True
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
atvnotif/__init__.py
|
|
4
|
+
atvnotif/client.py
|
|
5
|
+
atvnotif/discover.py
|
|
6
|
+
atvnotif/qr.py
|
|
7
|
+
atvnotif.egg-info/PKG-INFO
|
|
8
|
+
atvnotif.egg-info/SOURCES.txt
|
|
9
|
+
atvnotif.egg-info/dependency_links.txt
|
|
10
|
+
atvnotif.egg-info/requires.txt
|
|
11
|
+
atvnotif.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
atvnotif
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "atvnotif"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A Python client for sending encrypted notifications to Android TV Notifier"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
keywords = ["android-tv", "notifications", "home-automation", "atvnotif"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"cryptography>=35.0.0",
|
|
15
|
+
"httpx>=0.20.0",
|
|
16
|
+
"zeroconf>=0.33.0"
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Topic :: Home Automation",
|
|
27
|
+
"Topic :: Communications",
|
|
28
|
+
"Framework :: AsyncIO",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/atvnotif/atvnotif.py"
|
|
33
|
+
Repository = "https://github.com/atvnotif/atvnotif.py"
|
|
34
|
+
"Bug Tracker" = "https://github.com/atvnotif/atvnotif.py/issues"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["."]
|
|
38
|
+
include = ["atvnotif*"]
|
atvnotif-0.1.0/setup.cfg
ADDED