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 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
+ [![PyPI version](https://img.shields.io/pypi/v/fancli.svg)](https://pypi.org/project/fancli/)
33
+ [![Python versions](https://img.shields.io/pypi/pyversions/fancli.svg)](https://pypi.org/project/fancli/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fancli = fancli.cli:main
@@ -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