fancli 0.1.0__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.
- fancli/__init__.py +1 -0
- fancli/cli.py +874 -0
- fancli/help.txt +87 -0
- fancli-0.1.0.dist-info/METADATA +138 -0
- fancli-0.1.0.dist-info/RECORD +9 -0
- fancli-0.1.0.dist-info/WHEEL +5 -0
- fancli-0.1.0.dist-info/entry_points.txt +2 -0
- fancli-0.1.0.dist-info/licenses/LICENSE +21 -0
- fancli-0.1.0.dist-info/top_level.txt +1 -0
fancli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Atomberg IoT fan CLI."""
|
fancli/cli.py
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI for smart fan control (currently Atomberg): token cache, status, commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import getpass
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import textwrap
|
|
13
|
+
import warnings
|
|
14
|
+
|
|
15
|
+
# urllib3 warns on LibreSSL (macOS system Python) when imported; filter before requests.
|
|
16
|
+
warnings.filterwarnings(
|
|
17
|
+
"ignore",
|
|
18
|
+
message=r"urllib3 v2 only supports OpenSSL 1\.1\.1\+.*",
|
|
19
|
+
)
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
from dotenv import load_dotenv, set_key
|
|
26
|
+
|
|
27
|
+
TOKEN_MAX_AGE = timedelta(hours=23)
|
|
28
|
+
DEFAULT_API_URL = "https://api.developer.atomberg-iot.com"
|
|
29
|
+
|
|
30
|
+
# (Command title, Description, JSON, Accepted values, Comments)
|
|
31
|
+
SET_COMMAND_REFERENCE_ROWS: list[tuple[str, str, str, str, str]] = [
|
|
32
|
+
(
|
|
33
|
+
"Power",
|
|
34
|
+
"Turn the fan ON or OFF",
|
|
35
|
+
'{"power":val}',
|
|
36
|
+
"true, false",
|
|
37
|
+
"",
|
|
38
|
+
),
|
|
39
|
+
(
|
|
40
|
+
"Speed Absolute",
|
|
41
|
+
"Set the speed of the fan to an absolute value",
|
|
42
|
+
'{"speed":val}',
|
|
43
|
+
"1,2,3,4,5,6",
|
|
44
|
+
"",
|
|
45
|
+
),
|
|
46
|
+
(
|
|
47
|
+
"Speed Relative",
|
|
48
|
+
"Increase/decrease speed of the fan",
|
|
49
|
+
'{"speedDelta":val}',
|
|
50
|
+
"1,2,3,4,5,-1,-2,-3,-4,-5",
|
|
51
|
+
"",
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
"Sleep mode",
|
|
55
|
+
"Enable or disable sleep mode",
|
|
56
|
+
'{"sleep":val}',
|
|
57
|
+
"true, false",
|
|
58
|
+
"",
|
|
59
|
+
),
|
|
60
|
+
(
|
|
61
|
+
"Timer",
|
|
62
|
+
"Set timer",
|
|
63
|
+
'{"timer":val}',
|
|
64
|
+
"0,1,2,3,4",
|
|
65
|
+
"0: Turn off timer\n"
|
|
66
|
+
"1: Set timer for 1 hours\n"
|
|
67
|
+
"2: Set timer for 2 hours\n"
|
|
68
|
+
"3: Set timer for 3 hours\n"
|
|
69
|
+
"4: Set timer for 6 hours",
|
|
70
|
+
),
|
|
71
|
+
(
|
|
72
|
+
"Lights ON/OFF",
|
|
73
|
+
"Turn the light ON or OFF",
|
|
74
|
+
'{"led":val}',
|
|
75
|
+
"true, false",
|
|
76
|
+
"For Aris Starlight, it will set the fan light at the last known color or "
|
|
77
|
+
"brightness values",
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"Brightness Absolute",
|
|
81
|
+
"Set the brightness of the fan to an absolute value",
|
|
82
|
+
'{"brightness":val}',
|
|
83
|
+
"10 to 100",
|
|
84
|
+
"Percentage brightness",
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
"Brightness Delta",
|
|
88
|
+
"Increase/decrease brightness of the fan",
|
|
89
|
+
'{"brightnessDelta":val}',
|
|
90
|
+
"-90 to +90",
|
|
91
|
+
"Percentage brightness",
|
|
92
|
+
),
|
|
93
|
+
(
|
|
94
|
+
"Color",
|
|
95
|
+
"Change the color of the light",
|
|
96
|
+
'{"light_mode":val}',
|
|
97
|
+
'"warm","cool","daylight"',
|
|
98
|
+
"",
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_dotenv() -> Optional[Path]:
|
|
104
|
+
cwd = Path.cwd()
|
|
105
|
+
pkg_dir = Path(__file__).resolve().parent
|
|
106
|
+
repo_root = pkg_dir.parent
|
|
107
|
+
for base in (cwd, repo_root, pkg_dir):
|
|
108
|
+
candidate = base / ".env"
|
|
109
|
+
if candidate.is_file():
|
|
110
|
+
return candidate
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def help_file_path() -> Path:
|
|
115
|
+
return Path(__file__).resolve().parent / "help.txt"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_help() -> None:
|
|
119
|
+
path = help_file_path()
|
|
120
|
+
if not path.is_file():
|
|
121
|
+
raise SystemExit(f"help file not found: {path}")
|
|
122
|
+
try:
|
|
123
|
+
text = path.read_text(encoding="utf-8")
|
|
124
|
+
except OSError as e:
|
|
125
|
+
raise SystemExit(f"cannot read help file: {e}") from e
|
|
126
|
+
print(text.rstrip() + "\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_config() -> dict[str, str]:
|
|
130
|
+
dotenv_path = _find_dotenv()
|
|
131
|
+
if dotenv_path is not None:
|
|
132
|
+
load_dotenv(dotenv_path)
|
|
133
|
+
else:
|
|
134
|
+
load_dotenv()
|
|
135
|
+
refresh = os.environ.get("REFRESH_TOKEN", "").strip()
|
|
136
|
+
device_id = os.environ.get("DEVICE_ID", "").strip()
|
|
137
|
+
api_key = os.environ.get("API_KEY", "").strip()
|
|
138
|
+
raw_url = os.environ.get("API_URL", "").strip()
|
|
139
|
+
company = os.environ.get("FANCLI_COMPANY", "").strip()
|
|
140
|
+
return {
|
|
141
|
+
"refresh_token": refresh,
|
|
142
|
+
"device_id": device_id,
|
|
143
|
+
"api_key": api_key,
|
|
144
|
+
"api_url": normalize_api_url(raw_url),
|
|
145
|
+
"company": company,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def normalize_api_url(raw: str) -> str:
|
|
150
|
+
if not raw:
|
|
151
|
+
return DEFAULT_API_URL
|
|
152
|
+
raw = raw.rstrip("/")
|
|
153
|
+
if not raw.startswith(("http://", "https://")):
|
|
154
|
+
raw = f"https://{raw}"
|
|
155
|
+
return raw
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def token_file_path() -> Path:
|
|
159
|
+
override = os.environ.get("FANCLI_TOKEN_FILE", "").strip()
|
|
160
|
+
if override:
|
|
161
|
+
return Path(override).expanduser()
|
|
162
|
+
return Path.home() / ".config" / "fancli" / "token.json"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_token_cache(path: Path) -> Tuple[Optional[str], Optional[datetime]]:
|
|
166
|
+
if not path.is_file():
|
|
167
|
+
return None, None
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
170
|
+
except (OSError, json.JSONDecodeError):
|
|
171
|
+
return None, None
|
|
172
|
+
token = data.get("access_token")
|
|
173
|
+
if not isinstance(token, str) or not token:
|
|
174
|
+
return None, None
|
|
175
|
+
raw_ts = data.get("obtained_at")
|
|
176
|
+
if not isinstance(raw_ts, str):
|
|
177
|
+
return None, None
|
|
178
|
+
try:
|
|
179
|
+
# ISO 8601 from datetime.isoformat()
|
|
180
|
+
obtained = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
|
|
181
|
+
if obtained.tzinfo is None:
|
|
182
|
+
obtained = obtained.replace(tzinfo=timezone.utc)
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None, None
|
|
185
|
+
return token, obtained
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def write_token_cache(path: Path, access_token: str) -> None:
|
|
189
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
now = datetime.now(timezone.utc)
|
|
191
|
+
payload = {
|
|
192
|
+
"access_token": access_token,
|
|
193
|
+
"obtained_at": now.isoformat(),
|
|
194
|
+
}
|
|
195
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def token_is_fresh(obtained_at: Optional[datetime]) -> bool:
|
|
199
|
+
if obtained_at is None:
|
|
200
|
+
return False
|
|
201
|
+
now = datetime.now(timezone.utc)
|
|
202
|
+
return now - obtained_at < TOKEN_MAX_AGE
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def fetch_access_token(
|
|
206
|
+
session: requests.Session,
|
|
207
|
+
base_url: str,
|
|
208
|
+
refresh_token: str,
|
|
209
|
+
api_key: str,
|
|
210
|
+
) -> str:
|
|
211
|
+
url = f"{base_url}/v1/get_access_token"
|
|
212
|
+
headers = {
|
|
213
|
+
"Accept": "application/json",
|
|
214
|
+
"Authorization": f"Bearer {refresh_token}",
|
|
215
|
+
"x-api-key": api_key,
|
|
216
|
+
}
|
|
217
|
+
r = session.get(url, headers=headers, timeout=30)
|
|
218
|
+
if r.status_code != 200:
|
|
219
|
+
snippet = (r.text or "")[:500]
|
|
220
|
+
raise SystemExit(
|
|
221
|
+
f"get_access_token failed: HTTP {r.status_code}\n{snippet}"
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
data = r.json()
|
|
225
|
+
except json.JSONDecodeError as e:
|
|
226
|
+
raise SystemExit(f"get_access_token: invalid JSON: {e}") from e
|
|
227
|
+
msg = data.get("message")
|
|
228
|
+
if isinstance(msg, dict):
|
|
229
|
+
token = msg.get("access_token")
|
|
230
|
+
else:
|
|
231
|
+
token = None
|
|
232
|
+
if not isinstance(token, str) or not token:
|
|
233
|
+
raise SystemExit(
|
|
234
|
+
"get_access_token: missing message.access_token in response"
|
|
235
|
+
)
|
|
236
|
+
return token
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_valid_access_token(
|
|
240
|
+
session: requests.Session,
|
|
241
|
+
cfg: dict[str, str],
|
|
242
|
+
cache_path: Path,
|
|
243
|
+
force_refresh: bool = False,
|
|
244
|
+
) -> str:
|
|
245
|
+
if not cfg["refresh_token"]:
|
|
246
|
+
raise SystemExit("REFRESH_TOKEN is not set in the environment.")
|
|
247
|
+
if not cfg["api_key"]:
|
|
248
|
+
raise SystemExit("API_KEY is not set in the environment.")
|
|
249
|
+
|
|
250
|
+
cached, obtained = read_token_cache(cache_path)
|
|
251
|
+
if not force_refresh and cached and token_is_fresh(obtained):
|
|
252
|
+
return cached
|
|
253
|
+
|
|
254
|
+
token = fetch_access_token(
|
|
255
|
+
session,
|
|
256
|
+
cfg["api_url"],
|
|
257
|
+
cfg["refresh_token"],
|
|
258
|
+
cfg["api_key"],
|
|
259
|
+
)
|
|
260
|
+
write_token_cache(cache_path, token)
|
|
261
|
+
return token
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def api_headers(access_token: str, api_key: str) -> dict[str, str]:
|
|
265
|
+
return {
|
|
266
|
+
"Accept": "application/json",
|
|
267
|
+
"Authorization": f"Bearer {access_token}",
|
|
268
|
+
"x-api-key": api_key,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_device_state(
|
|
273
|
+
session: requests.Session,
|
|
274
|
+
base_url: str,
|
|
275
|
+
device_id: str,
|
|
276
|
+
access_token: str,
|
|
277
|
+
api_key: str,
|
|
278
|
+
) -> requests.Response:
|
|
279
|
+
url = f"{base_url}/v1/get_device_state"
|
|
280
|
+
params = {"device_id": device_id}
|
|
281
|
+
return session.get(
|
|
282
|
+
url,
|
|
283
|
+
params=params,
|
|
284
|
+
headers=api_headers(access_token, api_key),
|
|
285
|
+
timeout=30,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_list_of_devices(
|
|
290
|
+
session: requests.Session,
|
|
291
|
+
base_url: str,
|
|
292
|
+
access_token: str,
|
|
293
|
+
api_key: str,
|
|
294
|
+
) -> requests.Response:
|
|
295
|
+
url = f"{base_url}/v1/get_list_of_devices"
|
|
296
|
+
return session.get(
|
|
297
|
+
url,
|
|
298
|
+
headers=api_headers(access_token, api_key),
|
|
299
|
+
timeout=30,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def send_command(
|
|
304
|
+
session: requests.Session,
|
|
305
|
+
base_url: str,
|
|
306
|
+
device_id: str,
|
|
307
|
+
command: dict[str, Any],
|
|
308
|
+
access_token: str,
|
|
309
|
+
api_key: str,
|
|
310
|
+
) -> requests.Response:
|
|
311
|
+
url = f"{base_url}/v1/send_command"
|
|
312
|
+
body = {"device_id": device_id, "command": command}
|
|
313
|
+
headers = {
|
|
314
|
+
**api_headers(access_token, api_key),
|
|
315
|
+
"Content-Type": "application/json",
|
|
316
|
+
}
|
|
317
|
+
return session.post(
|
|
318
|
+
url,
|
|
319
|
+
headers=headers,
|
|
320
|
+
json=body,
|
|
321
|
+
timeout=30,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _term_width() -> int:
|
|
326
|
+
try:
|
|
327
|
+
w = shutil.get_terminal_size().columns
|
|
328
|
+
except OSError:
|
|
329
|
+
w = 80
|
|
330
|
+
return max(40, w)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _format_labeled_field(label: str, content: str) -> list[str]:
|
|
334
|
+
"""One labeled field; wraps to terminal width. Multi-line content uses a hanging block."""
|
|
335
|
+
if not content.strip():
|
|
336
|
+
return []
|
|
337
|
+
indent = " "
|
|
338
|
+
hang = " "
|
|
339
|
+
tw = _term_width()
|
|
340
|
+
if "\n" in content:
|
|
341
|
+
lines = [f"{indent}{label}:"]
|
|
342
|
+
avail = max(20, tw - len(hang) - 1)
|
|
343
|
+
for raw in content.strip().split("\n"):
|
|
344
|
+
ln = raw.strip()
|
|
345
|
+
if not ln:
|
|
346
|
+
continue
|
|
347
|
+
wrapped = textwrap.wrap(
|
|
348
|
+
ln,
|
|
349
|
+
width=avail,
|
|
350
|
+
break_long_words=True,
|
|
351
|
+
break_on_hyphens=False,
|
|
352
|
+
)
|
|
353
|
+
for wline in wrapped or [ln]:
|
|
354
|
+
lines.append(hang + wline)
|
|
355
|
+
return lines
|
|
356
|
+
|
|
357
|
+
prefix = f"{indent}{label}: "
|
|
358
|
+
avail = max(16, tw - len(prefix))
|
|
359
|
+
wrapped = textwrap.wrap(
|
|
360
|
+
content.strip(),
|
|
361
|
+
width=avail,
|
|
362
|
+
break_long_words=True,
|
|
363
|
+
break_on_hyphens=False,
|
|
364
|
+
)
|
|
365
|
+
if not wrapped:
|
|
366
|
+
return []
|
|
367
|
+
lines = [prefix + wrapped[0]]
|
|
368
|
+
pad = " " * len(prefix)
|
|
369
|
+
for wline in wrapped[1:]:
|
|
370
|
+
lines.append(pad + wline)
|
|
371
|
+
return lines
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _format_command_reference_block(
|
|
375
|
+
title: str,
|
|
376
|
+
desc: str,
|
|
377
|
+
json_snippet: str,
|
|
378
|
+
accepted: str,
|
|
379
|
+
comments: str,
|
|
380
|
+
) -> list[str]:
|
|
381
|
+
out: list[str] = [title]
|
|
382
|
+
out.extend(_format_labeled_field("Description", desc))
|
|
383
|
+
out.extend(_format_labeled_field("JSON", json_snippet))
|
|
384
|
+
out.extend(_format_labeled_field("Accepted", accepted))
|
|
385
|
+
if comments.strip():
|
|
386
|
+
out.extend(_format_labeled_field("Comments", comments))
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def get_set_command_reference_text() -> str:
|
|
391
|
+
"""Readable reference for `fancli set` (narrow blocks, wraps to terminal width)."""
|
|
392
|
+
parts: list[str] = [
|
|
393
|
+
"Values are parsed as JSON first, then int, float, or plain text.",
|
|
394
|
+
"",
|
|
395
|
+
]
|
|
396
|
+
for i, row in enumerate(SET_COMMAND_REFERENCE_ROWS):
|
|
397
|
+
cmd, desc, jsn, acc, com = row
|
|
398
|
+
parts.extend(_format_command_reference_block(cmd, desc, jsn, acc, com))
|
|
399
|
+
if i < len(SET_COMMAND_REFERENCE_ROWS) - 1:
|
|
400
|
+
parts.extend(["", "-" * min(40, _term_width()), ""])
|
|
401
|
+
return "\n".join(parts)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def print_set_command_help() -> None:
|
|
405
|
+
"""Full reference when `fancli set --help`, `fancli set -h`, `fancli set`, or `fancli set help`."""
|
|
406
|
+
print("Primary: fancli set --help (or fancli set -h)")
|
|
407
|
+
print("Shortcut: fancli set with no arguments, or: fancli set help")
|
|
408
|
+
print()
|
|
409
|
+
print("Usage: fancli set <key> <value>")
|
|
410
|
+
print()
|
|
411
|
+
print(get_set_command_reference_text())
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def parse_value(raw: str) -> Any:
|
|
415
|
+
s = raw.strip()
|
|
416
|
+
try:
|
|
417
|
+
return json.loads(s)
|
|
418
|
+
except json.JSONDecodeError:
|
|
419
|
+
pass
|
|
420
|
+
try:
|
|
421
|
+
return int(s)
|
|
422
|
+
except ValueError:
|
|
423
|
+
pass
|
|
424
|
+
try:
|
|
425
|
+
return float(s)
|
|
426
|
+
except ValueError:
|
|
427
|
+
pass
|
|
428
|
+
return raw
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _format_epoch_utc(epoch: Any) -> str:
|
|
432
|
+
if not isinstance(epoch, (int, float)):
|
|
433
|
+
return str(epoch)
|
|
434
|
+
try:
|
|
435
|
+
dt = datetime.fromtimestamp(int(epoch), tz=timezone.utc)
|
|
436
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
437
|
+
except (OSError, ValueError, OverflowError):
|
|
438
|
+
return str(epoch)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _print_status_pretty(data: dict[str, Any]) -> None:
|
|
442
|
+
"""Print get_device_state JSON in a readable layout when structure matches."""
|
|
443
|
+
status = data.get("status")
|
|
444
|
+
msg = data.get("message")
|
|
445
|
+
if status != "Success" or not isinstance(msg, dict):
|
|
446
|
+
print(json.dumps(data, indent=2))
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
ds = msg.get("device_state")
|
|
450
|
+
if not isinstance(ds, list):
|
|
451
|
+
print(json.dumps(data, indent=2))
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
lines: list[str] = ["Device status", ""]
|
|
455
|
+
|
|
456
|
+
if not ds:
|
|
457
|
+
lines.append(" (no devices in response)")
|
|
458
|
+
print("\n".join(lines))
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
for i, dev in enumerate(ds):
|
|
462
|
+
if not isinstance(dev, dict):
|
|
463
|
+
continue
|
|
464
|
+
if i > 0:
|
|
465
|
+
lines.append("-" * min(40, _term_width()))
|
|
466
|
+
lines.append("")
|
|
467
|
+
|
|
468
|
+
did = dev.get("device_id")
|
|
469
|
+
lines.append(f" {did}" if did is not None else " (unknown device)")
|
|
470
|
+
lines.extend(
|
|
471
|
+
_format_labeled_field(
|
|
472
|
+
"Power", "on" if dev.get("power") else "off"
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
spd = dev.get("last_recorded_speed")
|
|
476
|
+
if spd is not None:
|
|
477
|
+
lines.extend(_format_labeled_field("Speed", str(spd)))
|
|
478
|
+
lines.extend(
|
|
479
|
+
_format_labeled_field(
|
|
480
|
+
"Sleep mode", "on" if dev.get("sleep_mode") else "off"
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
lines.extend(
|
|
484
|
+
_format_labeled_field("LED", "on" if dev.get("led") else "off")
|
|
485
|
+
)
|
|
486
|
+
lines.extend(
|
|
487
|
+
_format_labeled_field(
|
|
488
|
+
"Online", "yes" if dev.get("is_online") else "no"
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
th = dev.get("timer_hours")
|
|
492
|
+
if th is not None:
|
|
493
|
+
lines.extend(_format_labeled_field("Timer", f"{th} h"))
|
|
494
|
+
te = dev.get("timer_time_elapsed_mins")
|
|
495
|
+
if te is not None:
|
|
496
|
+
lines.extend(_format_labeled_field("Timer elapsed", f"{te} min"))
|
|
497
|
+
ts = dev.get("ts_epoch_seconds")
|
|
498
|
+
if ts is not None:
|
|
499
|
+
lines.extend(_format_labeled_field("Last update", _format_epoch_utc(ts)))
|
|
500
|
+
|
|
501
|
+
print("\n".join(lines))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def run_status(cfg: dict[str, str], cache_path: Path, json_output: bool = False) -> None:
|
|
505
|
+
if not cfg["device_id"]:
|
|
506
|
+
raise SystemExit("DEVICE_ID is not set in the environment.")
|
|
507
|
+
|
|
508
|
+
session = requests.Session()
|
|
509
|
+
access = get_valid_access_token(session, cfg, cache_path)
|
|
510
|
+
|
|
511
|
+
def do_get(tok: str) -> requests.Response:
|
|
512
|
+
return get_device_state(
|
|
513
|
+
session,
|
|
514
|
+
cfg["api_url"],
|
|
515
|
+
cfg["device_id"],
|
|
516
|
+
tok,
|
|
517
|
+
cfg["api_key"],
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
r = do_get(access)
|
|
521
|
+
if r.status_code == 401:
|
|
522
|
+
access = get_valid_access_token(session, cfg, cache_path, force_refresh=True)
|
|
523
|
+
r = do_get(access)
|
|
524
|
+
|
|
525
|
+
if r.status_code != 200:
|
|
526
|
+
snippet = (r.text or "")[:500]
|
|
527
|
+
raise SystemExit(f"get_device_state failed: HTTP {r.status_code}\n{snippet}")
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
data = r.json()
|
|
531
|
+
except json.JSONDecodeError as e:
|
|
532
|
+
raise SystemExit(f"get_device_state: invalid JSON: {e}") from e
|
|
533
|
+
if json_output:
|
|
534
|
+
print(json.dumps(data, indent=2))
|
|
535
|
+
elif isinstance(data, dict):
|
|
536
|
+
_print_status_pretty(data)
|
|
537
|
+
else:
|
|
538
|
+
print(json.dumps(data, indent=2))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def run_set(cfg: dict[str, str], cache_path: Path, key: str, value_raw: str) -> None:
|
|
542
|
+
if not cfg["device_id"]:
|
|
543
|
+
raise SystemExit("DEVICE_ID is not set in the environment.")
|
|
544
|
+
|
|
545
|
+
value = parse_value(value_raw)
|
|
546
|
+
command = {key: value}
|
|
547
|
+
|
|
548
|
+
session = requests.Session()
|
|
549
|
+
access = get_valid_access_token(session, cfg, cache_path)
|
|
550
|
+
|
|
551
|
+
def do_post(tok: str) -> requests.Response:
|
|
552
|
+
return send_command(
|
|
553
|
+
session,
|
|
554
|
+
cfg["api_url"],
|
|
555
|
+
cfg["device_id"],
|
|
556
|
+
command,
|
|
557
|
+
tok,
|
|
558
|
+
cfg["api_key"],
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
r = do_post(access)
|
|
562
|
+
if r.status_code == 401:
|
|
563
|
+
access = get_valid_access_token(session, cfg, cache_path, force_refresh=True)
|
|
564
|
+
r = do_post(access)
|
|
565
|
+
|
|
566
|
+
if r.status_code != 200:
|
|
567
|
+
snippet = (r.text or "")[:500]
|
|
568
|
+
raise SystemExit(f"send_command failed: HTTP {r.status_code}\n{snippet}")
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
data = r.json()
|
|
572
|
+
except json.JSONDecodeError as e:
|
|
573
|
+
raise SystemExit(f"send_command: invalid JSON: {e}") from e
|
|
574
|
+
print(json.dumps(data, indent=2))
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# (slug or None, label, selectable) — extend when adding vendors.
|
|
578
|
+
SETUP_VENDOR_CHOICES: list[tuple[Optional[str], str, bool]] = [
|
|
579
|
+
("atomberg", "Atomberg", True),
|
|
580
|
+
(
|
|
581
|
+
None,
|
|
582
|
+
"Other vendors — coming soon (not available for setup yet)",
|
|
583
|
+
False,
|
|
584
|
+
),
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def dotenv_write_path() -> Path:
|
|
589
|
+
"""Prefer an existing .env from the usual search path; otherwise ~/.config/fancli/.env."""
|
|
590
|
+
existing = _find_dotenv()
|
|
591
|
+
if existing is not None:
|
|
592
|
+
return existing
|
|
593
|
+
return Path.home() / ".config" / "fancli" / ".env"
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _mask_secret(s: str) -> str:
|
|
597
|
+
if not s:
|
|
598
|
+
return ""
|
|
599
|
+
tail = s[-4:] if len(s) > 4 else s
|
|
600
|
+
return "****" + tail
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _prompt_secret(
|
|
604
|
+
label: str,
|
|
605
|
+
existing: str,
|
|
606
|
+
*,
|
|
607
|
+
stdin_tty: bool,
|
|
608
|
+
) -> str:
|
|
609
|
+
if existing:
|
|
610
|
+
print(
|
|
611
|
+
f"{label} (leave blank to keep {_mask_secret(existing)}): ",
|
|
612
|
+
end="",
|
|
613
|
+
flush=True,
|
|
614
|
+
)
|
|
615
|
+
else:
|
|
616
|
+
print(f"{label}: ", end="", flush=True)
|
|
617
|
+
if stdin_tty:
|
|
618
|
+
line = getpass.getpass("")
|
|
619
|
+
else:
|
|
620
|
+
line = sys.stdin.readline().rstrip("\n")
|
|
621
|
+
out = line.strip()
|
|
622
|
+
if not out and existing:
|
|
623
|
+
return existing
|
|
624
|
+
if not out:
|
|
625
|
+
raise SystemExit(f"{label} is required.")
|
|
626
|
+
return out
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _choose_company(cfg: dict[str, str]) -> str:
|
|
630
|
+
print("Select company (vendor):")
|
|
631
|
+
for i, (_slug, label, _sel) in enumerate(SETUP_VENDOR_CHOICES, start=1):
|
|
632
|
+
print(f" {i}) {label}")
|
|
633
|
+
cur = (cfg.get("company") or "").lower()
|
|
634
|
+
if cur:
|
|
635
|
+
print(f" Current saved vendor: {cur!r}")
|
|
636
|
+
print()
|
|
637
|
+
print(
|
|
638
|
+
"More integrations are planned. To contribute a new vendor integration, "
|
|
639
|
+
"open an issue or pull request on the fancli repository (see your source "
|
|
640
|
+
"checkout or where you installed the package from)."
|
|
641
|
+
)
|
|
642
|
+
print()
|
|
643
|
+
n = len(SETUP_VENDOR_CHOICES)
|
|
644
|
+
while True:
|
|
645
|
+
raw = input(f"Enter choice [1–{n}] (default 1): ").strip() or "1"
|
|
646
|
+
try:
|
|
647
|
+
idx = int(raw)
|
|
648
|
+
except ValueError:
|
|
649
|
+
raise SystemExit(f"Invalid choice. Enter a number from 1 to {n}.")
|
|
650
|
+
if idx < 1 or idx > n:
|
|
651
|
+
raise SystemExit(f"Choice out of range. Enter a number from 1 to {n}.")
|
|
652
|
+
slug, _label, selectable = SETUP_VENDOR_CHOICES[idx - 1]
|
|
653
|
+
if selectable and slug:
|
|
654
|
+
return slug
|
|
655
|
+
print()
|
|
656
|
+
print(
|
|
657
|
+
"Other vendors are not available yet. Additional integrations are "
|
|
658
|
+
"coming soon."
|
|
659
|
+
)
|
|
660
|
+
print(
|
|
661
|
+
"If you want to add support for another brand, contribute to fancli "
|
|
662
|
+
"(project README or repository where you obtained the source)."
|
|
663
|
+
)
|
|
664
|
+
print()
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _write_env_keys(path: Path, keys: dict[str, str]) -> None:
|
|
668
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
669
|
+
if not path.is_file():
|
|
670
|
+
path.touch()
|
|
671
|
+
for k, v in keys.items():
|
|
672
|
+
set_key(str(path), k, v)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _print_devices_json(data: dict[str, Any]) -> None:
|
|
676
|
+
print(json.dumps(data, indent=2))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def run_setup(cfg: dict[str, str], cache_path: Path) -> None:
|
|
680
|
+
stdin_tty = sys.stdin.isatty()
|
|
681
|
+
if not stdin_tty:
|
|
682
|
+
raise SystemExit("fancli setup requires an interactive terminal.")
|
|
683
|
+
|
|
684
|
+
company = _choose_company(cfg)
|
|
685
|
+
if company != "atomberg":
|
|
686
|
+
raise SystemExit("Only Atomberg is supported for now.")
|
|
687
|
+
|
|
688
|
+
api_key = _prompt_secret("API key", cfg.get("api_key") or "", stdin_tty=stdin_tty)
|
|
689
|
+
refresh = _prompt_secret(
|
|
690
|
+
"Refresh token",
|
|
691
|
+
cfg.get("refresh_token") or "",
|
|
692
|
+
stdin_tty=stdin_tty,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
work = {
|
|
696
|
+
**cfg,
|
|
697
|
+
"api_key": api_key,
|
|
698
|
+
"refresh_token": refresh,
|
|
699
|
+
"api_url": cfg["api_url"],
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
session = requests.Session()
|
|
703
|
+
print("Refreshing access token…", flush=True)
|
|
704
|
+
access = get_valid_access_token(session, work, cache_path, force_refresh=True)
|
|
705
|
+
|
|
706
|
+
print("Fetching devices…", flush=True)
|
|
707
|
+
|
|
708
|
+
def do_list(tok: str) -> requests.Response:
|
|
709
|
+
return get_list_of_devices(session, work["api_url"], tok, api_key)
|
|
710
|
+
|
|
711
|
+
r = do_list(access)
|
|
712
|
+
if r.status_code == 401:
|
|
713
|
+
access = get_valid_access_token(session, work, cache_path, force_refresh=True)
|
|
714
|
+
r = do_list(access)
|
|
715
|
+
|
|
716
|
+
if r.status_code != 200:
|
|
717
|
+
snippet = (r.text or "")[:500]
|
|
718
|
+
raise SystemExit(f"get_list_of_devices failed: HTTP {r.status_code}\n{snippet}")
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
data = r.json()
|
|
722
|
+
except json.JSONDecodeError as e:
|
|
723
|
+
raise SystemExit(f"get_list_of_devices: invalid JSON: {e}") from e
|
|
724
|
+
|
|
725
|
+
if not isinstance(data, dict):
|
|
726
|
+
raise SystemExit("Unexpected response shape from get_list_of_devices.")
|
|
727
|
+
|
|
728
|
+
_print_devices_json(data)
|
|
729
|
+
|
|
730
|
+
msg = data.get("message")
|
|
731
|
+
devices: list[Any] = []
|
|
732
|
+
if isinstance(msg, dict):
|
|
733
|
+
raw_list = msg.get("devices_list")
|
|
734
|
+
if isinstance(raw_list, list):
|
|
735
|
+
devices = raw_list
|
|
736
|
+
|
|
737
|
+
if not devices:
|
|
738
|
+
raise SystemExit(
|
|
739
|
+
"No devices in devices_list. Fix credentials or developer mode, then retry."
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
print()
|
|
743
|
+
print("Select a device:")
|
|
744
|
+
for i, dev in enumerate(devices, start=1):
|
|
745
|
+
if isinstance(dev, dict):
|
|
746
|
+
did = dev.get("device_id", "?")
|
|
747
|
+
name = dev.get("name", "")
|
|
748
|
+
room = dev.get("room", "")
|
|
749
|
+
model = dev.get("model", "")
|
|
750
|
+
line = f" {i}) {name or did}"
|
|
751
|
+
bits = [b for b in (room, model) if b]
|
|
752
|
+
if bits:
|
|
753
|
+
line += " — " + " | ".join(str(b) for b in bits)
|
|
754
|
+
line += f" [device_id={did}]"
|
|
755
|
+
print(line)
|
|
756
|
+
else:
|
|
757
|
+
print(f" {i}) {dev!r}")
|
|
758
|
+
|
|
759
|
+
print()
|
|
760
|
+
choice_raw = input(
|
|
761
|
+
f"Enter device number (1–{len(devices)}), or q to quit: "
|
|
762
|
+
).strip()
|
|
763
|
+
if choice_raw.lower() == "q":
|
|
764
|
+
raise SystemExit("Aborted.")
|
|
765
|
+
try:
|
|
766
|
+
pick = int(choice_raw)
|
|
767
|
+
except ValueError:
|
|
768
|
+
raise SystemExit("Invalid number.")
|
|
769
|
+
if pick < 1 or pick > len(devices):
|
|
770
|
+
raise SystemExit("Choice out of range.")
|
|
771
|
+
|
|
772
|
+
chosen = devices[pick - 1]
|
|
773
|
+
if not isinstance(chosen, dict) or not chosen.get("device_id"):
|
|
774
|
+
raise SystemExit("Selected entry has no device_id.")
|
|
775
|
+
device_id = str(chosen["device_id"]).strip()
|
|
776
|
+
if not device_id:
|
|
777
|
+
raise SystemExit("Empty device_id.")
|
|
778
|
+
|
|
779
|
+
out_path = dotenv_write_path()
|
|
780
|
+
_write_env_keys(
|
|
781
|
+
out_path,
|
|
782
|
+
{
|
|
783
|
+
"API_KEY": api_key,
|
|
784
|
+
"REFRESH_TOKEN": refresh,
|
|
785
|
+
"DEVICE_ID": device_id,
|
|
786
|
+
"FANCLI_COMPANY": "atomberg",
|
|
787
|
+
},
|
|
788
|
+
)
|
|
789
|
+
print()
|
|
790
|
+
print(f"Saved API_KEY, REFRESH_TOKEN, DEVICE_ID, and FANCLI_COMPANY to {out_path}")
|
|
791
|
+
print(f"Selected device_id: {device_id}")
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def main() -> None:
|
|
795
|
+
parser = argparse.ArgumentParser(
|
|
796
|
+
prog="fancli",
|
|
797
|
+
description="Control your smart fan from the terminal (Atomberg supported today).",
|
|
798
|
+
epilog="With no subcommand, prints the full user guide (same as `fancli help`).",
|
|
799
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
800
|
+
)
|
|
801
|
+
sub = parser.add_subparsers(dest="command", required=False)
|
|
802
|
+
|
|
803
|
+
sub.add_parser("help", help="Show the full user guide (from help.txt)")
|
|
804
|
+
|
|
805
|
+
sub.add_parser(
|
|
806
|
+
"setup",
|
|
807
|
+
help="Interactive setup: vendor, API key & refresh token, list devices, save DEVICE_ID",
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
status_p = sub.add_parser("status", help="Print device state (get_device_state)")
|
|
811
|
+
status_p.add_argument(
|
|
812
|
+
"--json",
|
|
813
|
+
action="store_true",
|
|
814
|
+
dest="status_json",
|
|
815
|
+
help="Print raw JSON (default is human-readable)",
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
set_p = sub.add_parser(
|
|
819
|
+
"set",
|
|
820
|
+
help="Send a command with a single key/value (send_command)",
|
|
821
|
+
description=(
|
|
822
|
+
"Send POST /v1/send_command with one key/value. "
|
|
823
|
+
"The key/value reference below is the same as "
|
|
824
|
+
"`fancli set` with no arguments or `fancli set help`."
|
|
825
|
+
),
|
|
826
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
827
|
+
epilog=get_set_command_reference_text(),
|
|
828
|
+
)
|
|
829
|
+
set_p.add_argument(
|
|
830
|
+
"key",
|
|
831
|
+
nargs="?",
|
|
832
|
+
default=None,
|
|
833
|
+
help="Command key (e.g. timer, power, speed)",
|
|
834
|
+
)
|
|
835
|
+
set_p.add_argument(
|
|
836
|
+
"value",
|
|
837
|
+
nargs="?",
|
|
838
|
+
default=None,
|
|
839
|
+
help="Value (number, true/false, or JSON literal)",
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
args = parser.parse_args()
|
|
843
|
+
cfg = load_config()
|
|
844
|
+
cache_path = token_file_path()
|
|
845
|
+
|
|
846
|
+
try:
|
|
847
|
+
if args.command is None or args.command == "help":
|
|
848
|
+
run_help()
|
|
849
|
+
elif args.command == "status":
|
|
850
|
+
run_status(cfg, cache_path, json_output=getattr(args, "status_json", False))
|
|
851
|
+
elif args.command == "set":
|
|
852
|
+
if args.key == "help" and args.value is None:
|
|
853
|
+
print_set_command_help()
|
|
854
|
+
return
|
|
855
|
+
if args.key is None and args.value is None:
|
|
856
|
+
print_set_command_help()
|
|
857
|
+
return
|
|
858
|
+
if args.key is None or args.value is None:
|
|
859
|
+
print_set_command_help()
|
|
860
|
+
raise SystemExit(
|
|
861
|
+
"error: both key and value are required (see the command list above)."
|
|
862
|
+
)
|
|
863
|
+
run_set(cfg, cache_path, args.key, args.value)
|
|
864
|
+
elif args.command == "setup":
|
|
865
|
+
run_setup(cfg, cache_path)
|
|
866
|
+
else:
|
|
867
|
+
parser.print_help()
|
|
868
|
+
sys.exit(1)
|
|
869
|
+
except requests.RequestException as e:
|
|
870
|
+
raise SystemExit(f"network error: {e}") from e
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
if __name__ == "__main__":
|
|
874
|
+
main()
|
fancli/help.txt
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Hello, and welcome to fancli
|
|
2
|
+
=============================
|
|
3
|
+
|
|
4
|
+
fancli — smart fan control from the terminal
|
|
5
|
+
--------------------------------------------
|
|
6
|
+
|
|
7
|
+
Overview
|
|
8
|
+
--------
|
|
9
|
+
fancli is designed for controlling your smart fan. It refreshes and caches an
|
|
10
|
+
access token, reads device state, and sends commands (power, speed, timer,
|
|
11
|
+
lights, and more). Current vendor support: Atomberg (via the Atomberg IoT
|
|
12
|
+
developer API). More brands may be added over time.
|
|
13
|
+
|
|
14
|
+
Run `fancli setup` first to pick a vendor, save your API credentials, list
|
|
15
|
+
devices, and choose which device to control. After that, use `fancli status`
|
|
16
|
+
and `fancli set` as usual.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Environment
|
|
20
|
+
-----------
|
|
21
|
+
Set these in the environment or in a .env file next to your project directory
|
|
22
|
+
or in the current working directory:
|
|
23
|
+
|
|
24
|
+
REFRESH_TOKEN Required. OAuth refresh token from the developer portal.
|
|
25
|
+
API_KEY Required. Your API key (x-api-key header).
|
|
26
|
+
DEVICE_ID Required for status and set. Target device UUID.
|
|
27
|
+
|
|
28
|
+
Optional:
|
|
29
|
+
|
|
30
|
+
API_URL Base URL for the API (default: https://api.developer.atomberg-iot.com).
|
|
31
|
+
FANCLI_COMPANY Vendor name (e.g. atomberg); set by `fancli setup`.
|
|
32
|
+
FANCLI_TOKEN_FILE Override path for the cached access token JSON (default:
|
|
33
|
+
~/.config/fancli/token.json).
|
|
34
|
+
|
|
35
|
+
Access tokens are cached for up to 23 hours; fancli refreshes automatically
|
|
36
|
+
when the cache is stale or the API returns 401.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Commands
|
|
40
|
+
--------
|
|
41
|
+
|
|
42
|
+
fancli
|
|
43
|
+
fancli help
|
|
44
|
+
Print this guide (read from help.txt shipped with the package).
|
|
45
|
+
|
|
46
|
+
fancli setup
|
|
47
|
+
Interactive setup wizard: choose vendor (Atomberg is available; other
|
|
48
|
+
vendors are listed as coming soon with a note on how to contribute), enter
|
|
49
|
+
API key and refresh token (or leave blank to keep saved values), call
|
|
50
|
+
GET /v1/get_list_of_devices, print the JSON response, pick a device, and
|
|
51
|
+
save API_KEY, REFRESH_TOKEN, DEVICE_ID, and FANCLI_COMPANY to your .env
|
|
52
|
+
(existing .env in the project/cwd if present, otherwise ~/.config/fancli/.env).
|
|
53
|
+
|
|
54
|
+
fancli status
|
|
55
|
+
Call GET /v1/get_device_state and print device state (human-readable by
|
|
56
|
+
default; use --json for raw JSON).
|
|
57
|
+
|
|
58
|
+
fancli set <key> <value>
|
|
59
|
+
Call POST /v1/send_command with command { "<key>": <parsed value> }.
|
|
60
|
+
|
|
61
|
+
Values are parsed as JSON first (e.g. true, false, "warm", [1,2]),
|
|
62
|
+
then as int, float, or plain text.
|
|
63
|
+
|
|
64
|
+
Primary: `fancli set --help` or `fancli set -h` for the built-in reference
|
|
65
|
+
for supported keys (power, speed, timer, led, brightness, light_mode, etc.).
|
|
66
|
+
Shortcut: `fancli set` with no arguments, or `fancli set help` (same text).
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Examples
|
|
70
|
+
--------
|
|
71
|
+
|
|
72
|
+
fancli setup
|
|
73
|
+
fancli status
|
|
74
|
+
fancli set power true
|
|
75
|
+
fancli set speed 3
|
|
76
|
+
fancli set timer 2
|
|
77
|
+
fancli set light_mode "warm"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Troubleshooting
|
|
81
|
+
---------------
|
|
82
|
+
• "REFRESH_TOKEN is not set" — run `fancli setup` or add credentials to .env
|
|
83
|
+
or your shell.
|
|
84
|
+
• HTTP 401 — fancli will try to refresh the token once; check refresh token
|
|
85
|
+
and API key if it keeps failing.
|
|
86
|
+
• "help file not found" — your install is missing help.txt; reinstall fancli
|
|
87
|
+
or run from a source checkout with the fancli package intact.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fancli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for controlling smart fans (currently Atomberg)
|
|
5
|
+
Author-email: Affan Sajid <inbox.affansajid@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Affan-sajid/fancli
|
|
8
|
+
Project-URL: Repository, https://github.com/Affan-sajid/fancli
|
|
9
|
+
Project-URL: Issues, https://github.com/Affan-sajid/fancli/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Home Automation
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: requests>=2.28.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# fancli
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/fancli/)
|
|
33
|
+
[](https://pypi.org/project/fancli/)
|
|
34
|
+
[](https://github.com/Affan-sajid/fancli/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
Terminal CLI for smart fans. It refreshes and caches an access token, reads device state, and sends commands (power, speed, timer, lights, and more). **Atomberg** is supported today via the [Atomberg IoT developer API](https://api.developer.atomberg-iot.com); more vendors may be added later.
|
|
37
|
+
|
|
38
|
+
**Repository:** [github.com/Affan-sajid/fancli](https://github.com/Affan-sajid/fancli)
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.9+
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
### From PyPI
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install fancli
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
CLI only, isolated from your default Python environment (recommended):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install fancli
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That installs the `fancli` command on your `PATH`. With plain `pip`, prefer a virtual environment or `pip install --user` and ensure your user scripts directory is on `PATH`.
|
|
59
|
+
|
|
60
|
+
### From source (development)
|
|
61
|
+
|
|
62
|
+
From a clone of this repository:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m venv .venv
|
|
66
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
You should then have the `fancli` command on your `PATH`.
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
1. Run interactive setup (credentials, device list, save `DEVICE_ID`):
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
fancli setup
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
2. Check status:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
fancli status
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
3. Send a command:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
fancli set power true
|
|
90
|
+
fancli set speed 3
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
4. Key/value reference for `set`:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
fancli set --help
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or: **`fancli set -h`**
|
|
100
|
+
|
|
101
|
+
Run **`fancli`** or **`fancli help`** for the full user guide (same text as the bundled `help.txt`).
|
|
102
|
+
|
|
103
|
+
## Environment
|
|
104
|
+
|
|
105
|
+
Configure via a `.env` file (project directory, current working directory, or after setup `~/.config/fancli/.env`) or your shell. **Do not commit** `.env` or paste real tokens into issues—use placeholders when asking for help.
|
|
106
|
+
|
|
107
|
+
Variables are **names only** below; get real values from the vendor developer portal and `fancli setup`.
|
|
108
|
+
|
|
109
|
+
| Variable | Required | Description |
|
|
110
|
+
|----------|----------|-------------|
|
|
111
|
+
| `REFRESH_TOKEN` | Yes | OAuth refresh token from the developer portal |
|
|
112
|
+
| `API_KEY` | Yes | API key (`x-api-key` header) |
|
|
113
|
+
| `DEVICE_ID` | For `status` / `set` | Target device UUID |
|
|
114
|
+
|
|
115
|
+
Optional:
|
|
116
|
+
|
|
117
|
+
| Variable | Description |
|
|
118
|
+
|----------|-------------|
|
|
119
|
+
| `API_URL` | API base URL (default: `https://api.developer.atomberg-iot.com`) |
|
|
120
|
+
| `FANCLI_COMPANY` | Vendor name (e.g. `atomberg`); set by `fancli setup` |
|
|
121
|
+
| `FANCLI_TOKEN_FILE` | Override path for cached access token JSON (default: `~/.config/fancli/token.json`) |
|
|
122
|
+
|
|
123
|
+
Access tokens are cached for up to 23 hours; fancli refreshes when the cache is stale or the API returns 401.
|
|
124
|
+
|
|
125
|
+
## Commands (summary)
|
|
126
|
+
|
|
127
|
+
| Command | Purpose |
|
|
128
|
+
|---------|---------|
|
|
129
|
+
| `fancli` / `fancli help` | Full user guide |
|
|
130
|
+
| `fancli setup` | Interactive wizard: vendor, credentials, list devices, save selection |
|
|
131
|
+
| `fancli status` | Device state (`--json` for raw JSON) |
|
|
132
|
+
| `fancli set <key> <value>` | Send a command; **`fancli set --help`** / **`-h`** for the key/value reference (Quick start) |
|
|
133
|
+
|
|
134
|
+
## Troubleshooting
|
|
135
|
+
|
|
136
|
+
- **`REFRESH_TOKEN is not set`** — Run `fancli setup` or set variables in `.env` or your environment.
|
|
137
|
+
- **HTTP 401** — fancli tries to refresh the token once; verify refresh token and API key if it keeps failing.
|
|
138
|
+
- **`help file not found`** — Reinstall the package or run from a checkout with `fancli/help.txt` present.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
fancli/__init__.py,sha256=KJMigaDcvY2PqAXYK6p-RI_dNnHLvJbHBXPrAytOS5I,28
|
|
2
|
+
fancli/cli.py,sha256=SNaq4n8C8nOS6Vt_jtypMfSIz66h6qKQOm0Rn24__Ws,25658
|
|
3
|
+
fancli/help.txt,sha256=whyDb-gfRDD_E7jYjFUVdqjXcno13qELHop6WxDI19U,3163
|
|
4
|
+
fancli-0.1.0.dist-info/licenses/LICENSE,sha256=W69Lfk4LAVPeI1hp-eOzJ0Xq9OBUzbwzLR8TK4BeN88,1068
|
|
5
|
+
fancli-0.1.0.dist-info/METADATA,sha256=wVwbpsxGXirQ-sU7gqxtRTtYdtPCMuLcfxONaiB8wC8,4739
|
|
6
|
+
fancli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
fancli-0.1.0.dist-info/entry_points.txt,sha256=ECeA_023NrekuBS_2ifYbPsA2NAERtXlXy7RGtlSq5M,43
|
|
8
|
+
fancli-0.1.0.dist-info/top_level.txt,sha256=lHwDiEeq_E0AVVcSdH8kPBkR3VBwirh87UguirxADYI,7
|
|
9
|
+
fancli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Affan Sajid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fancli
|