pygira 0.0.1__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.
pygira/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """pygira provisioning library."""
pygira/_http.py ADDED
@@ -0,0 +1,142 @@
1
+ """Minimal stdlib HTTP client — the small slice of httpx this project used.
2
+
3
+ Why: httpx is pre-1.0 (no stable API). We only used sync Client with cookie
4
+ persistence (the session-login handshake depends on it), verify=False for the
5
+ device's self-signed TLS, basic auth, and .json()/.content/.raise_for_status().
6
+ That maps cleanly onto urllib + http.cookiejar + ssl, so no third-party dep.
7
+ """
8
+
9
+ import base64
10
+ import json as _json
11
+ import ssl
12
+ import urllib.error
13
+ import urllib.request
14
+ from http.cookiejar import CookieJar
15
+ from types import TracebackType
16
+ from typing import Literal, cast
17
+ from urllib.parse import urlencode
18
+
19
+
20
+ # ponytail: one exception type. raise_for_status (HTTP 4xx/5xx) and transport
21
+ # failures (connection refused, timeout) both raise this — matches the single
22
+ # `except httpx.HTTPError` the callers rely on.
23
+ class HTTPError(Exception):
24
+ """Request failed (connection error or, via raise_for_status, 4xx/5xx)."""
25
+
26
+
27
+ HTTP_ERROR_STATUS = 400
28
+
29
+ _UNVERIFIED = ssl.create_default_context()
30
+ _UNVERIFIED.check_hostname = False
31
+ _UNVERIFIED.verify_mode = ssl.CERT_NONE
32
+
33
+
34
+ class Response:
35
+ def __init__(self, status_code: int, content: bytes) -> None:
36
+ self.status_code = status_code
37
+ self.content = content
38
+
39
+ def json(self: "Response") -> object:
40
+ return _json.loads(self.content)
41
+
42
+ def raise_for_status(self) -> None:
43
+ if self.status_code >= HTTP_ERROR_STATUS:
44
+ msg = f"HTTP {self.status_code}"
45
+ raise HTTPError(msg)
46
+
47
+
48
+ class Client:
49
+ """Context-managed session. Cookies persist across requests on one instance."""
50
+
51
+ def __init__(
52
+ self,
53
+ base_url: str = "",
54
+ headers: dict | None = None,
55
+ auth: tuple[str, str] | None = None,
56
+ verify: bool = True,
57
+ timeout: float = 30.0,
58
+ ) -> None:
59
+ self.base_url = base_url.rstrip("/")
60
+ self.timeout = timeout
61
+ self._headers = dict(headers or {})
62
+ if auth is not None:
63
+ token = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode()
64
+ self._headers.setdefault("Authorization", f"Basic {token}")
65
+ handlers: list[urllib.request.BaseHandler] = [
66
+ urllib.request.HTTPCookieProcessor(CookieJar()),
67
+ ]
68
+ if not verify:
69
+ handlers.append(urllib.request.HTTPSHandler(context=_UNVERIFIED))
70
+ self._opener = urllib.request.build_opener(*handlers)
71
+
72
+ def __enter__(self: "Client") -> "Client":
73
+ return self
74
+
75
+ def __exit__(
76
+ self: "Client",
77
+ exc_type: type[BaseException] | None,
78
+ exc_value: BaseException | None,
79
+ traceback: TracebackType | None,
80
+ ) -> Literal[False]:
81
+ return False
82
+
83
+ def _url(self, path: str, params: dict[str, object] | None = None) -> str:
84
+ url = path if path.startswith("http") else f"{self.base_url}{path}"
85
+ if params:
86
+ url = f"{url}?{urlencode(params)}"
87
+ return url
88
+
89
+ def _request(self, method: str, path: str, **kwargs: object) -> Response:
90
+ params = cast("dict[str, object] | None", kwargs.get("params"))
91
+ json_payload = cast("dict[str, object] | None", kwargs.get("json"))
92
+ content = cast("bytes | None", kwargs.get("content"))
93
+ headers = cast("dict[str, str] | None", kwargs.get("headers"))
94
+
95
+ body = content
96
+ hdrs = {**self._headers, **(headers or {})}
97
+ if json_payload is not None:
98
+ body = _json.dumps(json_payload).encode()
99
+ hdrs.setdefault("Content-Type", "application/json")
100
+ req = urllib.request.Request(
101
+ self._url(path, params),
102
+ data=body,
103
+ headers=hdrs,
104
+ method=method,
105
+ )
106
+ try:
107
+ with self._opener.open(req, timeout=self.timeout) as resp:
108
+ return Response(resp.status, resp.read())
109
+ except urllib.error.HTTPError as e:
110
+ # 4xx/5xx: a response, not a failure yet — surfaced via raise_for_status.
111
+ return Response(e.code, e.read())
112
+ except (urllib.error.URLError, OSError) as e:
113
+ raise HTTPError(str(e)) from e
114
+
115
+ def get(
116
+ self,
117
+ path: str,
118
+ *,
119
+ params: dict[str, object] | None = None,
120
+ headers: dict[str, str] | None = None,
121
+ ) -> Response:
122
+ return self._request("GET", path, params=params, headers=headers)
123
+
124
+ def post(
125
+ self,
126
+ path: str,
127
+ *,
128
+ json: dict[str, object] | None = None,
129
+ content: bytes | None = None,
130
+ headers: dict[str, str] | None = None,
131
+ ) -> Response:
132
+ return self._request("POST", path, json=json, content=content, headers=headers)
133
+
134
+ def put(
135
+ self,
136
+ path: str,
137
+ *,
138
+ json: dict[str, object] | None = None,
139
+ content: bytes | None = None,
140
+ headers: dict[str, str] | None = None,
141
+ ) -> Response:
142
+ return self._request("PUT", path, json=json, content=content, headers=headers)
pygira/api.py ADDED
@@ -0,0 +1,292 @@
1
+ """iscwebservice HTTP client — port 80 (via nginx proxy).
2
+
3
+ Auth: Authorization: Basic <base64(user:password)> (capital "Basic")
4
+ Handles firmware update commands.
5
+ """
6
+
7
+ import base64
8
+ import hashlib
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Literal, cast
12
+
13
+ from pygira import _http as httpx
14
+ from pygira.models import NetworkConfig
15
+
16
+
17
+ def _auth_header(username: str, password: str) -> str:
18
+ token = base64.b64encode(f"{username}:{password}".encode()).decode()
19
+ return f"Basic {token}"
20
+
21
+
22
+ class ApiClient:
23
+ """HTTP client for the iscwebservice API on port 80."""
24
+
25
+ def __init__(
26
+ self,
27
+ host: str,
28
+ username: str,
29
+ password: str,
30
+ timeout: float = 60.0,
31
+ api_prefix: str = "/api",
32
+ ) -> None:
33
+ """Initialize the ApiClient.
34
+
35
+ Args:
36
+ host: Hostname or IP of the device (no scheme, e.g. "192.168.1.100").
37
+ username: Username for basic auth.
38
+ password: Password for basic auth.
39
+ timeout: Request timeout in seconds.
40
+ api_prefix: URL path prefix, e.g. "/api" (G1) or "/webservice" (X1).
41
+
42
+ """
43
+ self.host = host
44
+ self.username = username
45
+ self.password = password
46
+ self.api_prefix = api_prefix.rstrip("/")
47
+ self._headers = {
48
+ "Authorization": _auth_header(username, password),
49
+ "Content-Type": "application/json",
50
+ }
51
+ self.timeout = timeout
52
+
53
+ def _post(self, payload: dict[str, Any]) -> dict:
54
+ with httpx.Client(base_url=f"http://{self.host}", timeout=self.timeout) as client:
55
+ resp = client.post(f"{self.api_prefix}", json=payload, headers=self._headers)
56
+ resp.raise_for_status()
57
+ data = cast("dict[str, Any]", resp.json() if resp.content else {})
58
+ if (
59
+ isinstance(data, dict)
60
+ and data.get("error")
61
+ and self.api_prefix in {"/api", "/webservice"}
62
+ ):
63
+ recovered = self._post_with_session_fallback(payload, data)
64
+ if recovered is not None:
65
+ return recovered
66
+ msg = (
67
+ f"API error {data.get('id', '?')}: {data.get('error')}"
68
+ " (session auth also failed — check password)"
69
+ )
70
+ raise RuntimeError(
71
+ msg,
72
+ )
73
+ return data
74
+
75
+ @staticmethod
76
+ def _sha256_hex(value: str) -> str:
77
+ return hashlib.sha256(value.encode()).hexdigest()
78
+
79
+ @staticmethod
80
+ def _sha256_hex_upper(value: str) -> str:
81
+ return hashlib.sha256(value.encode()).hexdigest().upper()
82
+
83
+ def _compute_auth_token(self, password: str, salt: str, session_salt: str, version: str) -> str:
84
+ if version == "GDS_1":
85
+ digest = hashlib.sha256((password + salt).encode("utf-8")).digest()
86
+ password_hash = base64.b64encode(digest).decode()[:43]
87
+ else:
88
+ first = self._sha256_hex(password)
89
+ password_hash = self._sha256_hex(f"{first}+{salt}")
90
+ return self._sha256_hex_upper(f"{password_hash}+{session_salt}")
91
+
92
+ def _ws_payload(self, command: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
93
+ payload: dict[str, Any] = {"command": command, "keepAlive": True}
94
+ if data is not None:
95
+ payload["data"] = data
96
+ return payload
97
+
98
+ def _post_with_session_fallback(
99
+ self,
100
+ payload: dict[str, Any],
101
+ first_error: dict[str, Any],
102
+ ) -> dict | None:
103
+ # Only retry on auth/session-style errors.
104
+ if str(first_error.get("id", "")) not in {"220", "235"}:
105
+ return None
106
+
107
+ path = self.api_prefix
108
+ with httpx.Client(base_url=f"http://{self.host}", timeout=self.timeout) as client:
109
+ salt_resp = client.post(
110
+ path,
111
+ json=self._ws_payload("getPasswordSalt", {"username": self.username}),
112
+ )
113
+ salt_resp.raise_for_status()
114
+ salt_data = cast("dict[str, Any]", salt_resp.json() if salt_resp.content else {})
115
+ session_data = (salt_data or {}).get("data") or {}
116
+ salt = session_data.get("salt")
117
+ session_salt = session_data.get("sessionSalt")
118
+ version = session_data.get("version", "1")
119
+ if not salt or not session_salt:
120
+ return None
121
+
122
+ token = self._compute_auth_token(self.password, salt, session_salt, version)
123
+ auth_resp = client.post(
124
+ path,
125
+ json=self._ws_payload(
126
+ "doAuthenticateSession",
127
+ {"username": self.username, "token": token},
128
+ ),
129
+ )
130
+ auth_resp.raise_for_status()
131
+ auth_data = cast("dict[str, Any]", auth_resp.json() if auth_resp.content else {})
132
+ if isinstance(auth_data, dict) and auth_data.get("error"):
133
+ return None
134
+
135
+ cmd = payload["command"]
136
+ retry_data = {k: v for k, v in payload.items() if k != "command"}
137
+ ws_data = (
138
+ retry_data.get("data")
139
+ if set(retry_data.keys()) == {"data"}
140
+ else (retry_data or None)
141
+ )
142
+ retry_resp = client.post(path, json=self._ws_payload(cmd, ws_data))
143
+ retry_resp.raise_for_status()
144
+ return cast("dict[str, Any]", retry_resp.json() if retry_resp.content else {})
145
+
146
+ def check_online_update(self) -> dict:
147
+ """Query available online firmware update info."""
148
+ return self._post({"command": "infoonline"})
149
+
150
+ def get_firmware_status(self) -> dict:
151
+ """Query firmware status (used by X1 web UI polling)."""
152
+ return self._post({"command": "getFirmwareStatus"})
153
+
154
+ def get_device_info(self, *, force_long: bool = False) -> dict:
155
+ """Fetch device info from webservice API."""
156
+ payload: dict[str, Any] = {"command": "getDeviceInfo"}
157
+ if force_long:
158
+ payload["data"] = {"forceLong": True}
159
+ return self._post(payload)
160
+
161
+ def get_diagnostic_page(self, *, completely: bool = True) -> dict:
162
+ """Fetch diagnostic page data from webservice API."""
163
+ return self._post({"command": "getDiagnosticPage", "data": {"completely": completely}})
164
+
165
+ def set_ntp_config(self, *, enabled: bool, server: str, interval_minutes: int) -> dict:
166
+ """Set NTP configuration via webservice API."""
167
+ return self._post(
168
+ {
169
+ "command": "setNtpConfig",
170
+ "data": {
171
+ "Ntp": enabled,
172
+ "NtpServerAddress": server,
173
+ "NtpInterval": str(interval_minutes),
174
+ },
175
+ },
176
+ )
177
+
178
+ def set_ip_config(self, cfg: NetworkConfig) -> dict:
179
+ """Set network configuration via webservice API."""
180
+ data: dict[str, Any] = {
181
+ "Dhcp": cfg.dhcp,
182
+ }
183
+ if not cfg.dhcp:
184
+ data["IpAddress"] = cfg.ip_address
185
+ data["SubnetMask"] = cfg.subnet_mask
186
+ data["DefaultGateway"] = cfg.default_gateway
187
+ data["NameServer"] = cfg.primary_dns
188
+ if cfg.secondary_dns:
189
+ data["SecondaryDns"] = cfg.secondary_dns
190
+ return self._post({"command": "setIpConfig", "data": data})
191
+
192
+ def get_logfile(self) -> bytes:
193
+ """Fetch diagnostic logfile ZIP returned by webservice command getLogfile."""
194
+ result = self._post({"command": "getLogfile"})
195
+ content_b64 = ((result or {}).get("data") or {}).get("content")
196
+ if not content_b64:
197
+ msg = f"getLogfile returned no content: {result}"
198
+ raise RuntimeError(msg)
199
+ return base64.b64decode(content_b64)
200
+
201
+ def trigger_online_update(self) -> dict:
202
+ """Start online firmware update from Gira download server."""
203
+ return self._post({"command": "startonlineupdate"})
204
+
205
+ def get_upgrade_progress(self) -> dict:
206
+ """Get current firmware update progress."""
207
+ return self._post({"command": "progress"})
208
+
209
+ def upload_firmware(self, firmware_path: Path) -> None:
210
+ """Upload a local firmware ZIP file to the device."""
211
+ data = firmware_path.read_bytes()
212
+ with httpx.Client(base_url=f"http://{self.host}", timeout=300.0) as client:
213
+ resp = client.post(
214
+ f"{self.api_prefix}/upload/v2",
215
+ content=data,
216
+ headers={
217
+ "Authorization": self._headers["Authorization"],
218
+ "Content-Type": "application/x-zip-compressed",
219
+ },
220
+ )
221
+ resp.raise_for_status()
222
+
223
+ def initiate_local_install(self) -> dict:
224
+ """Trigger installation of the previously uploaded firmware file."""
225
+ return self._post({"command": "initlocalupload"})
226
+
227
+ def wait_for_completion(self, poll_interval: float = 5.0, max_wait: float = 300.0) -> bool:
228
+ """Poll progress until done or timeout. Returns True if completed."""
229
+ deadline = time.monotonic() + max_wait
230
+ while time.monotonic() < deadline:
231
+ try:
232
+ resp = self.get_upgrade_progress()
233
+ state = resp.get("state", resp.get("status", ""))
234
+ if state in ("done", "completed", "finished", "success"):
235
+ return True
236
+ if state in ("error", "failed"):
237
+ error_msg = f"Firmware update failed: {resp}"
238
+ raise RuntimeError(error_msg)
239
+ except httpx.HTTPError:
240
+ # Device may be rebooting
241
+ pass
242
+ time.sleep(poll_interval)
243
+ return False
244
+
245
+ def control_service(self, service: str, control: str) -> dict:
246
+ """Control a system service (e.g. enable/disable/start/stop)."""
247
+ return self._post({"command": "controlService", "service": service, "control": control})
248
+
249
+ def enable_ssh(self, *, persistent: bool = True) -> None:
250
+ """Enable SSH access on the device.
251
+
252
+ persistent=True: touches /opt/userdata/.ssh-enabled (survives reboot) then starts sshd.
253
+ persistent=False: starts sshd once without persisting (start-once).
254
+ """
255
+ if persistent:
256
+ self.control_service("S50sshd", "enable")
257
+ self.control_service("S50sshd", "start")
258
+ else:
259
+ self.control_service("S50sshd", "start-once")
260
+
261
+ def disable_ssh(self) -> None:
262
+ """Stop sshd and remove the persistent-enable marker."""
263
+ self.control_service("S50sshd", "stop")
264
+ self.control_service("S50sshd", "disable")
265
+
266
+ def start_ssh(self, mode: Literal["persistent", "once"] = "persistent") -> None:
267
+ """Start SSH in the given mode.
268
+
269
+ Args:
270
+ mode: "persistent" to enable and start sshd persistently, or
271
+ "once" to start sshd for this session only.
272
+
273
+ """
274
+ if mode == "persistent":
275
+ self.enable_ssh(persistent=True)
276
+ else:
277
+ self.enable_ssh(persistent=False)
278
+
279
+ def reboot(self) -> None:
280
+ """Reboot the device via JSON API."""
281
+ self._post({"command": "reboot"})
282
+
283
+ def factory_reset(self) -> None:
284
+ """Reset the device via JSON API."""
285
+ self._post({"command": "factoryReset"})
286
+
287
+ def commissioning_test(self) -> dict:
288
+ """Run the built-in commissioning test (GET /api/commissioningtest)."""
289
+ with httpx.Client(base_url=f"http://{self.host}", timeout=self.timeout) as client:
290
+ resp = client.get(f"{self.api_prefix}/commissioningtest", headers=self._headers)
291
+ resp.raise_for_status()
292
+ return cast("dict[str, Any]", resp.json() if resp.content else {})
pygira/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ """pygira - Gira provisioning CLI."""
2
+
3
+ import click
4
+
5
+ from pygira.commands import bootstrap, config, device, firmware, gds, maintenance
6
+ from pygira.core.types import DeviceType
7
+
8
+
9
+ @click.group()
10
+ @click.option(
11
+ "--device",
12
+ type=click.Choice(["g1", "x1"]),
13
+ default=None,
14
+ help="Expected device type",
15
+ )
16
+ @click.option("--name", "device_name", default=None, help="Named device from devices.toml")
17
+ @click.option("--location", default=None, help="Optional location name from devices.toml")
18
+ @click.option(
19
+ "--config",
20
+ "config_path",
21
+ default="devices.toml",
22
+ show_default=True,
23
+ help="Path to devices.toml",
24
+ )
25
+ @click.pass_context
26
+ def main(
27
+ ctx: click.Context,
28
+ device: str | None,
29
+ device_name: str | None,
30
+ location: str | None,
31
+ config_path: str,
32
+ ) -> None:
33
+ """Provision and manage Gira devices."""
34
+ ctx.ensure_object(dict)
35
+ ctx.obj["requested_device"] = DeviceType(device) if device else None
36
+ ctx.obj["device_name"] = device_name
37
+ ctx.obj["location"] = location
38
+ ctx.obj["config_path"] = config_path
39
+
40
+
41
+ config.register(main)
42
+ device.register(main)
43
+ gds.register(main)
44
+ maintenance.register(main)
45
+ firmware.register(main)
46
+ bootstrap.register(main)
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1 @@
1
+ """Command handlers for pygira."""