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 +1 -0
- pygira/_http.py +142 -0
- pygira/api.py +292 -0
- pygira/cli.py +49 -0
- pygira/commands/__init__.py +1 -0
- pygira/commands/bootstrap.py +246 -0
- pygira/commands/config.py +232 -0
- pygira/commands/device.py +547 -0
- pygira/commands/firmware.py +163 -0
- pygira/commands/gds.py +219 -0
- pygira/commands/maintenance.py +604 -0
- pygira/config_service.py +394 -0
- pygira/context.py +206 -0
- pygira/core/__init__.py +1 -0
- pygira/core/detect.py +84 -0
- pygira/core/resolve.py +29 -0
- pygira/core/types.py +29 -0
- pygira/devices/__init__.py +1 -0
- pygira/devices/base.py +17 -0
- pygira/devices/g1.py +162 -0
- pygira/devices/registry.py +16 -0
- pygira/devices/x1.py +165 -0
- pygira/gds.py +342 -0
- pygira/models.py +105 -0
- pygira/options.py +84 -0
- pygira/tks_web.py +188 -0
- pygira/transports/__init__.py +1 -0
- pygira/transports/api.py +5 -0
- pygira/transports/config_service.py +17 -0
- pygira/transports/gds.py +5 -0
- pygira/weather.py +70 -0
- pygira-0.0.1.dist-info/METADATA +276 -0
- pygira-0.0.1.dist-info/RECORD +36 -0
- pygira-0.0.1.dist-info/WHEEL +4 -0
- pygira-0.0.1.dist-info/entry_points.txt +2 -0
- pygira-0.0.1.dist-info/licenses/LICENSE +674 -0
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."""
|