teltasync 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.
- teltasync-0.1.0/PKG-INFO +12 -0
- teltasync-0.1.0/pyproject.toml +29 -0
- teltasync-0.1.0/src/teltasync/__init__.py +18 -0
- teltasync-0.1.0/src/teltasync/api_base.py +42 -0
- teltasync-0.1.0/src/teltasync/auth.py +180 -0
- teltasync-0.1.0/src/teltasync/base_model.py +7 -0
- teltasync-0.1.0/src/teltasync/error_codes.py +66 -0
- teltasync-0.1.0/src/teltasync/exceptions.py +19 -0
- teltasync-0.1.0/src/teltasync/modems.py +322 -0
- teltasync-0.1.0/src/teltasync/system.py +213 -0
- teltasync-0.1.0/src/teltasync/teltasync.py +143 -0
- teltasync-0.1.0/src/teltasync/unauthorized.py +54 -0
- teltasync-0.1.0/src/teltasync/utils.py +6 -0
teltasync-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: teltasync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async, typed API client for Teltonika routers, built for Home Assistant
|
|
5
|
+
Author: Karl Beecken
|
|
6
|
+
Author-email: karl@beecken.berlin
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Dist: aiohttp (>=3.12,<4.0)
|
|
12
|
+
Requires-Dist: pydantic (>=2.7,<3.0)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "teltasync"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async, typed API client for Teltonika routers, built for Home Assistant"
|
|
5
|
+
authors = ["Karl Beecken <karl@beecken.berlin>"]
|
|
6
|
+
packages = [
|
|
7
|
+
{ include = "teltasync", from = "src" }
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = ">=3.13"
|
|
12
|
+
aiohttp = "^3.12"
|
|
13
|
+
pydantic = "^2.7"
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.dev.dependencies]
|
|
16
|
+
pytest = "^8.2"
|
|
17
|
+
pytest-asyncio = "^0.23"
|
|
18
|
+
aioresponses = "^0.7"
|
|
19
|
+
watchdog = "^5.0"
|
|
20
|
+
pytest-watch = "^4.2"
|
|
21
|
+
mypy = "^1.18.2"
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
asyncio_mode = "auto"
|
|
25
|
+
addopts = "-q"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["poetry-core>=1.8.0"]
|
|
29
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Teltonika API library."""
|
|
2
|
+
|
|
3
|
+
from teltasync.exceptions import (
|
|
4
|
+
TeltonikaException,
|
|
5
|
+
TeltonikaConnectionError,
|
|
6
|
+
TeltonikaAuthenticationError,
|
|
7
|
+
TeltonikaInvalidCredentialsError,
|
|
8
|
+
)
|
|
9
|
+
from teltasync.teltasync import Teltasync
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Teltasync",
|
|
14
|
+
"TeltonikaException",
|
|
15
|
+
"TeltonikaConnectionError",
|
|
16
|
+
"TeltonikaAuthenticationError",
|
|
17
|
+
"TeltonikaInvalidCredentialsError",
|
|
18
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Any, Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
from pydantic import model_validator
|
|
4
|
+
|
|
5
|
+
from teltasync.base_model import TeltasyncBaseModel
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _convert_na_to_none(value: Any) -> Any:
|
|
11
|
+
if value == "N/A":
|
|
12
|
+
return None
|
|
13
|
+
if isinstance(value, dict):
|
|
14
|
+
return {key: _convert_na_to_none(val) for key, val in value.items()}
|
|
15
|
+
if isinstance(value, list):
|
|
16
|
+
return [_convert_na_to_none(item) for item in value]
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiError(TeltasyncBaseModel):
|
|
21
|
+
code: int
|
|
22
|
+
error: str
|
|
23
|
+
source: str | None = None
|
|
24
|
+
section: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ApiResponse(TeltasyncBaseModel, Generic[T]):
|
|
28
|
+
success: bool
|
|
29
|
+
data: T | None = None
|
|
30
|
+
errors: list[ApiError] | None = None
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="before")
|
|
33
|
+
@classmethod
|
|
34
|
+
def _convert_na_strings(cls, values: Any) -> Any:
|
|
35
|
+
if isinstance(values, dict):
|
|
36
|
+
return _convert_na_to_none(values)
|
|
37
|
+
return values
|
|
38
|
+
|
|
39
|
+
def get_error_by_code(self, code: int) -> ApiError | None:
|
|
40
|
+
if not self.errors:
|
|
41
|
+
return None
|
|
42
|
+
return next((error for error in self.errors if error.code == code), None)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from aiohttp import ClientConnectorError, ClientSession, ClientTimeout
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from teltasync.api_base import ApiResponse
|
|
8
|
+
from teltasync.exceptions import (
|
|
9
|
+
TeltonikaAuthenticationError,
|
|
10
|
+
TeltonikaConnectionError,
|
|
11
|
+
TeltonikaInvalidCredentialsError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenData(BaseModel):
|
|
16
|
+
username: str
|
|
17
|
+
token: str
|
|
18
|
+
expires: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LogoutResponse(BaseModel):
|
|
22
|
+
response: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SessionStatusData(BaseModel):
|
|
26
|
+
active: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Auth:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
session: ClientSession,
|
|
33
|
+
base_url: str,
|
|
34
|
+
username: str,
|
|
35
|
+
password: str,
|
|
36
|
+
check_certificate: bool = True,
|
|
37
|
+
):
|
|
38
|
+
self.session = session
|
|
39
|
+
self.base_url = base_url
|
|
40
|
+
self.username = username
|
|
41
|
+
self.password = password
|
|
42
|
+
self.check_certificate = check_certificate
|
|
43
|
+
|
|
44
|
+
self._token: str | None = None
|
|
45
|
+
self._token_expires: int | None = None
|
|
46
|
+
self._token_username: str | None = None
|
|
47
|
+
self._token_time: float | None = None
|
|
48
|
+
self._authenticated = False
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def token(self) -> str | None:
|
|
52
|
+
return self._token
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_authenticated(self) -> bool:
|
|
56
|
+
return self._authenticated and self._token is not None
|
|
57
|
+
|
|
58
|
+
def is_token_expired(self) -> bool:
|
|
59
|
+
if not self._token or not self._token_expires or not self._token_time:
|
|
60
|
+
return True
|
|
61
|
+
return time.time() - self._token_time >= self._token_expires - 5
|
|
62
|
+
|
|
63
|
+
def clear_token(self) -> None:
|
|
64
|
+
self._token = None
|
|
65
|
+
self._token_expires = None
|
|
66
|
+
self._token_username = None
|
|
67
|
+
self._token_time = None
|
|
68
|
+
self._authenticated = False
|
|
69
|
+
|
|
70
|
+
async def authenticate(self) -> ApiResponse[TokenData]:
|
|
71
|
+
try:
|
|
72
|
+
async with self.session.post(
|
|
73
|
+
f"{self.base_url}/login",
|
|
74
|
+
json={"username": self.username, "password": self.password},
|
|
75
|
+
ssl=self.check_certificate,
|
|
76
|
+
timeout=ClientTimeout(total=10.0),
|
|
77
|
+
) as resp:
|
|
78
|
+
status = resp.status
|
|
79
|
+
payload = await resp.json()
|
|
80
|
+
except ClientConnectorError as exc:
|
|
81
|
+
raise TeltonikaConnectionError(
|
|
82
|
+
f"Cannot connect to device at {self.base_url}: {exc}",
|
|
83
|
+
exc,
|
|
84
|
+
) from exc
|
|
85
|
+
except asyncio.TimeoutError as exc:
|
|
86
|
+
raise TeltonikaConnectionError(
|
|
87
|
+
f"Connection timeout to device at {self.base_url}",
|
|
88
|
+
exc,
|
|
89
|
+
) from exc
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
message = str(exc)
|
|
92
|
+
timeout_hit = "timeout" in message.lower()
|
|
93
|
+
raise TeltonikaConnectionError(
|
|
94
|
+
f"Connection {'timeout' if timeout_hit else 'error'} to device at {self.base_url}: {message}",
|
|
95
|
+
exc,
|
|
96
|
+
) from exc
|
|
97
|
+
|
|
98
|
+
response = ApiResponse[TokenData](**payload)
|
|
99
|
+
|
|
100
|
+
if response.success and response.data:
|
|
101
|
+
self._token = response.data.token
|
|
102
|
+
self._token_expires = response.data.expires
|
|
103
|
+
self._token_username = response.data.username
|
|
104
|
+
self._token_time = time.time()
|
|
105
|
+
self._authenticated = True
|
|
106
|
+
return response
|
|
107
|
+
|
|
108
|
+
if status == 401:
|
|
109
|
+
raise TeltonikaInvalidCredentialsError()
|
|
110
|
+
|
|
111
|
+
if response.errors:
|
|
112
|
+
err = response.errors[0]
|
|
113
|
+
raise TeltonikaAuthenticationError(
|
|
114
|
+
f"Authentication failed: {err.error}",
|
|
115
|
+
err.code,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
raise TeltonikaAuthenticationError("Authentication failed")
|
|
119
|
+
|
|
120
|
+
async def logout(self) -> ApiResponse[LogoutResponse]:
|
|
121
|
+
if self._token is None:
|
|
122
|
+
return ApiResponse[LogoutResponse](
|
|
123
|
+
success=True,
|
|
124
|
+
data=LogoutResponse(response="No active session"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
async with self.session.post(
|
|
129
|
+
f"{self.base_url}/logout",
|
|
130
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
131
|
+
ssl=self.check_certificate,
|
|
132
|
+
timeout=ClientTimeout(total=10.0),
|
|
133
|
+
) as resp:
|
|
134
|
+
payload = await resp.json()
|
|
135
|
+
return ApiResponse[LogoutResponse](**payload)
|
|
136
|
+
finally:
|
|
137
|
+
self.clear_token()
|
|
138
|
+
|
|
139
|
+
async def get_session_status(self) -> ApiResponse[SessionStatusData]:
|
|
140
|
+
if self._token is None:
|
|
141
|
+
return ApiResponse[SessionStatusData](
|
|
142
|
+
success=True,
|
|
143
|
+
data=SessionStatusData(active=False),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
async with self.session.get(
|
|
148
|
+
f"{self.base_url}/session/status",
|
|
149
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
150
|
+
ssl=self.check_certificate,
|
|
151
|
+
timeout=ClientTimeout(total=10.0),
|
|
152
|
+
) as resp:
|
|
153
|
+
payload = await resp.json()
|
|
154
|
+
except Exception:
|
|
155
|
+
self.clear_token()
|
|
156
|
+
return ApiResponse[SessionStatusData](
|
|
157
|
+
success=True,
|
|
158
|
+
data=SessionStatusData(active=False),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
response = ApiResponse[SessionStatusData](**payload)
|
|
162
|
+
if response.success and response.data and not response.data.active:
|
|
163
|
+
self.clear_token()
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
async def request(self, method: str, endpoint: str, **kwargs):
|
|
167
|
+
if self.is_token_expired():
|
|
168
|
+
await self.authenticate()
|
|
169
|
+
|
|
170
|
+
headers = kwargs.pop("headers", {})
|
|
171
|
+
if self._token:
|
|
172
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
173
|
+
|
|
174
|
+
return self.session.request(
|
|
175
|
+
method,
|
|
176
|
+
f"{self.base_url}/{endpoint.lstrip('/')}",
|
|
177
|
+
headers=headers,
|
|
178
|
+
ssl=self.check_certificate,
|
|
179
|
+
**kwargs,
|
|
180
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Teltonika API error codes and their descriptions."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TeltonikaErrorCode(IntEnum):
|
|
8
|
+
"""Teltonika API error codes as defined in the API documentation."""
|
|
9
|
+
|
|
10
|
+
# General API errors (100-119)
|
|
11
|
+
RESPONSE_NOT_IMPLEMENTED = 100
|
|
12
|
+
NO_ACTION_PROVIDED = 101
|
|
13
|
+
PROVIDED_ACTION_NOT_AVAILABLE = 102
|
|
14
|
+
INVALID_OPTIONS = 103
|
|
15
|
+
UCI_GET_ERROR = 104
|
|
16
|
+
UCI_DELETE_ERROR = 105
|
|
17
|
+
UCI_CREATE_ERROR = 106
|
|
18
|
+
INVALID_STRUCTURE = 107
|
|
19
|
+
SECTION_CREATION_NOT_ALLOWED = 108
|
|
20
|
+
NAME_ALREADY_USED = 109
|
|
21
|
+
NAME_NOT_PROVIDED = 110
|
|
22
|
+
DELETE_NOT_ALLOWED = 111
|
|
23
|
+
DELETION_OF_WHOLE_CONFIG_NOT_ALLOWED = 112
|
|
24
|
+
INVALID_SECTION_PROVIDED = 113
|
|
25
|
+
NO_BODY_PROVIDED = 114
|
|
26
|
+
UCI_SET_ERROR = 115
|
|
27
|
+
INVALID_QUERY_PARAMETER = 116
|
|
28
|
+
GENERAL_CONFIGURATION_ERROR = 117
|
|
29
|
+
|
|
30
|
+
# Authentication and authorization errors (120-123)
|
|
31
|
+
UNAUTHORIZED_ACCESS = 120
|
|
32
|
+
LOGIN_FAILED = 121
|
|
33
|
+
GENERAL_STRUCTURE_INCORRECT = 122
|
|
34
|
+
INVALID_JWT_TOKEN = 123
|
|
35
|
+
|
|
36
|
+
# File upload errors (150-151)
|
|
37
|
+
NOT_ENOUGH_FREE_SPACE = 150
|
|
38
|
+
FILE_SIZE_TOO_BIG = 151
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ERROR_DESCRIPTIONS: Dict[int, str] = {
|
|
42
|
+
100: "Response not implemented",
|
|
43
|
+
101: "No action provided",
|
|
44
|
+
102: "Provided action is not available",
|
|
45
|
+
103: "Invalid options",
|
|
46
|
+
104: "UCI GET error",
|
|
47
|
+
105: "UCI DELETE error",
|
|
48
|
+
106: "UCI CREATE error",
|
|
49
|
+
107: "Invalid structure",
|
|
50
|
+
108: "Section creation is not allowed",
|
|
51
|
+
109: "Name already used",
|
|
52
|
+
110: "Name not provided",
|
|
53
|
+
111: "DELETE not allowed",
|
|
54
|
+
112: "Deletion of whole configuration is not allowed",
|
|
55
|
+
113: "Invalid section provided",
|
|
56
|
+
114: "No body provided for the request",
|
|
57
|
+
115: "UCI SET error",
|
|
58
|
+
116: "Invalid query parameter",
|
|
59
|
+
117: "General configuration error",
|
|
60
|
+
120: "Unauthorized access",
|
|
61
|
+
121: "Login failed for any reason",
|
|
62
|
+
122: "General structure of request is incorrect",
|
|
63
|
+
123: "JWT token that is provided with authorization header is invalid - unparsable, incomplete, etc.",
|
|
64
|
+
150: "Not enough free space in the device (when uploading files)",
|
|
65
|
+
151: "File size is bigger than the maximum size allowed (when uploading files)",
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class TeltonikaException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TeltonikaConnectionError(TeltonikaException):
|
|
6
|
+
def __init__(self, message: str, original_error: Exception | None = None):
|
|
7
|
+
super().__init__(message)
|
|
8
|
+
self.original_error = original_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TeltonikaAuthenticationError(TeltonikaException):
|
|
12
|
+
def __init__(self, message: str, error_code: int | None = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.error_code = error_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TeltonikaInvalidCredentialsError(TeltonikaAuthenticationError):
|
|
18
|
+
def __init__(self, message: str = "Invalid username or password"):
|
|
19
|
+
super().__init__(message, 121)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Bindings for the modem endpoints on Teltonika hardware."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, computed_field
|
|
6
|
+
|
|
7
|
+
from teltasync.api_base import ApiResponse
|
|
8
|
+
from teltasync.auth import Auth
|
|
9
|
+
from teltasync.base_model import TeltasyncBaseModel
|
|
10
|
+
|
|
11
|
+
# Enum types for the state fields
|
|
12
|
+
PinState = Literal[
|
|
13
|
+
"Inserted",
|
|
14
|
+
"Not ready",
|
|
15
|
+
"Required PIN, X attempts left",
|
|
16
|
+
"Required PUK, X attempts left",
|
|
17
|
+
"Not inserted",
|
|
18
|
+
"SIM failure",
|
|
19
|
+
"Busy",
|
|
20
|
+
"PUK"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
OperatorState = Literal[
|
|
24
|
+
"Not registered",
|
|
25
|
+
"Registered, home",
|
|
26
|
+
"Searching",
|
|
27
|
+
"Denied",
|
|
28
|
+
"Unknown",
|
|
29
|
+
"Roaming"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def decode_ue_state(ue_state: int | None) -> str | None:
|
|
34
|
+
"""Decode User Equipment state code to human-readable description.
|
|
35
|
+
|
|
36
|
+
Based on 3GPP TS 24.008.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
ue_state: The UE state code (integer)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Human-readable description of the UE state, or None if invalid/unknown
|
|
43
|
+
"""
|
|
44
|
+
if ue_state is None:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
ue_states = {
|
|
48
|
+
0: "Detached",
|
|
49
|
+
1: "Attached",
|
|
50
|
+
2: "Connecting",
|
|
51
|
+
3: "Connected",
|
|
52
|
+
4: "Idle",
|
|
53
|
+
5: "Disconnecting",
|
|
54
|
+
6: "Emergency Attached",
|
|
55
|
+
7: "Limited Service",
|
|
56
|
+
8: "No Service"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return ue_states.get(ue_state, f"Unknown UE state ({ue_state})")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decode_mobile_stage(mobile_stage: int | None) -> str | None:
|
|
63
|
+
"""Decode mobile stage code to human-readable description.
|
|
64
|
+
|
|
65
|
+
Key from https://developers.teltonika-networks.com/reference/rutx50/7.13.3/v1.5/modems#get-modems-status-id
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
mobile_stage: The mobile stage code (integer)
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Human-readable description of the mobile stage, or None if invalid/unknown
|
|
72
|
+
"""
|
|
73
|
+
if mobile_stage is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
mobile_stages = {
|
|
77
|
+
0: "Unknown state",
|
|
78
|
+
1: "Waiting for SIM to be inserted",
|
|
79
|
+
2: "SIM failure",
|
|
80
|
+
3: "Idling",
|
|
81
|
+
4: "Waiting for user action",
|
|
82
|
+
5: "Waiting for PIN to be entered",
|
|
83
|
+
6: "Waiting for PUK to be entered",
|
|
84
|
+
7: "SIM blocked, no PUK attempts left",
|
|
85
|
+
8: "Initializing mobile connection",
|
|
86
|
+
9: "Configuring Voice over LTE (VoLTE)",
|
|
87
|
+
10: "Setting up connection settings",
|
|
88
|
+
11: "Scanning for available operators",
|
|
89
|
+
12: "Currently handling SIM PIN event",
|
|
90
|
+
13: "Currently handling SIM switch event",
|
|
91
|
+
14: "Initializing modem",
|
|
92
|
+
15: "Changed default SIM card",
|
|
93
|
+
16: "Setting up data connection settings",
|
|
94
|
+
17: "Clearing PDP context",
|
|
95
|
+
18: "Currently handling config",
|
|
96
|
+
19: "Mobile connection setup is complete"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return mobile_stages.get(mobile_stage, f"Unknown mobile stage ({mobile_stage})")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CellInfo(TeltasyncBaseModel):
|
|
103
|
+
"""Cell information for modem."""
|
|
104
|
+
mcc: str | None = Field(None, description="Mobile Country Code")
|
|
105
|
+
mnc: str | None = Field(None, description="Mobile Network Code")
|
|
106
|
+
cell_id: str | None = Field(None, alias="cellid", description="Cell ID")
|
|
107
|
+
ue_state: int | None = Field(None, description="UE state code")
|
|
108
|
+
lac: str | None = Field(None, description="Location Area Code")
|
|
109
|
+
tac: str | None = Field(None, description="Tracking Area Code")
|
|
110
|
+
pcid: int | None = Field(None, description="Physical Cell ID")
|
|
111
|
+
earfcn: str | int | None = Field(None, description="E-ARFCN")
|
|
112
|
+
arfcn: str | int | None = Field(None, description="ARFCN")
|
|
113
|
+
uarfcn: str | int | None = Field(None, description="UARFCN")
|
|
114
|
+
nr_arfcn: str | int | None = Field(None, alias="nr-arfcn", description="NR-ARFCN")
|
|
115
|
+
rsrp: str | int | None = Field(None, description="Reference Signal Received Power")
|
|
116
|
+
rsrq: str | int | None = Field(None, description="Reference Signal Received Quality")
|
|
117
|
+
sinr: str | int | None = Field(None, description="Signal to Interference plus Noise Ratio")
|
|
118
|
+
bandwidth: str | None = Field(None, description="Bandwidth")
|
|
119
|
+
|
|
120
|
+
@computed_field
|
|
121
|
+
def ue_state_description(self) -> str | None:
|
|
122
|
+
"""Get human-readable description of the UE state."""
|
|
123
|
+
return decode_ue_state(self.ue_state)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ServiceModes(TeltasyncBaseModel):
|
|
127
|
+
"""Service modes available for each network type."""
|
|
128
|
+
field_2g: list[str] | None = Field(None, alias="2G", description="2G service modes")
|
|
129
|
+
field_3g: list[str] | None = Field(None, alias="3G", description="3G service modes")
|
|
130
|
+
field_4g: list[str] | None = Field(None, alias="4G", description="4G service modes")
|
|
131
|
+
nb: list[str] | None = Field(None, alias="NB", description="NB-IoT service modes")
|
|
132
|
+
field_5g_nsa: list[str] | None = Field(None, alias="5G_NSA", description="5G NSA service modes")
|
|
133
|
+
field_5g_sa: list[str] | None = Field(None, alias="5G_SA", description="5G SA service modes")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CarrierAggregationSignal(TeltasyncBaseModel):
|
|
137
|
+
"""Carrier aggregation signal information."""
|
|
138
|
+
band: str | None = Field(None, description="Band")
|
|
139
|
+
bandwidth: str | None = Field(None, description="Bandwidth")
|
|
140
|
+
sinr: int | None = Field(None, description="SINR")
|
|
141
|
+
rsrq: int | None = Field(None, description="RSRQ")
|
|
142
|
+
rsrp: int | None = Field(None, description="RSRP")
|
|
143
|
+
pcid: int | None = Field(None, description="Physical Cell ID")
|
|
144
|
+
frequency: str | int | None = Field(None, description="Frequency")
|
|
145
|
+
# Some CA signals might not have all fields
|
|
146
|
+
primary: bool | None = Field(None, description="Primary carrier")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ModemStatusFull(TeltasyncBaseModel):
|
|
150
|
+
"""Full modem status when online."""
|
|
151
|
+
id: str = Field(description="Modem ID")
|
|
152
|
+
imei: str | None = Field(None, description="IMEI")
|
|
153
|
+
model: str | None = Field(None, description="Modem model")
|
|
154
|
+
cell_info: list[CellInfo] | None = Field(None, description="Cell information")
|
|
155
|
+
dynamic_mtu: bool | None = Field(None, description="Dynamic MTU support")
|
|
156
|
+
service_modes: ServiceModes | None = Field(None, description="Available service modes")
|
|
157
|
+
lac: str | None = Field(None, description="Location Area Code")
|
|
158
|
+
tac: str | None = Field(None, description="Tracking Area Code")
|
|
159
|
+
name: str | None = Field(None, description="Modem name")
|
|
160
|
+
index: int | None = Field(None, description="Modem index")
|
|
161
|
+
sim_count: int | None = Field(None, description="Number of SIM cards")
|
|
162
|
+
version: str | None = Field(None, description="Modem version")
|
|
163
|
+
manufacturer: str | None = Field(None, description="Manufacturer")
|
|
164
|
+
builtin: bool | None = Field(None, description="Built-in modem")
|
|
165
|
+
mode: int | None = Field(None, description="Modem mode")
|
|
166
|
+
primary: bool | None = Field(None, description="Primary modem")
|
|
167
|
+
multi_apn: bool | None = Field(None, description="Multi APN support")
|
|
168
|
+
ipv6: bool | None = Field(None, description="IPv6 support")
|
|
169
|
+
volte_supported: bool | None = Field(None, description="VoLTE support")
|
|
170
|
+
auto_3g_bands: bool | None = Field(None, description="Auto 3G bands")
|
|
171
|
+
operators_scan: bool | None = Field(None, description="Operators scan support")
|
|
172
|
+
mobile_dfota: bool | None = Field(None, description="Mobile DFOTA support")
|
|
173
|
+
no_ussd: bool | None = Field(None, description="No USSD")
|
|
174
|
+
framed_routing: bool | None = Field(None, description="Framed routing")
|
|
175
|
+
low_signal_reconnect: bool | None = Field(None, description="Low signal reconnect")
|
|
176
|
+
active_sim: int | None = Field(None, description="Currently active SIM card")
|
|
177
|
+
conntype: str | None = Field(None, description="Connection type")
|
|
178
|
+
simstate: str | None = Field(None, description="SIM state")
|
|
179
|
+
simstate_id: int | None = Field(None, description="SIM state ID")
|
|
180
|
+
data_conn_state: str | None = Field(None, description="Data connection state")
|
|
181
|
+
data_conn_state_id: int | None = Field(None, description="Data connection state ID")
|
|
182
|
+
txbytes: int | None = Field(None, description="Transmitted bytes")
|
|
183
|
+
rxbytes: int | None = Field(None, description="Received bytes")
|
|
184
|
+
baudrate: int | None = Field(None, description="Baudrate")
|
|
185
|
+
is_busy: int | None = Field(None, description="Is busy")
|
|
186
|
+
data_off: bool | None = Field(None, description="Data turned off with mobileoff SMS")
|
|
187
|
+
busy_state: str | None = Field(None, description="Busy state")
|
|
188
|
+
busy_state_id: int | None = Field(None, description="Busy state ID")
|
|
189
|
+
pinstate: PinState | None = Field(None, description="PIN state")
|
|
190
|
+
pinstate_id: int | None = Field(None, description="PIN state ID (deprecated)", deprecated=True)
|
|
191
|
+
operator_state: OperatorState | None = Field(None, description="Operator state")
|
|
192
|
+
operator_state_id: int | None = Field(None, description="Operator state ID (deprecated)", deprecated=True)
|
|
193
|
+
rssi: int | None = Field(None, description="Received Signal Strength Indicator")
|
|
194
|
+
operator: str | None = Field(None, description="Operator name")
|
|
195
|
+
provider: str | None = Field(None, description="Provider")
|
|
196
|
+
ntype: str | None = Field(None, description="Network type")
|
|
197
|
+
imsi: str | None = Field(None, description="International Mobile Subscriber Identity")
|
|
198
|
+
iccid: str | None = Field(None, description="Integrated Circuit Card Identifier")
|
|
199
|
+
cellid: str | None = Field(None, description="Cell ID")
|
|
200
|
+
rscp: str | None = Field(None, description="Received Signal Code Power")
|
|
201
|
+
ecio: str | None = Field(None, description="Energy per chip to Interference power ratio")
|
|
202
|
+
rsrp: int | None = Field(None, description="Reference Signal Received Power")
|
|
203
|
+
rsrq: int | None = Field(None, description="Reference Signal Received Quality")
|
|
204
|
+
sinr: int | None = Field(None, description="Signal to Interference plus Noise Ratio")
|
|
205
|
+
pinleft: int | None = Field(None, description="PIN attempts left")
|
|
206
|
+
volte: bool | None = Field(None, description="VoLTE active")
|
|
207
|
+
sc_band_av: str | None = Field(None, description="Carrier aggregation status")
|
|
208
|
+
ca_signal: list[CarrierAggregationSignal] | None = Field(None, description="Carrier aggregation signal values")
|
|
209
|
+
temperature: int | None = Field(None, description="Modem temperature")
|
|
210
|
+
esim_profile: str | None = Field(None, description="Active eSIM profile")
|
|
211
|
+
mobile_stage: int | None = Field(None, description="Current mobile connection stage")
|
|
212
|
+
gnss_state: int | None = Field(None, description="GNSS state for devices that switch between mobile and GNSS")
|
|
213
|
+
|
|
214
|
+
# Additional fields found in API response (not documented)
|
|
215
|
+
nr5g_sa_disabled: bool | None = Field(None, description="5G SA disabled status")
|
|
216
|
+
wwan_gnss_conflict: bool | None = Field(None, description="WWAN GNSS conflict status")
|
|
217
|
+
modem_state_id: int | None = Field(None, description="Modem state ID")
|
|
218
|
+
sim_switch_enabled: bool | None = Field(None, description="SIM switch enabled")
|
|
219
|
+
serial: str | None = Field(None, description="Modem serial number")
|
|
220
|
+
auto_2g_bands: bool | None = Field(None, description="Auto 2G bands")
|
|
221
|
+
cfg_version: str | None = Field(None, description="Configuration version")
|
|
222
|
+
csd: bool | None = Field(None, description="CSD support")
|
|
223
|
+
pukleft: int | None = Field(None, description="PUK attempts left")
|
|
224
|
+
band: str | None = Field(None, description="Current band")
|
|
225
|
+
auto_5g_mode: bool | None = Field(None, description="Auto 5G mode")
|
|
226
|
+
|
|
227
|
+
# Deprecated fields (keeping for compatibility)
|
|
228
|
+
state: str | None = Field(None, description="Data connection state (deprecated)", deprecated=True)
|
|
229
|
+
state_id: int | None = Field(None, description="Data connection state ID (deprecated)", deprecated=True)
|
|
230
|
+
signal: int | None = Field(None, description="Signal strength (deprecated)", deprecated=True)
|
|
231
|
+
oper: str | None = Field(None, description="Operator (deprecated)", deprecated=True)
|
|
232
|
+
netstate: str | None = Field(None, description="Network state (deprecated)", deprecated=True)
|
|
233
|
+
netstate_id: int | None = Field(None, description="Network state ID (deprecated)", deprecated=True)
|
|
234
|
+
|
|
235
|
+
@computed_field
|
|
236
|
+
def mobile_stage_description(self) -> str | None:
|
|
237
|
+
"""Get human-readable description of the mobile stage."""
|
|
238
|
+
return decode_mobile_stage(self.mobile_stage)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ModemStatusOffline(TeltasyncBaseModel):
|
|
242
|
+
"""Limited modem status when offline."""
|
|
243
|
+
id: str = Field(description="Offline modem id")
|
|
244
|
+
name: str | None = Field(None, description="Offline modem name")
|
|
245
|
+
offline: str | None = Field(None, description="Modem state")
|
|
246
|
+
blocked: str | None = Field(None, description="Modem block state")
|
|
247
|
+
disabled: str | None = Field(None, description="Modem disable state")
|
|
248
|
+
builtin: bool | None = Field(None, description="Modem type")
|
|
249
|
+
primary: bool | None = Field(None, description="Primary modem")
|
|
250
|
+
sim_count: int | None = Field(None, description="Modem SIM count")
|
|
251
|
+
mode: int | None = Field(None, description="Modem mode")
|
|
252
|
+
multi_apn: bool | None = Field(None, description="Multi APN support")
|
|
253
|
+
operators_scan: bool | None = Field(None, description="Operators scan support")
|
|
254
|
+
dynamic_mtu: bool | None = Field(None, description="Dynamic MTU support")
|
|
255
|
+
ipv6: bool | None = Field(None, description="IPv6 support")
|
|
256
|
+
volte: bool | None = Field(None, description="VoLTE support")
|
|
257
|
+
esim_profile: str | None = Field(None, description="Active eSIM profile")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Union type for modem status, can be either full (=online) or offline
|
|
261
|
+
ModemStatus = ModemStatusFull | ModemStatusOffline
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Modems:
|
|
265
|
+
"""Modem management client for Teltonika devices."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, auth: Auth):
|
|
268
|
+
"""Initialize modems client."""
|
|
269
|
+
self.auth = auth
|
|
270
|
+
|
|
271
|
+
async def get_status(self) -> ApiResponse[list[ModemStatus]]:
|
|
272
|
+
"""Get status of all modems.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of modem statuses. Each modem can be either online (full status)
|
|
276
|
+
or offline (limited status) depending on its current state.
|
|
277
|
+
"""
|
|
278
|
+
async with await self.auth.request("GET", "modems/status") as resp:
|
|
279
|
+
json_response = await resp.json()
|
|
280
|
+
return ApiResponse[list[ModemStatus]](**json_response)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def is_online(modem: ModemStatus) -> bool:
|
|
284
|
+
"""Check if a modem is online based on its status type.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
modem: Modem status object
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
True if modem is online (has full status), False if offline.
|
|
291
|
+
"""
|
|
292
|
+
return isinstance(modem, ModemStatusFull)
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def get_online_modems(modems_response: ApiResponse[list[ModemStatus]]) -> list[ModemStatusFull]:
|
|
296
|
+
"""Get only the online modems from a modems status response.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
modems_response: Response from get_status()
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of only the online modems with full status.
|
|
303
|
+
"""
|
|
304
|
+
if not modems_response.success or not modems_response.data:
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
return [modem for modem in modems_response.data if isinstance(modem, ModemStatusFull)]
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def get_offline_modems(modems_response: ApiResponse[list[ModemStatus]]) -> list[ModemStatusOffline]:
|
|
311
|
+
"""Get only the offline modems from a modems status response.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
modems_response: Response from get_status()
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
List of only the offline modems with limited status.
|
|
318
|
+
"""
|
|
319
|
+
if not modems_response.success or not modems_response.data:
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
return [modem for modem in modems_response.data if isinstance(modem, ModemStatusOffline)]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""System endpoint bindings for the Teltonika API."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from teltasync.api_base import ApiResponse
|
|
6
|
+
from teltasync.auth import Auth
|
|
7
|
+
from teltasync.base_model import TeltasyncBaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ManufacturingInfo(TeltasyncBaseModel):
|
|
11
|
+
"""Manufacturing metadata returned by /system/device/status."""
|
|
12
|
+
mac_eth: str = Field(alias="macEth", description="Ethernet WAN MAC address")
|
|
13
|
+
name: str = Field(description="Product code")
|
|
14
|
+
hw_ver: str = Field(alias="hwver", description="Hardware revision")
|
|
15
|
+
batch: str = Field(description="Batch number")
|
|
16
|
+
serial: str = Field(description="Serial number")
|
|
17
|
+
mac: str = Field(description="Ethernet LAN MAC address")
|
|
18
|
+
bl_ver: str = Field(alias="blver", description="Bootloader version")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReleaseInfo(TeltasyncBaseModel):
|
|
22
|
+
"""Firmware release summary."""
|
|
23
|
+
distribution: str = Field(description="Distribution name")
|
|
24
|
+
revision: str = Field(description="Revision")
|
|
25
|
+
version: str = Field(description="Version of release")
|
|
26
|
+
target: str = Field(description="Target of device")
|
|
27
|
+
description: str = Field(description="Description of release")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StaticInfo(TeltasyncBaseModel):
|
|
31
|
+
"""Firmware and hardware identifiers for the device."""
|
|
32
|
+
fw_version: str = Field(description="Firmware version")
|
|
33
|
+
kernel: str = Field(description="Kernel version")
|
|
34
|
+
system: str = Field(description="Processor name")
|
|
35
|
+
device_name: str = Field(description="Device name")
|
|
36
|
+
hostname: str = Field(description="Hostname of router")
|
|
37
|
+
cpu_count: int = Field(description="Number of CPU cores")
|
|
38
|
+
release: ReleaseInfo
|
|
39
|
+
fw_build_date: str = Field(description="Firmware build date")
|
|
40
|
+
model: str = Field(description="Model name")
|
|
41
|
+
board_name: str = Field(description="Board name")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Features(TeltasyncBaseModel):
|
|
45
|
+
"""Feature flags advertised by the device."""
|
|
46
|
+
ipv6: bool = Field(description="IPv6 support")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Modem(TeltasyncBaseModel):
|
|
50
|
+
"""Single modem entry in the system response."""
|
|
51
|
+
id: str = Field(description="Modem ID")
|
|
52
|
+
num: str = Field(description="Modem number")
|
|
53
|
+
builtin: bool = Field(description="Modem built-in")
|
|
54
|
+
sim_count: int = Field(alias="simcount", description="Modem SIM count")
|
|
55
|
+
gps_out: bool = Field(description="GPS support")
|
|
56
|
+
primary: bool = Field(description="Modem primary")
|
|
57
|
+
revision: str = Field(description="Modem revision")
|
|
58
|
+
modem_func_id: int = Field(description="Modem func id")
|
|
59
|
+
multi_apn: bool = Field(description="Modem multiple APN support")
|
|
60
|
+
operator_scan: bool = Field(description="Modem operator scan support")
|
|
61
|
+
dhcp_filter: bool = Field(description="Modem DHCP filter support")
|
|
62
|
+
dynamic_mtu: bool = Field(description="Modem dynamic MTU support")
|
|
63
|
+
ipv6: bool = Field(description="Modem IPv6 support")
|
|
64
|
+
volte: bool = Field(description="Modem VoLTE support")
|
|
65
|
+
csd: bool = Field(description="Modem CSD support")
|
|
66
|
+
band_list: list[str] = Field(description="Modem supported bands")
|
|
67
|
+
product: str = Field(description="Modem product code")
|
|
68
|
+
vendor: str = Field(description="Modem vendor")
|
|
69
|
+
gps: str = Field(description="GPS support")
|
|
70
|
+
stop_bits: str = Field(description="Modem stop bits")
|
|
71
|
+
baudrate: str = Field(alias="boudrate", description="Modem baudrate")
|
|
72
|
+
type: str = Field(description="Modem type")
|
|
73
|
+
desc: str = Field(description="Modem description")
|
|
74
|
+
control: str = Field(description="Modem control")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NetworkInterface(TeltasyncBaseModel):
|
|
78
|
+
"""Configuration values for a network interface."""
|
|
79
|
+
proto: str = Field(description="Protocol")
|
|
80
|
+
device: str = Field(description="Device name")
|
|
81
|
+
default_ip: str | None = Field(None, description="Default IP address")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NetworkConfig(TeltasyncBaseModel):
|
|
85
|
+
"""WAN and LAN interface configuration."""
|
|
86
|
+
wan: NetworkInterface
|
|
87
|
+
lan: NetworkInterface
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ModelInfo(TeltasyncBaseModel):
|
|
91
|
+
"""Identifier and marketing name for the device."""
|
|
92
|
+
id: str = Field(description="Model ID")
|
|
93
|
+
platform: str = Field(description="Model platform")
|
|
94
|
+
name: str = Field(description="Model name")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NetworkOptions(TeltasyncBaseModel):
|
|
98
|
+
"""Limits and defaults used by the switch configuration."""
|
|
99
|
+
readonly_vlans: int
|
|
100
|
+
max_mtu: int
|
|
101
|
+
vlans: int
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SwitchRole(TeltasyncBaseModel):
|
|
105
|
+
"""Mapping of switch ports to their assigned role."""
|
|
106
|
+
ports: str = Field(description="Switch ports")
|
|
107
|
+
role: str = Field(description="Switch role")
|
|
108
|
+
device: str = Field(description="Switch device")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SwitchPort(TeltasyncBaseModel):
|
|
112
|
+
"""Switch port definition with tagging hints."""
|
|
113
|
+
device: str | None = Field(None, description="Switch port device")
|
|
114
|
+
num: int = Field(description="Switch port number")
|
|
115
|
+
want_untag: bool | None = Field(None, description="Switch port want untag")
|
|
116
|
+
need_tag: bool | None = Field(None, description="Switch port need tag")
|
|
117
|
+
role: str | None = Field(None, description="Switch port role")
|
|
118
|
+
index: int | None = Field(None, description="Switch port index")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SwitchConfig(TeltasyncBaseModel):
|
|
122
|
+
"""Complete switch profile for switch0."""
|
|
123
|
+
enable: bool
|
|
124
|
+
roles: list[SwitchRole]
|
|
125
|
+
ports: list[SwitchPort]
|
|
126
|
+
reset: bool = Field(description="Switch reset")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Switch(TeltasyncBaseModel):
|
|
130
|
+
"""Wrapper for switch configuration blocks."""
|
|
131
|
+
switch0: SwitchConfig
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HardwareInfo(TeltasyncBaseModel):
|
|
135
|
+
"""Hardware capabilities advertised by the platform."""
|
|
136
|
+
wps: bool | None = Field(None, description="WPS support")
|
|
137
|
+
rs232: bool | None = Field(None, description="RS232 support")
|
|
138
|
+
nat_offloading: bool | None = Field(None, description="NAT Offloading support")
|
|
139
|
+
dual_sim: bool | None = Field(None, description="Dual SIM support")
|
|
140
|
+
bluetooth: bool | None = Field(None, description="Bluetooth support")
|
|
141
|
+
soft_port_mirror: bool | None = Field(None, description="Software Port Mirroring support")
|
|
142
|
+
vcert: bool | None = Field(None, description="VCert support")
|
|
143
|
+
micro_usb: bool | None = Field(None, description="Micro USB support")
|
|
144
|
+
wifi: bool | None = Field(None, description="WiFi support")
|
|
145
|
+
sd_card: bool | None = Field(None, description="SD Card support")
|
|
146
|
+
multi_tag: bool | None = Field(None, description="Multi Tag support")
|
|
147
|
+
dual_modem: bool | None = Field(None, description="Dual Modem support")
|
|
148
|
+
sfp_switch: bool | None = Field(None, description="SFP switch support")
|
|
149
|
+
dsa: bool | None = Field(None, description="DSA support")
|
|
150
|
+
hw_nat: bool | None = Field(None, description="HW NAT support")
|
|
151
|
+
sw_rst_on_init: bool | None = Field(None, description="SW RST on init support")
|
|
152
|
+
at_sim: bool | None = Field(None, description="AT SIM support")
|
|
153
|
+
port_link: bool | None = Field(None, description="Port link support")
|
|
154
|
+
ios: bool | None = Field(None, description="iOS support")
|
|
155
|
+
usb: bool | None = Field(None, description="USB support")
|
|
156
|
+
console: bool | None = Field(None, description="Console support")
|
|
157
|
+
dual_band_ssid: bool | None = Field(None, description="Dual band SSID support")
|
|
158
|
+
gps: bool | None = Field(None, description="GPS support")
|
|
159
|
+
ethernet: bool | None = Field(None, description="Ethernet support")
|
|
160
|
+
sfp_port: bool | None = Field(None, description="SFP port support")
|
|
161
|
+
rs485: bool | None = Field(None, description="RS485 support")
|
|
162
|
+
mobile: bool | None = Field(None, description="Mobile support")
|
|
163
|
+
poe: bool | None = Field(None, description="POE support")
|
|
164
|
+
gigabit_port: bool | None = Field(None, description="Gigabit port support")
|
|
165
|
+
field_2_5_gigabit_port: bool | None = Field(None, alias="2_5_gigabit_port",
|
|
166
|
+
description="2.5 Gigabit port support")
|
|
167
|
+
|
|
168
|
+
# Additional undocumented fields found in the response
|
|
169
|
+
esim: bool | None = Field(None, description="eSIM support")
|
|
170
|
+
modem_reset: bool | None = Field(None, description="Modem reset support")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class BoardInfo(TeltasyncBaseModel):
|
|
174
|
+
"""High-level board configuration including modems and switch."""
|
|
175
|
+
modems: list[Modem]
|
|
176
|
+
network: NetworkConfig
|
|
177
|
+
model: ModelInfo
|
|
178
|
+
usb_jack: str = Field(description="USB ports")
|
|
179
|
+
network_options: NetworkOptions
|
|
180
|
+
switch: Switch
|
|
181
|
+
hw_info: HardwareInfo = Field(alias="hwinfo")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class DeviceStatusData(TeltasyncBaseModel):
|
|
185
|
+
"""Aggregated payload returned by the status endpoint."""
|
|
186
|
+
mnf_info: ManufacturingInfo = Field(alias="mnfinfo")
|
|
187
|
+
static: StaticInfo
|
|
188
|
+
features: Features
|
|
189
|
+
board: BoardInfo
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class RebootResponse(TeltasyncBaseModel):
|
|
193
|
+
"""Minimal response body for reboot requests."""
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class System:
|
|
198
|
+
"""API wrapper for /system endpoints."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, auth: Auth):
|
|
201
|
+
self.auth = auth
|
|
202
|
+
|
|
203
|
+
async def get_device_status(self) -> ApiResponse[DeviceStatusData]:
|
|
204
|
+
"""Return manufacturing, firmware and hardware details."""
|
|
205
|
+
async with await self.auth.request("GET", "system/device/status") as resp:
|
|
206
|
+
json_response = await resp.json()
|
|
207
|
+
return ApiResponse[DeviceStatusData](**json_response)
|
|
208
|
+
|
|
209
|
+
async def reboot(self) -> ApiResponse[RebootResponse]:
|
|
210
|
+
"""Trigger a reboot and return the raw API response."""
|
|
211
|
+
async with await self.auth.request("POST", "system/actions/reboot") as resp:
|
|
212
|
+
json_response = await resp.json()
|
|
213
|
+
return ApiResponse[RebootResponse](**json_response)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from aiohttp import ClientSession
|
|
2
|
+
|
|
3
|
+
from teltasync.auth import Auth
|
|
4
|
+
from teltasync.exceptions import TeltonikaAuthenticationError, TeltonikaConnectionError
|
|
5
|
+
from teltasync.modems import ModemStatusFull, ModemStatusOffline, Modems
|
|
6
|
+
from teltasync.system import DeviceStatusData, System
|
|
7
|
+
from teltasync.unauthorized import UnauthorizedClient, UnauthorizedStatusData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Teltasync:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
base_url: str,
|
|
14
|
+
username: str,
|
|
15
|
+
password: str,
|
|
16
|
+
*,
|
|
17
|
+
session: ClientSession | None = None,
|
|
18
|
+
verify_ssl: bool = True,
|
|
19
|
+
):
|
|
20
|
+
self._session = session
|
|
21
|
+
self._own_session = session is None
|
|
22
|
+
self._base_url = base_url
|
|
23
|
+
self._username = username
|
|
24
|
+
self._password = password
|
|
25
|
+
self._verify_ssl = verify_ssl
|
|
26
|
+
|
|
27
|
+
self._auth: Auth | None = None
|
|
28
|
+
self._system: System | None = None
|
|
29
|
+
self._modems: Modems | None = None
|
|
30
|
+
self._unauthorized: UnauthorizedClient | None = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
async def create(
|
|
34
|
+
cls,
|
|
35
|
+
base_url: str,
|
|
36
|
+
username: str,
|
|
37
|
+
password: str,
|
|
38
|
+
*,
|
|
39
|
+
verify_ssl: bool = True,
|
|
40
|
+
) -> "Teltasync":
|
|
41
|
+
session = ClientSession()
|
|
42
|
+
return cls(
|
|
43
|
+
base_url=base_url,
|
|
44
|
+
username=username,
|
|
45
|
+
password=password,
|
|
46
|
+
session=session,
|
|
47
|
+
verify_ssl=verify_ssl,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def session(self) -> ClientSession:
|
|
52
|
+
if self._session is None:
|
|
53
|
+
self._session = ClientSession()
|
|
54
|
+
return self._session
|
|
55
|
+
|
|
56
|
+
async def close(self) -> None:
|
|
57
|
+
if self._own_session and self._session:
|
|
58
|
+
await self._session.close()
|
|
59
|
+
self._session = None
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> "Teltasync":
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
65
|
+
await self.close()
|
|
66
|
+
|
|
67
|
+
async def get_device_info(self) -> UnauthorizedStatusData:
|
|
68
|
+
await self._ensure_session()
|
|
69
|
+
response = await self.unauthorized.get_status()
|
|
70
|
+
if response.success and response.data:
|
|
71
|
+
return response.data
|
|
72
|
+
raise TeltonikaConnectionError("Failed to get device info")
|
|
73
|
+
|
|
74
|
+
async def validate_credentials(self) -> bool:
|
|
75
|
+
try:
|
|
76
|
+
await self._ensure_session()
|
|
77
|
+
await self.auth.authenticate()
|
|
78
|
+
except TeltonikaAuthenticationError:
|
|
79
|
+
return False
|
|
80
|
+
finally:
|
|
81
|
+
await self.logout()
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
async def get_system_info(self) -> DeviceStatusData:
|
|
85
|
+
await self._ensure_session()
|
|
86
|
+
response = await self.system.get_device_status()
|
|
87
|
+
if response.success and response.data:
|
|
88
|
+
return response.data
|
|
89
|
+
raise TeltonikaConnectionError("Failed to get system info")
|
|
90
|
+
|
|
91
|
+
async def get_modem_status(self) -> list[ModemStatusFull | ModemStatusOffline]:
|
|
92
|
+
await self._ensure_session()
|
|
93
|
+
response = await self.modems.get_status()
|
|
94
|
+
if response.success and response.data:
|
|
95
|
+
return response.data
|
|
96
|
+
raise TeltonikaConnectionError("Failed to get modem status")
|
|
97
|
+
|
|
98
|
+
async def reboot_device(self) -> bool:
|
|
99
|
+
await self._ensure_session()
|
|
100
|
+
response = await self.system.reboot()
|
|
101
|
+
return bool(response and response.success)
|
|
102
|
+
|
|
103
|
+
async def logout(self) -> bool:
|
|
104
|
+
await self._ensure_session()
|
|
105
|
+
response = await self.auth.logout()
|
|
106
|
+
return bool(response and response.success)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def auth(self) -> Auth:
|
|
110
|
+
if self._auth is None:
|
|
111
|
+
self._auth = Auth(
|
|
112
|
+
self.session,
|
|
113
|
+
self._base_url,
|
|
114
|
+
self._username,
|
|
115
|
+
self._password,
|
|
116
|
+
check_certificate=self._verify_ssl,
|
|
117
|
+
)
|
|
118
|
+
return self._auth
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def system(self) -> System:
|
|
122
|
+
if self._system is None:
|
|
123
|
+
self._system = System(self.auth)
|
|
124
|
+
return self._system
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def modems(self) -> Modems:
|
|
128
|
+
if self._modems is None:
|
|
129
|
+
self._modems = Modems(self.auth)
|
|
130
|
+
return self._modems
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def unauthorized(self) -> UnauthorizedClient:
|
|
134
|
+
if self._unauthorized is None:
|
|
135
|
+
self._unauthorized = UnauthorizedClient(
|
|
136
|
+
self.session,
|
|
137
|
+
self._base_url,
|
|
138
|
+
check_certificate=self._verify_ssl,
|
|
139
|
+
)
|
|
140
|
+
return self._unauthorized
|
|
141
|
+
|
|
142
|
+
async def _ensure_session(self) -> ClientSession:
|
|
143
|
+
return self.session
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from aiohttp import ClientConnectorError, ClientSession, ClientTimeout
|
|
5
|
+
from pydantic import ConfigDict
|
|
6
|
+
|
|
7
|
+
from teltasync.api_base import ApiResponse
|
|
8
|
+
from teltasync.base_model import TeltasyncBaseModel
|
|
9
|
+
from teltasync.exceptions import TeltonikaConnectionError
|
|
10
|
+
from teltasync.utils import camel_to_snake
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SecurityBanner(TeltasyncBaseModel):
|
|
14
|
+
title: str
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnauthorizedStatusData(TeltasyncBaseModel):
|
|
19
|
+
lang: str
|
|
20
|
+
filename: Optional[str] = None
|
|
21
|
+
device_name: str
|
|
22
|
+
device_model: str
|
|
23
|
+
api_version: str
|
|
24
|
+
device_identifier: str
|
|
25
|
+
security_banner: Optional[SecurityBanner] = None
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(alias_generator=camel_to_snake, populate_by_name=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnauthorizedClient:
|
|
31
|
+
def __init__(self, session: ClientSession, base_url: str, check_certificate: bool = True):
|
|
32
|
+
self.session = session
|
|
33
|
+
self.base_url = base_url
|
|
34
|
+
self.check_certificate = check_certificate
|
|
35
|
+
|
|
36
|
+
async def get_status(self) -> ApiResponse[UnauthorizedStatusData]:
|
|
37
|
+
try:
|
|
38
|
+
async with self.session.get(
|
|
39
|
+
f"{self.base_url}/unauthorized/status",
|
|
40
|
+
ssl=self.check_certificate,
|
|
41
|
+
timeout=ClientTimeout(total=10.0),
|
|
42
|
+
) as resp:
|
|
43
|
+
payload = await resp.json()
|
|
44
|
+
return ApiResponse[UnauthorizedStatusData](**payload)
|
|
45
|
+
except ClientConnectorError as exc:
|
|
46
|
+
raise TeltonikaConnectionError(
|
|
47
|
+
f"Cannot connect to device at {self.base_url}: {exc}",
|
|
48
|
+
exc,
|
|
49
|
+
) from exc
|
|
50
|
+
except asyncio.TimeoutError as exc:
|
|
51
|
+
raise TeltonikaConnectionError(
|
|
52
|
+
f"Connection timeout to {self.base_url}",
|
|
53
|
+
exc,
|
|
54
|
+
) from exc
|