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.
@@ -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,7 @@
1
+ from pydantic import ConfigDict, BaseModel
2
+
3
+ from teltasync.utils import camel_to_snake
4
+
5
+
6
+ class TeltasyncBaseModel(BaseModel):
7
+ model_config = ConfigDict(alias_generator=camel_to_snake, populate_by_name=True)
@@ -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
@@ -0,0 +1,6 @@
1
+ import re
2
+
3
+
4
+ def camel_to_snake(name: str) -> str:
5
+ """Convert camelCase to snake_case."""
6
+ return re.sub(r'(?<!^)(?=[A-Z])', '_', name).lower()