ipguard-api 0.1.0b6__py3-none-any.whl
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.
- ipguard_api/__init__.py +63 -0
- ipguard_api/client.py +833 -0
- ipguard_api/errors.py +53 -0
- ipguard_api/models.py +205 -0
- ipguard_api/protocol.py +464 -0
- ipguard_api/py.typed +0 -0
- ipguard_api/transport.py +265 -0
- ipguard_api/validation.py +93 -0
- ipguard_api-0.1.0b6.dist-info/METADATA +47 -0
- ipguard_api-0.1.0b6.dist-info/RECORD +12 -0
- ipguard_api-0.1.0b6.dist-info/WHEEL +4 -0
- ipguard_api-0.1.0b6.dist-info/licenses/LICENSE +201 -0
ipguard_api/errors.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Public errors for the IPGUARD library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IpguardError(Exception):
|
|
5
|
+
"""Base error for IPGUARD operations."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidInputError(IpguardError, ValueError):
|
|
9
|
+
"""Input cannot be used in an IPGUARD enrollment or release request."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(IpguardError):
|
|
13
|
+
"""The service rejected authorization without a known specific reason."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidCodeError(AuthenticationError):
|
|
17
|
+
"""The service rejected the verification code."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VerificationExpiredError(AuthenticationError):
|
|
21
|
+
"""A service response explicitly identifies an expired challenge."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimitError(AuthenticationError):
|
|
25
|
+
"""A service response explicitly identifies a rate limit."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExistingInstallationError(AuthenticationError):
|
|
29
|
+
"""Registration would replace an existing mobile installation."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthRevokedError(AuthenticationError):
|
|
33
|
+
"""An activated installation received a terminal reset notice."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConnectionFailureError(IpguardError):
|
|
37
|
+
"""The cloud connection could not complete an ordinary read or request."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProtocolError(ConnectionFailureError):
|
|
41
|
+
"""A relevant service message has an unsupported structure."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RelayUnavailableError(IpguardError):
|
|
45
|
+
"""A relay is absent, unpermitted, pending confirmation, or already busy."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReleaseRejectedError(IpguardError):
|
|
49
|
+
"""A correlated acknowledgement explicitly rejected the release request."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OutcomeUnknownError(IpguardError):
|
|
53
|
+
"""A release may have been delivered but its acknowledgement was not received."""
|
ipguard_api/models.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Typed account authorization and relay discovery models.
|
|
2
|
+
|
|
3
|
+
Credential fields are omitted from representations. Explicit session serialization
|
|
4
|
+
still contains credentials and must be treated as confidential.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from .errors import InvalidInputError, IpguardError
|
|
12
|
+
|
|
13
|
+
DEFAULT_ACTIVATION_ENDPOINT = "tcp://zmq.campiserveur.com:5579"
|
|
14
|
+
DEFAULT_SECURE_ENDPOINT = "tcp://zmq.campiserveur.com:5577"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IdentifierKind(StrEnum):
|
|
18
|
+
"""Email and phone identifiers supported for enrollment."""
|
|
19
|
+
|
|
20
|
+
EMAIL = "email"
|
|
21
|
+
PHONE = "phone"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionStatus(StrEnum):
|
|
25
|
+
"""Enrollment phase used to interpret server reset notices."""
|
|
26
|
+
|
|
27
|
+
PENDING = "pending"
|
|
28
|
+
ACTIVATED = "activated"
|
|
29
|
+
DISABLED = "disabled"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class AuthIdentifier:
|
|
34
|
+
"""A registered email or API-normalized international telephone number."""
|
|
35
|
+
|
|
36
|
+
kind: IdentifierKind
|
|
37
|
+
value: str = field(repr=False)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, str]:
|
|
40
|
+
"""Return the identifier for explicit, confidential persistence."""
|
|
41
|
+
return {"kind": self.kind.value, "value": self.value}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class AndroidProfile:
|
|
46
|
+
"""Static Android metadata used for provider-facing messages."""
|
|
47
|
+
|
|
48
|
+
model: str = "Pixel 7"
|
|
49
|
+
os_version: str = "14"
|
|
50
|
+
connection_info: str = "WiFi"
|
|
51
|
+
language: str = "en_GB"
|
|
52
|
+
app_version: str = "3.4.8"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class Session:
|
|
57
|
+
"""Authorization for one enrolled installation, not a canonical account ID.
|
|
58
|
+
|
|
59
|
+
The installation label differs from the UUID used for routing and SIP
|
|
60
|
+
authorization. The server public key is a CURVE key rather than a bearer
|
|
61
|
+
token. No model field implies current physical door or lock state.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
identity: str = field(repr=False)
|
|
65
|
+
identifier: AuthIdentifier = field(repr=False)
|
|
66
|
+
label: str = field(repr=False)
|
|
67
|
+
verification_code: str = field(repr=False)
|
|
68
|
+
server_public_key: str = field(repr=False)
|
|
69
|
+
sip_password: str | None = field(default=None, repr=False)
|
|
70
|
+
android_profile: AndroidProfile = field(default_factory=AndroidProfile)
|
|
71
|
+
activation_endpoint: str = DEFAULT_ACTIVATION_ENDPOINT
|
|
72
|
+
secure_endpoint: str = DEFAULT_SECURE_ENDPOINT
|
|
73
|
+
status: SessionStatus = SessionStatus.ACTIVATED
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, object]:
|
|
76
|
+
"""Return a credential-bearing config-entry payload without redaction."""
|
|
77
|
+
return {
|
|
78
|
+
"identity": self.identity,
|
|
79
|
+
"identifier": self.identifier.to_dict(),
|
|
80
|
+
"label": self.label,
|
|
81
|
+
"verification_code": self.verification_code,
|
|
82
|
+
"server_public_key": self.server_public_key,
|
|
83
|
+
"sip_password": self.sip_password,
|
|
84
|
+
"android_profile": asdict(self.android_profile),
|
|
85
|
+
"activation_endpoint": self.activation_endpoint,
|
|
86
|
+
"secure_endpoint": self.secure_endpoint,
|
|
87
|
+
"status": self.status.value,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: Mapping[str, object]) -> Session:
|
|
92
|
+
"""Restore a serialized session; this does not authenticate or connect."""
|
|
93
|
+
identifier = data.get("identifier")
|
|
94
|
+
profile = data.get("android_profile", {})
|
|
95
|
+
if not isinstance(identifier, Mapping) or not isinstance(profile, Mapping):
|
|
96
|
+
raise InvalidInputError("Invalid saved session structure.")
|
|
97
|
+
try:
|
|
98
|
+
kind = IdentifierKind(_text(identifier, "kind"))
|
|
99
|
+
status = SessionStatus(_text(data, "status", SessionStatus.ACTIVATED.value))
|
|
100
|
+
except ValueError:
|
|
101
|
+
raise InvalidInputError("Invalid saved identifier kind.") from None
|
|
102
|
+
password = data.get("sip_password")
|
|
103
|
+
if password is not None and not isinstance(password, str):
|
|
104
|
+
raise InvalidInputError("Invalid saved session credential type.")
|
|
105
|
+
defaults = AndroidProfile()
|
|
106
|
+
return cls(
|
|
107
|
+
identity=_text(data, "identity"),
|
|
108
|
+
identifier=AuthIdentifier(kind, _text(identifier, "value")),
|
|
109
|
+
label=_text(data, "label"),
|
|
110
|
+
verification_code=_text(data, "verification_code"),
|
|
111
|
+
server_public_key=_text(data, "server_public_key"),
|
|
112
|
+
sip_password=password,
|
|
113
|
+
android_profile=AndroidProfile(
|
|
114
|
+
model=_text(profile, "model", defaults.model),
|
|
115
|
+
os_version=_text(profile, "os_version", defaults.os_version),
|
|
116
|
+
connection_info=_text(
|
|
117
|
+
profile, "connection_info", defaults.connection_info
|
|
118
|
+
),
|
|
119
|
+
language=_text(profile, "language", defaults.language),
|
|
120
|
+
app_version=_text(profile, "app_version", defaults.app_version),
|
|
121
|
+
),
|
|
122
|
+
activation_endpoint=_text(
|
|
123
|
+
data, "activation_endpoint", DEFAULT_ACTIVATION_ENDPOINT
|
|
124
|
+
),
|
|
125
|
+
secure_endpoint=_text(data, "secure_endpoint", DEFAULT_SECURE_ENDPOINT),
|
|
126
|
+
status=status,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class Board:
|
|
132
|
+
"""A server-discovered physical intercom board."""
|
|
133
|
+
|
|
134
|
+
identity: str
|
|
135
|
+
name: str = ""
|
|
136
|
+
site_id: str | None = None
|
|
137
|
+
site_name: str | None = None
|
|
138
|
+
device_id: str | int | None = None
|
|
139
|
+
device_type: str | int | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True)
|
|
143
|
+
class IncomingCall:
|
|
144
|
+
"""A notification containing only a uniquely mapped inventory board.
|
|
145
|
+
|
|
146
|
+
Unknown or ambiguous callers have ``board=None``. Call tokens, signalling
|
|
147
|
+
senders and media descriptions are never part of the public notification.
|
|
148
|
+
Optional board names and site fields are bounded, valid text without controls.
|
|
149
|
+
Unused device identifiers and device types are omitted from this projection.
|
|
150
|
+
This records receipt of an offer, not an active call or an answered call.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
board: Board | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class Relay:
|
|
158
|
+
"""A discovered relay and its permission to perform a release action.
|
|
159
|
+
|
|
160
|
+
``number`` is one-based, matching the transmitted ``body.door``. Eligibility
|
|
161
|
+
is configuration, not a contact sensor or a locked/unlocked state.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
board: Board
|
|
165
|
+
number: int
|
|
166
|
+
name: str = ""
|
|
167
|
+
remote_allowed: bool = False
|
|
168
|
+
pending_confirmation: bool = False
|
|
169
|
+
access_type: int | str | None = None
|
|
170
|
+
access_mode: int | str | None = None
|
|
171
|
+
|
|
172
|
+
def __post_init__(self) -> None:
|
|
173
|
+
if isinstance(self.number, bool) or not isinstance(self.number, int):
|
|
174
|
+
raise InvalidInputError("Relay number must be a one-based integer.")
|
|
175
|
+
if self.number < 1:
|
|
176
|
+
raise InvalidInputError("Relay number must be a one-based integer.")
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def board_identity(self) -> str:
|
|
180
|
+
"""Return the board routing identity."""
|
|
181
|
+
return self.board.identity
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def relay_number(self) -> int:
|
|
185
|
+
"""Return the one-based relay number."""
|
|
186
|
+
return self.number
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True, slots=True)
|
|
190
|
+
class ClientSnapshot:
|
|
191
|
+
"""Immutable pushed inventory and connectivity, without physical door state."""
|
|
192
|
+
|
|
193
|
+
relays: tuple[Relay, ...]
|
|
194
|
+
connected: bool
|
|
195
|
+
revoked: bool = False
|
|
196
|
+
error: IpguardError | None = field(default=None, repr=False)
|
|
197
|
+
session: Session | None = field(default=None, repr=False)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _text(data: Mapping[str, object], key: str, default: str | None = None) -> str:
|
|
201
|
+
value = data.get(key, default)
|
|
202
|
+
if not isinstance(value, str):
|
|
203
|
+
# Values are deliberately omitted: the payload can contain credentials.
|
|
204
|
+
raise InvalidInputError("Invalid saved session field type.")
|
|
205
|
+
return value
|