mock-device-cli.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. #!/usr/bin/env python3
  2. """
  3. mock-device-cli.py
  4. CLI mô phỏng thiết bị timelapse kết nối server theo 2 mode:
  5. 1) claim mode:
  6. - tạo N device UUID giả
  7. - gọi POST /v1/devices/claim
  8. - in claim code ra terminal để approve trên dashboard
  9. - (optional) poll trạng thái claim
  10. 2) heartbeat mode:
  11. - gửi heartbeat định kỳ cho danh sách device đã provisioned
  12. - dùng X-API-Key header như thiết bị thật
  13. Ví dụ:
  14. # tạo 5 device claim giả
  15. python3 mock-device-cli.py claim --server http://localhost:3001 --count 5
  16. # heartbeat cho 3 device seed mặc định
  17. python3 mock-device-cli.py heartbeat --server http://localhost:3001 --demo --interval 5
  18. # heartbeat từ danh sách custom
  19. python3 mock-device-cli.py heartbeat \
  20. --device DEMO-001:dev-api-key-demo-001 \
  21. --device DEMO-002:dev-api-key-demo-002
  22. """
  23. from __future__ import annotations
  24. import argparse
  25. import os
  26. import random
  27. import string
  28. import time
  29. from datetime import datetime
  30. import requests
  31. def log(msg: str) -> None:
  32. print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
  33. def random_uuid(prefix: str = "mock") -> str:
  34. rand = "".join(random.choices(string.ascii_lowercase + string.digits, k=10))
  35. return f"{prefix}-{rand}"
  36. def claim_devices(server: str, count: int, poll: bool, poll_interval: int) -> None:
  37. claimed: list[dict] = []
  38. for i in range(1, count + 1):
  39. device_uuid = random_uuid("pi4")
  40. payload = {
  41. "deviceUuid": device_uuid,
  42. "deviceName": f"Mock-Pi-{i:03d}",
  43. "serialNo": f"MOCK-{i:03d}",
  44. }
  45. try:
  46. r = requests.post(f"{server}/v1/devices/claim", json=payload, timeout=10)
  47. if not r.ok:
  48. log(f"claim failed [{payload['serialNo']}]: {r.status_code} {r.text[:120]}")
  49. continue
  50. data = r.json()
  51. claim_code = data.get("claimCode", "")
  52. status = data.get("status", "pending")
  53. claimed.append({"serial": payload["serialNo"], "claimCode": claim_code})
  54. log(f"claimed {payload['serialNo']} | code={claim_code} | status={status}")
  55. except Exception as e:
  56. log(f"claim error [{payload['serialNo']}]: {e}")
  57. if not claimed:
  58. log("no device claimed")
  59. return
  60. print("\n=== CLAIM CODES ===")
  61. for item in claimed:
  62. print(f" {item['serial']}: {item['claimCode']}")
  63. if not poll:
  64. print("\nTip: approve các claim code trên dashboard rồi chạy lại ở heartbeat mode.")
  65. return
  66. log("start polling claim status...")
  67. pending = {c["claimCode"]: c for c in claimed}
  68. while pending:
  69. done_codes = []
  70. for code, item in pending.items():
  71. try:
  72. r = requests.get(f"{server}/v1/devices/claim/{code}/status", timeout=10)
  73. if not r.ok:
  74. log(f"poll failed [{item['serial']}] {r.status_code}")
  75. continue
  76. s = r.json()
  77. st = s.get("status")
  78. if st == "approved":
  79. log(f"approved {item['serial']} | deviceId={s.get('deviceId')} | apiKey={'yes' if s.get('apiKey') else 'no'}")
  80. done_codes.append(code)
  81. elif st in ("rejected", "expired"):
  82. log(f"{st} {item['serial']}")
  83. done_codes.append(code)
  84. except Exception as e:
  85. log(f"poll error [{item['serial']}]: {e}")
  86. for c in done_codes:
  87. pending.pop(c, None)
  88. if pending:
  89. time.sleep(poll_interval)
  90. log("all claims resolved")
  91. def parse_devices(device_args: list[str], demo: bool) -> list[tuple[str, str]]:
  92. devices: list[tuple[str, str]] = []
  93. if demo:
  94. devices.extend([
  95. ("DEMO-001", "dev-api-key-demo-001"),
  96. ("DEMO-002", "dev-api-key-demo-002"),
  97. ("DEMO-003", "dev-api-key-demo-003"),
  98. ])
  99. for d in device_args:
  100. if ":" not in d:
  101. raise ValueError(f"invalid --device format: {d} (expected DEVICE_ID:API_KEY)")
  102. device_id, api_key = d.split(":", 1)
  103. devices.append((device_id.strip(), api_key.strip()))
  104. return devices
  105. def heartbeat_loop(server: str, devices: list[tuple[str, str]], interval: int, jitter: int) -> None:
  106. if not devices:
  107. raise ValueError("no devices provided. use --demo or --device DEVICE_ID:API_KEY")
  108. log(f"heartbeat mode start | devices={len(devices)} | interval={interval}s")
  109. while True:
  110. for device_id, api_key in devices:
  111. payload = {
  112. "deviceId": device_id,
  113. "status": random.choice(["online", "online", "online", "degraded"]),
  114. "tempC": round(random.uniform(38, 62), 1),
  115. "batteryPct": random.choice([None, random.randint(30, 100)]),
  116. "storageFreeGb": random.randint(8, 120),
  117. "capturesToday": random.randint(0, 240),
  118. "lastCaptureAt": datetime.utcnow().isoformat(),
  119. "firmwareVersion": "mock-1.0.0",
  120. "networkStatus": random.choice(["online", "online", "degraded"]),
  121. }
  122. try:
  123. r = requests.post(
  124. f"{server}/v1/devices/{device_id}/heartbeat",
  125. json=payload,
  126. headers={"X-API-Key": api_key, "Content-Type": "application/json"},
  127. timeout=10,
  128. )
  129. if r.ok:
  130. data = r.json()
  131. log(f"hb ok {device_id} | pending={data.get('pendingCommands', 0)}")
  132. else:
  133. log(f"hb fail {device_id} | {r.status_code} {r.text[:100]}")
  134. except Exception as e:
  135. log(f"hb err {device_id}: {e}")
  136. sleep_for = interval + random.randint(0, max(jitter, 0))
  137. time.sleep(sleep_for)
  138. def main() -> None:
  139. parser = argparse.ArgumentParser(description="Mock device simulator for timelapse server")
  140. sub = parser.add_subparsers(dest="mode", required=True)
  141. p_claim = sub.add_parser("claim", help="simulate fresh devices requesting claim")
  142. p_claim.add_argument("--server", default=os.environ.get("API_BASE", "http://localhost:3001"))
  143. p_claim.add_argument("--count", type=int, default=3)
  144. p_claim.add_argument("--poll", action="store_true", help="poll claim status until resolved")
  145. p_claim.add_argument("--poll-interval", type=int, default=10)
  146. p_hb = sub.add_parser("heartbeat", help="simulate provisioned devices sending heartbeat")
  147. p_hb.add_argument("--server", default=os.environ.get("API_BASE", "http://localhost:3001"))
  148. p_hb.add_argument("--device", action="append", default=[], help="DEVICE_ID:API_KEY (can repeat)")
  149. p_hb.add_argument("--demo", action="store_true", help="use demo seed devices DEMO-001..003")
  150. p_hb.add_argument("--interval", type=int, default=10)
  151. p_hb.add_argument("--jitter", type=int, default=2)
  152. args = parser.parse_args()
  153. if args.mode == "claim":
  154. claim_devices(args.server.rstrip("/"), args.count, args.poll, args.poll_interval)
  155. return
  156. if args.mode == "heartbeat":
  157. devices = parse_devices(args.device, args.demo)
  158. heartbeat_loop(args.server.rstrip("/"), devices, args.interval, args.jitter)
  159. return
  160. if __name__ == "__main__":
  161. main()