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/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