ventilatepro-cli 0.1.1__tar.gz → 0.2.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.
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/PKG-INFO +8 -1
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/README.md +7 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/tests/test_cli.py +116 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro/__init__.py +1 -1
- ventilatepro_cli-0.2.0/ventilatepro/api_client.py +221 -0
- ventilatepro_cli-0.2.0/ventilatepro/calc.py +142 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro/cli.py +13 -35
- ventilatepro_cli-0.2.0/ventilatepro/design_day.py +95 -0
- ventilatepro_cli-0.2.0/ventilatepro/entities.py +210 -0
- ventilatepro_cli-0.2.0/ventilatepro/output.py +31 -0
- ventilatepro_cli-0.2.0/ventilatepro/projects.py +55 -0
- ventilatepro_cli-0.2.0/ventilatepro/systems.py +76 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/PKG-INFO +8 -1
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/SOURCES.txt +6 -0
- ventilatepro_cli-0.1.1/ventilatepro/api_client.py +0 -122
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/pyproject.toml +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/setup.cfg +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro/queue.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/requires.txt +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ventilatepro-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: VentilatePro CLI
|
|
5
5
|
Project-URL: Homepage, https://ventilatepro.com/exam/cli/
|
|
6
6
|
Project-URL: Documentation, https://ventilatepro.com/exam/cli/
|
|
@@ -42,6 +42,13 @@ pip install -e .
|
|
|
42
42
|
```bash
|
|
43
43
|
ventilatepro auth login --url http://localhost:8000
|
|
44
44
|
ventilatepro projects list
|
|
45
|
+
ventilatepro projects show 1
|
|
46
|
+
ventilatepro rooms list --project 1
|
|
47
|
+
ventilatepro ahus list --project 1
|
|
48
|
+
ventilatepro systems hierarchy --project 1
|
|
49
|
+
ventilatepro calc status --project 1
|
|
50
|
+
ventilatepro calc ahu-properties --ahu 1
|
|
51
|
+
ventilatepro design-day context --project 1
|
|
45
52
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
46
53
|
ventilatepro notes sync
|
|
47
54
|
```
|
|
@@ -28,6 +28,13 @@ pip install -e .
|
|
|
28
28
|
```bash
|
|
29
29
|
ventilatepro auth login --url http://localhost:8000
|
|
30
30
|
ventilatepro projects list
|
|
31
|
+
ventilatepro projects show 1
|
|
32
|
+
ventilatepro rooms list --project 1
|
|
33
|
+
ventilatepro ahus list --project 1
|
|
34
|
+
ventilatepro systems hierarchy --project 1
|
|
35
|
+
ventilatepro calc status --project 1
|
|
36
|
+
ventilatepro calc ahu-properties --ahu 1
|
|
37
|
+
ventilatepro design-day context --project 1
|
|
31
38
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
32
39
|
ventilatepro notes sync
|
|
33
40
|
```
|
|
@@ -286,3 +286,119 @@ class CliTests(unittest.TestCase):
|
|
|
286
286
|
tags=["field", "urgent"],
|
|
287
287
|
query="terminal",
|
|
288
288
|
)
|
|
289
|
+
|
|
290
|
+
def test_projects_list_json_outputs_paginated_payload(self):
|
|
291
|
+
fake_client = mock.Mock()
|
|
292
|
+
fake_client.projects_list.return_value = {
|
|
293
|
+
"count": 1,
|
|
294
|
+
"limit": 20,
|
|
295
|
+
"offset": 0,
|
|
296
|
+
"results": [
|
|
297
|
+
{
|
|
298
|
+
"id": 1,
|
|
299
|
+
"name": "CLI Project",
|
|
300
|
+
}
|
|
301
|
+
],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
305
|
+
result = self.runner.invoke(app, ["projects", "list", "--json"])
|
|
306
|
+
|
|
307
|
+
self.assertEqual(result.exit_code, 0)
|
|
308
|
+
payload = json.loads(result.stdout)
|
|
309
|
+
self.assertEqual(payload["results"][0]["name"], "CLI Project")
|
|
310
|
+
fake_client.projects_list.assert_called_once_with(limit=20, offset=0, query=None)
|
|
311
|
+
|
|
312
|
+
def test_projects_show_calls_api_client(self):
|
|
313
|
+
fake_client = mock.Mock()
|
|
314
|
+
fake_client.project_show.return_value = {
|
|
315
|
+
"id": 7,
|
|
316
|
+
"name": "Hospital South Wing",
|
|
317
|
+
"counts": {"rooms": 3, "zones": 2, "ahus": 1},
|
|
318
|
+
"location": "Los Angeles",
|
|
319
|
+
"time_zone": "America/Los_Angeles",
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
323
|
+
result = self.runner.invoke(app, ["projects", "show", "7"])
|
|
324
|
+
|
|
325
|
+
self.assertEqual(result.exit_code, 0)
|
|
326
|
+
self.assertIn("Hospital South Wing", result.stdout)
|
|
327
|
+
fake_client.project_show.assert_called_once_with(7)
|
|
328
|
+
|
|
329
|
+
def test_rooms_list_passes_filters_to_api_client(self):
|
|
330
|
+
fake_client = mock.Mock()
|
|
331
|
+
fake_client.list_rooms.return_value = {
|
|
332
|
+
"project_id": 1,
|
|
333
|
+
"count": 0,
|
|
334
|
+
"limit": 5,
|
|
335
|
+
"offset": 10,
|
|
336
|
+
"results": [],
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
340
|
+
result = self.runner.invoke(
|
|
341
|
+
app,
|
|
342
|
+
[
|
|
343
|
+
"rooms",
|
|
344
|
+
"list",
|
|
345
|
+
"--project",
|
|
346
|
+
"1",
|
|
347
|
+
"--limit",
|
|
348
|
+
"5",
|
|
349
|
+
"--offset",
|
|
350
|
+
"10",
|
|
351
|
+
"--query",
|
|
352
|
+
"operating",
|
|
353
|
+
"--json",
|
|
354
|
+
],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
self.assertEqual(result.exit_code, 0)
|
|
358
|
+
fake_client.list_rooms.assert_called_once_with(1, limit=5, offset=10, query="operating")
|
|
359
|
+
|
|
360
|
+
def test_calc_recalculate_stale_passes_mode_scope_and_force(self):
|
|
361
|
+
fake_client = mock.Mock()
|
|
362
|
+
fake_client.calc_recalculate_stale.return_value = {
|
|
363
|
+
"message": "Queued stale recalculation.",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
367
|
+
result = self.runner.invoke(
|
|
368
|
+
app,
|
|
369
|
+
[
|
|
370
|
+
"calc",
|
|
371
|
+
"recalculate-stale",
|
|
372
|
+
"--project",
|
|
373
|
+
"1",
|
|
374
|
+
"--scope",
|
|
375
|
+
"zones",
|
|
376
|
+
"--mode",
|
|
377
|
+
"sync",
|
|
378
|
+
"--force",
|
|
379
|
+
"--json",
|
|
380
|
+
],
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
self.assertEqual(result.exit_code, 0)
|
|
384
|
+
fake_client.calc_recalculate_stale.assert_called_once_with(1, scope="zones", mode="sync", force=True)
|
|
385
|
+
|
|
386
|
+
def test_design_day_run_reads_json_payload_from_file(self):
|
|
387
|
+
fake_client = mock.Mock()
|
|
388
|
+
fake_client.design_day_run.return_value = {
|
|
389
|
+
"id": 42,
|
|
390
|
+
"state": "QUEUED",
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
with tempfile.NamedTemporaryFile("w+", encoding="utf-8") as handle:
|
|
394
|
+
json.dump({"zones": [{"zone_id": 1}]}, handle)
|
|
395
|
+
handle.flush()
|
|
396
|
+
|
|
397
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
398
|
+
result = self.runner.invoke(
|
|
399
|
+
app,
|
|
400
|
+
["design-day", "run", "--project", "1", "--input", handle.name, "--json"],
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
self.assertEqual(result.exit_code, 0)
|
|
404
|
+
fake_client.design_day_run.assert_called_once_with(1, {"zones": [{"zone_id": 1}]})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiError(Exception):
|
|
9
|
+
def __init__(self, status_code: int, message: str, payload: Any = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.payload = payload
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NetworkError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_error_message(payload: Any, fallback: str) -> str:
|
|
20
|
+
if isinstance(payload, dict):
|
|
21
|
+
if isinstance(payload.get("detail"), str):
|
|
22
|
+
return payload["detail"]
|
|
23
|
+
error = payload.get("error")
|
|
24
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
25
|
+
return error["message"]
|
|
26
|
+
return " ".join(str(value) for value in payload.values() if value)
|
|
27
|
+
if isinstance(payload, str) and payload.strip():
|
|
28
|
+
return payload
|
|
29
|
+
return fallback
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiClient:
|
|
33
|
+
def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 15):
|
|
34
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
35
|
+
self.token = token
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def request(
|
|
39
|
+
self,
|
|
40
|
+
method: str,
|
|
41
|
+
path: str,
|
|
42
|
+
*,
|
|
43
|
+
json_payload: Optional[Dict[str, Any]] = None,
|
|
44
|
+
headers: Optional[Dict[str, str]] = None,
|
|
45
|
+
params: Optional[Dict[str, Any]] = None,
|
|
46
|
+
):
|
|
47
|
+
final_headers = {
|
|
48
|
+
"Accept": "application/json",
|
|
49
|
+
}
|
|
50
|
+
if headers:
|
|
51
|
+
final_headers.update(headers)
|
|
52
|
+
if self.token:
|
|
53
|
+
final_headers["Authorization"] = f"Bearer {self.token}"
|
|
54
|
+
try:
|
|
55
|
+
response = requests.request(
|
|
56
|
+
method=method,
|
|
57
|
+
url=f"{self.base_url}{path}",
|
|
58
|
+
json=json_payload,
|
|
59
|
+
headers=final_headers,
|
|
60
|
+
params=params,
|
|
61
|
+
timeout=self.timeout,
|
|
62
|
+
)
|
|
63
|
+
except requests.RequestException as exc:
|
|
64
|
+
raise NetworkError(str(exc)) from exc
|
|
65
|
+
|
|
66
|
+
payload: Any = None
|
|
67
|
+
if response.content:
|
|
68
|
+
try:
|
|
69
|
+
payload = response.json()
|
|
70
|
+
except ValueError:
|
|
71
|
+
payload = response.text
|
|
72
|
+
|
|
73
|
+
if not response.ok:
|
|
74
|
+
raise ApiError(
|
|
75
|
+
status_code=response.status_code,
|
|
76
|
+
message=extract_error_message(payload, f"Request failed with status {response.status_code}."),
|
|
77
|
+
payload=payload,
|
|
78
|
+
)
|
|
79
|
+
return payload if payload is not None else {}
|
|
80
|
+
|
|
81
|
+
def auth_me(self):
|
|
82
|
+
return self.request("GET", "/api/cli/auth/me/")
|
|
83
|
+
|
|
84
|
+
def projects_list(self, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
85
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
86
|
+
if query:
|
|
87
|
+
params["q"] = query
|
|
88
|
+
return self.request("GET", "/api/cli/projects/", params=params)
|
|
89
|
+
|
|
90
|
+
def project_show(self, project_id: int):
|
|
91
|
+
return self.request("GET", f"/api/cli/projects/{project_id}/")
|
|
92
|
+
|
|
93
|
+
def _list_project_entities(
|
|
94
|
+
self,
|
|
95
|
+
entity_name: str,
|
|
96
|
+
project: int,
|
|
97
|
+
*,
|
|
98
|
+
limit: int = 20,
|
|
99
|
+
offset: int = 0,
|
|
100
|
+
query: Optional[str] = None,
|
|
101
|
+
):
|
|
102
|
+
params: Dict[str, Any] = {"project": project, "limit": limit, "offset": offset}
|
|
103
|
+
if query:
|
|
104
|
+
params["q"] = query
|
|
105
|
+
return self.request("GET", f"/api/cli/{entity_name}/", params=params)
|
|
106
|
+
|
|
107
|
+
def _show_entity(self, entity_name: str, entity_id: int):
|
|
108
|
+
return self.request("GET", f"/api/cli/{entity_name}/{entity_id}/")
|
|
109
|
+
|
|
110
|
+
def list_rooms(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
111
|
+
return self._list_project_entities("rooms", project, limit=limit, offset=offset, query=query)
|
|
112
|
+
|
|
113
|
+
def show_room(self, room_id: int):
|
|
114
|
+
return self._show_entity("rooms", room_id)
|
|
115
|
+
|
|
116
|
+
def list_zones(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
117
|
+
return self._list_project_entities("zones", project, limit=limit, offset=offset, query=query)
|
|
118
|
+
|
|
119
|
+
def show_zone(self, zone_id: int):
|
|
120
|
+
return self._show_entity("zones", zone_id)
|
|
121
|
+
|
|
122
|
+
def list_ahus(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
123
|
+
return self._list_project_entities("ahus", project, limit=limit, offset=offset, query=query)
|
|
124
|
+
|
|
125
|
+
def show_ahu(self, ahu_id: int):
|
|
126
|
+
return self._show_entity("ahus", ahu_id)
|
|
127
|
+
|
|
128
|
+
def list_plants(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
129
|
+
return self._list_project_entities("plants", project, limit=limit, offset=offset, query=query)
|
|
130
|
+
|
|
131
|
+
def show_plant(self, plant_ref: str):
|
|
132
|
+
return self.request("GET", f"/api/cli/plants/{plant_ref}/")
|
|
133
|
+
|
|
134
|
+
def system_hierarchy(self, project_id: int, *, include_envelope: bool = True):
|
|
135
|
+
return self.request(
|
|
136
|
+
"GET",
|
|
137
|
+
f"/api/cli/projects/{project_id}/systems/hierarchy/",
|
|
138
|
+
params={"include_envelope": str(bool(include_envelope)).lower()},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def system_graph(self, project_id: int):
|
|
142
|
+
return self.request("GET", f"/api/cli/projects/{project_id}/systems/graph/")
|
|
143
|
+
|
|
144
|
+
def system_topology(self, project_id: int):
|
|
145
|
+
return self.request("GET", f"/api/cli/projects/{project_id}/systems/topology/")
|
|
146
|
+
|
|
147
|
+
def calc_status(self, project_id: int):
|
|
148
|
+
return self.request("GET", f"/api/cli/projects/{project_id}/calc/status/")
|
|
149
|
+
|
|
150
|
+
def calc_ventilation(self, ahu_id: int):
|
|
151
|
+
return self.request("GET", f"/api/cli/ahus/{ahu_id}/calc/ventilation/")
|
|
152
|
+
|
|
153
|
+
def calc_ahu_steps(self, ahu_id: int):
|
|
154
|
+
return self.request("GET", f"/api/cli/ahus/{ahu_id}/calc/steps/")
|
|
155
|
+
|
|
156
|
+
def calc_ahu_properties(self, ahu_id: int, *, procedure: Optional[str] = None):
|
|
157
|
+
params = {"procedure": procedure} if procedure else None
|
|
158
|
+
return self.request("GET", f"/api/cli/ahus/{ahu_id}/calc/properties/", params=params)
|
|
159
|
+
|
|
160
|
+
def calc_recalculate_airflows(self, project_id: int):
|
|
161
|
+
return self.request("POST", f"/api/cli/projects/{project_id}/calc/recalculate-airflows/")
|
|
162
|
+
|
|
163
|
+
def calc_recalculate_stale(self, project_id: int, *, scope: str, mode: str, force: bool):
|
|
164
|
+
return self.request(
|
|
165
|
+
"POST",
|
|
166
|
+
f"/api/cli/projects/{project_id}/calc/recalculate-stale/",
|
|
167
|
+
json_payload={
|
|
168
|
+
"scope": scope,
|
|
169
|
+
"mode": mode,
|
|
170
|
+
"force": force,
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def calc_cav(self, ahu_id: int):
|
|
175
|
+
return self.request("GET", f"/api/cli/ahus/{ahu_id}/calc/cav/")
|
|
176
|
+
|
|
177
|
+
def design_day_context(self, project_id: int):
|
|
178
|
+
return self.request("GET", f"/api/cli/projects/{project_id}/design-day/context/")
|
|
179
|
+
|
|
180
|
+
def design_day_run(self, project_id: int, payload: Dict[str, Any]):
|
|
181
|
+
return self.request("POST", f"/api/cli/projects/{project_id}/design-day/run/", json_payload=payload)
|
|
182
|
+
|
|
183
|
+
def design_day_results(self, job_id: int):
|
|
184
|
+
return self.request("GET", f"/api/cli/design-day/jobs/{job_id}/results/")
|
|
185
|
+
|
|
186
|
+
def create_note(self, payload: Dict[str, Any], *, idempotency_key: str):
|
|
187
|
+
return self.request(
|
|
188
|
+
"POST",
|
|
189
|
+
"/api/cli/notes/",
|
|
190
|
+
json_payload=payload,
|
|
191
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def list_notes(
|
|
195
|
+
self,
|
|
196
|
+
project: int,
|
|
197
|
+
*,
|
|
198
|
+
limit: int = 20,
|
|
199
|
+
offset: int = 0,
|
|
200
|
+
note_type: Optional[str] = None,
|
|
201
|
+
source: Optional[str] = None,
|
|
202
|
+
tags: Optional[list[str]] = None,
|
|
203
|
+
query: Optional[str] = None,
|
|
204
|
+
):
|
|
205
|
+
params: Dict[str, Any] = {
|
|
206
|
+
"project": project,
|
|
207
|
+
"limit": limit,
|
|
208
|
+
"offset": offset,
|
|
209
|
+
}
|
|
210
|
+
if note_type:
|
|
211
|
+
params["type"] = note_type
|
|
212
|
+
if source:
|
|
213
|
+
params["source"] = source
|
|
214
|
+
if tags:
|
|
215
|
+
params["tag"] = tags
|
|
216
|
+
if query:
|
|
217
|
+
params["q"] = query
|
|
218
|
+
return self.request("GET", "/api/cli/notes/", params=params)
|
|
219
|
+
|
|
220
|
+
def show_note(self, note_ref: str, *, project: int):
|
|
221
|
+
return self.request("GET", f"/api/cli/notes/{note_ref}/", params={"project": project})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from . import auth
|
|
6
|
+
from .api_client import ApiError, NetworkError
|
|
7
|
+
from .output import emit, emit_error
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Calculation commands")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("status")
|
|
13
|
+
def calc_status(
|
|
14
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
15
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
16
|
+
):
|
|
17
|
+
try:
|
|
18
|
+
client, _ = auth.get_authenticated_client()
|
|
19
|
+
payload = client.calc_status(project)
|
|
20
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
21
|
+
emit_error(str(exc), json_output=json_output)
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
summary = payload.get("summary") or {}
|
|
25
|
+
zone_summary = summary.get("zones") or {}
|
|
26
|
+
ahu_summary = summary.get("ahus") or {}
|
|
27
|
+
lines = [
|
|
28
|
+
f"Project: {payload.get('project_name')}",
|
|
29
|
+
f"Zones: total={zone_summary.get('total', 0)} stale={zone_summary.get('stale', 0)} failed={zone_summary.get('failed', 0)}",
|
|
30
|
+
f"AHUs: total={ahu_summary.get('total', 0)} stale={ahu_summary.get('stale', 0)} failed={ahu_summary.get('failed', 0)}",
|
|
31
|
+
]
|
|
32
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("ventilation")
|
|
36
|
+
def calc_ventilation(
|
|
37
|
+
ahu: int = typer.Option(..., "--ahu", help="AHU ID"),
|
|
38
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
39
|
+
):
|
|
40
|
+
try:
|
|
41
|
+
client, _ = auth.get_authenticated_client()
|
|
42
|
+
payload = client.calc_ventilation(ahu)
|
|
43
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
44
|
+
emit_error(str(exc), json_output=json_output)
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
|
|
47
|
+
lines = [
|
|
48
|
+
f"AHU: {payload.get('ahu_name') or ahu}",
|
|
49
|
+
f"OA CFM: {payload.get('final_outside_air_cfm') or payload.get('outside_air_cfm') or 'N/A'}",
|
|
50
|
+
]
|
|
51
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("ahu-steps")
|
|
55
|
+
def calc_ahu_steps(
|
|
56
|
+
ahu: int = typer.Option(..., "--ahu", help="AHU ID"),
|
|
57
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
58
|
+
):
|
|
59
|
+
try:
|
|
60
|
+
client, _ = auth.get_authenticated_client()
|
|
61
|
+
payload = client.calc_ahu_steps(ahu)
|
|
62
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
63
|
+
emit_error(str(exc), json_output=json_output)
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
|
|
66
|
+
lines = [f"Returned AHU calculation steps for AHU {ahu}."]
|
|
67
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("ahu-properties")
|
|
71
|
+
def calc_ahu_properties(
|
|
72
|
+
ahu: int = typer.Option(..., "--ahu", help="AHU ID"),
|
|
73
|
+
procedure: str = typer.Option(None, "--procedure", help="Optional AHU calculation procedure"),
|
|
74
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
75
|
+
):
|
|
76
|
+
try:
|
|
77
|
+
client, _ = auth.get_authenticated_client()
|
|
78
|
+
payload = client.calc_ahu_properties(ahu, procedure=procedure)
|
|
79
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
80
|
+
emit_error(str(exc), json_output=json_output)
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
lines = [f"Calculated AHU properties for AHU {ahu}."]
|
|
84
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("recalculate-airflows")
|
|
88
|
+
def calc_recalculate_airflows(
|
|
89
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
90
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
91
|
+
):
|
|
92
|
+
try:
|
|
93
|
+
client, _ = auth.get_authenticated_client()
|
|
94
|
+
payload = client.calc_recalculate_airflows(project)
|
|
95
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
96
|
+
emit_error(str(exc), json_output=json_output)
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
|
|
99
|
+
lines = [payload.get("message") or "Recalculated room airflows."]
|
|
100
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("recalculate-stale")
|
|
104
|
+
def calc_recalculate_stale(
|
|
105
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
106
|
+
scope: str = typer.Option("all", "--scope", help="all, ahus, or zones"),
|
|
107
|
+
mode: str = typer.Option("async", "--mode", help="async or sync"),
|
|
108
|
+
force: bool = typer.Option(False, "--force", help="Force recalculation"),
|
|
109
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
110
|
+
):
|
|
111
|
+
try:
|
|
112
|
+
client, _ = auth.get_authenticated_client()
|
|
113
|
+
payload = client.calc_recalculate_stale(project, scope=scope, mode=mode, force=force)
|
|
114
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
115
|
+
emit_error(str(exc), json_output=json_output)
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
message = payload.get("message") or payload.get("detail") or "Requested stale recalculation."
|
|
119
|
+
emit(payload, json_output=json_output, lines=[message])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("cav")
|
|
123
|
+
def calc_cav(
|
|
124
|
+
ahu: int = typer.Option(..., "--ahu", help="AHU ID"),
|
|
125
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
126
|
+
):
|
|
127
|
+
try:
|
|
128
|
+
client, _ = auth.get_authenticated_client()
|
|
129
|
+
payload = client.calc_cav(ahu)
|
|
130
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
131
|
+
emit_error(str(exc), json_output=json_output)
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
analysis = payload.get("analysis") or {}
|
|
135
|
+
critical_room = analysis.get("critical_room") or {}
|
|
136
|
+
lines = [
|
|
137
|
+
f"AHU: {payload.get('ahu_name') or ahu}",
|
|
138
|
+
f"Status: {payload.get('status') or 'N/A'}",
|
|
139
|
+
f"Critical Room: {critical_room.get('room_number') or '-'} {critical_room.get('room_name') or ''}".strip(),
|
|
140
|
+
f"Required AHU OSA Fraction: {analysis.get('required_ahu_osa_fraction')}",
|
|
141
|
+
]
|
|
142
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
3
|
import typer
|
|
6
4
|
|
|
7
5
|
from . import auth, notes
|
|
8
6
|
from .api_client import ApiError, NetworkError
|
|
7
|
+
from .calc import app as calc_app
|
|
8
|
+
from .design_day import app as design_day_app
|
|
9
|
+
from .entities import ahus_app, plants_app, rooms_app, zones_app
|
|
10
|
+
from .output import emit, emit_error
|
|
11
|
+
from .projects import app as projects_app
|
|
9
12
|
from .queue import QueueStore
|
|
13
|
+
from .systems import app as systems_app
|
|
10
14
|
|
|
11
15
|
app = typer.Typer(help="VentilatePro CLI")
|
|
12
16
|
auth_app = typer.Typer(help="Authentication commands")
|
|
13
|
-
projects_app = typer.Typer(help="Project commands")
|
|
14
17
|
notes_app = typer.Typer(help="Notes commands")
|
|
15
18
|
|
|
16
19
|
app.add_typer(auth_app, name="auth")
|
|
17
20
|
app.add_typer(projects_app, name="projects")
|
|
21
|
+
app.add_typer(rooms_app, name="rooms")
|
|
22
|
+
app.add_typer(zones_app, name="zones")
|
|
23
|
+
app.add_typer(ahus_app, name="ahus")
|
|
24
|
+
app.add_typer(plants_app, name="plants")
|
|
25
|
+
app.add_typer(systems_app, name="systems")
|
|
26
|
+
app.add_typer(calc_app, name="calc")
|
|
27
|
+
app.add_typer(design_day_app, name="design-day")
|
|
18
28
|
app.add_typer(notes_app, name="notes")
|
|
19
29
|
|
|
20
30
|
|
|
@@ -22,21 +32,6 @@ def main():
|
|
|
22
32
|
app()
|
|
23
33
|
|
|
24
34
|
|
|
25
|
-
def emit(payload, *, json_output: bool, lines: list[str]):
|
|
26
|
-
if json_output:
|
|
27
|
-
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
28
|
-
return
|
|
29
|
-
for line in lines:
|
|
30
|
-
typer.echo(line)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def emit_error(message: str, *, json_output: bool):
|
|
34
|
-
if json_output:
|
|
35
|
-
typer.echo(json.dumps({"error": message}))
|
|
36
|
-
else:
|
|
37
|
-
typer.echo(f"Error: {message}", err=True)
|
|
38
|
-
|
|
39
|
-
|
|
40
35
|
@auth_app.command("login")
|
|
41
36
|
def auth_login(
|
|
42
37
|
url: str = typer.Option(None, "--url", help="VentilatePro base URL"),
|
|
@@ -108,23 +103,6 @@ def auth_logout(
|
|
|
108
103
|
emit(result, json_output=json_output, lines=["Removed local credentials."])
|
|
109
104
|
|
|
110
105
|
|
|
111
|
-
@projects_app.command("list")
|
|
112
|
-
def projects_list(
|
|
113
|
-
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
114
|
-
):
|
|
115
|
-
try:
|
|
116
|
-
client, _ = auth.get_authenticated_client()
|
|
117
|
-
result = client.projects_list()
|
|
118
|
-
except (ValueError, ApiError, NetworkError) as exc:
|
|
119
|
-
emit_error(str(exc), json_output=json_output)
|
|
120
|
-
raise typer.Exit(1)
|
|
121
|
-
|
|
122
|
-
lines = [f"{project['id']}\t{project['name']}" for project in result]
|
|
123
|
-
if not lines:
|
|
124
|
-
lines = ["No accessible projects found."]
|
|
125
|
-
emit(result, json_output=json_output, lines=lines)
|
|
126
|
-
|
|
127
|
-
|
|
128
106
|
@notes_app.command("create")
|
|
129
107
|
def notes_create(
|
|
130
108
|
project: int = typer.Option(..., "--project", help="Target project ID"),
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from . import auth
|
|
10
|
+
from .api_client import ApiError, NetworkError
|
|
11
|
+
from .output import emit, emit_error
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Design-day commands")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_json_payload(input_path: str | None, use_stdin: bool):
|
|
17
|
+
if input_path and use_stdin:
|
|
18
|
+
raise ValueError("Use either --input or --stdin, not both.")
|
|
19
|
+
if use_stdin:
|
|
20
|
+
raw = sys.stdin.read()
|
|
21
|
+
if not raw.strip():
|
|
22
|
+
return {}
|
|
23
|
+
return json.loads(raw)
|
|
24
|
+
if input_path:
|
|
25
|
+
raw = Path(input_path).read_text(encoding="utf-8")
|
|
26
|
+
if not raw.strip():
|
|
27
|
+
return {}
|
|
28
|
+
return json.loads(raw)
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("context")
|
|
33
|
+
def design_day_context(
|
|
34
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
35
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
36
|
+
):
|
|
37
|
+
try:
|
|
38
|
+
client, _ = auth.get_authenticated_client()
|
|
39
|
+
payload = client.design_day_context(project)
|
|
40
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
41
|
+
emit_error(str(exc), json_output=json_output)
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
readiness = payload.get("readiness") or {}
|
|
45
|
+
lines = [
|
|
46
|
+
f"Project: {payload.get('project', {}).get('name')}",
|
|
47
|
+
f"Zones: {len(payload.get('zones') or [])}",
|
|
48
|
+
f"Ready: {not bool(readiness.get('missing_required'))}",
|
|
49
|
+
]
|
|
50
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("run")
|
|
54
|
+
def design_day_run(
|
|
55
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
56
|
+
input_path: str = typer.Option(None, "--input", help="Path to a JSON request payload"),
|
|
57
|
+
use_stdin: bool = typer.Option(False, "--stdin", help="Read a JSON request payload from stdin"),
|
|
58
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
59
|
+
):
|
|
60
|
+
try:
|
|
61
|
+
payload_in = _load_json_payload(input_path, use_stdin)
|
|
62
|
+
client, _ = auth.get_authenticated_client()
|
|
63
|
+
payload = client.design_day_run(project, payload_in)
|
|
64
|
+
except json.JSONDecodeError as exc:
|
|
65
|
+
emit_error(f"Invalid JSON input: {exc}", json_output=json_output)
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
68
|
+
emit_error(str(exc), json_output=json_output)
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
|
|
71
|
+
lines = [
|
|
72
|
+
f"Submitted design-day job {payload.get('id')}.",
|
|
73
|
+
f"State: {payload.get('state') or 'queued'}",
|
|
74
|
+
]
|
|
75
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("results")
|
|
79
|
+
def design_day_results(
|
|
80
|
+
job: int = typer.Option(..., "--job", help="Design-day job ID"),
|
|
81
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
82
|
+
):
|
|
83
|
+
try:
|
|
84
|
+
client, _ = auth.get_authenticated_client()
|
|
85
|
+
payload = client.design_day_results(job)
|
|
86
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
87
|
+
emit_error(str(exc), json_output=json_output)
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
|
|
90
|
+
lines = [
|
|
91
|
+
f"Status: {payload.get('status') or 'unknown'}",
|
|
92
|
+
f"Method: {payload.get('method') or 'N/A'}",
|
|
93
|
+
f"Generated At: {payload.get('generated_at') or 'N/A'}",
|
|
94
|
+
]
|
|
95
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from . import auth
|
|
6
|
+
from .api_client import ApiError, NetworkError
|
|
7
|
+
from .output import emit, emit_error, extract_results
|
|
8
|
+
|
|
9
|
+
rooms_app = typer.Typer(help="Room commands")
|
|
10
|
+
zones_app = typer.Typer(help="Zone commands")
|
|
11
|
+
ahus_app = typer.Typer(help="AHU commands")
|
|
12
|
+
plants_app = typer.Typer(help="Plant commands")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _handle_list(payload, *, json_output: bool, line_builder, empty_message: str):
|
|
16
|
+
results = extract_results(payload)
|
|
17
|
+
lines = [line_builder(item) for item in results]
|
|
18
|
+
if not lines:
|
|
19
|
+
lines = [empty_message]
|
|
20
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@rooms_app.command("list")
|
|
24
|
+
def rooms_list(
|
|
25
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
26
|
+
limit: int = typer.Option(20, "--limit", help="Maximum rooms to return"),
|
|
27
|
+
offset: int = typer.Option(0, "--offset", help="Starting offset"),
|
|
28
|
+
query: str = typer.Option(None, "--query", help="Filter by room name or number"),
|
|
29
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
30
|
+
):
|
|
31
|
+
try:
|
|
32
|
+
client, _ = auth.get_authenticated_client()
|
|
33
|
+
payload = client.list_rooms(project, limit=limit, offset=offset, query=query)
|
|
34
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
35
|
+
emit_error(str(exc), json_output=json_output)
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
|
|
38
|
+
_handle_list(
|
|
39
|
+
payload,
|
|
40
|
+
json_output=json_output,
|
|
41
|
+
line_builder=lambda item: f"{item['id']}\t{item.get('number') or '-'}\t{item['name']}",
|
|
42
|
+
empty_message="No rooms found for the current filters.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@rooms_app.command("show")
|
|
47
|
+
def rooms_show(
|
|
48
|
+
room_id: int = typer.Argument(..., help="Room ID"),
|
|
49
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
50
|
+
):
|
|
51
|
+
try:
|
|
52
|
+
client, _ = auth.get_authenticated_client()
|
|
53
|
+
payload = client.show_room(room_id)
|
|
54
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
55
|
+
emit_error(str(exc), json_output=json_output)
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
airflows = payload.get("airflows") or {}
|
|
59
|
+
lines = [
|
|
60
|
+
f"Room: {payload.get('number') or '-'} {payload.get('name')}",
|
|
61
|
+
f"Project: {payload.get('project_id')}",
|
|
62
|
+
f"Area: {payload.get('area')}",
|
|
63
|
+
f"Supply: {airflows.get('supply_airflow')}",
|
|
64
|
+
f"Return: {airflows.get('return_airflow')}",
|
|
65
|
+
f"Exhaust: {airflows.get('exhaust_airflow')}",
|
|
66
|
+
]
|
|
67
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@zones_app.command("list")
|
|
71
|
+
def zones_list(
|
|
72
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
73
|
+
limit: int = typer.Option(20, "--limit", help="Maximum zones to return"),
|
|
74
|
+
offset: int = typer.Option(0, "--offset", help="Starting offset"),
|
|
75
|
+
query: str = typer.Option(None, "--query", help="Filter by zone name"),
|
|
76
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
77
|
+
):
|
|
78
|
+
try:
|
|
79
|
+
client, _ = auth.get_authenticated_client()
|
|
80
|
+
payload = client.list_zones(project, limit=limit, offset=offset, query=query)
|
|
81
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
82
|
+
emit_error(str(exc), json_output=json_output)
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
_handle_list(
|
|
86
|
+
payload,
|
|
87
|
+
json_output=json_output,
|
|
88
|
+
line_builder=lambda item: f"{item['id']}\t{item['name']}\trooms={len(item.get('rooms') or [])}\tahus={len(item.get('ahus') or [])}",
|
|
89
|
+
empty_message="No zones found for the current filters.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@zones_app.command("show")
|
|
94
|
+
def zones_show(
|
|
95
|
+
zone_id: int = typer.Argument(..., help="Zone ID"),
|
|
96
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
97
|
+
):
|
|
98
|
+
try:
|
|
99
|
+
client, _ = auth.get_authenticated_client()
|
|
100
|
+
payload = client.show_zone(zone_id)
|
|
101
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
102
|
+
emit_error(str(exc), json_output=json_output)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
airflows = payload.get("airflows") or {}
|
|
106
|
+
calc = payload.get("calc") or {}
|
|
107
|
+
lines = [
|
|
108
|
+
f"Zone: {payload.get('name')}",
|
|
109
|
+
f"Project: {payload.get('project_id')}",
|
|
110
|
+
f"Max CFM: {airflows.get('maximum_airflow_cfm')}",
|
|
111
|
+
f"Min CFM: {airflows.get('minimum_airflow_cfm')}",
|
|
112
|
+
f"Heating CFM: {airflows.get('heating_airflow_cfm')}",
|
|
113
|
+
f"Calc Status: {calc.get('calc_status') or 'N/A'}",
|
|
114
|
+
]
|
|
115
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@ahus_app.command("list")
|
|
119
|
+
def ahus_list(
|
|
120
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
121
|
+
limit: int = typer.Option(20, "--limit", help="Maximum AHUs to return"),
|
|
122
|
+
offset: int = typer.Option(0, "--offset", help="Starting offset"),
|
|
123
|
+
query: str = typer.Option(None, "--query", help="Filter by AHU name"),
|
|
124
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
125
|
+
):
|
|
126
|
+
try:
|
|
127
|
+
client, _ = auth.get_authenticated_client()
|
|
128
|
+
payload = client.list_ahus(project, limit=limit, offset=offset, query=query)
|
|
129
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
130
|
+
emit_error(str(exc), json_output=json_output)
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
|
|
133
|
+
_handle_list(
|
|
134
|
+
payload,
|
|
135
|
+
json_output=json_output,
|
|
136
|
+
line_builder=lambda item: f"{item['id']}\t{item['name']}\t{item.get('system_type') or '-'}",
|
|
137
|
+
empty_message="No AHUs found for the current filters.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@ahus_app.command("show")
|
|
142
|
+
def ahus_show(
|
|
143
|
+
ahu_id: int = typer.Argument(..., help="AHU ID"),
|
|
144
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
145
|
+
):
|
|
146
|
+
try:
|
|
147
|
+
client, _ = auth.get_authenticated_client()
|
|
148
|
+
payload = client.show_ahu(ahu_id)
|
|
149
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
150
|
+
emit_error(str(exc), json_output=json_output)
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
|
|
153
|
+
airflows = payload.get("airflows") or {}
|
|
154
|
+
calc = payload.get("calc") or {}
|
|
155
|
+
lines = [
|
|
156
|
+
f"AHU: {payload.get('name')}",
|
|
157
|
+
f"System Type: {payload.get('system_type') or '-'}",
|
|
158
|
+
f"Supply CFM: {airflows.get('total_supply_air_cfm')}",
|
|
159
|
+
f"Return CFM: {airflows.get('total_return_air_cfm')}",
|
|
160
|
+
f"OA CFM: {airflows.get('final_outside_air_cfm')}",
|
|
161
|
+
f"Calc Status: {calc.get('calc_status') or 'N/A'}",
|
|
162
|
+
]
|
|
163
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@plants_app.command("list")
|
|
167
|
+
def plants_list(
|
|
168
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
169
|
+
limit: int = typer.Option(20, "--limit", help="Maximum plants to return"),
|
|
170
|
+
offset: int = typer.Option(0, "--offset", help="Starting offset"),
|
|
171
|
+
query: str = typer.Option(None, "--query", help="Filter by plant name or type"),
|
|
172
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
173
|
+
):
|
|
174
|
+
try:
|
|
175
|
+
client, _ = auth.get_authenticated_client()
|
|
176
|
+
payload = client.list_plants(project, limit=limit, offset=offset, query=query)
|
|
177
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
178
|
+
emit_error(str(exc), json_output=json_output)
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
|
|
181
|
+
_handle_list(
|
|
182
|
+
payload,
|
|
183
|
+
json_output=json_output,
|
|
184
|
+
line_builder=lambda item: f"{item['plant_ref']}\t{item['name']}\t{item['type_label']}",
|
|
185
|
+
empty_message="No plants found for the current filters.",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@plants_app.command("show")
|
|
190
|
+
def plants_show(
|
|
191
|
+
plant_ref: str = typer.Argument(..., help="Typed plant reference, such as chilled-water:12"),
|
|
192
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
193
|
+
):
|
|
194
|
+
try:
|
|
195
|
+
client, _ = auth.get_authenticated_client()
|
|
196
|
+
payload = client.show_plant(plant_ref)
|
|
197
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
198
|
+
emit_error(str(exc), json_output=json_output)
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
metrics = payload.get("metrics") or {}
|
|
202
|
+
lines = [
|
|
203
|
+
f"Plant: {payload.get('name')}",
|
|
204
|
+
f"Ref: {payload.get('plant_ref')}",
|
|
205
|
+
f"Type: {payload.get('type_label')}",
|
|
206
|
+
f"Project: {payload.get('project_id')}",
|
|
207
|
+
f"Design Supply Temp: {metrics.get('design_supply_temp_f')}",
|
|
208
|
+
f"Design Return Temp: {metrics.get('design_return_temp_f')}",
|
|
209
|
+
]
|
|
210
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def emit(payload: Any, *, json_output: bool, lines: list[str]):
|
|
10
|
+
if json_output:
|
|
11
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
12
|
+
return
|
|
13
|
+
for line in lines:
|
|
14
|
+
typer.echo(line)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit_error(message: str, *, json_output: bool):
|
|
18
|
+
if json_output:
|
|
19
|
+
typer.echo(json.dumps({"error": message}))
|
|
20
|
+
else:
|
|
21
|
+
typer.echo(f"Error: {message}", err=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_results(payload: Any):
|
|
25
|
+
if isinstance(payload, list):
|
|
26
|
+
return payload
|
|
27
|
+
if isinstance(payload, dict):
|
|
28
|
+
results = payload.get("results")
|
|
29
|
+
if isinstance(results, list):
|
|
30
|
+
return results
|
|
31
|
+
return []
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from . import auth
|
|
6
|
+
from .api_client import ApiError, NetworkError
|
|
7
|
+
from .output import emit, emit_error, extract_results
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Project commands")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("list")
|
|
13
|
+
def projects_list(
|
|
14
|
+
limit: int = typer.Option(20, "--limit", help="Maximum projects to return"),
|
|
15
|
+
offset: int = typer.Option(0, "--offset", help="Starting offset"),
|
|
16
|
+
query: str = typer.Option(None, "--query", help="Filter by project name or metadata"),
|
|
17
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
18
|
+
):
|
|
19
|
+
try:
|
|
20
|
+
client, _ = auth.get_authenticated_client()
|
|
21
|
+
payload = client.projects_list(limit=limit, offset=offset, query=query)
|
|
22
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
23
|
+
emit_error(str(exc), json_output=json_output)
|
|
24
|
+
raise typer.Exit(1)
|
|
25
|
+
|
|
26
|
+
results = extract_results(payload)
|
|
27
|
+
lines = [f"{project['id']}\t{project['name']}" for project in results]
|
|
28
|
+
if not lines:
|
|
29
|
+
lines = ["No accessible projects found."]
|
|
30
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("show")
|
|
34
|
+
def projects_show(
|
|
35
|
+
project_id: int = typer.Argument(..., help="Project ID"),
|
|
36
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
37
|
+
):
|
|
38
|
+
try:
|
|
39
|
+
client, _ = auth.get_authenticated_client()
|
|
40
|
+
payload = client.project_show(project_id)
|
|
41
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
42
|
+
emit_error(str(exc), json_output=json_output)
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
|
|
45
|
+
counts = payload.get("counts") or {}
|
|
46
|
+
lines = [
|
|
47
|
+
f"Project: {payload.get('name')}",
|
|
48
|
+
f"ID: {payload.get('id')}",
|
|
49
|
+
f"Location: {payload.get('location') or 'N/A'}",
|
|
50
|
+
f"Time Zone: {payload.get('time_zone') or 'N/A'}",
|
|
51
|
+
f"Rooms: {counts.get('rooms', 0)}",
|
|
52
|
+
f"Zones: {counts.get('zones', 0)}",
|
|
53
|
+
f"AHUs: {counts.get('ahus', 0)}",
|
|
54
|
+
]
|
|
55
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from . import auth
|
|
6
|
+
from .api_client import ApiError, NetworkError
|
|
7
|
+
from .output import emit, emit_error
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="System graph and hierarchy commands")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("hierarchy")
|
|
13
|
+
def system_hierarchy(
|
|
14
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
15
|
+
include_envelope: bool = typer.Option(True, "--include-envelope/--no-include-envelope", help="Include envelope data"),
|
|
16
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
17
|
+
):
|
|
18
|
+
try:
|
|
19
|
+
client, _ = auth.get_authenticated_client()
|
|
20
|
+
payload = client.system_hierarchy(project, include_envelope=include_envelope)
|
|
21
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
22
|
+
emit_error(str(exc), json_output=json_output)
|
|
23
|
+
raise typer.Exit(1)
|
|
24
|
+
|
|
25
|
+
hierarchy = payload.get("hierarchy") or {}
|
|
26
|
+
lines = [
|
|
27
|
+
f"Project: {payload.get('project', {}).get('name')}",
|
|
28
|
+
f"Chilled Water Plants: {len(hierarchy.get('chilled_water_plants') or [])}",
|
|
29
|
+
f"Heating Plants: {len(hierarchy.get('heating_plants') or [])}",
|
|
30
|
+
f"AHUs Without Plants: {len(hierarchy.get('ahus_without_plants') or [])}",
|
|
31
|
+
f"Zones Without AHUs: {len(hierarchy.get('zones_without_ahus') or [])}",
|
|
32
|
+
f"Rooms Without Zones: {len(hierarchy.get('rooms_without_zones') or [])}",
|
|
33
|
+
]
|
|
34
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("graph")
|
|
38
|
+
def system_graph(
|
|
39
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
40
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
41
|
+
):
|
|
42
|
+
try:
|
|
43
|
+
client, _ = auth.get_authenticated_client()
|
|
44
|
+
payload = client.system_graph(project)
|
|
45
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
46
|
+
emit_error(str(exc), json_output=json_output)
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
|
|
49
|
+
counts = payload.get("graph", {}).get("counts") or {}
|
|
50
|
+
lines = [
|
|
51
|
+
f"Project: {payload.get('project', {}).get('name')}",
|
|
52
|
+
f"Nodes: {counts.get('nodes', 0)}",
|
|
53
|
+
f"Edges: {counts.get('edges', 0)}",
|
|
54
|
+
]
|
|
55
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("topology")
|
|
59
|
+
def system_topology(
|
|
60
|
+
project: int = typer.Option(..., "--project", help="Project ID"),
|
|
61
|
+
json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
|
|
62
|
+
):
|
|
63
|
+
try:
|
|
64
|
+
client, _ = auth.get_authenticated_client()
|
|
65
|
+
payload = client.system_topology(project)
|
|
66
|
+
except (ValueError, ApiError, NetworkError) as exc:
|
|
67
|
+
emit_error(str(exc), json_output=json_output)
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
|
|
70
|
+
topology = payload.get("topology") or {}
|
|
71
|
+
lines = [
|
|
72
|
+
f"Project: {payload.get('project', {}).get('name')}",
|
|
73
|
+
f"Nodes: {len(topology.get('nodes') or [])}",
|
|
74
|
+
f"Links: {len(topology.get('links') or [])}",
|
|
75
|
+
]
|
|
76
|
+
emit(payload, json_output=json_output, lines=lines)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ventilatepro-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: VentilatePro CLI
|
|
5
5
|
Project-URL: Homepage, https://ventilatepro.com/exam/cli/
|
|
6
6
|
Project-URL: Documentation, https://ventilatepro.com/exam/cli/
|
|
@@ -42,6 +42,13 @@ pip install -e .
|
|
|
42
42
|
```bash
|
|
43
43
|
ventilatepro auth login --url http://localhost:8000
|
|
44
44
|
ventilatepro projects list
|
|
45
|
+
ventilatepro projects show 1
|
|
46
|
+
ventilatepro rooms list --project 1
|
|
47
|
+
ventilatepro ahus list --project 1
|
|
48
|
+
ventilatepro systems hierarchy --project 1
|
|
49
|
+
ventilatepro calc status --project 1
|
|
50
|
+
ventilatepro calc ahu-properties --ahu 1
|
|
51
|
+
ventilatepro design-day context --project 1
|
|
45
52
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
46
53
|
ventilatepro notes sync
|
|
47
54
|
```
|
|
@@ -6,10 +6,16 @@ ventilatepro/__init__.py
|
|
|
6
6
|
ventilatepro/__main__.py
|
|
7
7
|
ventilatepro/api_client.py
|
|
8
8
|
ventilatepro/auth.py
|
|
9
|
+
ventilatepro/calc.py
|
|
9
10
|
ventilatepro/cli.py
|
|
10
11
|
ventilatepro/config.py
|
|
12
|
+
ventilatepro/design_day.py
|
|
13
|
+
ventilatepro/entities.py
|
|
11
14
|
ventilatepro/notes.py
|
|
15
|
+
ventilatepro/output.py
|
|
16
|
+
ventilatepro/projects.py
|
|
12
17
|
ventilatepro/queue.py
|
|
18
|
+
ventilatepro/systems.py
|
|
13
19
|
ventilatepro_cli.egg-info/PKG-INFO
|
|
14
20
|
ventilatepro_cli.egg-info/SOURCES.txt
|
|
15
21
|
ventilatepro_cli.egg-info/dependency_links.txt
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any, Dict, Optional
|
|
4
|
-
|
|
5
|
-
import requests
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class ApiError(Exception):
|
|
9
|
-
def __init__(self, status_code: int, message: str, payload: Any = None):
|
|
10
|
-
super().__init__(message)
|
|
11
|
-
self.status_code = status_code
|
|
12
|
-
self.payload = payload
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class NetworkError(Exception):
|
|
16
|
-
pass
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def extract_error_message(payload: Any, fallback: str) -> str:
|
|
20
|
-
if isinstance(payload, dict):
|
|
21
|
-
if isinstance(payload.get("detail"), str):
|
|
22
|
-
return payload["detail"]
|
|
23
|
-
error = payload.get("error")
|
|
24
|
-
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
25
|
-
return error["message"]
|
|
26
|
-
return " ".join(str(value) for value in payload.values() if value)
|
|
27
|
-
if isinstance(payload, str) and payload.strip():
|
|
28
|
-
return payload
|
|
29
|
-
return fallback
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class ApiClient:
|
|
33
|
-
def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 15):
|
|
34
|
-
self.base_url = (base_url or "").rstrip("/")
|
|
35
|
-
self.token = token
|
|
36
|
-
self.timeout = timeout
|
|
37
|
-
|
|
38
|
-
def request(
|
|
39
|
-
self,
|
|
40
|
-
method: str,
|
|
41
|
-
path: str,
|
|
42
|
-
*,
|
|
43
|
-
json_payload: Optional[Dict[str, Any]] = None,
|
|
44
|
-
headers: Optional[Dict[str, str]] = None,
|
|
45
|
-
params: Optional[Dict[str, Any]] = None,
|
|
46
|
-
):
|
|
47
|
-
final_headers = {
|
|
48
|
-
"Accept": "application/json",
|
|
49
|
-
}
|
|
50
|
-
if headers:
|
|
51
|
-
final_headers.update(headers)
|
|
52
|
-
if self.token:
|
|
53
|
-
final_headers["Authorization"] = f"Bearer {self.token}"
|
|
54
|
-
try:
|
|
55
|
-
response = requests.request(
|
|
56
|
-
method=method,
|
|
57
|
-
url=f"{self.base_url}{path}",
|
|
58
|
-
json=json_payload,
|
|
59
|
-
headers=final_headers,
|
|
60
|
-
params=params,
|
|
61
|
-
timeout=self.timeout,
|
|
62
|
-
)
|
|
63
|
-
except requests.RequestException as exc:
|
|
64
|
-
raise NetworkError(str(exc)) from exc
|
|
65
|
-
|
|
66
|
-
payload: Any = None
|
|
67
|
-
if response.content:
|
|
68
|
-
try:
|
|
69
|
-
payload = response.json()
|
|
70
|
-
except ValueError:
|
|
71
|
-
payload = response.text
|
|
72
|
-
|
|
73
|
-
if not response.ok:
|
|
74
|
-
raise ApiError(
|
|
75
|
-
status_code=response.status_code,
|
|
76
|
-
message=extract_error_message(payload, f"Request failed with status {response.status_code}."),
|
|
77
|
-
payload=payload,
|
|
78
|
-
)
|
|
79
|
-
return payload if payload is not None else {}
|
|
80
|
-
|
|
81
|
-
def auth_me(self):
|
|
82
|
-
return self.request("GET", "/api/cli/auth/me/")
|
|
83
|
-
|
|
84
|
-
def projects_list(self):
|
|
85
|
-
return self.request("GET", "/api/cli/projects/")
|
|
86
|
-
|
|
87
|
-
def create_note(self, payload: Dict[str, Any], *, idempotency_key: str):
|
|
88
|
-
return self.request(
|
|
89
|
-
"POST",
|
|
90
|
-
"/api/cli/notes/",
|
|
91
|
-
json_payload=payload,
|
|
92
|
-
headers={"Idempotency-Key": idempotency_key},
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
def list_notes(
|
|
96
|
-
self,
|
|
97
|
-
project: int,
|
|
98
|
-
*,
|
|
99
|
-
limit: int = 20,
|
|
100
|
-
offset: int = 0,
|
|
101
|
-
note_type: Optional[str] = None,
|
|
102
|
-
source: Optional[str] = None,
|
|
103
|
-
tags: Optional[list[str]] = None,
|
|
104
|
-
query: Optional[str] = None,
|
|
105
|
-
):
|
|
106
|
-
params: Dict[str, Any] = {
|
|
107
|
-
"project": project,
|
|
108
|
-
"limit": limit,
|
|
109
|
-
"offset": offset,
|
|
110
|
-
}
|
|
111
|
-
if note_type:
|
|
112
|
-
params["type"] = note_type
|
|
113
|
-
if source:
|
|
114
|
-
params["source"] = source
|
|
115
|
-
if tags:
|
|
116
|
-
params["tag"] = tags
|
|
117
|
-
if query:
|
|
118
|
-
params["q"] = query
|
|
119
|
-
return self.request("GET", "/api/cli/notes/", params=params)
|
|
120
|
-
|
|
121
|
-
def show_note(self, note_ref: str, *, project: int):
|
|
122
|
-
return self.request("GET", f"/api/cli/notes/{note_ref}/", params={"project": project})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ventilatepro_cli-0.1.1 → ventilatepro_cli-0.2.0}/ventilatepro_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|