aiotja470-intercom 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.
- aiotja470_intercom-0.1.0/PKG-INFO +59 -0
- aiotja470_intercom-0.1.0/README.md +43 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom/__init__.py +24 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom/cli.py +233 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom/client.py +91 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom/exceptions.py +11 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom/models.py +63 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom/runner.py +116 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom.egg-info/PKG-INFO +59 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom.egg-info/SOURCES.txt +15 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom.egg-info/dependency_links.txt +1 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom.egg-info/entry_points.txt +2 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom.egg-info/requires.txt +5 -0
- aiotja470_intercom-0.1.0/aiotja470_intercom.egg-info/top_level.txt +1 -0
- aiotja470_intercom-0.1.0/pyproject.toml +34 -0
- aiotja470_intercom-0.1.0/setup.cfg +4 -0
- aiotja470_intercom-0.1.0/tests/test_client.py +134 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiotja470_intercom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for the Hager TJA470 Intercom API
|
|
5
|
+
Author-email: Manuel Klimek <klimek@box4.net>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
|
|
16
|
+
|
|
17
|
+
# aiotja470_intercom
|
|
18
|
+
|
|
19
|
+
Asynchronous Python client for the Hager TJA470 Intercom API. This package is designed to be integrated into Home Assistant.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install aiotja470_intercom
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
from aiotja470_intercom import TJA470IntercomClient, AiohttpRunner
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
runner = AiohttpRunner()
|
|
35
|
+
client = TJA470IntercomClient(
|
|
36
|
+
host="192.168.1.100",
|
|
37
|
+
username="user",
|
|
38
|
+
password="password",
|
|
39
|
+
runner=runner
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Check manifest
|
|
44
|
+
await client.get_manifest()
|
|
45
|
+
|
|
46
|
+
# Get free devices for pairing
|
|
47
|
+
devices = await client.get_free_devices()
|
|
48
|
+
print(devices)
|
|
49
|
+
|
|
50
|
+
# Retrieve provisioning config
|
|
51
|
+
config = await client.get_provisioning("your-uuid")
|
|
52
|
+
print(config)
|
|
53
|
+
|
|
54
|
+
finally:
|
|
55
|
+
await runner.close()
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# aiotja470_intercom
|
|
2
|
+
|
|
3
|
+
Asynchronous Python client for the Hager TJA470 Intercom API. This package is designed to be integrated into Home Assistant.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install aiotja470_intercom
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from aiotja470_intercom import TJA470IntercomClient, AiohttpRunner
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
runner = AiohttpRunner()
|
|
19
|
+
client = TJA470IntercomClient(
|
|
20
|
+
host="192.168.1.100",
|
|
21
|
+
username="user",
|
|
22
|
+
password="password",
|
|
23
|
+
runner=runner
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
# Check manifest
|
|
28
|
+
await client.get_manifest()
|
|
29
|
+
|
|
30
|
+
# Get free devices for pairing
|
|
31
|
+
devices = await client.get_free_devices()
|
|
32
|
+
print(devices)
|
|
33
|
+
|
|
34
|
+
# Retrieve provisioning config
|
|
35
|
+
config = await client.get_provisioning("your-uuid")
|
|
36
|
+
print(config)
|
|
37
|
+
|
|
38
|
+
finally:
|
|
39
|
+
await runner.close()
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .client import TJA470IntercomClient
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
TJA470AuthError,
|
|
4
|
+
TJA470ConnectionError,
|
|
5
|
+
TJA470Error,
|
|
6
|
+
TJA470ResponseError,
|
|
7
|
+
)
|
|
8
|
+
from .models import CalledElement, FreeDevice, Manifest, ProvisioningInfo, SipInfo
|
|
9
|
+
from .runner import AiohttpRunner, Runner
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TJA470IntercomClient",
|
|
13
|
+
"TJA470Error",
|
|
14
|
+
"TJA470ConnectionError",
|
|
15
|
+
"TJA470AuthError",
|
|
16
|
+
"TJA470ResponseError",
|
|
17
|
+
"Manifest",
|
|
18
|
+
"FreeDevice",
|
|
19
|
+
"ProvisioningInfo",
|
|
20
|
+
"SipInfo",
|
|
21
|
+
"CalledElement",
|
|
22
|
+
"Runner",
|
|
23
|
+
"AiohttpRunner",
|
|
24
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
from aiotja470_intercom.client import TJA470IntercomClient
|
|
11
|
+
from aiotja470_intercom.runner import AiohttpRunner
|
|
12
|
+
from aiotja470_intercom.exceptions import TJA470AuthError
|
|
13
|
+
|
|
14
|
+
CONFIG_FILE = os.path.expanduser("~/.tja470_config.json")
|
|
15
|
+
|
|
16
|
+
def load_config() -> Dict[str, Any]:
|
|
17
|
+
if not os.path.exists(CONFIG_FILE):
|
|
18
|
+
return {}
|
|
19
|
+
with open(CONFIG_FILE, "r") as f:
|
|
20
|
+
return json.load(f)
|
|
21
|
+
|
|
22
|
+
def save_config(config: Dict[str, Any]) -> None:
|
|
23
|
+
with open(CONFIG_FILE, "w") as f:
|
|
24
|
+
json.dump(config, f, indent=4)
|
|
25
|
+
|
|
26
|
+
class CustomParser(argparse.ArgumentParser):
|
|
27
|
+
def error(self, message):
|
|
28
|
+
sys.stderr.write(f'error: {message}\n\n')
|
|
29
|
+
self.print_help()
|
|
30
|
+
sys.exit(2)
|
|
31
|
+
|
|
32
|
+
async def async_main():
|
|
33
|
+
parser = CustomParser(description="TJA-470 Intercom CLI")
|
|
34
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging (prints raw HTTP requests and responses)")
|
|
35
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
|
|
37
|
+
# `pair` command
|
|
38
|
+
pair_parser = subparsers.add_parser("pair", help="Authenticate and pair a new client UUID")
|
|
39
|
+
pair_parser.add_argument("--host", required=True, help="IP address of the TJA-470")
|
|
40
|
+
pair_parser.add_argument("--username", required=True, help="Username for the TJA-470")
|
|
41
|
+
pair_parser.add_argument("--password", required=True, help="Password for the TJA-470")
|
|
42
|
+
pair_parser.add_argument("--uuid", help="Optional custom UUID. If omitted, a random one is generated.")
|
|
43
|
+
|
|
44
|
+
# `status` command
|
|
45
|
+
status_parser = subparsers.add_parser("status", help="Show current connection status and device information")
|
|
46
|
+
|
|
47
|
+
# `run` command
|
|
48
|
+
run_parser = subparsers.add_parser("run", help="Run commands using the cached session")
|
|
49
|
+
run_parser.add_argument("--open-door", action="store_true", help="Open the door (door ID 1)")
|
|
50
|
+
run_parser.add_argument("--switch-camera", action="store_true", help="Switch the camera feed")
|
|
51
|
+
run_parser.add_argument("--provisioning", action="store_true", help="Print the provisioning info")
|
|
52
|
+
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
if args.debug:
|
|
56
|
+
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
|
|
57
|
+
|
|
58
|
+
if args.command == "pair":
|
|
59
|
+
await run_pair(args)
|
|
60
|
+
elif args.command == "status":
|
|
61
|
+
await run_status(args)
|
|
62
|
+
elif args.command == "run":
|
|
63
|
+
await run_command(args)
|
|
64
|
+
|
|
65
|
+
async def run_pair(args):
|
|
66
|
+
client_uuid = args.uuid if args.uuid else str(uuid.uuid4())
|
|
67
|
+
print(f"Connecting to TJA470 at {args.host}...")
|
|
68
|
+
runner = AiohttpRunner()
|
|
69
|
+
client = TJA470IntercomClient(
|
|
70
|
+
host=args.host,
|
|
71
|
+
username=args.username,
|
|
72
|
+
password=args.password,
|
|
73
|
+
runner=runner
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
print("Checking API manifest...")
|
|
78
|
+
await client.get_manifest()
|
|
79
|
+
|
|
80
|
+
print("Fetching free devices...")
|
|
81
|
+
devices = await client.get_free_devices()
|
|
82
|
+
|
|
83
|
+
if not devices:
|
|
84
|
+
print("\n❌ No free devices found!")
|
|
85
|
+
print("Please create/free a mobile client in the TJA-470 UI.")
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
target_device = devices[0]
|
|
89
|
+
|
|
90
|
+
print(f"\nRegistering UUID '{client_uuid}' to device ID {target_device.id}...")
|
|
91
|
+
await client.set_uid(target_device.id, client_uuid)
|
|
92
|
+
print("UUID registered successfully!")
|
|
93
|
+
|
|
94
|
+
# Save config
|
|
95
|
+
config = {
|
|
96
|
+
"host": args.host,
|
|
97
|
+
"username": args.username,
|
|
98
|
+
"password": args.password,
|
|
99
|
+
"uuid": client_uuid,
|
|
100
|
+
"cookies": client.get_cookies()
|
|
101
|
+
}
|
|
102
|
+
save_config(config)
|
|
103
|
+
print(f"\n✅ Configuration and session cookies saved to {CONFIG_FILE}!")
|
|
104
|
+
print("You can now use `tja470 run ...` commands.")
|
|
105
|
+
|
|
106
|
+
except Exception as e:
|
|
107
|
+
print(f"\n❌ An error occurred: {e}")
|
|
108
|
+
finally:
|
|
109
|
+
await runner.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def run_command(args):
|
|
113
|
+
config = load_config()
|
|
114
|
+
if not config:
|
|
115
|
+
print(f"❌ Configuration not found at {CONFIG_FILE}.")
|
|
116
|
+
print("Please run `tja470 pair` first.")
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
|
|
119
|
+
runner = AiohttpRunner()
|
|
120
|
+
client = TJA470IntercomClient(
|
|
121
|
+
host=config["host"],
|
|
122
|
+
username=config["username"],
|
|
123
|
+
password=config["password"],
|
|
124
|
+
runner=runner
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Load cached cookies
|
|
128
|
+
cached_cookies = config.get("cookies", {})
|
|
129
|
+
if cached_cookies:
|
|
130
|
+
client.set_cookies(cached_cookies)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
uuid = config["uuid"]
|
|
134
|
+
|
|
135
|
+
# Validate session by getting manifest (this will re-authenticate with basic auth if cookie is expired)
|
|
136
|
+
try:
|
|
137
|
+
await client.get_manifest()
|
|
138
|
+
except TJA470AuthError:
|
|
139
|
+
print("❌ Authentication failed. Your credentials might be invalid.")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
# Update cached cookies just in case they were refreshed
|
|
143
|
+
new_cookies = client.get_cookies()
|
|
144
|
+
if new_cookies != cached_cookies:
|
|
145
|
+
config["cookies"] = new_cookies
|
|
146
|
+
save_config(config)
|
|
147
|
+
|
|
148
|
+
if args.provisioning:
|
|
149
|
+
print("\nRetrieving provisioning configuration...")
|
|
150
|
+
prov = await client.get_provisioning(uuid)
|
|
151
|
+
print("\n✅ Provisioning Configuration:")
|
|
152
|
+
print(f" SIP ID: {prov.sip_info.sip_id}")
|
|
153
|
+
print(f" SIP Password: {prov.sip_info.sip_password}")
|
|
154
|
+
print(f" RTSP Video URL: {prov.rtsp_video_url}")
|
|
155
|
+
|
|
156
|
+
if args.open_door:
|
|
157
|
+
print("\n🚪 Opening the door...")
|
|
158
|
+
await client.open_door(door_id=1)
|
|
159
|
+
print("Door opened successfully!")
|
|
160
|
+
|
|
161
|
+
if args.switch_camera:
|
|
162
|
+
print("\n📷 Switching the camera feed...")
|
|
163
|
+
await client.switch_camera(uuid)
|
|
164
|
+
print("Camera switched successfully!")
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
print(f"\n❌ An error occurred: {e}")
|
|
168
|
+
finally:
|
|
169
|
+
await runner.close()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def run_status(args):
|
|
173
|
+
config = load_config()
|
|
174
|
+
if not config:
|
|
175
|
+
print(f"❌ Configuration not found at {CONFIG_FILE}.")
|
|
176
|
+
print("Please run `tja470 pair` first.")
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
|
|
179
|
+
print("Checking connection to TJA-470...")
|
|
180
|
+
runner = AiohttpRunner()
|
|
181
|
+
client = TJA470IntercomClient(
|
|
182
|
+
host=config["host"],
|
|
183
|
+
username=config["username"],
|
|
184
|
+
password=config["password"],
|
|
185
|
+
runner=runner
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if config.get("cookies"):
|
|
189
|
+
client.set_cookies(config["cookies"])
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
# Check authentication
|
|
193
|
+
await client.get_manifest()
|
|
194
|
+
|
|
195
|
+
# Save cookies if updated
|
|
196
|
+
new_cookies = client.get_cookies()
|
|
197
|
+
if new_cookies != config.get("cookies", {}):
|
|
198
|
+
config["cookies"] = new_cookies
|
|
199
|
+
save_config(config)
|
|
200
|
+
|
|
201
|
+
print("\n✅ Authentication successful!")
|
|
202
|
+
print(f"Host: {config['host']}")
|
|
203
|
+
print(f"UUID: {config['uuid']}")
|
|
204
|
+
|
|
205
|
+
print("\nFetching provisioning data...")
|
|
206
|
+
prov = await client.get_provisioning(config["uuid"])
|
|
207
|
+
print("\n📋 Intercom Information:")
|
|
208
|
+
print(f" SIP ID: {prov.sip_info.sip_id}")
|
|
209
|
+
print(f" SIP Password: {'*' * len(prov.sip_info.sip_password)}")
|
|
210
|
+
print(f" RTSP Video URL: {prov.rtsp_video_url}")
|
|
211
|
+
|
|
212
|
+
if prov.called_elements:
|
|
213
|
+
print("\n Extensions (Called Elements):")
|
|
214
|
+
for ext in prov.called_elements:
|
|
215
|
+
print(f" - Name: {ext.name or 'Unknown'} (SIP ID: {ext.sip_id})")
|
|
216
|
+
|
|
217
|
+
except TJA470AuthError:
|
|
218
|
+
print("\n❌ Authentication failed. Your session or credentials might be invalid.")
|
|
219
|
+
print("Try running `tja470 pair` again.")
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"\n❌ An error occurred while fetching status: {e}")
|
|
222
|
+
finally:
|
|
223
|
+
await runner.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main():
|
|
227
|
+
try:
|
|
228
|
+
asyncio.run(async_main())
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from .exceptions import TJA470ResponseError, TJA470AuthError
|
|
5
|
+
from .models import FreeDevice, Manifest, ProvisioningInfo
|
|
6
|
+
from .runner import Runner
|
|
7
|
+
|
|
8
|
+
class TJA470IntercomClient:
|
|
9
|
+
"""Client for the Hager TJA470 Intercom API."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
host: str,
|
|
14
|
+
username: str,
|
|
15
|
+
password: str,
|
|
16
|
+
runner: Runner,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.host = host
|
|
19
|
+
self._auth = aiohttp.BasicAuth(username, password)
|
|
20
|
+
self._runner = runner
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def base_url(self) -> str:
|
|
24
|
+
return f"http://{self.host}/API"
|
|
25
|
+
|
|
26
|
+
def get_cookies(self) -> dict[str, str]:
|
|
27
|
+
"""Get the current cookies for the API base URL."""
|
|
28
|
+
return self._runner.get_cookies(self.base_url)
|
|
29
|
+
|
|
30
|
+
def set_cookies(self, cookies: dict[str, str]) -> None:
|
|
31
|
+
"""Set cookies for the API base URL."""
|
|
32
|
+
self._runner.set_cookies(self.base_url, cookies)
|
|
33
|
+
|
|
34
|
+
async def _request(self, method: str, url: str, json: Optional[Dict[str, Any]] = None) -> Any:
|
|
35
|
+
try:
|
|
36
|
+
return await self._runner.request(method, url, json=json)
|
|
37
|
+
except TJA470AuthError:
|
|
38
|
+
return await self._runner.request(method, url, auth=self._auth, json=json)
|
|
39
|
+
|
|
40
|
+
async def get_manifest(self) -> Manifest:
|
|
41
|
+
"""Verify authentication and retrieve the API manifest."""
|
|
42
|
+
url = f"{self.base_url}/manifest"
|
|
43
|
+
response = await self._request("GET", url)
|
|
44
|
+
if isinstance(response, dict):
|
|
45
|
+
return Manifest(raw_data=response)
|
|
46
|
+
elif isinstance(response, str):
|
|
47
|
+
# Sometimes manifest might just return empty string or non-json if successful
|
|
48
|
+
return Manifest()
|
|
49
|
+
else:
|
|
50
|
+
return Manifest()
|
|
51
|
+
|
|
52
|
+
async def get_free_devices(self) -> List[FreeDevice]:
|
|
53
|
+
"""List devices available for pairing."""
|
|
54
|
+
url = f"{self.base_url}/runtime/provisioning/freedevices"
|
|
55
|
+
response = await self._request("GET", url)
|
|
56
|
+
|
|
57
|
+
if not isinstance(response, list):
|
|
58
|
+
raise TJA470ResponseError("Expected a list of free devices")
|
|
59
|
+
|
|
60
|
+
return [FreeDevice.from_dict(item) for item in response]
|
|
61
|
+
|
|
62
|
+
async def set_uid(self, device_id: int, uid: str) -> None:
|
|
63
|
+
"""Register the UUID as client to the device."""
|
|
64
|
+
url = f"{self.base_url}/runtime/pairing/setuid"
|
|
65
|
+
payload = {
|
|
66
|
+
"id": device_id,
|
|
67
|
+
"uid": uid,
|
|
68
|
+
"description": ""
|
|
69
|
+
}
|
|
70
|
+
await self._request("POST", url, json=payload)
|
|
71
|
+
|
|
72
|
+
async def get_provisioning(self, uid: str) -> ProvisioningInfo:
|
|
73
|
+
"""Retrieve the configuration details for the client."""
|
|
74
|
+
url = f"{self.base_url}/runtime/provisioning"
|
|
75
|
+
payload = {"uid": uid}
|
|
76
|
+
response = await self._request("POST", url, json=payload)
|
|
77
|
+
|
|
78
|
+
if not isinstance(response, dict):
|
|
79
|
+
raise TJA470ResponseError("Expected a dictionary for provisioning info")
|
|
80
|
+
|
|
81
|
+
return ProvisioningInfo.from_dict(response)
|
|
82
|
+
|
|
83
|
+
async def switch_camera(self, uid: str) -> None:
|
|
84
|
+
"""Switch the camera between different intercom views."""
|
|
85
|
+
url = f"{self.base_url}/runtime/command/camera/switch/{uid}"
|
|
86
|
+
await self._request("POST", url, json={})
|
|
87
|
+
|
|
88
|
+
async def open_door(self, door_id: int = 1) -> None:
|
|
89
|
+
"""Trigger the door release."""
|
|
90
|
+
url = f"{self.base_url}/runtime/command/doorrelease/{door_id}"
|
|
91
|
+
await self._request("POST", url, json={})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class TJA470Error(Exception):
|
|
2
|
+
"""Base exception for TJA470 errors."""
|
|
3
|
+
|
|
4
|
+
class TJA470ConnectionError(TJA470Error):
|
|
5
|
+
"""Exception raised for connection errors."""
|
|
6
|
+
|
|
7
|
+
class TJA470AuthError(TJA470Error):
|
|
8
|
+
"""Exception raised for authentication errors."""
|
|
9
|
+
|
|
10
|
+
class TJA470ResponseError(TJA470Error):
|
|
11
|
+
"""Exception raised for unexpected responses."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Manifest:
|
|
6
|
+
"""Representation of a Manifest response."""
|
|
7
|
+
raw_data: Dict[str, Any] = field(default_factory=dict)
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FreeDevice:
|
|
11
|
+
"""Representation of a free device."""
|
|
12
|
+
id: int
|
|
13
|
+
name: Optional[str] = None
|
|
14
|
+
mac: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, data: Dict[str, Any]) -> "FreeDevice":
|
|
18
|
+
return cls(
|
|
19
|
+
id=data.get("id", -1),
|
|
20
|
+
name=data.get("name"),
|
|
21
|
+
mac=data.get("mac")
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SipInfo:
|
|
26
|
+
"""SIP connection details."""
|
|
27
|
+
sip_id: str
|
|
28
|
+
sip_password: str
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CalledElement:
|
|
32
|
+
"""A called element, e.g. a station."""
|
|
33
|
+
sip_id: str
|
|
34
|
+
name: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ProvisioningInfo:
|
|
38
|
+
"""Provisioning details including SIP info and camera streams."""
|
|
39
|
+
sip_info: SipInfo
|
|
40
|
+
rtsp_video_url: str
|
|
41
|
+
called_elements: List[CalledElement] = field(default_factory=list)
|
|
42
|
+
raw_data: Dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ProvisioningInfo":
|
|
46
|
+
sip_info = SipInfo(
|
|
47
|
+
sip_id=data.get("sipId", ""),
|
|
48
|
+
sip_password=data.get("sipPassword", "")
|
|
49
|
+
)
|
|
50
|
+
called_elements = []
|
|
51
|
+
for element in data.get("calledElements", []):
|
|
52
|
+
called_elements.append(
|
|
53
|
+
CalledElement(
|
|
54
|
+
sip_id=element.get("sipId", ""),
|
|
55
|
+
name=element.get("name")
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return cls(
|
|
59
|
+
sip_info=sip_info,
|
|
60
|
+
rtsp_video_url=data.get("rtspVideoUrl", ""),
|
|
61
|
+
called_elements=called_elements,
|
|
62
|
+
raw_data=data
|
|
63
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, Optional, Protocol, Union
|
|
4
|
+
|
|
5
|
+
from .exceptions import TJA470ConnectionError, TJA470AuthError, TJA470ResponseError
|
|
6
|
+
|
|
7
|
+
_LOGGER = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class Runner(Protocol):
|
|
10
|
+
"""Protocol for executing HTTP requests."""
|
|
11
|
+
|
|
12
|
+
async def request(
|
|
13
|
+
self,
|
|
14
|
+
method: str,
|
|
15
|
+
url: str,
|
|
16
|
+
auth: Optional[aiohttp.BasicAuth] = None,
|
|
17
|
+
json: Optional[Dict[str, Any]] = None,
|
|
18
|
+
) -> Union[Dict[str, Any], str, bytes, None]:
|
|
19
|
+
"""Execute the HTTP request and return the parsed JSON, text, or bytes."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def get_cookies(self, url: str) -> Dict[str, str]:
|
|
23
|
+
"""Get the cookies currently stored for a specific URL."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def set_cookies(self, url: str, cookies: Dict[str, str]) -> None:
|
|
27
|
+
"""Set cookies for a specific URL."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
async def close(self) -> None:
|
|
31
|
+
"""Close the underlying session/resources."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AiohttpRunner(Runner):
|
|
36
|
+
"""Runner implementation using aiohttp.ClientSession."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, session: Optional[aiohttp.ClientSession] = None) -> None:
|
|
39
|
+
self._session = session
|
|
40
|
+
self._close_session = False
|
|
41
|
+
|
|
42
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
43
|
+
if self._session is None:
|
|
44
|
+
jar = aiohttp.CookieJar(unsafe=True)
|
|
45
|
+
self._session = aiohttp.ClientSession(cookie_jar=jar)
|
|
46
|
+
self._close_session = True
|
|
47
|
+
return self._session
|
|
48
|
+
|
|
49
|
+
async def request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
url: str,
|
|
53
|
+
auth: Optional[aiohttp.BasicAuth] = None,
|
|
54
|
+
json: Optional[Dict[str, Any]] = None,
|
|
55
|
+
) -> Union[Dict[str, Any], str, bytes, None]:
|
|
56
|
+
session = await self._get_session()
|
|
57
|
+
|
|
58
|
+
_LOGGER.debug(f"Request: {method} {url}")
|
|
59
|
+
if json is not None:
|
|
60
|
+
_LOGGER.debug(f"Request JSON: {json}")
|
|
61
|
+
|
|
62
|
+
from yarl import URL
|
|
63
|
+
req_cookies = session.cookie_jar.filter_cookies(URL(url))
|
|
64
|
+
if req_cookies:
|
|
65
|
+
_LOGGER.debug(f"Sending Cookies: {req_cookies}")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
async with session.request(method, url, auth=auth, json=json) as response:
|
|
69
|
+
_LOGGER.debug(f"Response Status: {response.status}")
|
|
70
|
+
_LOGGER.debug(f"Response Headers: {response.headers}")
|
|
71
|
+
|
|
72
|
+
if response.status == 401 or response.status == 403:
|
|
73
|
+
raise TJA470AuthError("Authentication failed")
|
|
74
|
+
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
|
|
77
|
+
content_type = response.headers.get("Content-Type", "")
|
|
78
|
+
if "application/json" in content_type:
|
|
79
|
+
data = await response.json()
|
|
80
|
+
elif "text/" in content_type:
|
|
81
|
+
data = await response.text()
|
|
82
|
+
else:
|
|
83
|
+
data = await response.read()
|
|
84
|
+
|
|
85
|
+
_LOGGER.debug(f"Response Content: {data}")
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
except aiohttp.ClientConnectorError as e:
|
|
89
|
+
raise TJA470ConnectionError(f"Connection failed: {e}") from e
|
|
90
|
+
except aiohttp.ClientResponseError as e:
|
|
91
|
+
raise TJA470ResponseError(f"HTTP Error {e.status}: {e.message}") from e
|
|
92
|
+
except Exception as e:
|
|
93
|
+
raise TJA470Error(f"An unexpected error occurred: {e}") from e
|
|
94
|
+
|
|
95
|
+
def get_cookies(self, url: str) -> Dict[str, str]:
|
|
96
|
+
if not self._session:
|
|
97
|
+
return {}
|
|
98
|
+
# We iterate over all cookies in the jar to ensure we don't miss any due to path/domain mismatches
|
|
99
|
+
cookies = {}
|
|
100
|
+
for cookie in self._session.cookie_jar:
|
|
101
|
+
cookies[cookie.key] = cookie.value
|
|
102
|
+
return cookies
|
|
103
|
+
|
|
104
|
+
def set_cookies(self, url: str, cookies: Dict[str, str]) -> None:
|
|
105
|
+
if not self._session:
|
|
106
|
+
# We must instantiate the session first to have a cookie jar
|
|
107
|
+
jar = aiohttp.CookieJar(unsafe=True)
|
|
108
|
+
self._session = aiohttp.ClientSession(cookie_jar=jar)
|
|
109
|
+
self._close_session = True
|
|
110
|
+
|
|
111
|
+
from yarl import URL
|
|
112
|
+
self._session.cookie_jar.update_cookies(cookies, response_url=URL(url))
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
if self._session and self._close_session:
|
|
116
|
+
await self._session.close()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiotja470_intercom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for the Hager TJA470 Intercom API
|
|
5
|
+
Author-email: Manuel Klimek <klimek@box4.net>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
|
|
16
|
+
|
|
17
|
+
# aiotja470_intercom
|
|
18
|
+
|
|
19
|
+
Asynchronous Python client for the Hager TJA470 Intercom API. This package is designed to be integrated into Home Assistant.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install aiotja470_intercom
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
from aiotja470_intercom import TJA470IntercomClient, AiohttpRunner
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
runner = AiohttpRunner()
|
|
35
|
+
client = TJA470IntercomClient(
|
|
36
|
+
host="192.168.1.100",
|
|
37
|
+
username="user",
|
|
38
|
+
password="password",
|
|
39
|
+
runner=runner
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Check manifest
|
|
44
|
+
await client.get_manifest()
|
|
45
|
+
|
|
46
|
+
# Get free devices for pairing
|
|
47
|
+
devices = await client.get_free_devices()
|
|
48
|
+
print(devices)
|
|
49
|
+
|
|
50
|
+
# Retrieve provisioning config
|
|
51
|
+
config = await client.get_provisioning("your-uuid")
|
|
52
|
+
print(config)
|
|
53
|
+
|
|
54
|
+
finally:
|
|
55
|
+
await runner.close()
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
aiotja470_intercom/__init__.py
|
|
4
|
+
aiotja470_intercom/cli.py
|
|
5
|
+
aiotja470_intercom/client.py
|
|
6
|
+
aiotja470_intercom/exceptions.py
|
|
7
|
+
aiotja470_intercom/models.py
|
|
8
|
+
aiotja470_intercom/runner.py
|
|
9
|
+
aiotja470_intercom.egg-info/PKG-INFO
|
|
10
|
+
aiotja470_intercom.egg-info/SOURCES.txt
|
|
11
|
+
aiotja470_intercom.egg-info/dependency_links.txt
|
|
12
|
+
aiotja470_intercom.egg-info/entry_points.txt
|
|
13
|
+
aiotja470_intercom.egg-info/requires.txt
|
|
14
|
+
aiotja470_intercom.egg-info/top_level.txt
|
|
15
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiotja470_intercom
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aiotja470_intercom"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Manuel Klimek", email="klimek@box4.net" },
|
|
10
|
+
]
|
|
11
|
+
description = "Asynchronous Python client for the Hager TJA470 Intercom API"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"aiohttp>=3.8.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
tja470 = "aiotja470_intercom.cli:main"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = [
|
|
29
|
+
"pytest>=7.0",
|
|
30
|
+
"pytest-asyncio>=0.21.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
include = ["aiotja470_intercom*"]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import aiohttp
|
|
3
|
+
from typing import Any, Dict, Optional, Union
|
|
4
|
+
|
|
5
|
+
from aiotja470_intercom.client import TJA470IntercomClient
|
|
6
|
+
from aiotja470_intercom.exceptions import TJA470ResponseError
|
|
7
|
+
from aiotja470_intercom.models import FreeDevice, Manifest, ProvisioningInfo
|
|
8
|
+
|
|
9
|
+
class MockRunner:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.requests = []
|
|
12
|
+
self.next_response: Union[Dict[str, Any], str, list, Exception] = {}
|
|
13
|
+
self.cookies = {}
|
|
14
|
+
|
|
15
|
+
async def request(
|
|
16
|
+
self,
|
|
17
|
+
method: str,
|
|
18
|
+
url: str,
|
|
19
|
+
auth: Optional[aiohttp.BasicAuth] = None,
|
|
20
|
+
json: Optional[Dict[str, Any]] = None,
|
|
21
|
+
) -> Union[Dict[str, Any], str, bytes, list, None]:
|
|
22
|
+
self.requests.append({"method": method, "url": url, "auth": auth, "json": json})
|
|
23
|
+
if isinstance(self.next_response, Exception):
|
|
24
|
+
raise self.next_response
|
|
25
|
+
return self.next_response
|
|
26
|
+
|
|
27
|
+
def get_cookies(self, url: str) -> Dict[str, str]:
|
|
28
|
+
return self.cookies.get(url, {})
|
|
29
|
+
|
|
30
|
+
def set_cookies(self, url: str, cookies: Dict[str, str]) -> None:
|
|
31
|
+
if url not in self.cookies:
|
|
32
|
+
self.cookies[url] = {}
|
|
33
|
+
self.cookies[url].update(cookies)
|
|
34
|
+
|
|
35
|
+
async def close(self) -> None:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def runner():
|
|
41
|
+
return MockRunner()
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def client(runner):
|
|
45
|
+
return TJA470IntercomClient("127.0.0.1", "testuser", "testpass", runner)
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_get_manifest(client, runner):
|
|
49
|
+
runner.next_response = {"status": "ok"}
|
|
50
|
+
manifest = await client.get_manifest()
|
|
51
|
+
|
|
52
|
+
assert isinstance(manifest, Manifest)
|
|
53
|
+
assert manifest.raw_data == {"status": "ok"}
|
|
54
|
+
assert len(runner.requests) == 1
|
|
55
|
+
assert runner.requests[0]["method"] == "GET"
|
|
56
|
+
assert "manifest" in runner.requests[0]["url"]
|
|
57
|
+
|
|
58
|
+
@pytest.mark.asyncio
|
|
59
|
+
async def test_get_free_devices(client, runner):
|
|
60
|
+
runner.next_response = [
|
|
61
|
+
{"id": 42, "name": "Test Device", "mac": "00:11:22:33:44:55"}
|
|
62
|
+
]
|
|
63
|
+
devices = await client.get_free_devices()
|
|
64
|
+
|
|
65
|
+
assert len(devices) == 1
|
|
66
|
+
assert isinstance(devices[0], FreeDevice)
|
|
67
|
+
assert devices[0].id == 42
|
|
68
|
+
assert devices[0].name == "Test Device"
|
|
69
|
+
assert devices[0].mac == "00:11:22:33:44:55"
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_get_free_devices_error(client, runner):
|
|
73
|
+
runner.next_response = {"error": "not a list"}
|
|
74
|
+
with pytest.raises(TJA470ResponseError):
|
|
75
|
+
await client.get_free_devices()
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_set_uid(client, runner):
|
|
79
|
+
runner.next_response = ""
|
|
80
|
+
await client.set_uid(42, "test-uuid")
|
|
81
|
+
|
|
82
|
+
assert len(runner.requests) == 1
|
|
83
|
+
req = runner.requests[0]
|
|
84
|
+
assert req["method"] == "POST"
|
|
85
|
+
assert "setuid" in req["url"]
|
|
86
|
+
assert req["json"] == {"id": 42, "uid": "test-uuid", "description": ""}
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_get_provisioning(client, runner):
|
|
90
|
+
runner.next_response = {
|
|
91
|
+
"sipId": "1001",
|
|
92
|
+
"sipPassword": "secretpassword",
|
|
93
|
+
"rtspVideoUrl": "rtsp://127.0.0.1/stream",
|
|
94
|
+
"calledElements": [
|
|
95
|
+
{"sipId": "1002", "name": "Station 1"}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
config = await client.get_provisioning("test-uuid")
|
|
99
|
+
|
|
100
|
+
assert isinstance(config, ProvisioningInfo)
|
|
101
|
+
assert config.sip_info.sip_id == "1001"
|
|
102
|
+
assert config.sip_info.sip_password == "secretpassword"
|
|
103
|
+
assert config.rtsp_video_url == "rtsp://127.0.0.1/stream"
|
|
104
|
+
assert len(config.called_elements) == 1
|
|
105
|
+
assert config.called_elements[0].sip_id == "1002"
|
|
106
|
+
assert config.called_elements[0].name == "Station 1"
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_get_provisioning_error(client, runner):
|
|
110
|
+
runner.next_response = ["not", "a", "dict"]
|
|
111
|
+
with pytest.raises(TJA470ResponseError):
|
|
112
|
+
await client.get_provisioning("test-uuid")
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_switch_camera(client, runner):
|
|
116
|
+
runner.next_response = ""
|
|
117
|
+
await client.switch_camera("test-uuid")
|
|
118
|
+
|
|
119
|
+
assert len(runner.requests) == 1
|
|
120
|
+
assert "camera/switch/test-uuid" in runner.requests[0]["url"]
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_open_door(client, runner):
|
|
124
|
+
runner.next_response = ""
|
|
125
|
+
await client.open_door(1)
|
|
126
|
+
|
|
127
|
+
assert len(runner.requests) == 1
|
|
128
|
+
assert "doorrelease/1" in runner.requests[0]["url"]
|
|
129
|
+
|
|
130
|
+
def test_cookies(client, runner):
|
|
131
|
+
client.set_cookies({"session_id": "12345"})
|
|
132
|
+
cookies = client.get_cookies()
|
|
133
|
+
assert cookies == {"session_id": "12345"}
|
|
134
|
+
|